mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c66139e63b | |||
| 19b9dab5f7 | |||
| 4a4fd8c71c | |||
| 0cfcdff074 | |||
| 0f1b44f94c | |||
| e8d5a5de14 | |||
| 42c4cfb250 | |||
| 30a86d00db | |||
| 1f416f616c | |||
| 52775acd4d | |||
| e8186b63c0 | |||
| 7d64ceccab | |||
| 5fd823028e | |||
| c5a46c5163 | |||
| fc9d25ae75 | |||
| be0596abd6 | |||
| e77957aa92 | |||
| d04c4c493e | |||
| 5cb534217a | |||
| 9331f5e8a7 | |||
| 8d16567428 | |||
| 907cc3e1a1 | |||
| 84c8d157f6 | |||
| 446b35ef58 | |||
| 5495b4660f | |||
| 3c0a47275f | |||
| c06b8546d5 | |||
| 7efcf8989d | |||
| 9f51778b07 | |||
| 440e737425 | |||
| 747f85b1a9 | |||
| c7757d3359 | |||
| 9089672dc5 | |||
| bc112cf513 | |||
| 823669c335 | |||
| 910348649d | |||
| 9530fb8fae | |||
| 5028456321 | |||
| 7056d6f1d8 | |||
| 11e9a75def | |||
| 850d8a7d52 | |||
| 87380c7405 |
@@ -9,8 +9,8 @@ on:
|
||||
- main
|
||||
pull_request:
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- '.github/workflows/fe-unit-tests.yml'
|
||||
- "frontend/**"
|
||||
- ".github/workflows/fe-unit-tests.yml"
|
||||
|
||||
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
|
||||
concurrency:
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
run: npm ci
|
||||
- name: Run TypeScript compilation
|
||||
working-directory: ./frontend
|
||||
run: npm run make-i18n && tsc
|
||||
run: npm run build
|
||||
- name: Run tests and collect coverage
|
||||
working-directory: ./frontend
|
||||
run: npm run test:coverage
|
||||
|
||||
+2
-1
@@ -13,8 +13,9 @@
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:@tanstack/query/recommended",
|
||||
],
|
||||
"plugins": ["prettier", "unused-imports"],
|
||||
"plugins": ["prettier", "unused-imports", "i18next"],
|
||||
"rules": {
|
||||
"i18next/no-literal-string": "error",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"prettier/prettier": ["error"],
|
||||
// Resolves https://stackoverflow.com/questions/59265981/typescript-eslint-missing-file-extension-ts-import-extensions/59268871#59268871
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Run frontend checks
|
||||
echo "Running frontend checks..."
|
||||
cd frontend
|
||||
npm run check-unlocalized-strings
|
||||
npm run lint
|
||||
npm run check-translation-completeness
|
||||
npx lint-staged
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import SettingsScreen from "#/routes/settings";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import SettingsScreen, { clientLoader } from "#/routes/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
// Mock the i18next hook
|
||||
@@ -31,16 +31,27 @@ vi.mock("react-i18next", async () => {
|
||||
});
|
||||
|
||||
describe("Settings Screen", () => {
|
||||
const { handleLogoutMock } = vi.hoisted(() => ({
|
||||
const { handleLogoutMock, mockQueryClient } = vi.hoisted(() => ({
|
||||
handleLogoutMock: vi.fn(),
|
||||
mockQueryClient: (() => {
|
||||
const { QueryClient } = require("@tanstack/react-query");
|
||||
return new QueryClient();
|
||||
})(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-app-logout", () => ({
|
||||
useAppLogout: vi.fn().mockReturnValue({ handleLogout: handleLogoutMock }),
|
||||
}));
|
||||
|
||||
vi.mock("#/query-client-config", () => ({
|
||||
queryClient: mockQueryClient,
|
||||
}));
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: SettingsScreen,
|
||||
// @ts-expect-error - custom loader
|
||||
clientLoader,
|
||||
path: "/settings",
|
||||
children: [
|
||||
{
|
||||
@@ -56,8 +67,8 @@ describe("Settings Screen", () => {
|
||||
path: "/settings/app",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="credits-settings-screen" />,
|
||||
path: "/settings/credits",
|
||||
Component: () => <div data-testid="billing-settings-screen" />,
|
||||
path: "/settings/billing",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="api-keys-settings-screen" />,
|
||||
@@ -67,26 +78,27 @@ describe("Settings Screen", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const renderSettingsScreen = (path = "/settings") => {
|
||||
const queryClient = new QueryClient();
|
||||
return render(<RouterStub initialEntries={[path]} />, {
|
||||
const renderSettingsScreen = (path = "/settings") =>
|
||||
render(<RouterStub initialEntries={[path]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
it("should render the navbar", async () => {
|
||||
const sectionsToInclude = ["llm", "integrations", "application", "secrets"];
|
||||
const sectionsToExclude = ["api keys", "credits"];
|
||||
const sectionsToExclude = ["api keys", "credits", "billing"];
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return app mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
});
|
||||
|
||||
// Clear any existing query data
|
||||
mockQueryClient.clear();
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
@@ -102,6 +114,8 @@ describe("Settings Screen", () => {
|
||||
});
|
||||
expect(sectionElement).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should render the saas navbar", async () => {
|
||||
@@ -113,12 +127,15 @@ describe("Settings Screen", () => {
|
||||
const sectionsToInclude = [
|
||||
"integrations",
|
||||
"application",
|
||||
"credits",
|
||||
"credits", // The nav item shows "credits" text but routes to /billing
|
||||
"secrets",
|
||||
"api keys",
|
||||
];
|
||||
const sectionsToExclude = ["llm"];
|
||||
|
||||
// Clear any existing query data
|
||||
mockQueryClient.clear();
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
@@ -134,30 +151,44 @@ describe("Settings Screen", () => {
|
||||
});
|
||||
expect(sectionElement).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not be able to access oss-restricted routes in oss", async () => {
|
||||
it("should not be able to access saas-only routes in oss mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return app mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
});
|
||||
|
||||
const { rerender } = renderSettingsScreen("/settings/credits");
|
||||
// Clear any existing query data
|
||||
mockQueryClient.clear();
|
||||
|
||||
// In OSS mode, accessing restricted routes should redirect to /settings
|
||||
// Since createRoutesStub doesn't handle clientLoader redirects properly,
|
||||
// we test that the correct navbar is shown (OSS navbar) and that
|
||||
// the restricted route components are not rendered when accessing /settings
|
||||
renderSettingsScreen("/settings");
|
||||
|
||||
// Verify we're in OSS mode by checking the navbar
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
expect(within(navbar).getByText("LLM")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("credits-settings-screen"),
|
||||
within(navbar).queryByText("credits", { exact: false }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(<RouterStub initialEntries={["/settings/api-keys"]} />);
|
||||
expect(
|
||||
screen.queryByTestId("api-keys-settings-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
rerender(<RouterStub initialEntries={["/settings/billing"]} />);
|
||||
// Verify the LLM settings screen is shown
|
||||
expect(screen.getByTestId("llm-settings-screen")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("billing-settings-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
rerender(<RouterStub initialEntries={["/settings"]} />);
|
||||
expect(
|
||||
screen.queryByTestId("api-keys-settings-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
it.todo("should not be able to access saas-restricted routes in saas");
|
||||
it.todo("should not be able to access oss-only routes in saas mode");
|
||||
});
|
||||
|
||||
@@ -83,16 +83,18 @@ describe("extractModelAndProvider", () => {
|
||||
separator: "/",
|
||||
});
|
||||
|
||||
// These models are no longer in the VERIFIED_ANTHROPIC_MODELS list
|
||||
// but we keep the tests for backward compatibility
|
||||
expect(extractModelAndProvider("claude-3-haiku-20240307")).toEqual({
|
||||
provider: "anthropic",
|
||||
provider: "",
|
||||
model: "claude-3-haiku-20240307",
|
||||
separator: "/",
|
||||
separator: "",
|
||||
});
|
||||
|
||||
expect(extractModelAndProvider("claude-2.1")).toEqual({
|
||||
provider: "anthropic",
|
||||
provider: "",
|
||||
model: "claude-2.1",
|
||||
separator: "/",
|
||||
separator: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,14 +52,16 @@ test("organizeModelsAndProviders", () => {
|
||||
separator: "/",
|
||||
models: [
|
||||
"claude-3-5-sonnet-20241022",
|
||||
],
|
||||
},
|
||||
other: {
|
||||
separator: "",
|
||||
models: [
|
||||
"together-ai-21.1b-41b",
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-2",
|
||||
"claude-2.1",
|
||||
],
|
||||
},
|
||||
other: {
|
||||
separator: "",
|
||||
models: ["together-ai-21.1b-41b"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+37
-12
@@ -27,14 +27,14 @@
|
||||
"clsx": "^2.1.1",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.23.0",
|
||||
"i18next": "^25.3.1",
|
||||
"i18next": "^25.3.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.28",
|
||||
"jose": "^6.0.11",
|
||||
"lucide-react": "^0.525.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.256.2",
|
||||
"posthog-js": "^1.257.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -67,7 +67,7 @@
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.0.10",
|
||||
"@types/node": "^24.0.12",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
@@ -82,6 +82,7 @@
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-i18next": "^6.1.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.1",
|
||||
@@ -6186,9 +6187,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.10.tgz",
|
||||
"integrity": "sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==",
|
||||
"version": "24.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.12.tgz",
|
||||
"integrity": "sha512-LtOrbvDf5ndC9Xi+4QZjVL0woFymF/xSTKZKPgrrl7H7XoeDvnD+E2IclKVDyaK9UM756W/3BXqSU+JEHopA9g==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.8.0"
|
||||
@@ -9121,6 +9122,20 @@
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-i18next": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-6.1.2.tgz",
|
||||
"integrity": "sha512-hvTmws4kouNHkk314+9MHNj+RQmsqrkejWhTXGlRC0j8H+EXq2qDRLe6UqIjrFZo7/ogyd4btuqsnKCBi8wHbw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
"requireindex": "~1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import": {
|
||||
"version": "2.32.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
||||
@@ -10669,9 +10684,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.3.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz",
|
||||
"integrity": "sha512-S4CPAx8LfMOnURnnJa8jFWvur+UX/LWcl6+61p9VV7SK2m0445JeBJ6tLD0D5SR0H29G4PYfWkEhivKG5p4RDg==",
|
||||
"version": "25.3.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.2.tgz",
|
||||
"integrity": "sha512-JSnbZDxRVbphc5jiptxr3o2zocy5dEqpVm9qCGdJwRNO+9saUJS0/u4LnM/13C23fUEWxAylPqKU/NpMV/IjqA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -14298,9 +14313,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.256.2",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.256.2.tgz",
|
||||
"integrity": "sha512-ypepnUHr33i5a1Uk39mozZXXTENRPC17HCG3WHKK6aRcpNwNs8uEqXaIKICGNM+qre+totKeTgl0WoaUFYmyoQ==",
|
||||
"version": "1.257.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.257.0.tgz",
|
||||
"integrity": "sha512-Ujg9RGtWVCu+4tmlRpALSy2ZOZI6JtieSYXIDDdgMWm167KYKvTtbMPHdoBaPWcNu0Km+1hAIBnQFygyn30KhA==",
|
||||
"dependencies": {
|
||||
"core-js": "^3.38.1",
|
||||
"fflate": "^0.4.8",
|
||||
@@ -15117,6 +15132,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/requireindex": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.1.0.tgz",
|
||||
"integrity": "sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.5"
|
||||
}
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
|
||||
@@ -26,14 +26,14 @@
|
||||
"clsx": "^2.1.1",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.23.0",
|
||||
"i18next": "^25.3.1",
|
||||
"i18next": "^25.3.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.28",
|
||||
"jose": "^6.0.11",
|
||||
"lucide-react": "^0.525.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.256.2",
|
||||
"posthog-js": "^1.257.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -70,7 +70,6 @@
|
||||
"lint:fix": "eslint src --ext .ts,.tsx,.js --fix && prettier --write src/**/*.{ts,tsx}",
|
||||
"prepare": "cd .. && husky frontend/.husky",
|
||||
"typecheck": "react-router typegen && tsc",
|
||||
"check-unlocalized-strings": "node scripts/check-unlocalized-strings.cjs",
|
||||
"check-translation-completeness": "node scripts/check-translation-completeness.cjs"
|
||||
},
|
||||
"lint-staged": {
|
||||
@@ -92,7 +91,7 @@
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.0.10",
|
||||
"@types/node": "^24.0.12",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
@@ -107,6 +106,7 @@
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-i18next": "^6.1.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.1",
|
||||
|
||||
@@ -1,740 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Pre-commit hook script to check for unlocalized strings in the frontend code
|
||||
* This script is based on the test in __tests__/utils/check-hardcoded-strings.test.tsx
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const parser = require('@babel/parser');
|
||||
const traverse = require('@babel/traverse').default;
|
||||
|
||||
// Files/directories to ignore
|
||||
const IGNORE_PATHS = [
|
||||
// Build and dependency files
|
||||
"node_modules",
|
||||
"dist",
|
||||
".git",
|
||||
"test",
|
||||
"__tests__",
|
||||
".d.ts",
|
||||
"i18n",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"tsconfig.json",
|
||||
|
||||
// Internal code that doesn't need localization
|
||||
"mocks", // Mock data
|
||||
"assets", // SVG paths and CSS classes
|
||||
"types", // Type definitions and constants
|
||||
"state", // Redux state management
|
||||
"api", // API endpoints
|
||||
"services", // Internal services
|
||||
"hooks", // React hooks
|
||||
"context", // React context
|
||||
"store", // Redux store
|
||||
"routes.ts", // Route definitions
|
||||
"root.tsx", // Root component
|
||||
"entry.client.tsx", // Client entry point
|
||||
"utils/scan-unlocalized-strings.ts", // Original scanner
|
||||
"utils/scan-unlocalized-strings-ast.ts", // This file itself
|
||||
"frontend/src/components/features/home/tasks/get-prompt-for-query.ts", // Only contains agent prompts
|
||||
];
|
||||
|
||||
// Extensions to scan
|
||||
const SCAN_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
|
||||
|
||||
// Attributes that typically don't contain user-facing text
|
||||
const NON_TEXT_ATTRIBUTES = [
|
||||
"allow",
|
||||
"className",
|
||||
"i18nKey",
|
||||
"testId",
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"href",
|
||||
"src",
|
||||
"rel",
|
||||
"target",
|
||||
"style",
|
||||
"onClick",
|
||||
"onChange",
|
||||
"onSubmit",
|
||||
"data-testid",
|
||||
"aria-labelledby",
|
||||
"aria-describedby",
|
||||
"aria-hidden",
|
||||
"role",
|
||||
"sandbox",
|
||||
];
|
||||
|
||||
function shouldIgnorePath(filePath) {
|
||||
return IGNORE_PATHS.some((ignore) => filePath.includes(ignore));
|
||||
}
|
||||
|
||||
// Check if a string looks like a translation key
|
||||
// Translation keys typically use dots, underscores, or are all caps
|
||||
// Also check for the pattern with $ which is used in our translation keys
|
||||
function isLikelyTranslationKey(str) {
|
||||
return (
|
||||
/^[A-Z0-9_$.]+$/.test(str) ||
|
||||
str.includes(".") ||
|
||||
/[A-Z0-9_]+\$[A-Z0-9_]+/.test(str)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if a string is a raw translation key that should be wrapped in t()
|
||||
function isRawTranslationKey(str) {
|
||||
// Check for our specific translation key pattern (e.g., "SETTINGS$GITHUB_SETTINGS")
|
||||
// Exclude specific keys that are already properly used with i18next.t() in the code
|
||||
const excludedKeys = [
|
||||
"STATUS$ERROR_LLM_OUT_OF_CREDITS",
|
||||
"ERROR$GENERIC",
|
||||
"GITHUB$AUTH_SCOPE",
|
||||
];
|
||||
|
||||
if (excludedKeys.includes(str)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /^[A-Z0-9_]+\$[A-Z0-9_]+$/.test(str);
|
||||
}
|
||||
|
||||
// Specific technical strings that should be excluded from localization
|
||||
const EXCLUDED_TECHNICAL_STRINGS = [
|
||||
"openid email profile", // OAuth scope string - not user-facing
|
||||
"OPEN_ISSUE", // Task type identifier, not a UI string
|
||||
"Merge Request", // Git provider specific terminology
|
||||
"GitLab API", // Git provider specific terminology
|
||||
"Pull Request", // Git provider specific terminology
|
||||
"GitHub API", // Git provider specific terminology
|
||||
"add-secret-form", // Test ID for secret form
|
||||
"edit-secret-form", // Test ID for secret form
|
||||
"search-api-key-input", // Input name for search API key
|
||||
"noopener,noreferrer", // Options for window.open
|
||||
".openhands/microagents/", // Path to microagents directory
|
||||
"STATUS$READY",
|
||||
"STATUS$STOPPED",
|
||||
"STATUS$ERROR",
|
||||
];
|
||||
|
||||
function isExcludedTechnicalString(str) {
|
||||
return EXCLUDED_TECHNICAL_STRINGS.includes(str);
|
||||
}
|
||||
|
||||
function isLikelyCode(str) {
|
||||
// A string with no spaces and at least one underscore or colon is likely a code.
|
||||
// (e.g.: "browser_interactive" or "error:")
|
||||
if (str.includes(" ")) {
|
||||
return false
|
||||
}
|
||||
if (str.includes(":") || str.includes("_")){
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isCommonDevelopmentString(str) {
|
||||
|
||||
// Technical patterns that are definitely not UI strings
|
||||
const technicalPatterns = [
|
||||
// URLs and paths
|
||||
/^https?:\/\//, // URLs
|
||||
/^\/[a-zA-Z0-9_\-./]*$/, // File paths
|
||||
/^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$/, // File extensions, class names
|
||||
/^@[a-zA-Z0-9/-]+$/, // Import paths
|
||||
/^#\/[a-zA-Z0-9/-]+$/, // Alias imports
|
||||
/^[a-zA-Z0-9/-]+\/[a-zA-Z0-9/-]+$/, // Module paths
|
||||
/^data:image\/[a-zA-Z0-9;,]+$/, // Data URLs
|
||||
/^application\/[a-zA-Z0-9-]+$/, // MIME types
|
||||
/^!\[image]\(data:image\/png;base64,$/, // Markdown image with base64 data
|
||||
|
||||
// Numbers, IDs, and technical values
|
||||
/^\d+(\.\d+)?$/, // Numbers
|
||||
/^#[0-9a-fA-F]{3,8}$/, // Color codes
|
||||
/^[a-zA-Z0-9_-]+=[a-zA-Z0-9_-]+$/, // Key-value pairs
|
||||
/^mm:ss$/, // Time format
|
||||
/^[a-zA-Z0-9]+\/[a-zA-Z0-9-]+$/, // Provider/model format
|
||||
/^\?[a-zA-Z0-9_-]+$/, // URL parameters
|
||||
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i, // UUID
|
||||
/^[A-Za-z0-9+/=]+$/, // Base64
|
||||
|
||||
// HTML and CSS selectors
|
||||
/^[a-z]+(\[[^\]]+\])+$/, // CSS attribute selectors
|
||||
/^[a-z]+:[a-z-]+$/, // CSS pseudo-selectors
|
||||
/^[a-z]+\.[a-z0-9_-]+$/, // CSS class selectors
|
||||
/^[a-z]+#[a-z0-9_-]+$/, // CSS ID selectors
|
||||
/^[a-z]+\s*>\s*[a-z]+$/, // CSS child selectors
|
||||
/^[a-z]+\s+[a-z]+$/, // CSS descendant selectors
|
||||
|
||||
// CSS and styling patterns
|
||||
/^[a-z0-9-]+:[a-z0-9-]+$/, // CSS property:value
|
||||
/^[a-z0-9-]+:[a-z0-9-]+;[a-z0-9-]+:[a-z0-9-]+$/, // Multiple CSS properties
|
||||
];
|
||||
|
||||
// File extensions and media types
|
||||
const fileExtensionPattern =
|
||||
/^\.(png|jpg|jpeg|gif|svg|webp|bmp|ico|pdf|mp4|webm|ogg|mp3|wav|json|xml|csv|txt|md|html|css|js|jsx|ts|tsx)$/i;
|
||||
if (fileExtensionPattern.test(str)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// AI model and provider patterns
|
||||
const aiRelatedPattern =
|
||||
/^(AI|OpenAI|VertexAI|PaLM|Gemini|Anthropic|Anyscale|Databricks|Ollama|FriendliAI|Groq|DeepInfra|AI21|Replicate|OpenRouter|Azure|AWS|SageMaker|Bedrock|Mistral|Perplexity|Fireworks|Cloudflare|Workers|Voyage|claude-|gpt-|o1-|o3-)/i;
|
||||
if (aiRelatedPattern.test(str)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// CSS units and values
|
||||
const cssUnitsPattern =
|
||||
/\b\d+(px|rem|em|vh|vw|vmin|vmax|ch|ex|fr|deg|rad|turn|grad|ms|s)$|^(px|rem|em|vh|vw|vmin|vmax|ch|ex|fr|deg|rad|turn|grad|ms|s)$/;
|
||||
const cssValuesPattern =
|
||||
/(rgb|rgba|hsl|hsla|#[0-9a-fA-F]+|solid|absolute|relative|sticky|fixed|static|block|inline|flex|grid|none|auto|hidden|visible)/;
|
||||
|
||||
if (cssUnitsPattern.test(str) || cssValuesPattern.test(str)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for CSS class strings with brackets (common in the codebase)
|
||||
if (
|
||||
str.includes("[") &&
|
||||
str.includes("]") &&
|
||||
(str.includes("px") ||
|
||||
str.includes("rem") ||
|
||||
str.includes("em") ||
|
||||
str.includes("w-") ||
|
||||
str.includes("h-") ||
|
||||
str.includes("p-") ||
|
||||
str.includes("m-"))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for CSS class strings with specific patterns
|
||||
if (
|
||||
str.includes("border-") ||
|
||||
str.includes("rounded-") ||
|
||||
str.includes("cursor-") ||
|
||||
str.includes("opacity-") ||
|
||||
str.includes("disabled:") ||
|
||||
str.includes("hover:") ||
|
||||
str.includes("focus-within:") ||
|
||||
str.includes("first-of-type:") ||
|
||||
str.includes("last-of-type:") ||
|
||||
str.includes("group-data-")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it looks like a Tailwind class string
|
||||
if (/^[a-z0-9-]+(\s+[a-z0-9-]+)*$/.test(str)) {
|
||||
// Common Tailwind prefixes and patterns
|
||||
const tailwindPrefixes = [
|
||||
"bg-", "text-", "border-", "rounded-", "p-", "m-", "px-", "py-", "mx-", "my-",
|
||||
"w-", "h-", "min-w-", "min-h-", "max-w-", "max-h-", "flex-", "grid-", "gap-",
|
||||
"space-", "items-", "justify-", "self-", "col-", "row-", "order-", "object-",
|
||||
"overflow-", "opacity-", "z-", "top-", "right-", "bottom-", "left-", "inset-",
|
||||
"font-", "tracking-", "leading-", "list-", "placeholder-", "shadow-", "ring-",
|
||||
"transition-", "duration-", "ease-", "delay-", "animate-", "scale-", "rotate-",
|
||||
"translate-", "skew-", "origin-", "cursor-", "select-", "resize-", "fill-", "stroke-",
|
||||
];
|
||||
|
||||
// Check if any word in the string starts with a Tailwind prefix
|
||||
const words = str.split(/\s+/);
|
||||
for (const word of words) {
|
||||
for (const prefix of tailwindPrefixes) {
|
||||
if (word.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Tailwind modifiers
|
||||
const tailwindModifiers = [
|
||||
"hover:", "focus:", "active:", "disabled:", "visited:", "first:", "last:",
|
||||
"odd:", "even:", "group-hover:", "focus-within:", "focus-visible:", "motion-safe:",
|
||||
"motion-reduce:", "dark:", "light:", "sm:", "md:", "lg:", "xl:", "2xl:",
|
||||
];
|
||||
|
||||
for (const word of words) {
|
||||
for (const modifier of tailwindModifiers) {
|
||||
if (word.includes(modifier)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for CSS property combinations
|
||||
const cssProperties = [
|
||||
"border", "rounded", "px", "py", "mx", "my", "p", "m", "w", "h", "flex",
|
||||
"grid", "gap", "transition", "duration", "font", "leading", "tracking",
|
||||
];
|
||||
|
||||
// If the string contains multiple CSS properties, it's likely a CSS class string
|
||||
let cssPropertyCount = 0;
|
||||
for (const word of words) {
|
||||
if (
|
||||
cssProperties.some(
|
||||
(prop) => word === prop || word.startsWith(`${prop}-`),
|
||||
)
|
||||
) {
|
||||
cssPropertyCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (cssPropertyCount >= 2) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for specific CSS class patterns that appear in the test failures
|
||||
if (
|
||||
str.match(
|
||||
/^(border|rounded|flex|grid|transition|duration|ease|hover:|focus:|active:|disabled:|placeholder:|text-|bg-|w-|h-|p-|m-|gap-|items-|justify-|self-|overflow-|cursor-|opacity-|z-|top-|right-|bottom-|left-|inset-|font-|tracking-|leading-|whitespace-|break-|truncate|shadow-|ring-|outline-|animate-|transform|rotate-|scale-|skew-|translate-|origin-|first-of-type:|last-of-type:|group-data-|max-|min-|px-|py-|mx-|my-|grow|shrink|resize-|underline|italic|normal)/,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// HTML tags and attributes
|
||||
if (
|
||||
/^<[a-z0-9]+(?:\s[^>]*)?>.*<\/[a-z0-9]+>$/i.test(str) ||
|
||||
/^<[a-z0-9]+ [^>]+\/>$/i.test(str)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for specific patterns in suggestions and examples
|
||||
if (
|
||||
str.includes("* ") &&
|
||||
(str.includes("create a") ||
|
||||
str.includes("build a") ||
|
||||
str.includes("make a"))
|
||||
) {
|
||||
// This is likely a suggestion or example, not a UI string
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for specific technical identifiers from the test failures
|
||||
if (
|
||||
/^(download_via_vscode_button_clicked|open-vscode-error-|set-indicator|settings_saved|openhands-trace-|provider-item-|last_browser_action_error)$/.test(
|
||||
str,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for URL paths and query parameters
|
||||
if (
|
||||
str.startsWith("?") ||
|
||||
str.startsWith("/") ||
|
||||
str.includes("auth.") ||
|
||||
str.includes("$1auth.")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for specific strings that should be excluded
|
||||
if (
|
||||
str === "Cache Hit:" ||
|
||||
str === "Cache Write:" ||
|
||||
str === "ADD_DOCS" ||
|
||||
str === "ADD_DOCKERFILE" ||
|
||||
str === "Verified" ||
|
||||
str === "Others" ||
|
||||
str === "Feedback" ||
|
||||
str === "JSON File" ||
|
||||
str === "mt-0.5 md:mt-0"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for long suggestion texts
|
||||
if (
|
||||
str.length > 100 &&
|
||||
(str.includes("Please write a bash script") ||
|
||||
str.includes("Please investigate the repo") ||
|
||||
str.includes("Please push the changes") ||
|
||||
str.includes("Examine the dependencies") ||
|
||||
str.includes("Investigate the documentation") ||
|
||||
str.includes("Investigate the current repo") ||
|
||||
str.includes("I want to create a Hello World app") ||
|
||||
str.includes("I want to create a VueJS app") ||
|
||||
str.includes("This should be a client-only app"))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for specific error messages and UI text
|
||||
if (
|
||||
str === "All data associated with this project will be lost." ||
|
||||
str === "You will lose any unsaved information." ||
|
||||
str ===
|
||||
"This conversation does not exist, or you do not have permission to access it." ||
|
||||
str === "Failed to fetch settings. Please try reloading." ||
|
||||
str ===
|
||||
"If you tell OpenHands to start a web server, the app will appear here." ||
|
||||
str ===
|
||||
"Your browser doesn't support downloading files. Please use Chrome, Edge, or another browser that supports the File System Access API." ||
|
||||
str ===
|
||||
"Something went wrong while fetching settings. Please reload the page." ||
|
||||
str ===
|
||||
"To help us improve, we collect feedback from your interactions to improve our prompts. By submitting this form, you consent to us collecting this data." ||
|
||||
str === "Please push the latest changes to the existing pull request."
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check against all technical patterns
|
||||
return technicalPatterns.some((pattern) => pattern.test(str));
|
||||
}
|
||||
|
||||
function isLikelyUserFacingText(str) {
|
||||
|
||||
// Basic validation - skip very short strings or strings without letters
|
||||
if (!str || str.length <= 2 || !/[a-zA-Z]/.test(str)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's a specifically excluded technical string
|
||||
if (isExcludedTechnicalString(str)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it looks like a code rather than a key
|
||||
if (isLikelyCode(str)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it's a raw translation key that should be wrapped in t()
|
||||
if (isRawTranslationKey(str)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's a translation key pattern (e.g., "SETTINGS$BASE_URL")
|
||||
// These should be wrapped in t() or use I18nKey enum
|
||||
if (isLikelyTranslationKey(str) && /^[A-Z0-9_]+\$[A-Z0-9_]+$/.test(str)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// First, check if it's a common development string (not user-facing)
|
||||
if (isCommonDevelopmentString(str)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Multi-word phrases are likely UI text
|
||||
const hasMultipleWords = /\s+/.test(str) && str.split(/\s+/).length > 1;
|
||||
|
||||
// Sentences and questions are likely UI text
|
||||
const hasPunctuation = /[?!.,:]/.test(str);
|
||||
const isCapitalizedPhrase = /^[A-Z]/.test(str) && hasMultipleWords;
|
||||
const isTitleCase = hasMultipleWords && /\s[A-Z]/.test(str);
|
||||
const hasSentenceStructure = /^[A-Z].*[.!?]$/.test(str); // Starts with capital, ends with punctuation
|
||||
const hasQuestionForm =
|
||||
/^(What|How|Why|When|Where|Who|Can|Could|Would|Will|Is|Are|Do|Does|Did|Should|May|Might)/.test(
|
||||
str,
|
||||
);
|
||||
|
||||
// Product names and camelCase identifiers are likely UI text
|
||||
const hasInternalCapitals = /[a-z][A-Z]/.test(str); // CamelCase product names
|
||||
|
||||
// Instruction text patterns are likely UI text
|
||||
const looksLikeInstruction =
|
||||
/^(Enter|Type|Select|Choose|Provide|Specify|Search|Find|Input|Add|Write|Describe|Set|Pick|Browse|Upload|Download|Click|Tap|Press|Go to|Visit|Open|Close)/i.test(
|
||||
str,
|
||||
);
|
||||
|
||||
// Error and status messages are likely UI text
|
||||
const looksLikeErrorOrStatus =
|
||||
/(failed|error|invalid|required|missing|incorrect|wrong|unavailable|not found|not available|try again|success|completed|finished|done|saved|updated|created|deleted|removed|added)/i.test(
|
||||
str,
|
||||
);
|
||||
|
||||
// Single word check - assume it's UI text unless proven otherwise
|
||||
const isSingleWord =
|
||||
!str.includes(" ") && str.length > 1 && /^[a-zA-Z]+$/.test(str);
|
||||
|
||||
// For single words, we need to be more careful
|
||||
if (isSingleWord) {
|
||||
// Skip common programming terms and variable names
|
||||
const isCommonProgrammingTerm =
|
||||
/^(null|undefined|true|false|function|class|interface|type|enum|const|let|var|return|import|export|default|async|await|try|catch|finally|throw|new|this|super|extends|implements|instanceof|typeof|void|delete|in|of|for|while|do|if|else|switch|case|break|continue|yield|static|get|set|public|private|protected|readonly|abstract|implements|namespace|module|declare|as|from|with)$/i.test(
|
||||
str,
|
||||
);
|
||||
|
||||
if (isCommonProgrammingTerm) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip common variable name patterns
|
||||
const looksLikeVariableName =
|
||||
/^[a-z][a-zA-Z0-9]*$/.test(str) && str.length <= 20;
|
||||
|
||||
if (looksLikeVariableName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip common CSS values
|
||||
const isCommonCssValue =
|
||||
/^(auto|none|hidden|visible|block|inline|flex|grid|row|column|wrap|nowrap|center|start|end|stretch|cover|contain|fixed|absolute|relative|static|sticky|pointer|default|inherit|initial|unset)$/i.test(
|
||||
str,
|
||||
);
|
||||
|
||||
if (isCommonCssValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip common file extensions
|
||||
const isFileExtension = /^\.[a-z0-9]+$/i.test(str);
|
||||
if (isFileExtension) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip common abbreviations
|
||||
const isCommonAbbreviation =
|
||||
/^(id|src|href|url|alt|img|btn|nav|div|span|ul|li|ol|dl|dt|dd|svg|png|jpg|gif|pdf|doc|txt|md|js|ts|jsx|tsx|css|scss|less|html|xml|json|yaml|yml|toml|csv|mp3|mp4|wav|avi|mov|mpeg|webm|webp|ttf|woff|eot|otf)$/i.test(
|
||||
str,
|
||||
);
|
||||
|
||||
if (isCommonAbbreviation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it's a single word that's not a programming term, variable name, CSS value, file extension, or abbreviation,
|
||||
// it might be UI text, but we'll be conservative and return false
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it has multiple words, punctuation, or looks like a sentence, it's likely UI text
|
||||
return (
|
||||
hasMultipleWords ||
|
||||
hasPunctuation ||
|
||||
isCapitalizedPhrase ||
|
||||
isTitleCase ||
|
||||
hasSentenceStructure ||
|
||||
hasQuestionForm ||
|
||||
hasInternalCapitals ||
|
||||
looksLikeInstruction ||
|
||||
looksLikeErrorOrStatus
|
||||
);
|
||||
}
|
||||
|
||||
function isInTranslationContext(path) {
|
||||
// Check if the JSX text is inside a <Trans> component
|
||||
let current = path;
|
||||
while (current.parentPath) {
|
||||
if (
|
||||
current.isJSXElement() &&
|
||||
current.node.openingElement &&
|
||||
current.node.openingElement.name &&
|
||||
current.node.openingElement.name.name === "Trans"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
current = current.parentPath;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function scanFileForUnlocalizedStrings(filePath) {
|
||||
// Skip suggestion content files as they contain special strings that are already properly localized
|
||||
if (filePath.includes("utils/suggestions/") || filePath.includes("mocks/task-suggestions-handlers.ts")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const unlocalizedStrings = [];
|
||||
|
||||
// Skip files that are too large
|
||||
if (content.length > 1000000) {
|
||||
console.warn(`Skipping large file: ${filePath}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the file
|
||||
const ast = parser.parse(content, {
|
||||
sourceType: "module",
|
||||
plugins: ["jsx", "typescript", "classProperties", "decorators-legacy"],
|
||||
});
|
||||
|
||||
// Traverse the AST
|
||||
traverse(ast, {
|
||||
// Find JSX text content
|
||||
JSXText(jsxTextPath) {
|
||||
const text = jsxTextPath.node.value.trim();
|
||||
if (
|
||||
text &&
|
||||
isLikelyUserFacingText(text) &&
|
||||
!isInTranslationContext(jsxTextPath)
|
||||
) {
|
||||
unlocalizedStrings.push(text);
|
||||
}
|
||||
},
|
||||
|
||||
// Find string literals in JSX attributes
|
||||
JSXAttribute(jsxAttrPath) {
|
||||
const attrName = jsxAttrPath.node.name.name.toString();
|
||||
|
||||
// Skip technical attributes that don't contain user-facing text
|
||||
if (NON_TEXT_ATTRIBUTES.includes(attrName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip styling attributes
|
||||
if (
|
||||
attrName === "className" ||
|
||||
attrName === "class" ||
|
||||
attrName === "style"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip data attributes and event handlers
|
||||
if (attrName.startsWith("data-") || attrName.startsWith("on")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check the attribute value
|
||||
const value = jsxAttrPath.node.value;
|
||||
if (value && value.type === "StringLiteral") {
|
||||
const text = value.value.trim();
|
||||
if (text && isLikelyUserFacingText(text)) {
|
||||
unlocalizedStrings.push(text);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Find string literals in code
|
||||
StringLiteral(stringPath) {
|
||||
// Skip if parent is JSX attribute (already handled above)
|
||||
if (stringPath.parent.type === "JSXAttribute") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if parent is import/export declaration
|
||||
if (
|
||||
stringPath.parent.type === "ImportDeclaration" ||
|
||||
stringPath.parent.type === "ExportDeclaration"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if parent is object property key
|
||||
if (
|
||||
stringPath.parent.type === "ObjectProperty" &&
|
||||
stringPath.parent.key === stringPath.node
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if inside a t() call or Trans component
|
||||
let isInsideTranslation = false;
|
||||
let current = stringPath;
|
||||
|
||||
while (current.parentPath && !isInsideTranslation) {
|
||||
// Check for t() function call
|
||||
if (
|
||||
current.parent.type === "CallExpression" &&
|
||||
current.parent.callee &&
|
||||
((current.parent.callee.type === "Identifier" &&
|
||||
current.parent.callee.name === "t") ||
|
||||
(current.parent.callee.type === "MemberExpression" &&
|
||||
current.parent.callee.property &&
|
||||
current.parent.callee.property.name === "t"))
|
||||
) {
|
||||
isInsideTranslation = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for <Trans> component
|
||||
if (
|
||||
current.parent.type === "JSXElement" &&
|
||||
current.parent.openingElement &&
|
||||
current.parent.openingElement.name &&
|
||||
current.parent.openingElement.name.name === "Trans"
|
||||
) {
|
||||
isInsideTranslation = true;
|
||||
break;
|
||||
}
|
||||
|
||||
current = current.parentPath;
|
||||
}
|
||||
|
||||
if (!isInsideTranslation) {
|
||||
const text = stringPath.node.value.trim();
|
||||
if (text && isLikelyUserFacingText(text)) {
|
||||
unlocalizedStrings.push(text);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return unlocalizedStrings;
|
||||
} catch (error) {
|
||||
console.error(`Error parsing file ${filePath}:`, error);
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading file ${filePath}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function scanDirectoryForUnlocalizedStrings(dirPath) {
|
||||
const results = new Map();
|
||||
|
||||
function scanDir(currentPath) {
|
||||
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentPath, entry.name);
|
||||
|
||||
if (!shouldIgnorePath(fullPath)) {
|
||||
if (entry.isDirectory()) {
|
||||
scanDir(fullPath);
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
SCAN_EXTENSIONS.includes(path.extname(fullPath))
|
||||
) {
|
||||
const unlocalized = scanFileForUnlocalizedStrings(fullPath);
|
||||
if (unlocalized.length > 0) {
|
||||
results.set(fullPath, unlocalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scanDir(dirPath);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Run the check
|
||||
try {
|
||||
const srcPath = path.resolve(__dirname, '../src');
|
||||
console.log('Checking for unlocalized strings in frontend code...');
|
||||
|
||||
// Get unlocalized strings using the AST scanner
|
||||
const results = scanDirectoryForUnlocalizedStrings(srcPath);
|
||||
|
||||
// If we found any unlocalized strings, format them for output and exit with error
|
||||
if (results.size > 0) {
|
||||
const formattedResults = Array.from(results.entries())
|
||||
.map(([file, strings]) => `\n${file}:\n ${strings.join('\n ')}`)
|
||||
.join('\n');
|
||||
|
||||
console.error(`Error: Found unlocalized strings in the following files:${formattedResults}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('✅ No unlocalized strings found in frontend code.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error running unlocalized strings check:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -17,8 +17,12 @@ export function BudgetUsageText({
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<span className="text-xs text-neutral-400">
|
||||
${currentCost.toFixed(4)} / ${maxBudget.toFixed(4)} (
|
||||
{usagePercentage.toFixed(2)}% {t(I18nKey.CONVERSATION$USED)})
|
||||
{t(I18nKey.CONVERSATION$BUDGET_USAGE_FORMAT, {
|
||||
currentCost: `$${currentCost.toFixed(4)}`,
|
||||
maxBudget: `$${maxBudget.toFixed(4)}`,
|
||||
usagePercentage: usagePercentage.toFixed(2),
|
||||
used: t(I18nKey.CONVERSATION$USED),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -330,11 +330,15 @@ export function ConversationCard({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 pl-4 text-sm">
|
||||
<span className="text-neutral-400">Cache Hit:</span>
|
||||
<span className="text-neutral-400">
|
||||
{t(I18nKey.CONVERSATION$CACHE_HIT)}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{metrics.usage.cache_read_tokens.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-neutral-400">Cache Write:</span>
|
||||
<span className="text-neutral-400">
|
||||
{t(I18nKey.CONVERSATION$CACHE_WRITE)}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{metrics.usage.cache_write_tokens.toLocaleString()}
|
||||
</span>
|
||||
|
||||
@@ -207,7 +207,7 @@ export function LikertScale({
|
||||
className={cn("text-xl transition-all", getButtonClass(rating))}
|
||||
aria-label={`Rate ${rating} stars`}
|
||||
>
|
||||
★
|
||||
{t(I18nKey.FEEDBACK$STAR_RATING)}
|
||||
</button>
|
||||
))}
|
||||
{/* Show selected reason inline with stars when submitted (only for ratings <= 3) */}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RootState } from "#/store";
|
||||
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
|
||||
import { JupyterCell } from "./jupyter-cell";
|
||||
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
|
||||
interface JupyterEditorProps {
|
||||
maxWidth: number;
|
||||
@@ -11,28 +13,43 @@ interface JupyterEditorProps {
|
||||
|
||||
export function JupyterEditor({ maxWidth }: JupyterEditorProps) {
|
||||
const cells = useSelector((state: RootState) => state.jupyter?.cells ?? []);
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
const jupyterRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
|
||||
const { hitBottom, scrollDomToBottom, onChatBodyScroll } =
|
||||
useScrollToBottom(jupyterRef);
|
||||
|
||||
return (
|
||||
<div className="flex-1 h-full flex flex-col" style={{ maxWidth }}>
|
||||
<div
|
||||
data-testid="jupyter-container"
|
||||
className="flex-1 overflow-y-auto fast-smooth-scroll"
|
||||
ref={jupyterRef}
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
>
|
||||
{cells.map((cell, index) => (
|
||||
<JupyterCell key={index} cell={cell} />
|
||||
))}
|
||||
</div>
|
||||
{!hitBottom && (
|
||||
<div className="sticky bottom-2 flex items-center justify-center">
|
||||
<ScrollToBottomButton onClick={scrollDomToBottom} />
|
||||
<>
|
||||
{isRuntimeInactive && (
|
||||
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
|
||||
{t("DIFF_VIEWER$WAITING_FOR_RUNTIME")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isRuntimeInactive && (
|
||||
<div className="flex-1 h-full flex flex-col" style={{ maxWidth }}>
|
||||
<div
|
||||
data-testid="jupyter-container"
|
||||
className="flex-1 overflow-y-auto fast-smooth-scroll"
|
||||
ref={jupyterRef}
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
>
|
||||
{cells.map((cell, index) => (
|
||||
<JupyterCell key={index} cell={cell} />
|
||||
))}
|
||||
</div>
|
||||
{!hitBottom && (
|
||||
<div className="sticky bottom-2 flex items-center justify-center">
|
||||
<ScrollToBottomButton onClick={scrollDomToBottom} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,21 +4,32 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { ApiKey, CreateApiKeyResponse } from "#/api/api-keys";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { CreateApiKeyModal } from "./create-api-key-modal";
|
||||
import { DeleteApiKeyModal } from "./delete-api-key-modal";
|
||||
import { NewApiKeyModal } from "./new-api-key-modal";
|
||||
import { useApiKeys } from "#/hooks/query/use-api-keys";
|
||||
import {
|
||||
useLlmApiKey,
|
||||
useRefreshLlmApiKey,
|
||||
} from "#/hooks/query/use-llm-api-key";
|
||||
|
||||
export function ApiKeysManager() {
|
||||
const { t } = useTranslation();
|
||||
const { data: apiKeys = [], isLoading, error } = useApiKeys();
|
||||
const { data: llmApiKey, isLoading: isLoadingLlmKey } = useLlmApiKey();
|
||||
const refreshLlmApiKey = useRefreshLlmApiKey();
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [keyToDelete, setKeyToDelete] = useState<ApiKey | null>(null);
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] =
|
||||
useState<CreateApiKeyResponse | null>(null);
|
||||
const [showNewKeyModal, setShowNewKeyModal] = useState(false);
|
||||
const [isRefreshingLlmKey, setIsRefreshingLlmKey] = useState(false);
|
||||
const [showLlmApiKey, setShowLlmApiKey] = useState(false);
|
||||
|
||||
// Display error toast if the query fails
|
||||
if (error) {
|
||||
@@ -45,6 +56,22 @@ export function ApiKeysManager() {
|
||||
setNewlyCreatedKey(null);
|
||||
};
|
||||
|
||||
const handleRefreshLlmApiKey = async () => {
|
||||
try {
|
||||
setIsRefreshingLlmKey(true);
|
||||
await refreshLlmApiKey.mutateAsync();
|
||||
displaySuccessToast(
|
||||
t(I18nKey.SETTINGS$API_KEY_REFRESHED, {
|
||||
defaultValue: "API key refreshed successfully",
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
displayErrorToast(t(I18nKey.ERROR$GENERIC));
|
||||
} finally {
|
||||
setIsRefreshingLlmKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return "Never";
|
||||
return new Date(dateString).toLocaleString();
|
||||
@@ -53,6 +80,128 @@ export function ApiKeysManager() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-6">
|
||||
{!isLoadingLlmKey && llmApiKey && (
|
||||
<div className="border-b border-gray-200 pb-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xl font-medium text-white">
|
||||
{t(I18nKey.SETTINGS$LLM_API_KEY)}
|
||||
</h3>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleRefreshLlmApiKey}
|
||||
isDisabled={isRefreshingLlmKey}
|
||||
>
|
||||
{isRefreshingLlmKey ? (
|
||||
<LoadingSpinner size="small" />
|
||||
) : (
|
||||
t(I18nKey.SETTINGS$REFRESH_LLM_API_KEY)
|
||||
)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
<p className="text-sm text-white mb-4">
|
||||
{t(I18nKey.SETTINGS$LLM_API_KEY_DESCRIPTION)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<div className="flex-1 bg-base-tertiary rounded-md py-2 flex items-center">
|
||||
<div className="flex-1 pl-2">
|
||||
{llmApiKey.key ? (
|
||||
<div className="flex items-center">
|
||||
{showLlmApiKey ? (
|
||||
<span className="text-white font-mono">
|
||||
{llmApiKey.key}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-white">{"•".repeat(20)}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-white">
|
||||
{t(I18nKey.API$NO_KEY_AVAILABLE)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{llmApiKey.key && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-white hover:text-gray-300 mr-2"
|
||||
aria-label={
|
||||
showLlmApiKey ? "Hide API key" : "Show API key"
|
||||
}
|
||||
title={showLlmApiKey ? "Hide API key" : "Show API key"}
|
||||
onClick={() => setShowLlmApiKey(!showLlmApiKey)}
|
||||
>
|
||||
{showLlmApiKey ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="text-white hover:text-gray-300 mr-2"
|
||||
aria-label="Copy API key"
|
||||
title="Copy API key"
|
||||
onClick={() => {
|
||||
if (llmApiKey.key) {
|
||||
navigator.clipboard.writeText(llmApiKey.key);
|
||||
displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_COPIED));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="text-xl font-medium text-white">
|
||||
{t(I18nKey.SETTINGS$OPENHANDS_API_KEYS)}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<BrandButton
|
||||
type="button"
|
||||
|
||||
@@ -3,9 +3,16 @@ interface HelpLinkProps {
|
||||
text: string;
|
||||
linkText: string;
|
||||
href: string;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
export function HelpLink({ testId, text, linkText, href }: HelpLinkProps) {
|
||||
export function HelpLink({
|
||||
testId,
|
||||
text,
|
||||
linkText,
|
||||
href,
|
||||
suffix,
|
||||
}: HelpLinkProps) {
|
||||
return (
|
||||
<p data-testid={testId} className="text-xs">
|
||||
{text}{" "}
|
||||
@@ -17,6 +24,7 @@ export function HelpLink({ testId, text, linkText, href }: HelpLinkProps) {
|
||||
>
|
||||
{linkText}
|
||||
</a>
|
||||
{suffix && ` ${suffix}`}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "../brand-button";
|
||||
|
||||
interface ResetSettingsModalProps {
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function ResetSettingsModal({ onReset }: ResetSettingsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<div className="bg-base-secondary p-4 rounded-xl flex flex-col gap-4 border border-tertiary">
|
||||
<p>{t(I18nKey.SETTINGS$RESET_CONFIRMATION)}</p>
|
||||
<div className="w-full flex gap-2" data-testid="reset-settings-modal">
|
||||
<BrandButton
|
||||
testId="confirm-button"
|
||||
type="submit"
|
||||
name="reset-settings"
|
||||
variant="primary"
|
||||
className="grow"
|
||||
>
|
||||
Reset
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
testId="cancel-button"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="grow"
|
||||
onClick={onReset}
|
||||
>
|
||||
Cancel
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export function MCPConfigEditor({ mcpConfig, onChange }: MCPConfigEditorProps) {
|
||||
className="text-sm text-blue-400 hover:underline mr-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Documentation
|
||||
{t(I18nKey.COMMON$DOCUMENTATION)}
|
||||
</a>
|
||||
<BrandButton
|
||||
type="button"
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function OptionalTag() {
|
||||
return <span className="text-xs text-tertiary-alt">(Optional)</span>;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className="text-xs text-tertiary-alt">
|
||||
{t(I18nKey.COMMON$OPTIONAL)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useCreateSecret } from "#/hooks/mutation/use-create-secret";
|
||||
import { useUpdateSecret } from "#/hooks/mutation/use-update-secret";
|
||||
import { SettingsInput } from "../settings-input";
|
||||
@@ -151,7 +152,7 @@ export function SecretForm({
|
||||
|
||||
{mode === "add" && (
|
||||
<label className="flex flex-col gap-2.5 w-full max-w-[680px]">
|
||||
<span className="text-sm">Value</span>
|
||||
<span className="text-sm">{t(I18nKey.FORM$VALUE)}</span>
|
||||
<textarea
|
||||
data-testid="value-input"
|
||||
name="secret-value"
|
||||
@@ -168,7 +169,7 @@ export function SecretForm({
|
||||
|
||||
<label className="flex flex-col gap-2.5 w-full max-w-[680px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">Description</span>
|
||||
<span className="text-sm">{t(I18nKey.FORM$DESCRIPTION)}</span>
|
||||
<OptionalTag />
|
||||
</div>
|
||||
<input
|
||||
@@ -190,7 +191,7 @@ export function SecretForm({
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
{t(I18nKey.BUTTON$CANCEL)}
|
||||
</BrandButton>
|
||||
<BrandButton testId="submit-button" type="submit" variant="primary">
|
||||
{mode === "add" && t("SECRETS$ADD_SECRET")}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { StyledSwitchComponent } from "./styled-switch-component";
|
||||
|
||||
interface SettingsSwitchProps {
|
||||
@@ -19,6 +21,7 @@ export function SettingsSwitch({
|
||||
isToggled: controlledIsToggled,
|
||||
isBeta,
|
||||
}: React.PropsWithChildren<SettingsSwitchProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [isToggled, setIsToggled] = React.useState(defaultIsToggled ?? false);
|
||||
|
||||
const handleToggle = (value: boolean) => {
|
||||
@@ -35,7 +38,6 @@ export function SettingsSwitch({
|
||||
type="checkbox"
|
||||
onChange={(e) => handleToggle(e.target.checked)}
|
||||
checked={controlledIsToggled ?? isToggled}
|
||||
defaultChecked={defaultIsToggled}
|
||||
/>
|
||||
|
||||
<StyledSwitchComponent isToggled={controlledIsToggled ?? isToggled} />
|
||||
@@ -44,7 +46,7 @@ export function SettingsSwitch({
|
||||
<span className="text-sm">{children}</span>
|
||||
{isBeta && (
|
||||
<span className="text-[11px] leading-4 text-[#0D0F11] font-[500] tracking-tighter bg-primary px-1 rounded-full">
|
||||
Beta
|
||||
{t(I18nKey.BADGE$BETA)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { ModalBackdrop } from "./modal-backdrop";
|
||||
|
||||
@@ -12,6 +14,7 @@ export function ConfirmationModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmationModalProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ModalBackdrop onClose={onCancel}>
|
||||
<div
|
||||
@@ -27,7 +30,7 @@ export function ConfirmationModal({
|
||||
variant="secondary"
|
||||
className="grow"
|
||||
>
|
||||
Cancel
|
||||
{t(I18nKey.BUTTON$CANCEL)}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
testId="confirm-button"
|
||||
@@ -36,7 +39,7 @@ export function ConfirmationModal({
|
||||
variant="primary"
|
||||
className="grow"
|
||||
>
|
||||
Confirm
|
||||
{t(I18nKey.BUTTON$CONFIRM)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,26 +97,30 @@ export function ModelSelector({
|
||||
}}
|
||||
>
|
||||
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$VERIFIED)}>
|
||||
{Object.keys(models)
|
||||
.filter((provider) => VERIFIED_PROVIDERS.includes(provider))
|
||||
.map((provider) => (
|
||||
{VERIFIED_PROVIDERS.filter((provider) => models[provider]).map(
|
||||
(provider) => (
|
||||
<AutocompleteItem
|
||||
data-testid={`provider-item-${provider}`}
|
||||
key={provider}
|
||||
>
|
||||
{mapProvider(provider)}
|
||||
</AutocompleteItem>
|
||||
))}
|
||||
</AutocompleteSection>
|
||||
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$OTHERS)}>
|
||||
{Object.keys(models)
|
||||
.filter((provider) => !VERIFIED_PROVIDERS.includes(provider))
|
||||
.map((provider) => (
|
||||
<AutocompleteItem key={provider}>
|
||||
{mapProvider(provider)}
|
||||
</AutocompleteItem>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</AutocompleteSection>
|
||||
{Object.keys(models).some(
|
||||
(provider) => !VERIFIED_PROVIDERS.includes(provider),
|
||||
) ? (
|
||||
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$OTHERS)}>
|
||||
{Object.keys(models)
|
||||
.filter((provider) => !VERIFIED_PROVIDERS.includes(provider))
|
||||
.map((provider) => (
|
||||
<AutocompleteItem key={provider}>
|
||||
{mapProvider(provider)}
|
||||
</AutocompleteItem>
|
||||
))}
|
||||
</AutocompleteSection>
|
||||
) : null}
|
||||
</Autocomplete>
|
||||
</fieldset>
|
||||
|
||||
@@ -147,24 +151,28 @@ export function ModelSelector({
|
||||
}}
|
||||
>
|
||||
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$VERIFIED)}>
|
||||
{models[selectedProvider || ""]?.models
|
||||
.filter((model) => VERIFIED_MODELS.includes(model))
|
||||
.map((model) => (
|
||||
<AutocompleteItem key={model}>{model}</AutocompleteItem>
|
||||
))}
|
||||
</AutocompleteSection>
|
||||
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$OTHERS)}>
|
||||
{models[selectedProvider || ""]?.models
|
||||
.filter((model) => !VERIFIED_MODELS.includes(model))
|
||||
.map((model) => (
|
||||
<AutocompleteItem
|
||||
data-testid={`model-item-${model}`}
|
||||
key={model}
|
||||
>
|
||||
{model}
|
||||
</AutocompleteItem>
|
||||
))}
|
||||
{VERIFIED_MODELS.filter((model) =>
|
||||
models[selectedProvider || ""]?.models?.includes(model),
|
||||
).map((model) => (
|
||||
<AutocompleteItem key={model}>{model}</AutocompleteItem>
|
||||
))}
|
||||
</AutocompleteSection>
|
||||
{models[selectedProvider || ""]?.models?.some(
|
||||
(model) => !VERIFIED_MODELS.includes(model),
|
||||
) ? (
|
||||
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$OTHERS)}>
|
||||
{models[selectedProvider || ""]?.models
|
||||
.filter((model) => !VERIFIED_MODELS.includes(model))
|
||||
.map((model) => (
|
||||
<AutocompleteItem
|
||||
data-testid={`model-item-${model}`}
|
||||
key={model}
|
||||
>
|
||||
{model}
|
||||
</AutocompleteItem>
|
||||
))}
|
||||
</AutocompleteSection>
|
||||
) : null}
|
||||
</Autocomplete>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
import { useConfig } from "./use-config";
|
||||
|
||||
export const LLM_API_KEY_QUERY_KEY = "llm-api-key";
|
||||
|
||||
export interface LlmApiKeyResponse {
|
||||
key: string | null;
|
||||
}
|
||||
|
||||
export function useLlmApiKey() {
|
||||
const { data: config } = useConfig();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [LLM_API_KEY_QUERY_KEY],
|
||||
enabled: config?.APP_MODE === "saas",
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const { data } =
|
||||
await openHands.get<LlmApiKeyResponse>("/api/keys/llm/byor");
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { key: null };
|
||||
}
|
||||
},
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
}
|
||||
|
||||
export function useRefreshLlmApiKey() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await openHands.post<LlmApiKeyResponse>(
|
||||
"/api/keys/llm/byor/refresh",
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate the LLM API key query to trigger a refetch
|
||||
queryClient.invalidateQueries({ queryKey: [LLM_API_KEY_QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -368,6 +368,9 @@ export enum I18nKey {
|
||||
SETTINGS$LANGUAGE_TOOLTIP = "SETTINGS$LANGUAGE_TOOLTIP",
|
||||
SETTINGS$DISABLED_RUNNING = "SETTINGS$DISABLED_RUNNING",
|
||||
SETTINGS$API_KEY_PLACEHOLDER = "SETTINGS$API_KEY_PLACEHOLDER",
|
||||
SETTINGS$LLM_API_KEY = "SETTINGS$LLM_API_KEY",
|
||||
SETTINGS$LLM_API_KEY_DESCRIPTION = "SETTINGS$LLM_API_KEY_DESCRIPTION",
|
||||
SETTINGS$REFRESH_LLM_API_KEY = "SETTINGS$REFRESH_LLM_API_KEY",
|
||||
SETTINGS$CONFIRMATION_MODE = "SETTINGS$CONFIRMATION_MODE",
|
||||
SETTINGS$CONFIRMATION_MODE_TOOLTIP = "SETTINGS$CONFIRMATION_MODE_TOOLTIP",
|
||||
SETTINGS$AGENT_SELECT_ENABLED = "SETTINGS$AGENT_SELECT_ENABLED",
|
||||
@@ -380,6 +383,9 @@ export enum I18nKey {
|
||||
SETTINGS$RESET = "SETTINGS$RESET",
|
||||
SETTINGS$API_KEYS = "SETTINGS$API_KEYS",
|
||||
SETTINGS$API_KEYS_DESCRIPTION = "SETTINGS$API_KEYS_DESCRIPTION",
|
||||
SETTINGS$OPENHANDS_API_KEY_HELP = "SETTINGS$OPENHANDS_API_KEY_HELP",
|
||||
SETTINGS$OPENHANDS_API_KEY_HELP_TEXT = "SETTINGS$OPENHANDS_API_KEY_HELP_TEXT",
|
||||
SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX = "SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX",
|
||||
SETTINGS$CREATE_API_KEY = "SETTINGS$CREATE_API_KEY",
|
||||
SETTINGS$CREATE_API_KEY_DESCRIPTION = "SETTINGS$CREATE_API_KEY_DESCRIPTION",
|
||||
SETTINGS$DELETE_API_KEY = "SETTINGS$DELETE_API_KEY",
|
||||
@@ -394,6 +400,7 @@ export enum I18nKey {
|
||||
SETTINGS$API_KEY_DELETED = "SETTINGS$API_KEY_DELETED",
|
||||
SETTINGS$API_KEY_WARNING = "SETTINGS$API_KEY_WARNING",
|
||||
SETTINGS$API_KEY_COPIED = "SETTINGS$API_KEY_COPIED",
|
||||
SETTINGS$API_KEY_REFRESHED = "SETTINGS$API_KEY_REFRESHED",
|
||||
SETTINGS$API_KEY_NAME_PLACEHOLDER = "SETTINGS$API_KEY_NAME_PLACEHOLDER",
|
||||
BUTTON$CREATE = "BUTTON$CREATE",
|
||||
BUTTON$DELETE = "BUTTON$DELETE",
|
||||
@@ -663,4 +670,15 @@ export enum I18nKey {
|
||||
API$TAVILY_KEY_EXAMPLE = "API$TAVILY_KEY_EXAMPLE",
|
||||
API$TVLY_KEY_EXAMPLE = "API$TVLY_KEY_EXAMPLE",
|
||||
SECRETS$CONNECT_GIT_PROVIDER = "SECRETS$CONNECT_GIT_PROVIDER",
|
||||
SETTINGS$OPENHANDS_API_KEYS = "SETTINGS$OPENHANDS_API_KEYS",
|
||||
CONVERSATION$BUDGET_USAGE_FORMAT = "CONVERSATION$BUDGET_USAGE_FORMAT",
|
||||
CONVERSATION$CACHE_HIT = "CONVERSATION$CACHE_HIT",
|
||||
CONVERSATION$CACHE_WRITE = "CONVERSATION$CACHE_WRITE",
|
||||
FEEDBACK$STAR_RATING = "FEEDBACK$STAR_RATING",
|
||||
BUTTON$CONFIRM = "BUTTON$CONFIRM",
|
||||
FORM$VALUE = "FORM$VALUE",
|
||||
FORM$DESCRIPTION = "FORM$DESCRIPTION",
|
||||
COMMON$OPTIONAL = "COMMON$OPTIONAL",
|
||||
BROWSER$SERVER_MESSAGE = "BROWSER$SERVER_MESSAGE",
|
||||
API$NO_KEY_AVAILABLE = "API$NO_KEY_AVAILABLE",
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"de": "Kein Repository gefunden, um Microagent zu starten",
|
||||
"uk": "Не знайдено репозиторій для запуску мікроагента"
|
||||
},
|
||||
|
||||
"MICROAGENT$ADD_TO_MICROAGENT": {
|
||||
"en": "Add to Microagent",
|
||||
"ja": "マイクロエージェントに追加",
|
||||
@@ -4384,7 +4383,7 @@
|
||||
"ja": "設定を更新しました",
|
||||
"uk": "Налаштування оновлено"
|
||||
},
|
||||
"CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE":{
|
||||
"CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE": {
|
||||
"en": "NEW FILES ADDED",
|
||||
"de": "NEUE DATEIEN HINZUGEFÜGT",
|
||||
"zh-CN": "已添加新文件",
|
||||
@@ -4398,8 +4397,8 @@
|
||||
"fr": "NOUVEAUX FICHIERS AJOUTÉS",
|
||||
"tr": "YENİ DOSYALAR EKLENDİ",
|
||||
"ja": "新しいファイルが追加されました",
|
||||
"uk": "ДОДАНО НОВІ ФАЙЛИ"
|
||||
},
|
||||
"uk": "ДОДАНО НОВІ ФАЙЛИ"
|
||||
},
|
||||
"CHAT_INTERFACE$DISCONNECTED": {
|
||||
"en": "Disconnected",
|
||||
"ja": "切断されました",
|
||||
@@ -5888,6 +5887,54 @@
|
||||
"ja": "APIキーを入力",
|
||||
"uk": "Введіть свій ключ API."
|
||||
},
|
||||
"SETTINGS$LLM_API_KEY": {
|
||||
"en": "LLM API Key",
|
||||
"zh-CN": "LLM API 密钥",
|
||||
"zh-TW": "LLM API 金鑰",
|
||||
"de": "LLM API Schlüssel",
|
||||
"ko-KR": "LLM API 키",
|
||||
"no": "LLM API-nøkkel",
|
||||
"it": "Chiave API LLM",
|
||||
"pt": "Chave API LLM",
|
||||
"es": "Clave API LLM",
|
||||
"ar": "مفتاح API للنماذج اللغوية الكبيرة",
|
||||
"fr": "Clé API LLM",
|
||||
"tr": "LLM API Anahtarı",
|
||||
"ja": "LLM APIキー",
|
||||
"uk": "Ключ API LLM"
|
||||
},
|
||||
"SETTINGS$LLM_API_KEY_DESCRIPTION": {
|
||||
"en": "You can use this API Key as the LLM API Key for OpenHands open-source and CLI. It will incur cost on your OpenHands Cloud account. Do NOT share this key elsewhere.",
|
||||
"zh-CN": "您可以将此 API 密钥用作 OpenHands 开源和 CLI 的 LLM API 密钥。它将在您的 OpenHands Cloud 账户上产生费用。请勿在其他地方共享此密钥。",
|
||||
"zh-TW": "您可以將此 API 金鑰用作 OpenHands 開源和 CLI 的 LLM API 金鑰。它將在您的 OpenHands Cloud 帳戶上產生費用。請勿在其他地方共享此金鑰。",
|
||||
"de": "Sie können diesen API-Schlüssel als LLM-API-Schlüssel für OpenHands Open-Source und CLI verwenden. Es fallen Kosten für Ihr OpenHands Cloud-Konto an. Teilen Sie diesen Schlüssel NICHT anderswo.",
|
||||
"ko-KR": "이 API 키를 OpenHands 오픈소스 및 CLI용 LLM API 키로 사용할 수 있습니다. OpenHands Cloud 계정에 비용이 청구됩니다. 이 키를 다른 곳에서 공유하지 마세요.",
|
||||
"no": "Du kan bruke denne API-nøkkelen som LLM API-nøkkel for OpenHands åpen kildekode og CLI. Det vil påløpe kostnader på din OpenHands Cloud-konto. IKKE del denne nøkkelen andre steder.",
|
||||
"it": "Puoi utilizzare questa chiave API come chiave API LLM per OpenHands open-source e CLI. Comporterà costi sul tuo account OpenHands Cloud. NON condividere questa chiave altrove.",
|
||||
"pt": "Você pode usar esta Chave API como a Chave API LLM para OpenHands de código aberto e CLI. Isso incorrerá em custos na sua conta OpenHands Cloud. NÃO compartilhe esta chave em outros lugares.",
|
||||
"es": "Puede usar esta Clave API como la Clave API LLM para OpenHands de código abierto y CLI. Incurrirá en costos en su cuenta de OpenHands Cloud. NO comparta esta clave en ningún otro lugar.",
|
||||
"ar": "يمكنك استخدام مفتاح API هذا كمفتاح API للنماذج اللغوية الكبيرة لـ OpenHands مفتوح المصدر وواجهة سطر الأوامر. سيتكبد تكلفة على حساب OpenHands Cloud الخاص بك. لا تشارك هذا المفتاح في أي مكان آخر.",
|
||||
"fr": "Vous pouvez utiliser cette clé API comme clé API LLM pour OpenHands open-source et CLI. Cela entraînera des coûts sur votre compte OpenHands Cloud. NE partagez PAS cette clé ailleurs.",
|
||||
"tr": "Bu API Anahtarını, OpenHands açık kaynak ve CLI için LLM API Anahtarı olarak kullanabilirsiniz. OpenHands Cloud hesabınızda maliyet oluşturacaktır. Bu anahtarı başka yerlerde paylaşmayın.",
|
||||
"ja": "このAPIキーをOpenHandsオープンソースおよびCLIのLLM APIキーとして使用できます。OpenHands Cloudアカウントに費用が発生します。このキーを他の場所で共有しないでください。",
|
||||
"uk": "Ви можете використовувати цей ключ API як ключ API LLM для OpenHands з відкритим кодом та CLI. Це призведе до витрат на вашому обліковому записі OpenHands Cloud. НЕ діліться цим ключем деінде."
|
||||
},
|
||||
"SETTINGS$REFRESH_LLM_API_KEY": {
|
||||
"en": "Refresh API Key",
|
||||
"zh-CN": "刷新 API 密钥",
|
||||
"zh-TW": "刷新 API 金鑰",
|
||||
"de": "API-Schlüssel aktualisieren",
|
||||
"ko-KR": "API 키 새로고침",
|
||||
"no": "Oppdater API-nøkkel",
|
||||
"it": "Aggiorna chiave API",
|
||||
"pt": "Atualizar chave API",
|
||||
"es": "Actualizar clave API",
|
||||
"ar": "تحديث مفتاح API",
|
||||
"fr": "Actualiser la clé API",
|
||||
"tr": "API Anahtarını Yenile",
|
||||
"ja": "APIキーを更新",
|
||||
"uk": "Оновити ключ API"
|
||||
},
|
||||
"SETTINGS$CONFIRMATION_MODE": {
|
||||
"en": "Enable Confirmation Mode",
|
||||
"de": "Bestätigungsmodus aktivieren",
|
||||
@@ -5952,7 +5999,7 @@
|
||||
"ja": "セキュリティアナライザー",
|
||||
"uk": "Увімкнути аналізатор безпеки"
|
||||
},
|
||||
"SETTINGS$SECURITY_ANALYZER_PLACEHOLDER":{
|
||||
"SETTINGS$SECURITY_ANALYZER_PLACEHOLDER": {
|
||||
"en": "Select a security analyzer…",
|
||||
"de": "Wählen Sie einen Sicherheitsanalysator aus…",
|
||||
"zh-CN": "选择一个安全分析器…",
|
||||
@@ -6080,6 +6127,54 @@
|
||||
"de": "API-Schlüssel ermöglichen es Ihnen, sich programmatisch bei der OpenHands-API zu authentifizieren. Halten Sie Ihre API-Schlüssel sicher; jeder mit Ihrem API-Schlüssel kann auf Ihr Konto zugreifen. Weitere Informationen zur Verwendung der API finden Sie in unserer <a>API-Dokumentation</a>.",
|
||||
"uk": "Ключі API дозволяють вам програмно автентифікуватися за допомогою API OpenHands. Зберігайте свої ключі API в безпеці; будь-хто, хто має ваш ключ API, може отримати доступ до вашого облікового запису. Для отримання додаткової інформації про використання API, перегляньте нашу <a>документацію API</a>."
|
||||
},
|
||||
"SETTINGS$OPENHANDS_API_KEY_HELP": {
|
||||
"en": "You can find your OpenHands API Key in the <a>API Keys</a> tab of OpenHands Cloud.",
|
||||
"ja": "OpenHands APIキーはOpenHands Cloudの<a>APIキー</a>タブで確認できます。",
|
||||
"zh-CN": "您可以在OpenHands Cloud的<a>API密钥</a>标签页中找到您的OpenHands API密钥。",
|
||||
"zh-TW": "您可以在OpenHands Cloud的<a>API密鑰</a>標籤頁中找到您的OpenHands API密鑰。",
|
||||
"ko-KR": "OpenHands API 키는 OpenHands Cloud의 <a>API 키</a> 탭에서 찾을 수 있습니다.",
|
||||
"no": "Du kan finne din OpenHands API-nøkkel i <a>API-nøkler</a>-fanen i OpenHands Cloud.",
|
||||
"it": "Puoi trovare la tua chiave API OpenHands nella scheda <a>Chiavi API</a> di OpenHands Cloud.",
|
||||
"pt": "Você pode encontrar sua chave de API OpenHands na guia <a>Chaves de API</a> do OpenHands Cloud.",
|
||||
"es": "Puede encontrar su clave API de OpenHands en la pestaña <a>Claves API</a> de OpenHands Cloud.",
|
||||
"ar": "يمكنك العثور على مفتاح API الخاص بـ OpenHands في علامة التبويب <a>مفاتيح API</a> في OpenHands Cloud.",
|
||||
"fr": "Vous pouvez trouver votre clé API OpenHands dans l'onglet <a>Clés API</a> d'OpenHands Cloud.",
|
||||
"tr": "OpenHands API Anahtarınızı OpenHands Cloud'un <a>API Anahtarları</a> sekmesinde bulabilirsiniz.",
|
||||
"de": "Sie finden Ihren OpenHands API-Schlüssel im Tab <a>API-Schlüssel</a> von OpenHands Cloud.",
|
||||
"uk": "Ви можете знайти свій ключ API OpenHands у вкладці <a>Ключі API</a> OpenHands Cloud."
|
||||
},
|
||||
"SETTINGS$OPENHANDS_API_KEY_HELP_TEXT": {
|
||||
"en": "You can find your OpenHands API Key in the",
|
||||
"ja": "OpenHands APIキーは",
|
||||
"zh-CN": "您可以在",
|
||||
"zh-TW": "您可以在",
|
||||
"ko-KR": "OpenHands API 키는",
|
||||
"no": "Du kan finne din OpenHands API-nøkkel i",
|
||||
"it": "Puoi trovare la tua chiave API OpenHands nella",
|
||||
"pt": "Você pode encontrar sua chave de API OpenHands na",
|
||||
"es": "Puede encontrar su clave API de OpenHands en la",
|
||||
"ar": "يمكنك العثور على مفتاح API الخاص بـ OpenHands في",
|
||||
"fr": "Vous pouvez trouver votre clé API OpenHands dans",
|
||||
"tr": "OpenHands API Anahtarınızı",
|
||||
"de": "Sie finden Ihren OpenHands API-Schlüssel im",
|
||||
"uk": "Ви можете знайти свій ключ API OpenHands у"
|
||||
},
|
||||
"SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX": {
|
||||
"en": "tab of OpenHands Cloud.",
|
||||
"ja": "タブで確認できます。",
|
||||
"zh-CN": "标签页中找到您的OpenHands API密钥。",
|
||||
"zh-TW": "標籤頁中找到您的OpenHands API密鑰。",
|
||||
"ko-KR": "탭에서 찾을 수 있습니다.",
|
||||
"no": "-fanen i OpenHands Cloud.",
|
||||
"it": "scheda di OpenHands Cloud.",
|
||||
"pt": "guia do OpenHands Cloud.",
|
||||
"es": "pestaña de OpenHands Cloud.",
|
||||
"ar": "علامة التبويب في OpenHands Cloud.",
|
||||
"fr": "l'onglet d'OpenHands Cloud.",
|
||||
"tr": "OpenHands Cloud'un sekmesinde bulabilirsiniz.",
|
||||
"de": "Tab von OpenHands Cloud.",
|
||||
"uk": "вкладці OpenHands Cloud."
|
||||
},
|
||||
"SETTINGS$CREATE_API_KEY": {
|
||||
"en": "Create API Key",
|
||||
"uk": "Створити API ключ",
|
||||
@@ -6304,6 +6399,22 @@
|
||||
"es": "Clave API copiada al portapapeles",
|
||||
"tr": "API anahtarı panoya kopyalandı"
|
||||
},
|
||||
"SETTINGS$API_KEY_REFRESHED": {
|
||||
"en": "API key refreshed successfully",
|
||||
"uk": "Ключ API успішно оновлено",
|
||||
"ja": "APIキーが正常に更新されました",
|
||||
"zh-CN": "API密钥已成功刷新",
|
||||
"zh-TW": "API金鑰已成功刷新",
|
||||
"ko-KR": "API 키가 성공적으로 새로고침되었습니다",
|
||||
"no": "API-nøkkel oppdatert",
|
||||
"ar": "تم تحديث مفتاح API بنجاح",
|
||||
"de": "API-Schlüssel erfolgreich aktualisiert",
|
||||
"fr": "Clé API actualisée avec succès",
|
||||
"it": "Chiave API aggiornata con successo",
|
||||
"pt": "Chave API atualizada com sucesso",
|
||||
"es": "Clave API actualizada con éxito",
|
||||
"tr": "API anahtarı başarıyla yenilendi"
|
||||
},
|
||||
"SETTINGS$API_KEY_NAME_PLACEHOLDER": {
|
||||
"en": "My API Key",
|
||||
"uk": "Мій ключ API",
|
||||
@@ -10607,5 +10718,181 @@
|
||||
"tr": "Gizli anahtarları yönetmek için bir Git sağlayıcısına bağlan",
|
||||
"de": "Git-Anbieter verbinden, um Geheimnisse zu verwalten",
|
||||
"uk": "Підключити провайдера Git для управління секретами"
|
||||
},
|
||||
"SETTINGS$OPENHANDS_API_KEYS": {
|
||||
"en": "OpenHands API Keys",
|
||||
"ja": "OpenHands APIキー",
|
||||
"zh-CN": "OpenHands API密钥",
|
||||
"zh-TW": "OpenHands API密鑰",
|
||||
"ko-KR": "OpenHands API 키",
|
||||
"no": "OpenHands API-nøkler",
|
||||
"it": "Chiavi API OpenHands",
|
||||
"pt": "Chaves de API OpenHands",
|
||||
"es": "Claves API de OpenHands",
|
||||
"ar": "مفاتيح API لـ OpenHands",
|
||||
"fr": "Clés API OpenHands",
|
||||
"tr": "OpenHands API Anahtarları",
|
||||
"de": "OpenHands API-Schlüssel",
|
||||
"uk": "API-ключі OpenHands"
|
||||
},
|
||||
"CONVERSATION$BUDGET_USAGE_FORMAT": {
|
||||
"en": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"ja": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"zh-CN": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"zh-TW": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"ko-KR": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"no": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"it": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"pt": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"es": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"ar": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"fr": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"tr": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"de": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"uk": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})"
|
||||
},
|
||||
"CONVERSATION$CACHE_HIT": {
|
||||
"en": "Cache Hit",
|
||||
"ja": "キャッシュヒット",
|
||||
"zh-CN": "缓存命中",
|
||||
"zh-TW": "緩存命中",
|
||||
"ko-KR": "캐시 히트",
|
||||
"no": "Cache Treff",
|
||||
"it": "Cache Hit",
|
||||
"pt": "Cache Hit",
|
||||
"es": "Cache Hit",
|
||||
"ar": "إصابة الذاكرة المؤقتة",
|
||||
"fr": "Cache Hit",
|
||||
"tr": "Önbellek İsabeti",
|
||||
"de": "Cache-Treffer",
|
||||
"uk": "Кеш-хіт"
|
||||
},
|
||||
"CONVERSATION$CACHE_WRITE": {
|
||||
"en": "Cache Write",
|
||||
"ja": "キャッシュ書き込み",
|
||||
"zh-CN": "缓存写入",
|
||||
"zh-TW": "緩存寫入",
|
||||
"ko-KR": "캐시 쓰기",
|
||||
"no": "Cache Skriv",
|
||||
"it": "Scrittura Cache",
|
||||
"pt": "Escrita em Cache",
|
||||
"es": "Escritura en Caché",
|
||||
"ar": "كتابة الذاكرة المؤقتة",
|
||||
"fr": "Écriture Cache",
|
||||
"tr": "Önbellek Yazma",
|
||||
"de": "Cache-Schreiben",
|
||||
"uk": "Запис у кеш"
|
||||
},
|
||||
"FEEDBACK$STAR_RATING": {
|
||||
"en": "Star Rating",
|
||||
"ja": "星評価",
|
||||
"zh-CN": "星级评分",
|
||||
"zh-TW": "星級評分",
|
||||
"ko-KR": "별점",
|
||||
"no": "Stjerne Vurdering",
|
||||
"it": "Valutazione a Stelle",
|
||||
"pt": "Avaliação por Estrelas",
|
||||
"es": "Calificación por Estrellas",
|
||||
"ar": "تقييم النجوم",
|
||||
"fr": "Évaluation par Étoiles",
|
||||
"tr": "Yıldız Değerlendirmesi",
|
||||
"de": "Sternebewertung",
|
||||
"uk": "Зіркова оцінка"
|
||||
},
|
||||
"BUTTON$CONFIRM": {
|
||||
"en": "Confirm",
|
||||
"ja": "確認",
|
||||
"zh-CN": "确认",
|
||||
"zh-TW": "確認",
|
||||
"ko-KR": "확인",
|
||||
"no": "Bekreft",
|
||||
"it": "Conferma",
|
||||
"pt": "Confirmar",
|
||||
"es": "Confirmar",
|
||||
"ar": "تأكيد",
|
||||
"fr": "Confirmer",
|
||||
"tr": "Onayla",
|
||||
"de": "Bestätigen",
|
||||
"uk": "Підтвердити"
|
||||
},
|
||||
"FORM$VALUE": {
|
||||
"en": "Value",
|
||||
"ja": "値",
|
||||
"zh-CN": "值",
|
||||
"zh-TW": "值",
|
||||
"ko-KR": "값",
|
||||
"no": "Verdi",
|
||||
"it": "Valore",
|
||||
"pt": "Valor",
|
||||
"es": "Valor",
|
||||
"ar": "قيمة",
|
||||
"fr": "Valeur",
|
||||
"tr": "Değer",
|
||||
"de": "Wert",
|
||||
"uk": "Значення"
|
||||
},
|
||||
"FORM$DESCRIPTION": {
|
||||
"en": "Description",
|
||||
"ja": "説明",
|
||||
"zh-CN": "描述",
|
||||
"zh-TW": "描述",
|
||||
"ko-KR": "설명",
|
||||
"no": "Beskrivelse",
|
||||
"it": "Descrizione",
|
||||
"pt": "Descrição",
|
||||
"es": "Descripción",
|
||||
"ar": "وصف",
|
||||
"fr": "Description",
|
||||
"tr": "Açıklama",
|
||||
"de": "Beschreibung",
|
||||
"uk": "Опис"
|
||||
},
|
||||
"COMMON$OPTIONAL": {
|
||||
"en": "Optional",
|
||||
"ja": "任意",
|
||||
"zh-CN": "可选",
|
||||
"zh-TW": "可選",
|
||||
"ko-KR": "선택 사항",
|
||||
"no": "Valgfritt",
|
||||
"it": "Opzionale",
|
||||
"pt": "Opcional",
|
||||
"es": "Opcional",
|
||||
"ar": "اختياري",
|
||||
"fr": "Optionnel",
|
||||
"tr": "İsteğe Bağlı",
|
||||
"de": "Optional",
|
||||
"uk": "Необов'язково"
|
||||
},
|
||||
"BROWSER$SERVER_MESSAGE": {
|
||||
"en": "Server Message",
|
||||
"ja": "サーバーメッセージ",
|
||||
"zh-CN": "服务器消息",
|
||||
"zh-TW": "伺服器訊息",
|
||||
"ko-KR": "서버 메시지",
|
||||
"no": "Servermelding",
|
||||
"it": "Messaggio del Server",
|
||||
"pt": "Mensagem do Servidor",
|
||||
"es": "Mensaje del Servidor",
|
||||
"ar": "رسالة الخادم",
|
||||
"fr": "Message du Serveur",
|
||||
"tr": "Sunucu Mesajı",
|
||||
"de": "Server-Nachricht",
|
||||
"uk": "Повідомлення сервера"
|
||||
},
|
||||
"API$NO_KEY_AVAILABLE": {
|
||||
"en": "No API key available",
|
||||
"ja": "APIキーが利用できません",
|
||||
"zh-CN": "没有可用的API密钥",
|
||||
"zh-TW": "沒有可用的API密鑰",
|
||||
"ko-KR": "사용 가능한 API 키 없음",
|
||||
"no": "Ingen API-nøkkel tilgjengelig",
|
||||
"it": "Nessuna chiave API disponibile",
|
||||
"pt": "Nenhuma chave API disponível",
|
||||
"es": "No hay clave API disponible",
|
||||
"ar": "لا يوجد مفتاح API متاح",
|
||||
"fr": "Aucune clé API disponible",
|
||||
"tr": "Kullanılabilir API anahtarı yok",
|
||||
"de": "Kein API-Schlüssel verfügbar",
|
||||
"uk": "Немає доступного API-ключа"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,11 @@ function LlmSettingsScreen() {
|
||||
securityAnalyzer: false,
|
||||
});
|
||||
|
||||
// Track the currently selected model to show help text
|
||||
const [currentSelectedModel, setCurrentSelectedModel] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const modelsAndProviders = organizeModelsAndProviders(
|
||||
resources?.models || [],
|
||||
);
|
||||
@@ -74,6 +79,13 @@ function LlmSettingsScreen() {
|
||||
else setView("basic");
|
||||
}, [settings, resources]);
|
||||
|
||||
// Initialize currentSelectedModel with the current settings
|
||||
React.useEffect(() => {
|
||||
if (settings?.LLM_MODEL) {
|
||||
setCurrentSelectedModel(settings.LLM_MODEL);
|
||||
}
|
||||
}, [settings?.LLM_MODEL]);
|
||||
|
||||
const handleSuccessfulMutation = () => {
|
||||
displaySuccessToast(t(I18nKey.SETTINGS$SAVED_WARNING));
|
||||
setDirtyInputs({
|
||||
@@ -184,6 +196,9 @@ function LlmSettingsScreen() {
|
||||
...prev,
|
||||
model: modelIsDirty,
|
||||
}));
|
||||
|
||||
// Track the currently selected model for help text display
|
||||
setCurrentSelectedModel(model);
|
||||
};
|
||||
|
||||
const handleApiKeyIsDirty = (apiKey: string) => {
|
||||
@@ -208,6 +223,9 @@ function LlmSettingsScreen() {
|
||||
...prev,
|
||||
model: modelIsDirty,
|
||||
}));
|
||||
|
||||
// Track the currently selected model for help text display
|
||||
setCurrentSelectedModel(model);
|
||||
};
|
||||
|
||||
const handleBaseUrlIsDirty = (baseUrl: string) => {
|
||||
@@ -279,13 +297,25 @@ function LlmSettingsScreen() {
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
{!isLoading && !isFetching && (
|
||||
<ModelSelector
|
||||
models={modelsAndProviders}
|
||||
currentModel={
|
||||
settings.LLM_MODEL || "anthropic/claude-sonnet-4-20250514"
|
||||
}
|
||||
onChange={handleModelIsDirty}
|
||||
/>
|
||||
<>
|
||||
<ModelSelector
|
||||
models={modelsAndProviders}
|
||||
currentModel={
|
||||
settings.LLM_MODEL || "openhands/claude-sonnet-4-20250514"
|
||||
}
|
||||
onChange={handleModelIsDirty}
|
||||
/>
|
||||
{(settings.LLM_MODEL?.startsWith("openhands/") ||
|
||||
currentSelectedModel?.startsWith("openhands/")) && (
|
||||
<HelpLink
|
||||
testId="openhands-api-key-help"
|
||||
text={t(I18nKey.SETTINGS$OPENHANDS_API_KEY_HELP_TEXT)}
|
||||
linkText={t(I18nKey.SETTINGS$NAV_API_KEYS)}
|
||||
href="https://app.all-hands.dev/settings/api-keys"
|
||||
suffix={t(I18nKey.SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<SettingsInput
|
||||
@@ -345,13 +375,23 @@ function LlmSettingsScreen() {
|
||||
name="llm-custom-model-input"
|
||||
label={t(I18nKey.SETTINGS$CUSTOM_MODEL)}
|
||||
defaultValue={
|
||||
settings.LLM_MODEL || "anthropic/claude-sonnet-4-20250514"
|
||||
settings.LLM_MODEL || "openhands/claude-sonnet-4-20250514"
|
||||
}
|
||||
placeholder="anthropic/claude-sonnet-4-20250514"
|
||||
placeholder="openhands/claude-sonnet-4-20250514"
|
||||
type="text"
|
||||
className="w-full max-w-[680px]"
|
||||
onChange={handleCustomModelIsDirty}
|
||||
/>
|
||||
{(settings.LLM_MODEL?.startsWith("openhands/") ||
|
||||
currentSelectedModel?.startsWith("openhands/")) && (
|
||||
<HelpLink
|
||||
testId="openhands-api-key-help-2"
|
||||
text={t(I18nKey.SETTINGS$OPENHANDS_API_KEY_HELP_TEXT)}
|
||||
linkText={t(I18nKey.SETTINGS$NAV_API_KEYS)}
|
||||
href="https://app.all-hands.dev/settings/api-keys"
|
||||
suffix={t(I18nKey.SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsInput
|
||||
testId="base-url-input"
|
||||
|
||||
@@ -50,7 +50,7 @@ function ServedApp() {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full p-10">
|
||||
<span className="text-neutral-400 font-bold">
|
||||
If you tell OpenHands to start a web server, the app will appear here.
|
||||
{t(I18nKey.BROWSER$SERVER_MESSAGE)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,55 +1,70 @@
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router";
|
||||
import { NavLink, Outlet, redirect } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React from "react";
|
||||
import SettingsIcon from "#/icons/settings.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Route } from "./+types/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
|
||||
function SettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const { data: config } = useConfig();
|
||||
const SAAS_ONLY_PATHS = [
|
||||
"/settings/user",
|
||||
"/settings/billing",
|
||||
"/settings/credits",
|
||||
"/settings/api-keys",
|
||||
];
|
||||
|
||||
const SAAS_NAV_ITEMS = [
|
||||
{ to: "/settings/user", text: "SETTINGS$NAV_USER" },
|
||||
{ to: "/settings/integrations", text: "SETTINGS$NAV_INTEGRATIONS" },
|
||||
{ to: "/settings/app", text: "SETTINGS$NAV_APPLICATION" },
|
||||
{ to: "/settings/billing", text: "SETTINGS$NAV_CREDITS" },
|
||||
{ to: "/settings/secrets", text: "SETTINGS$NAV_SECRETS" },
|
||||
{ to: "/settings/api-keys", text: "SETTINGS$NAV_API_KEYS" },
|
||||
];
|
||||
|
||||
const OSS_NAV_ITEMS = [
|
||||
{ to: "/settings", text: "SETTINGS$NAV_LLM" },
|
||||
{ to: "/settings/mcp", text: "SETTINGS$NAV_MCP" },
|
||||
{ to: "/settings/integrations", text: "SETTINGS$NAV_INTEGRATIONS" },
|
||||
{ to: "/settings/app", text: "SETTINGS$NAV_APPLICATION" },
|
||||
{ to: "/settings/secrets", text: "SETTINGS$NAV_SECRETS" },
|
||||
];
|
||||
|
||||
export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
|
||||
const url = new URL(request.url);
|
||||
const { pathname } = url;
|
||||
|
||||
let config = queryClient.getQueryData<GetConfigResponse>(["config"]);
|
||||
if (!config) {
|
||||
config = await OpenHands.getConfig();
|
||||
queryClient.setQueryData<GetConfigResponse>(["config"], config);
|
||||
}
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
|
||||
const saasNavItems = [
|
||||
{ to: "/settings/user", text: t("SETTINGS$NAV_USER") },
|
||||
{ to: "/settings/integrations", text: t("SETTINGS$NAV_INTEGRATIONS") },
|
||||
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
|
||||
{ to: "/settings/billing", text: t("SETTINGS$NAV_CREDITS") },
|
||||
{ to: "/settings/secrets", text: t("SETTINGS$NAV_SECRETS") },
|
||||
{ to: "/settings/api-keys", text: t("SETTINGS$NAV_API_KEYS") },
|
||||
];
|
||||
if (isSaas && pathname === "/settings") {
|
||||
// no llm settings in saas mode, so redirect to user settings
|
||||
return redirect("/settings/user");
|
||||
}
|
||||
|
||||
const ossNavItems = [
|
||||
{ to: "/settings", text: t("SETTINGS$NAV_LLM") },
|
||||
{ to: "/settings/mcp", text: t("SETTINGS$NAV_MCP") },
|
||||
{ to: "/settings/integrations", text: t("SETTINGS$NAV_INTEGRATIONS") },
|
||||
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
|
||||
{ to: "/settings/secrets", text: t("SETTINGS$NAV_SECRETS") },
|
||||
];
|
||||
if (!isSaas && SAAS_ONLY_PATHS.includes(pathname)) {
|
||||
// if in OSS mode, do not allow access to saas-only paths
|
||||
return redirect("/settings");
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSaas) {
|
||||
if (pathname === "/settings") {
|
||||
navigate("/settings/user");
|
||||
}
|
||||
} else {
|
||||
const noEnteringPaths = [
|
||||
"/settings/user",
|
||||
"/settings/billing",
|
||||
"/settings/credits",
|
||||
"/settings/api-keys",
|
||||
];
|
||||
if (noEnteringPaths.includes(pathname)) {
|
||||
navigate("/settings");
|
||||
}
|
||||
}
|
||||
}, [isSaas, pathname]);
|
||||
return null;
|
||||
};
|
||||
|
||||
const navItems = isSaas ? saasNavItems : ossNavItems;
|
||||
function SettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
// this is used to determine which settings are available in the UI
|
||||
const navItems = isSaas ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS;
|
||||
|
||||
return (
|
||||
<main
|
||||
@@ -77,7 +92,7 @@ function SettingsScreen() {
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="text-[#F9FBFE] text-sm">{text}</span>
|
||||
<span className="text-[#F9FBFE] text-sm">{t(text)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { execSync } from "child_process";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
describe("Localization Fix Tests", () => {
|
||||
it("should not find any unlocalized strings in the frontend code", () => {
|
||||
const scriptPath = path.join(
|
||||
__dirname,
|
||||
"../../scripts/check-unlocalized-strings.cjs",
|
||||
);
|
||||
|
||||
// Run the localization check script
|
||||
const result = execSync(`node ${scriptPath}`, {
|
||||
cwd: path.join(__dirname, "../.."),
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
// The script should output success message and exit with code 0
|
||||
expect(result).toContain(
|
||||
"✅ No unlocalized strings found in frontend code.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should properly detect user-facing attributes like placeholder, alt, and aria-label", () => {
|
||||
// This test verifies that our fix to include placeholder, alt, and aria-label
|
||||
// attributes in the localization check is working correctly by testing the regex patterns
|
||||
|
||||
const scriptPath = path.join(
|
||||
__dirname,
|
||||
"../../scripts/check-unlocalized-strings.cjs",
|
||||
);
|
||||
const scriptContent = fs.readFileSync(scriptPath, "utf8");
|
||||
|
||||
// Verify that these attributes are now being checked for localization
|
||||
// by ensuring they're not excluded from text extraction
|
||||
const nonTextAttributesMatch = scriptContent.match(
|
||||
/const NON_TEXT_ATTRIBUTES = \[(.*?)\]/s,
|
||||
);
|
||||
expect(nonTextAttributesMatch).toBeTruthy();
|
||||
|
||||
const nonTextAttributes = nonTextAttributesMatch![1];
|
||||
expect(nonTextAttributes).not.toContain('"placeholder"');
|
||||
expect(nonTextAttributes).not.toContain('"alt"');
|
||||
expect(nonTextAttributes).not.toContain('"aria-label"');
|
||||
|
||||
// Verify that the script contains the correct attributes that should be excluded
|
||||
expect(nonTextAttributes).toContain('"className"');
|
||||
expect(nonTextAttributes).toContain('"testId"');
|
||||
expect(nonTextAttributes).toContain('"href"');
|
||||
});
|
||||
|
||||
it("should not incorrectly flag CSS units as unlocalized strings", () => {
|
||||
// This test verifies that our fix to the CSS units regex pattern
|
||||
// prevents false positives like "Suggested Tasks" being flagged
|
||||
|
||||
const testStrings = [
|
||||
"Suggested Tasks",
|
||||
"No tasks available",
|
||||
"Select a branch",
|
||||
"Select a repo",
|
||||
"Custom Models",
|
||||
"API Keys",
|
||||
"Git Settings",
|
||||
];
|
||||
|
||||
// These strings should not be flagged as CSS units
|
||||
const cssUnitsPattern =
|
||||
/\b\d+(px|rem|em|vh|vw|vmin|vmax|ch|ex|fr|deg|rad|turn|grad|ms|s)$|^(px|rem|em|vh|vw|vmin|vmax|ch|ex|fr|deg|rad|turn|grad|ms|s)$/;
|
||||
|
||||
testStrings.forEach((str) => {
|
||||
expect(cssUnitsPattern.test(str)).toBe(false);
|
||||
});
|
||||
|
||||
// But actual CSS units should still be detected
|
||||
const actualCssUnits = ["10px", "2rem", "100vh", "px", "rem", "s"];
|
||||
actualCssUnits.forEach((unit) => {
|
||||
expect(cssUnitsPattern.test(unit)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { isNumber } from "./is-number";
|
||||
import {
|
||||
VERIFIED_ANTHROPIC_MODELS,
|
||||
VERIFIED_MISTRAL_MODELS,
|
||||
VERIFIED_OPENAI_MODELS,
|
||||
VERIFIED_OPENHANDS_MODELS,
|
||||
} from "./verified-models";
|
||||
|
||||
/**
|
||||
@@ -47,6 +49,12 @@ export const extractModelAndProvider = (model: string) => {
|
||||
if (VERIFIED_ANTHROPIC_MODELS.includes(split[0])) {
|
||||
return { provider: "anthropic", model: split[0], separator: "/" };
|
||||
}
|
||||
if (VERIFIED_MISTRAL_MODELS.includes(split[0])) {
|
||||
return { provider: "mistral", model: split[0], separator: "/" };
|
||||
}
|
||||
if (VERIFIED_OPENHANDS_MODELS.includes(split[0])) {
|
||||
return { provider: "openhands", model: split[0], separator: "/" };
|
||||
}
|
||||
// return as model only
|
||||
return { provider: "", model, separator: "" };
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export const MAP_PROVIDER = {
|
||||
replicate: "Replicate",
|
||||
voyage: "Voyage AI",
|
||||
openrouter: "OpenRouter",
|
||||
openhands: "OpenHands",
|
||||
};
|
||||
|
||||
export const mapProvider = (provider: string) =>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// Here are the list of verified models and providers that we know work well with OpenHands.
|
||||
export const VERIFIED_PROVIDERS = ["openai", "azure", "anthropic", "deepseek"];
|
||||
export const VERIFIED_PROVIDERS = [
|
||||
"openhands",
|
||||
"anthropic",
|
||||
"openai",
|
||||
"mistral",
|
||||
];
|
||||
export const VERIFIED_MODELS = [
|
||||
"o3-mini-2025-01-31",
|
||||
"o3-2025-04-16",
|
||||
@@ -8,7 +13,10 @@ export const VERIFIED_MODELS = [
|
||||
"claude-3-7-sonnet-20250219",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-20250514",
|
||||
"gemini-2.5-pro",
|
||||
"o4-mini",
|
||||
"deepseek-chat",
|
||||
"devstral-small-2505",
|
||||
];
|
||||
|
||||
// LiteLLM does not return OpenAI models with the provider, so we list them here to set them ourselves for consistency
|
||||
@@ -16,31 +24,36 @@ export const VERIFIED_MODELS = [
|
||||
export const VERIFIED_OPENAI_MODELS = [
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4-turbo",
|
||||
"gpt-4",
|
||||
"gpt-4-32k",
|
||||
"o1-mini",
|
||||
"o1",
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-2025-04-14",
|
||||
"o3",
|
||||
"o3-2025-04-16",
|
||||
"o3-mini",
|
||||
"o3-mini-2025-01-31",
|
||||
"o4-mini",
|
||||
"o4-mini-2025-04-16",
|
||||
"codex-mini-latest",
|
||||
];
|
||||
|
||||
// LiteLLM does not return the compatible Anthropic models with the provider, so we list them here to set them ourselves
|
||||
// (e.g., they return `claude-3-5-sonnet-20241022` instead of `anthropic/claude-3-5-sonnet-20241022`)
|
||||
export const VERIFIED_ANTHROPIC_MODELS = [
|
||||
"claude-2",
|
||||
"claude-2.1",
|
||||
"claude-3-5-sonnet-20240620",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"claude-3-5-haiku-20241022",
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-3-opus-20240229",
|
||||
"claude-3-sonnet-20240229",
|
||||
"claude-3-7-sonnet-20250219",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-20250514",
|
||||
];
|
||||
|
||||
// LiteLLM does not return the compatible Mistral models with the provider, so we list them here to set them ourselves
|
||||
// (e.g., they return `devstral-small-2505` instead of `mistral/devstral-small-2505`)
|
||||
export const VERIFIED_MISTRAL_MODELS = ["devstral-small-2505"];
|
||||
|
||||
// LiteLLM does not return the compatible OpenHands models with the provider, so we list them here to set them ourselves
|
||||
// (e.g., they return `claude-sonnet-4-20250514` instead of `openhands/claude-sonnet-4-20250514`)
|
||||
export const VERIFIED_OPENHANDS_MODELS = [
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-20250514",
|
||||
"gemini-2.5-pro",
|
||||
"o4-mini",
|
||||
"devstral-small-2505",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Spinner",
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const DeterminateSpinner = () => {
|
||||
const [percentage, setPercentage] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => setPercentage(Math.min(100, percentage + 30)), 600);
|
||||
}, [percentage]);
|
||||
return <Spinner determinate value={percentage} />;
|
||||
};
|
||||
|
||||
export const Determinate: Story = {
|
||||
render: () => <DeterminateSpinner />,
|
||||
};
|
||||
|
||||
export const Indeterminate: Story = {
|
||||
render: () => <Spinner />,
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useMemo } from "react";
|
||||
import type { HTMLProps } from "../../shared/types";
|
||||
import { cn } from "../../shared/utils/cn";
|
||||
import "./index.css";
|
||||
|
||||
type BaseSpinnerProps = HTMLProps<"svg">;
|
||||
|
||||
export type DeterminateSpinnerProps = BaseSpinnerProps & {
|
||||
determinate: true;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type IndeterminateSpinnerProps = BaseSpinnerProps & {
|
||||
determinate?: false | null | undefined;
|
||||
value?: never;
|
||||
};
|
||||
|
||||
export type SpinnerProps = DeterminateSpinnerProps | IndeterminateSpinnerProps;
|
||||
|
||||
const SIZE = 48;
|
||||
const STROKE_WIDTH = 6;
|
||||
const radius = (SIZE - STROKE_WIDTH) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
|
||||
export const Spinner = ({
|
||||
value = 10,
|
||||
determinate = false,
|
||||
className,
|
||||
...props
|
||||
}: SpinnerProps) => {
|
||||
const offset = useMemo(
|
||||
() => circumference - (value / 100) * circumference,
|
||||
[value]
|
||||
);
|
||||
|
||||
return (
|
||||
<svg width={SIZE} height={SIZE} className={className} {...props}>
|
||||
<circle
|
||||
cx={SIZE / 2}
|
||||
cy={SIZE / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
className="stroke-grey-970"
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
/>
|
||||
<g
|
||||
className={cn(
|
||||
!determinate && "animate-indeterminate-spinner origin-center"
|
||||
)}
|
||||
>
|
||||
<circle
|
||||
cx={SIZE / 2}
|
||||
cy={SIZE / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
className="stroke-primary-500 animate-determinate-spinner"
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
@layer utilities {
|
||||
@keyframes spinner-animate {
|
||||
0% {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-indeterminate-spinner {
|
||||
animation: spinner-animate 2s linear infinite;
|
||||
}
|
||||
|
||||
.animate-determinate-spinner {
|
||||
transition: stroke-dashoffset 0.3s ease;
|
||||
}
|
||||
}
|
||||
+67
-39
@@ -15,6 +15,7 @@ from openhands.cli.utils import (
|
||||
VERIFIED_ANTHROPIC_MODELS,
|
||||
VERIFIED_MISTRAL_MODELS,
|
||||
VERIFIED_OPENAI_MODELS,
|
||||
VERIFIED_OPENHANDS_MODELS,
|
||||
VERIFIED_PROVIDERS,
|
||||
organize_models_and_providers,
|
||||
)
|
||||
@@ -234,6 +235,11 @@ async def modify_llm_settings_basic(
|
||||
m for m in provider_models if m not in VERIFIED_MISTRAL_MODELS
|
||||
]
|
||||
provider_models = VERIFIED_MISTRAL_MODELS + provider_models
|
||||
if provider == 'openhands':
|
||||
provider_models = [
|
||||
m for m in provider_models if m not in VERIFIED_OPENHANDS_MODELS
|
||||
]
|
||||
provider_models = VERIFIED_OPENHANDS_MODELS + provider_models
|
||||
|
||||
# Set default model to the best verified model for the provider
|
||||
if provider == 'anthropic' and VERIFIED_ANTHROPIC_MODELS:
|
||||
@@ -245,54 +251,76 @@ async def modify_llm_settings_basic(
|
||||
elif provider == 'mistral' and VERIFIED_MISTRAL_MODELS:
|
||||
# Use the first model in the VERIFIED_MISTRAL_MODELS list as it's the best/newest
|
||||
default_model = VERIFIED_MISTRAL_MODELS[0]
|
||||
elif provider == 'openhands' and VERIFIED_OPENHANDS_MODELS:
|
||||
# Use the first model in the VERIFIED_OPENHANDS_MODELS list as it's the best/newest
|
||||
default_model = VERIFIED_OPENHANDS_MODELS[0]
|
||||
else:
|
||||
# For other providers, use the first model in the list
|
||||
default_model = (
|
||||
provider_models[0] if provider_models else 'claude-sonnet-4-20250514'
|
||||
)
|
||||
|
||||
# Show the default model but allow changing it
|
||||
print_formatted_text(
|
||||
HTML(f'\n<grey>Default model: </grey><green>{default_model}</green>')
|
||||
)
|
||||
change_model = (
|
||||
cli_confirm(
|
||||
# For OpenHands provider, directly show all verified models without the "use default" option
|
||||
if provider == 'openhands':
|
||||
print_formatted_text(
|
||||
HTML(f'\n<grey>Available OpenHands models:</grey>')
|
||||
)
|
||||
|
||||
# Create a list of models for the cli_confirm function
|
||||
model_choices = VERIFIED_OPENHANDS_MODELS
|
||||
|
||||
model_choice = cli_confirm(
|
||||
config,
|
||||
'Do you want to use a different model?',
|
||||
[f'Use {default_model}', 'Select another model'],
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
if change_model:
|
||||
model_completer = FuzzyWordCompleter(provider_models)
|
||||
|
||||
# Define a validator function that allows custom models but shows a warning
|
||||
def model_validator(x):
|
||||
# Allow any non-empty model name
|
||||
if not x.strip():
|
||||
return False
|
||||
|
||||
# Show a warning for models not in the predefined list, but still allow them
|
||||
if x not in provider_models:
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
f'<yellow>Warning: {x} is not in the predefined list for provider {provider}. '
|
||||
f'Make sure this model name is correct.</yellow>'
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
model = await get_validated_input(
|
||||
session,
|
||||
'(Step 2/3) Select LLM Model (TAB for options, CTRL-c to cancel): ',
|
||||
completer=model_completer,
|
||||
validator=model_validator,
|
||||
error_message='Model name cannot be empty',
|
||||
'(Step 2/3) Select LLM Model:',
|
||||
model_choices,
|
||||
)
|
||||
|
||||
# Get the selected model from the list
|
||||
model = model_choices[model_choice]
|
||||
|
||||
else:
|
||||
# Use the default model
|
||||
model = default_model
|
||||
# For other providers, show the default model but allow changing it
|
||||
print_formatted_text(
|
||||
HTML(f'\n<grey>Default model: </grey><green>{default_model}</green>')
|
||||
)
|
||||
change_model = (
|
||||
cli_confirm(
|
||||
config,
|
||||
'Do you want to use a different model?',
|
||||
[f'Use {default_model}', 'Select another model'],
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
if change_model:
|
||||
model_completer = FuzzyWordCompleter(provider_models)
|
||||
|
||||
# Define a validator function that allows custom models but shows a warning
|
||||
def model_validator(x):
|
||||
# Allow any non-empty model name
|
||||
if not x.strip():
|
||||
return False
|
||||
|
||||
# Show a warning for models not in the predefined list, but still allow them
|
||||
if x not in provider_models:
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
f'<yellow>Warning: {x} is not in the predefined list for provider {provider}. '
|
||||
f'Make sure this model name is correct.</yellow>'
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
model = await get_validated_input(
|
||||
session,
|
||||
'(Step 2/3) Select LLM Model (TAB for options, CTRL-c to cancel): ',
|
||||
completer=model_completer,
|
||||
validator=model_validator,
|
||||
error_message='Model name cannot be empty',
|
||||
)
|
||||
else:
|
||||
# Use the default model
|
||||
model = default_model
|
||||
|
||||
api_key = await get_validated_input(
|
||||
session,
|
||||
|
||||
+15
-6
@@ -104,6 +104,8 @@ def extract_model_and_provider(model: str) -> ModelInfo:
|
||||
return ModelInfo(provider='anthropic', model=split[0], separator='/')
|
||||
if split[0] in VERIFIED_MISTRAL_MODELS:
|
||||
return ModelInfo(provider='mistral', model=split[0], separator='/')
|
||||
if split[0] in VERIFIED_OPENHANDS_MODELS:
|
||||
return ModelInfo(provider='openhands', model=split[0], separator='/')
|
||||
# return as model only
|
||||
return ModelInfo(provider='', model=model, separator='')
|
||||
|
||||
@@ -145,19 +147,18 @@ def organize_models_and_providers(
|
||||
return result_dict
|
||||
|
||||
|
||||
VERIFIED_PROVIDERS = ['anthropic', 'openai', 'mistral']
|
||||
VERIFIED_PROVIDERS = ['openhands', 'anthropic', 'openai', 'mistral']
|
||||
|
||||
VERIFIED_OPENAI_MODELS = [
|
||||
'o4-mini',
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
'gpt-4-turbo',
|
||||
'gpt-4',
|
||||
'gpt-4-32k',
|
||||
'gpt-4.1',
|
||||
'gpt-4.1-2025-04-14',
|
||||
'o1-mini',
|
||||
'o1',
|
||||
'o3-mini',
|
||||
'o3-mini-2025-01-31',
|
||||
'o3',
|
||||
'codex-mini-latest',
|
||||
]
|
||||
|
||||
VERIFIED_ANTHROPIC_MODELS = [
|
||||
@@ -178,6 +179,14 @@ VERIFIED_MISTRAL_MODELS = [
|
||||
'devstral-small-2505',
|
||||
]
|
||||
|
||||
VERIFIED_OPENHANDS_MODELS = [
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-opus-4-20250514',
|
||||
'gemini-2.5-pro',
|
||||
'o4-mini',
|
||||
'devstral-small-2505',
|
||||
]
|
||||
|
||||
|
||||
class ProviderInfo(BaseModel):
|
||||
"""Information about a provider and its models."""
|
||||
|
||||
@@ -759,6 +759,12 @@ def get_parser() -> argparse.ArgumentParser:
|
||||
type=bool,
|
||||
default=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--log-level',
|
||||
help='Set the log level',
|
||||
type=str,
|
||||
default=None,
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
|
||||
@@ -172,6 +172,15 @@ class LLM(RetryMixin, DebugMixin):
|
||||
# litellm will handle it a bit differently than the openai-compatible params
|
||||
kwargs['top_k'] = self.config.top_k
|
||||
|
||||
# Handle OpenHands provider - rewrite to litellm_proxy
|
||||
if self.config.model.startswith('openhands/'):
|
||||
model_name = self.config.model.removeprefix('openhands/')
|
||||
self.config.model = f'litellm_proxy/{model_name}'
|
||||
self.config.base_url = 'https://llm-proxy.app.all-hands.dev/'
|
||||
logger.debug(
|
||||
f'Rewrote openhands/{model_name} to {self.config.model} with base URL {self.config.base_url}'
|
||||
)
|
||||
|
||||
if (
|
||||
self.config.model.lower() in REASONING_EFFORT_SUPPORTED_MODELS
|
||||
or self.config.model.split('/')[-1] in REASONING_EFFORT_SUPPORTED_MODELS
|
||||
|
||||
@@ -497,12 +497,10 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
env_vars['SESSION_API_KEY'] = self._get_session_api_key_for_conversation(sid)
|
||||
# We need to be able to specify the nested conversation id within the nested runtime
|
||||
env_vars['ALLOW_SET_CONVERSATION_ID'] = '1'
|
||||
env_vars['WORKSPACE_BASE'] = '/workspace'
|
||||
env_vars['SANDBOX_CLOSE_DELAY'] = '0'
|
||||
env_vars['SKIP_DEPENDENCY_CHECK'] = '1'
|
||||
env_vars['INITIAL_NUM_WARM_SERVERS'] = '1'
|
||||
|
||||
# Set up mounted volume for conversation directory within workspace
|
||||
# TODO: Check if we are using the standard event store and file store
|
||||
volumes: list[str | None]
|
||||
if not config.sandbox.volumes:
|
||||
volumes = []
|
||||
@@ -510,9 +508,17 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
volumes = [v.strip() for v in config.sandbox.volumes.split(',')]
|
||||
conversation_dir = get_conversation_dir(sid, user_id)
|
||||
|
||||
volumes.append(
|
||||
f'{config.file_store_path}/{conversation_dir}:/root/.openhands/file_store/{conversation_dir}:rw'
|
||||
)
|
||||
# Set up mounted volume for conversation directory within workspace
|
||||
if config.file_store == 'local':
|
||||
# Resolve ~ from path as the docker container does not work otherwise
|
||||
file_store_path = os.path.realpath(
|
||||
os.path.expanduser(config.file_store_path)
|
||||
)
|
||||
|
||||
volumes.append(
|
||||
f'{file_store_path}/{conversation_dir}:/root/.openhands/{conversation_dir}:rw'
|
||||
)
|
||||
|
||||
config.sandbox.volumes = ','.join([v for v in volumes if v is not None])
|
||||
if not config.sandbox.runtime_container_image:
|
||||
config.sandbox.runtime_container_image = self._runtime_container_image
|
||||
|
||||
@@ -53,4 +53,14 @@ def get_supported_llm_models(config: OpenHandsConfig) -> list[str]:
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f'Error getting OLLAMA models: {e}')
|
||||
|
||||
# Add OpenHands provider models
|
||||
openhands_models = [
|
||||
'openhands/claude-sonnet-4-20250514',
|
||||
'openhands/claude-opus-4-20250514',
|
||||
'openhands/gemini-2.5-pro',
|
||||
'openhands/o4-mini',
|
||||
'openhands/devstral-small-2505',
|
||||
]
|
||||
model_list = openhands_models + model_list
|
||||
|
||||
return list(sorted(set(model_list)))
|
||||
|
||||
@@ -138,13 +138,14 @@ def test_help_message(capsys):
|
||||
'--no-auto-continue',
|
||||
'--selected-repo SELECTED_REPO',
|
||||
'--override-cli-mode OVERRIDE_CLI_MODE',
|
||||
'--log-level LOG_LEVEL',
|
||||
]
|
||||
|
||||
for element in expected_elements:
|
||||
assert element in help_output, f"Expected '{element}' to be in the help message"
|
||||
|
||||
option_count = help_output.count(' -')
|
||||
assert option_count == 20, f'Expected 20 options, found {option_count}'
|
||||
assert option_count == 21, f'Expected 21 options, found {option_count}'
|
||||
|
||||
|
||||
def test_selected_repo_format():
|
||||
|
||||
@@ -397,7 +397,7 @@ class TestModelAndProviderFunctions:
|
||||
models = [
|
||||
'openai/gpt-4o',
|
||||
'anthropic/claude-sonnet-4-20250514',
|
||||
'o3-mini',
|
||||
'o3',
|
||||
'o4-mini',
|
||||
'devstral-small-2505',
|
||||
'mistral/devstral-small-2505',
|
||||
@@ -414,7 +414,7 @@ class TestModelAndProviderFunctions:
|
||||
|
||||
assert len(result['openai']['models']) == 3
|
||||
assert 'gpt-4o' in result['openai']['models']
|
||||
assert 'o3-mini' in result['openai']['models']
|
||||
assert 'o3' in result['openai']['models']
|
||||
assert 'o4-mini' in result['openai']['models']
|
||||
|
||||
assert len(result['anthropic']['models']) == 1
|
||||
|
||||
Reference in New Issue
Block a user