diff --git a/ui/bun.lock b/ui/bun.lock index c2e29de1..6a181f1a 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -28,12 +28,14 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "i18next": "^25.8.13", "lucide-react": "^0.556.0", "prism-react-renderer": "^2.4.1", "react": "^19.2.0", "react-day-picker": "^9.12.0", "react-dom": "^19.2.0", "react-hook-form": "^7.68.0", + "react-i18next": "^16.5.4", "react-resizable-panels": "^3.0.6", "react-router-dom": "^7.10.1", "react-simple-code-editor": "^0.14.1", @@ -761,10 +763,14 @@ "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "i18next": ["i18next@25.8.13", "", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA=="], + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -931,6 +937,8 @@ "react-hook-form": ["react-hook-form@7.68.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q=="], + "react-i18next": ["react-i18next@16.5.4", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g=="], + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], @@ -1069,12 +1077,16 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], "vite": ["vite@7.2.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ=="], "vitest": ["vitest@4.0.16", "", { "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", "@vitest/pretty-format": "4.0.16", "@vitest/runner": "4.0.16", "@vitest/snapshot": "4.0.16", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.16", "@vitest/browser-preview": "4.0.16", "@vitest/browser-webdriverio": "4.0.16", "@vitest/ui": "4.0.16", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q=="], + "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], "webidl-conversions": ["webidl-conversions@8.0.0", "", {}, "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA=="], diff --git a/ui/package.json b/ui/package.json index 23c50f38..0dc9b46e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -42,12 +42,14 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "i18next": "^25.8.13", "lucide-react": "^0.556.0", "prism-react-renderer": "^2.4.1", "react": "^19.2.0", "react-day-picker": "^9.12.0", "react-dom": "^19.2.0", "react-hook-form": "^7.68.0", + "react-i18next": "^16.5.4", "react-resizable-panels": "^3.0.6", "react-router-dom": "^7.10.1", "react-simple-code-editor": "^0.14.1", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 62ecb304..7b70e49b 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -3,6 +3,7 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { QueryClientProvider } from '@tanstack/react-query'; import { Toaster } from 'sonner'; import { queryClient } from '@/lib/query-client'; +import '@/lib/i18n'; import { ThemeProvider } from '@/components/layout/theme-provider'; import { PrivacyProvider } from '@/contexts/privacy-context'; import { AuthProvider } from '@/contexts/auth-context'; diff --git a/ui/src/components/account/account-safety-warning-card.tsx b/ui/src/components/account/account-safety-warning-card.tsx index ad646ad1..97fe4fa7 100644 --- a/ui/src/components/account/account-safety-warning-card.tsx +++ b/ui/src/components/account/account-safety-warning-card.tsx @@ -3,6 +3,7 @@ import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants'; interface AccountSafetyWarningCardProps { @@ -24,21 +25,14 @@ export function AccountSafetyWarningCard({ disabled = false, showProxySettingsLink = false, }: AccountSafetyWarningCardProps) { - const title = 'OAuth Account Safety Warning'; - const subtitle = 'Issue #509 · Gemini + AGY OAuth risk'; - const firstLine = ( - <> - Issue #509 documents suspension/ban reports tied to ccs agy{' '} - and shared-account usage between ccs gemini and{' '} - ccs agy. - - ); - const secondLine = ( - <>Continue only if you accept full responsibility for OAuth and account-access risk. - ); + const { t } = useTranslation(); + const title = t('accountSafetyWarning.title'); + const subtitle = t('accountSafetyWarning.subtitle'); + const firstLine = t('accountSafetyWarning.firstLine'); + const secondLine = t('accountSafetyWarning.secondLine'); const issueUrl = 'https://github.com/kaitranntt/ccs/issues/509'; - const issueLabel = 'Read issue #509'; - const proxySettingsLabel = 'Gemini + AGY controls: Settings > Proxy'; + const issueLabel = t('accountSafetyWarning.issueLabel'); + const proxySettingsLabel = t('accountSafetyWarning.proxySettingsLabel'); return (
[] = [ { accessorKey: 'name', - header: 'Name', + header: t('accountsTable.name'), size: 200, cell: ({ row }) => (
{row.original.name} {row.original.name === defaultAccount && ( - default + {t('accountsTable.defaultBadge')} )}
@@ -65,7 +67,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { }, { accessorKey: 'type', - header: 'Type', + header: t('accountsTable.type'), size: 100, cell: ({ row }) => ( {row.original.type || 'oauth'} @@ -73,7 +75,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { }, { accessorKey: 'created', - header: 'Created', + header: t('accountsTable.created'), size: 150, cell: ({ row }) => { const date = new Date(row.original.created); @@ -82,7 +84,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { }, { accessorKey: 'last_used', - header: 'Last Used', + header: t('accountsTable.lastUsed'), size: 150, cell: ({ row }) => { if (!row.original.last_used) return -; @@ -92,7 +94,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { }, { id: 'context', - header: 'History Sync', + header: t('accountsTable.historySync'), size: 170, cell: ({ row }) => { if (row.original.type === 'cliproxy') { @@ -103,32 +105,42 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { if (mode === 'shared') { const group = row.original.context_group || 'default'; if (row.original.continuity_mode === 'deeper') { - return shared ({group}, deeper); + return ( + + {t('accountsTable.sharedGroupDeeper', { group })} + + ); } if (row.original.continuity_inferred) { return ( - shared ({group}, standard legacy) + {t('accountsTable.sharedGroupLegacy', { group })} ); } - return shared ({group}, standard); + return ( + + {t('accountsTable.sharedGroupStandard', { group })} + + ); } if (row.original.context_inferred) { return ( - isolated (legacy default) + + {t('accountsTable.isolatedLegacy')} + ); } - return isolated; + return {t('accountsTable.isolated')}; }, }, { id: 'actions', - header: 'Actions', + header: t('accountsTable.actions'), size: 220, cell: ({ row }) => { const isDefault = row.original.name === defaultAccount; @@ -149,10 +161,10 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { className="h-8 px-2" disabled={isPending} onClick={() => setContextTarget(row.original)} - title="Edit sync mode, group, and continuity depth" + title={t('accountsTable.syncTitle')} > - Sync + {t('accountsTable.sync')} )} {!isCliproxy && hasLegacyInference && ( @@ -177,10 +189,10 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { : undefined, }) } - title="Confirm this legacy account's current mode as explicit" + title={t('accountsTable.confirmLegacyTitle')} > - Confirm + {t('accountsTable.confirm')} )} @@ -218,10 +234,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { if (data.length === 0) { return ( -
- No CCS auth accounts found. Use{' '} - ccs auth create to add accounts. -
+
{t('accountsTable.noAccounts')}
); } @@ -279,7 +292,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { disabled={resetDefaultMutation.isPending} > - Reset to CCS Default + {t('accountsTable.resetToDefault')} )} @@ -293,14 +306,13 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { !open && setDeleteTarget(null)}> - Delete Account + {t('accountsTable.deleteDialogTitle')} - Are you sure you want to delete the account "{deleteTarget}"? This will - remove the profile and all its session data. This action cannot be undone. + {t('accountsTable.deleteDialogDesc', { name: deleteTarget ?? '' })} - Cancel + {t('accountsTable.cancel')} { @@ -310,7 +322,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { } }} > - Delete + {t('accountsTable.delete')} diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 094ce6ff..bdb2e90a 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -45,6 +45,7 @@ import { KIRO_AUTH_METHOD_OPTIONS, } from '@/lib/provider-config'; import type { KiroAuthMethod } from '@/lib/provider-config'; +import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; interface AddAccountDialogProps { @@ -76,6 +77,7 @@ export function AddAccountDialog({ const [agyAckBypassEnabled, setAgyAckBypassEnabled] = useState(false); const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(false); const [kiroAuthMethod, setKiroAuthMethod] = useState(DEFAULT_KIRO_AUTH_METHOD); + const { t } = useTranslation(); const wasAuthenticatingRef = useRef(false); const authFlow = useCliproxyAuthFlow(); const kiroImportMutation = useKiroImport(); @@ -318,13 +320,13 @@ export function AddAccountDialog({ }} > - Add {displayName} Account + {t('addAccountDialog.title', { displayName })} {isKiro - ? 'Choose a Kiro auth method, then authenticate via browser or import from Kiro IDE.' + ? t('addAccountDialog.descKiro') : isDeviceCode - ? 'Click Authenticate. A verification code will appear for you to enter on the provider website.' - : 'Click Authenticate to get an OAuth URL. Open it in any browser to sign in.'} + ? t('addAccountDialog.descDeviceCode') + : t('addAccountDialog.descOauth')} @@ -344,10 +346,9 @@ export function AddAccountDialog({
- Power user mode enabled + {t('addAccountDialog.powerUserEnabled')}
- AGY responsibility checklist is skipped from Settings {'>'} Proxy. You accept full - responsibility for OAuth/account risk. + {t('addAccountDialog.powerUserSkipped')}
)} @@ -367,7 +368,7 @@ export function AddAccountDialog({ {/* Kiro auth method */} {isKiro && !showAuthUI && (
- + setCallbackUrl(e.target.value)} - placeholder="Paste the redirect URL here..." + placeholder={t('addAccountDialog.callbackPlaceholder')} className="font-mono text-xs" />
@@ -511,7 +511,7 @@ export function AddAccountDialog({ {!authFlow.authUrl && !authFlow.isDeviceCodeFlow && (

- Preparing sign-in URL... + {t('addAccountDialog.preparingUrl')}

)} @@ -524,26 +524,26 @@ export function AddAccountDialog({ {kiroImportMutation.isPending && (

- Importing token from Kiro IDE... + {t('addAccountDialog.importingToken')}

)} {/* Action buttons */}
{isKiro && !showAuthUI && ( @@ -560,7 +560,7 @@ export function AddAccountDialog({ } > - Authenticate + {t('addAccountDialog.authenticate')} )}
diff --git a/ui/src/components/account/antigravity-responsibility-checklist.tsx b/ui/src/components/account/antigravity-responsibility-checklist.tsx index 716fc130..8efd6eda 100644 --- a/ui/src/components/account/antigravity-responsibility-checklist.tsx +++ b/ui/src/components/account/antigravity-responsibility-checklist.tsx @@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Progress } from '@/components/ui/progress'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; import { ANTIGRAVITY_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants'; import type { AntigravityRiskChecklistValue } from '@/components/account/antigravity-responsibility-constants'; @@ -21,6 +22,7 @@ export function AntigravityResponsibilityChecklist({ onChange, disabled = false, }: AntigravityResponsibilityChecklistProps) { + const { t } = useTranslation(); const completedSteps = [ value.reviewedIssue509, value.understandsBanRisk, @@ -50,21 +52,19 @@ export function AntigravityResponsibilityChecklist({
-

Antigravity OAuth Responsibility

-

- Complete all 4 steps before you can authenticate. -

+

{t('antigravityChecklist.title')}

+

{t('antigravityChecklist.subtitle')}

- Mandatory + {t('antigravityChecklist.mandatory')}
- Completion - {completedSteps}/4 steps + {t('antigravityChecklist.completion')} + {t('antigravityChecklist.stepsCount', { current: completedSteps })}
diff --git a/ui/src/components/account/create-auth-profile-dialog.tsx b/ui/src/components/account/create-auth-profile-dialog.tsx index 1c6cd3ec..66517d20 100644 --- a/ui/src/components/account/create-auth-profile-dialog.tsx +++ b/ui/src/components/account/create-auth-profile-dialog.tsx @@ -16,6 +16,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { Copy, Check, Terminal } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; interface CreateAuthProfileDialogProps { open: boolean; @@ -25,6 +26,7 @@ interface CreateAuthProfileDialogProps { const MAX_CONTEXT_GROUP_LENGTH = 64; export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDialogProps) { + const { t } = useTranslation(); const [profileName, setProfileName] = useState(''); const [shareContext, setShareContext] = useState(false); const [contextGroup, setContextGroup] = useState(''); @@ -52,7 +54,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial ] .filter(Boolean) .join(' ') - : 'ccs auth create '; + : t('createAuthProfileDialog.commandFallback'); const handleCopy = async () => { if (!isValidName || (shareContext && !isValidContextGroup)) return; @@ -74,27 +76,23 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial !isOpen && handleClose()}> - Create New Account - - Auth profiles require Claude CLI login. Run the command below in your terminal. You can - edit sync mode, group, and continuity depth later from the Accounts table. - + {t('createAuthProfileDialog.title')} + {t('createAuthProfileDialog.description')}
- + setProfileName(e.target.value)} - placeholder="e.g., work, personal, client" + placeholder={t('createAuthProfileDialog.profileNamePlaceholder')} autoComplete="off" /> {profileName && !isValidName && (

- Name must start with a letter and contain only letters, numbers, dashes, or - underscores. + {t('createAuthProfileDialog.invalidProfileName')}

)}
@@ -107,22 +105,24 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial onCheckedChange={(checked) => setShareContext(checked === true)} />
{shareContext && (
- + setContextGroup(e.target.value)} - placeholder="default, sprint-a, client-x" + placeholder={t('createAuthProfileDialog.historySyncGroupPlaceholder')} autoComplete="off" />

- Leave empty to use the default shared group. Spaces are normalized to dashes. + {t('createAuthProfileDialog.historySyncGroupHint')}

setDeeperContinuity(checked === true)} />

- Adds sync for session-env, file-history,{' '} - shell-snapshots, and todos. Credentials stay isolated. + {t('createAuthProfileDialog.deeperContinuityHint')}

{contextGroup.trim().length > 0 && !isValidContextGroup && (

- Group must start with a letter and use only letters, numbers, dashes, or - underscores (max {MAX_CONTEXT_GROUP_LENGTH} chars). + {t('createAuthProfileDialog.invalidContextGroup', { + max: MAX_CONTEXT_GROUP_LENGTH, + })}

)}
@@ -149,7 +149,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
- +
{command} @@ -170,20 +170,17 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
-

After running the command:

+

{t('createAuthProfileDialog.afterRunPrefix')}

    -
  1. Complete the Claude login in your browser
  2. -
  3. Return here and refresh to see the new account
  4. +
  5. {t('createAuthProfileDialog.afterRunStep1')}
  6. +
  7. {t('createAuthProfileDialog.afterRunStep2')}
-

- Prefer pooled Claude OAuth routing instead? Use CLIProxy Claude pool from the Accounts - page action button. -

+

{t('createAuthProfileDialog.poolingHint')}

diff --git a/ui/src/components/account/edit-account-context-dialog.tsx b/ui/src/components/account/edit-account-context-dialog.tsx index 4edf98ad..650c76e0 100644 --- a/ui/src/components/account/edit-account-context-dialog.tsx +++ b/ui/src/components/account/edit-account-context-dialog.tsx @@ -17,6 +17,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { useTranslation } from 'react-i18next'; import type { Account } from '@/lib/api-client'; import { useUpdateAccountContext } from '@/hooks/use-accounts'; @@ -32,6 +33,7 @@ interface EditAccountContextDialogProps { } export function EditAccountContextDialog({ account, onClose }: EditAccountContextDialogProps) { + const { t } = useTranslation(); const updateContextMutation = useUpdateAccountContext(); const [mode, setMode] = useState( account.context_mode === 'shared' ? 'shared' : 'isolated' @@ -78,89 +80,86 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex - Edit History Sync + {t('editAccountContext.title')} - Configure how "{account.name}" shares history and continuity with other - ccs auth - accounts. + {t('editAccountContext.description', { name: account.name })}
- +

{mode === 'shared' - ? 'Shared mode reuses workspace context for accounts in the same history sync group.' - : 'Isolated mode keeps this account fully separate from other ccs auth accounts.'} + ? t('editAccountContext.sharedModeHint') + : t('editAccountContext.isolatedModeHint')}

{mode === 'shared' && (
- + setGroup(event.target.value)} - placeholder="default" + placeholder={t('editAccountContext.groupPlaceholder')} autoComplete="off" />

- Normalized to lowercase (spaces become dashes). Allowed: letters, numbers, `_`, `-` - (max {MAX_CONTEXT_GROUP_LENGTH} chars). + {t('editAccountContext.groupHint', { max: MAX_CONTEXT_GROUP_LENGTH })}

{!isSharedGroupValid && ( -

- Enter a valid group name that starts with a letter. -

+

{t('editAccountContext.invalidGroup')}

)}
)} {mode === 'shared' && (
- +

{continuityMode === 'deeper' - ? 'Advanced mode also syncs session-env, file-history, shell-snapshots, and todos.' - : 'Standard mode syncs project workspace context only.'} + ? t('editAccountContext.deeperHint') + : t('editAccountContext.standardHint')}

)}

- Credentials and `.anthropic` remain isolated per account in all modes. + {t('editAccountContext.credentialsIsolated')}

diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index f3410a91..dfecbf8a 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -17,6 +17,7 @@ import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; +import { useTranslation } from 'react-i18next'; import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content'; import type { AccountData, DragOffset } from './types'; @@ -94,6 +95,7 @@ export function AccountCard({ onPauseToggle, isPausingAccount, }: AccountCardProps) { + const { t } = useTranslation(); const borderSide = BORDER_SIDE_MAP[zone]; const borderColor = getBorderColorStyle(zone, account.color); const connectorPosition = CONNECTOR_POSITION_MAP[zone]; @@ -245,7 +247,7 @@ export function AccountCard({ - {account.paused ? 'Resume account' : 'Pause account'} + {account.paused ? t('accountCard.resumeAccount') : t('accountCard.pauseAccount')} @@ -266,7 +268,7 @@ export function AccountCard({ {quotaLoading ? (
- Quota... + {t('accountCard.quotaLoading')}
) : minQuotaValue !== null ? ( @@ -275,7 +277,7 @@ export function AccountCard({
- Quota + {t('accountCard.quota')} ) : quota?.success ? ( -
Quota limits unavailable
+
+ {t('accountCard.quotaUnavailable')} +
) : quota?.needsReauth ? (
- Reauth needed + {t('accountCard.reauthNeeded')}

{quota.error?.includes('No refresh token') - ? 'Remove and re-add account' - : quota.error || 'Auto-refresh failed'} + ? t('accountCard.removeAndReadd') + : quota.error || t('accountCard.autoRefreshFailed')}

diff --git a/ui/src/components/account/flow-viz/connection-timeline.tsx b/ui/src/components/account/flow-viz/connection-timeline.tsx index f7743435..15a32b91 100644 --- a/ui/src/components/account/flow-viz/connection-timeline.tsx +++ b/ui/src/components/account/flow-viz/connection-timeline.tsx @@ -2,6 +2,7 @@ * Connection Timeline Component - right sidebar panel */ +import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { STATUS_COLORS } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; @@ -16,10 +17,13 @@ interface ConnectionTimelineProps { } export function ConnectionTimeline({ events, privacyMode }: ConnectionTimelineProps) { + const { t } = useTranslation(); if (events.length === 0) { return (
-
No recent connections
+
+ {t('flowViz.noRecentConnections')} +
); } @@ -30,7 +34,7 @@ export function ConnectionTimeline({ events, privacyMode }: ConnectionTimelinePr
- Connection Timeline + {t('flowViz.connectionTimeline')}
diff --git a/ui/src/components/account/flow-viz/flow-viz-header.tsx b/ui/src/components/account/flow-viz/flow-viz-header.tsx index 01065856..3d66d3bd 100644 --- a/ui/src/components/account/flow-viz/flow-viz-header.tsx +++ b/ui/src/components/account/flow-viz/flow-viz-header.tsx @@ -2,6 +2,7 @@ * Flow Visualization Header Component */ +import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { ChevronRight, Eye, EyeOff, RotateCcw } from 'lucide-react'; @@ -20,6 +21,7 @@ export function FlowVizHeader({ hasCustomPositions, onResetPositions, }: FlowVizHeaderProps) { + const { t } = useTranslation(); return (
{onBack ? ( @@ -28,7 +30,7 @@ export function FlowVizHeader({ className="group flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-all duration-200 px-3 py-1.5 rounded-md hover:bg-muted/50 border border-transparent hover:border-border/50" > - Back to providers + {t('flowViz.backToProviders')} ) : (
@@ -44,7 +46,7 @@ export function FlowVizHeader({ )} > {showDetails ? : } - {showDetails ? 'Hide Details' : 'Show Details'} + {showDetails ? t('flowViz.hideDetails') : t('flowViz.showDetails')} {hasCustomPositions && ( )}
diff --git a/ui/src/components/account/flow-viz/provider-card.tsx b/ui/src/components/account/flow-viz/provider-card.tsx index 95328d94..3973ca31 100644 --- a/ui/src/components/account/flow-viz/provider-card.tsx +++ b/ui/src/components/account/flow-viz/provider-card.tsx @@ -2,6 +2,7 @@ * Provider Card Component for Flow Visualization */ +import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { ProviderIcon } from '@/components/shared/provider-icon'; import { GripVertical } from 'lucide-react'; @@ -39,6 +40,7 @@ export function ProviderCard({ onPointerMove, onPointerUp, }: ProviderCardProps) { + const { t } = useTranslation(); const { accounts } = providerData; return ( @@ -115,17 +117,19 @@ export function ProviderCard({

{providerData.displayName}

-

Provider

+

+ {t('flowViz.provider')} +

- Total Requests + {t('flowViz.totalRequests')} {totalRequests.toLocaleString()}
- Accounts + {t('flowViz.accounts')} {accounts.length}
diff --git a/ui/src/components/account/history-sync-learning-map.tsx b/ui/src/components/account/history-sync-learning-map.tsx index 7c7b2f94..4c941b47 100644 --- a/ui/src/components/account/history-sync-learning-map.tsx +++ b/ui/src/components/account/history-sync-learning-map.tsx @@ -13,6 +13,7 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; interface HistorySyncLearningMapProps { @@ -76,6 +77,7 @@ export function HistorySyncLearningMap({ legacyTargetCount, cliproxyCount, }: HistorySyncLearningMapProps) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); const groupsToShow = sharedGroups.length > 0 ? sharedGroups : ['default']; @@ -84,33 +86,47 @@ export function HistorySyncLearningMap({
- How History Sync Works + {t('historySyncLearningMap.title')} - Isolated -> Shared -> Deeper. Use Sync per row for all changes. + {t('historySyncLearningMap.description')}
- Learning Map + {t('historySyncLearningMap.learningMap')}
{cliproxyCount > 0 && (
- {cliproxyCount} CLIProxy Claude pool account{cliproxyCount > 1 ? 's are' : ' is'} - managed in Action Center / CLIProxy page. + {t('historySyncLearningMap.cliproxyManaged', { count: cliproxyCount })}
)}
- +
- +
- +
@@ -119,7 +135,7 @@ export function HistorySyncLearningMap({ variant="ghost" className="h-8 w-full justify-between rounded-md px-2 text-xs text-muted-foreground hover:bg-muted/40 hover:text-foreground" > - Show details: groups, switching, and legacy policy + {t('historySyncLearningMap.showDetails')} @@ -128,21 +144,20 @@ export function HistorySyncLearningMap({
-

Mode Switch

+

{t('historySyncLearningMap.modeSwitch')}

- Sync dialog lets users move between isolated/shared and choose deeper continuity. + {t('historySyncLearningMap.modeSwitchDesc')}

-

History Sync Group

+

{t('historySyncLearningMap.historySyncGroup')}

- Same group means shared project context lane. Default fallback is{' '} - default. + {t('historySyncLearningMap.historySyncGroupDesc')}

{groupsToShow.map((group) => ( diff --git a/ui/src/components/analytics/usage-summary-cards.tsx b/ui/src/components/analytics/usage-summary-cards.tsx index 1bd7a3fc..0ca03eee 100644 --- a/ui/src/components/analytics/usage-summary-cards.tsx +++ b/ui/src/components/analytics/usage-summary-cards.tsx @@ -12,6 +12,7 @@ import { DollarSign, Database, FileText, ArrowDownRight, ArrowUpRight } from 'lu import { cn } from '@/lib/utils'; import type { UsageSummary } from '@/hooks/use-usage'; import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; +import { useTranslation } from 'react-i18next'; interface UsageSummaryCardsProps { data?: UsageSummary; @@ -20,6 +21,7 @@ interface UsageSummaryCardsProps { export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) { const { privacyMode } = usePrivacy(); + const { t } = useTranslation(); if (isLoading) { return ( @@ -48,49 +50,61 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) { const cards = [ { - title: 'Total Tokens', + title: t('analyticsSummary.totalTokens'), value: data?.totalTokens ?? 0, icon: FileText, format: (v: number) => formatNumber(v), color: 'text-blue-600', bgColor: 'bg-blue-100 dark:bg-blue-900/20', - subtitle: `${formatNumber(data?.totalInputTokens ?? 0)} in / ${formatNumber(data?.totalOutputTokens ?? 0)} out`, + subtitle: t('analyticsSummary.totalTokensSubtitle', { + input: formatNumber(data?.totalInputTokens ?? 0), + output: formatNumber(data?.totalOutputTokens ?? 0), + }), }, { - title: 'Total Cost', + title: t('analyticsSummary.totalCost'), value: data?.totalCost ?? 0, icon: DollarSign, format: (v: number) => `$${v.toFixed(2)}`, color: 'text-green-600', bgColor: 'bg-green-100 dark:bg-green-900/20', - subtitle: `$${data?.averageCostPerDay?.toFixed(2) ?? '0.00'}/day avg`, + subtitle: t('analyticsSummary.totalCostSubtitle', { + value: data?.averageCostPerDay?.toFixed(2) ?? '0.00', + }), }, { - title: 'Cache Tokens', + title: t('analyticsSummary.cacheTokens'), value: data?.totalCacheTokens ?? 0, icon: Database, format: (v: number) => formatNumber(v), color: 'text-cyan-600', bgColor: 'bg-cyan-100 dark:bg-cyan-900/20', - subtitle: `$${cacheCost.toFixed(2)} (${cacheCostPercent}% of cost)`, + subtitle: t('analyticsSummary.cacheTokensSubtitle', { + cost: cacheCost.toFixed(2), + percent: cacheCostPercent, + }), }, { - title: 'Input Cost', + title: t('analyticsSummary.inputCost'), value: data?.tokenBreakdown?.input?.cost ?? 0, icon: ArrowDownRight, format: (v: number) => `$${v.toFixed(2)}`, color: 'text-purple-600', bgColor: 'bg-purple-100 dark:bg-purple-900/20', - subtitle: `${formatNumber(data?.tokenBreakdown?.input?.tokens ?? 0)} tokens`, + subtitle: t('analyticsSummary.tokensSubtitle', { + value: formatNumber(data?.tokenBreakdown?.input?.tokens ?? 0), + }), }, { - title: 'Output Cost', + title: t('analyticsSummary.outputCost'), value: data?.tokenBreakdown?.output?.cost ?? 0, icon: ArrowUpRight, format: (v: number) => `$${v.toFixed(2)}`, color: 'text-orange-600', bgColor: 'bg-orange-100 dark:bg-orange-900/20', - subtitle: `${formatNumber(data?.tokenBreakdown?.output?.tokens ?? 0)} tokens`, + subtitle: t('analyticsSummary.tokensSubtitle', { + value: formatNumber(data?.tokenBreakdown?.output?.tokens ?? 0), + }), }, ]; diff --git a/ui/src/components/cliproxy/categorized-model-selector.tsx b/ui/src/components/cliproxy/categorized-model-selector.tsx index 4418ed5b..c52f8794 100644 --- a/ui/src/components/cliproxy/categorized-model-selector.tsx +++ b/ui/src/components/cliproxy/categorized-model-selector.tsx @@ -18,21 +18,22 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Cpu } from 'lucide-react'; import type { CliproxyModelsResponse } from '@/lib/api-client'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; /** Provider display configuration */ -const CATEGORY_CONFIG: Record = { - google: { name: 'Google (Gemini)', color: 'text-blue-600' }, - openai: { name: 'OpenAI (GPT)', color: 'text-green-600' }, - anthropic: { name: 'Anthropic (Claude)', color: 'text-orange-600' }, - antigravity: { name: 'Antigravity', color: 'text-purple-600' }, - other: { name: 'Other', color: 'text-gray-600' }, +const CATEGORY_CONFIG: Record = { + google: { key: 'google', color: 'text-blue-600' }, + openai: { key: 'openai', color: 'text-green-600' }, + anthropic: { key: 'anthropic', color: 'text-orange-600' }, + antigravity: { key: 'antigravity', color: 'text-purple-600' }, + other: { key: 'other', color: 'text-gray-600' }, }; /** Get display name for category */ function getCategoryDisplay(category: string) { return ( CATEGORY_CONFIG[category.toLowerCase()] || { - name: category.charAt(0).toUpperCase() + category.slice(1), + key: 'other', color: 'text-gray-600', } ); @@ -61,9 +62,12 @@ export function CategorizedModelSelector({ value, onChange, disabled, - placeholder = 'Select a model', + placeholder, className, }: CategorizedModelSelectorProps) { + const { t } = useTranslation(); + const resolvedPlaceholder = placeholder ?? t('categorizedModelSelector.selectModel'); + // Sort categories by model count (descending) const sortedCategories = useMemo(() => { if (!modelsData?.byCategory) return []; @@ -84,7 +88,7 @@ export function CategorizedModelSelector({ return (
- No models available + {t('categorizedModelSelector.noModelsAvailable')}
); } @@ -92,7 +96,7 @@ export function CategorizedModelSelector({ return ( + + {singleForm.formState.errors.name && ( {singleForm.formState.errors.name.message} @@ -161,13 +167,13 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
- + - + {providerAccounts.map((acc) => ( ))} @@ -201,39 +207,45 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { )}
- - + +
- +
) : (
- + {compositeForm.formState.errors.name && ( @@ -243,17 +255,17 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
- + - Opus - Sonnet - Haiku + {t('cliproxyDialog.opus')} + {t('cliproxyDialog.sonnet')} + {t('cliproxyDialog.haiku')} {(['opus', 'sonnet', 'haiku'] as const).map((tier) => (
- + {compositeForm.formState.errors.tiers?.[tier]?.model && ( @@ -280,11 +292,13 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { )}
- +
@@ -293,36 +307,38 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
- +
- +
diff --git a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx index 82abaf5e..ba532a99 100644 --- a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx @@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { useTranslation } from 'react-i18next'; import { useUpdateVariant } from '@/hooks/use-cliproxy'; import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config'; import type { UpdateVariant, Variant } from '@/lib/api-client'; @@ -117,6 +118,7 @@ function isCompositeVariantOnlyTargetChange(variant: Variant, data: CompositeFor } export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEditDialogProps) { + const { t } = useTranslation(); const updateMutation = useUpdateVariant(); const isComposite = variant?.type === 'composite'; @@ -264,24 +266,28 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit - Edit {isComposite ? 'Composite' : 'Single'} Variant: {variant.name} + {isComposite + ? t('cliproxyDialog.editCompositeTitle', { name: variant.name }) + : t('cliproxyDialog.editSingleTitle', { name: variant.name })} {isComposite ? (
- + - Opus - Sonnet - Haiku + {t('cliproxyDialog.opus')} + {t('cliproxyDialog.sonnet')} + {t('cliproxyDialog.haiku')} {(['opus', 'sonnet', 'haiku'] as const).map((tier) => (
- + {compositeForm.formState.errors.tiers?.[tier]?.model && ( @@ -308,11 +314,13 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit )}
- +
@@ -321,43 +329,45 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
- +
- +
) : (
- + -
- -
- +
- + + +
+ +
+
diff --git a/ui/src/components/cliproxy/overview/credential-health-list.tsx b/ui/src/components/cliproxy/overview/credential-health-list.tsx index 5d08146a..4e484036 100644 --- a/ui/src/components/cliproxy/overview/credential-health-list.tsx +++ b/ui/src/components/cliproxy/overview/credential-health-list.tsx @@ -11,6 +11,7 @@ import { useCliproxyAuth } from '@/hooks/use-cliproxy'; import { useCliproxyStats } from '@/hooks/use-cliproxy-stats'; import { cn } from '@/lib/utils'; import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; +import { useTranslation } from 'react-i18next'; type CredentialStatus = 'ready' | 'warning' | 'error' | 'disabled'; @@ -35,6 +36,8 @@ function CredentialRow({ onRefresh, privacyMode, }: CredentialRowProps) { + const { t } = useTranslation(); + const statusConfig = { ready: { icon: CheckCircle2, @@ -62,21 +65,21 @@ function CredentialRow({ const Icon = config.icon; const formatLastUsed = (date?: string) => { - if (!date) return 'Never used'; + if (!date) return t('credentialHealth.neverUsed'); try { const lastUsed = new Date(date); const now = new Date(); const diff = now.getTime() - lastUsed.getTime(); - if (diff < 0) return 'Just now'; + if (diff < 0) return t('credentialHealth.justNow'); const minutes = Math.floor(diff / 60000); const hours = Math.floor(diff / 3600000); const days = Math.floor(diff / 86400000); - if (days > 0) return `${days}d ago`; - if (hours > 0) return `${hours}h ago`; - if (minutes > 0) return `${minutes}m ago`; - return 'Just now'; + if (days > 0) return t('credentialHealth.daysAgo', { count: days }); + if (hours > 0) return t('credentialHealth.hoursAgo', { count: hours }); + if (minutes > 0) return t('credentialHealth.minutesAgo', { count: minutes }); + return t('credentialHealth.justNow'); } catch { - return 'Unknown'; + return t('credentialHealth.unknown'); } }; @@ -120,10 +123,12 @@ function CredentialRow({ } function CredentialHealthSkeleton() { + const { t } = useTranslation(); + return ( - Credential Health + {t('credentialHealth.title')}
@@ -137,6 +142,7 @@ function CredentialHealthSkeleton() { } export function CredentialHealthList() { + const { t } = useTranslation(); const { data: authData, isLoading } = useCliproxyAuth(); const { data: stats } = useCliproxyStats(true); const { privacyMode } = usePrivacy(); @@ -155,7 +161,8 @@ export function CredentialHealthList() { name: account.id, provider: status.provider, status: (account as { status?: CredentialStatus }).status ?? 'ready', - statusMessage: (account as { statusMessage?: string }).statusMessage ?? 'Ready', + statusMessage: + (account as { statusMessage?: string }).statusMessage ?? t('credentialHealth.ready'), email: account.email, lastUsedAt: runtimeLastUsed || account.lastUsedAt, }; @@ -166,11 +173,11 @@ export function CredentialHealthList() { return ( - Credential Health + {t('credentialHealth.title')}

- No credentials configured. Use the login buttons above to authenticate. + {t('credentialHealth.noCredentialsConfigured')}

@@ -180,7 +187,7 @@ export function CredentialHealthList() { return ( - Credential Health + {t('credentialHealth.title')} {credentials.map((cred, i) => ( diff --git a/ui/src/components/cliproxy/overview/model-preferences-grid.tsx b/ui/src/components/cliproxy/overview/model-preferences-grid.tsx index 8bdd8f81..ab5040ef 100644 --- a/ui/src/components/cliproxy/overview/model-preferences-grid.tsx +++ b/ui/src/components/cliproxy/overview/model-preferences-grid.tsx @@ -10,31 +10,34 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Cpu, AlertCircle } from 'lucide-react'; import { useCliproxyModels, useCliproxyUpdateCheck } from '@/hooks/use-cliproxy'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; /** Category display configuration */ -const CATEGORY_CONFIG: Record = { - google: { name: 'Google (Gemini)', color: 'text-blue-600', bgColor: 'bg-blue-50' }, - openai: { name: 'OpenAI (GPT)', color: 'text-green-600', bgColor: 'bg-green-50' }, - anthropic: { name: 'Anthropic (Claude)', color: 'text-orange-600', bgColor: 'bg-orange-50' }, - antigravity: { name: 'Antigravity', color: 'text-purple-600', bgColor: 'bg-purple-50' }, - other: { name: 'Other', color: 'text-gray-600', bgColor: 'bg-gray-50' }, +const CATEGORY_CONFIG: Record = { + google: { key: 'google', color: 'text-blue-600', bgColor: 'bg-blue-50' }, + openai: { key: 'openai', color: 'text-green-600', bgColor: 'bg-green-50' }, + anthropic: { key: 'anthropic', color: 'text-orange-600', bgColor: 'bg-orange-50' }, + antigravity: { key: 'antigravity', color: 'text-purple-600', bgColor: 'bg-purple-50' }, + other: { key: 'other', color: 'text-gray-600', bgColor: 'bg-gray-50' }, }; function getCategoryDisplay(category: string) { + const normalized = category.toLowerCase(); + const configured = CATEGORY_CONFIG[normalized]; return ( - CATEGORY_CONFIG[category.toLowerCase()] || { - name: category.charAt(0).toUpperCase() + category.slice(1), + configured || { + key: 'other', color: 'text-gray-600', bgColor: 'bg-gray-50', } ); } -function ModelPreferencesSkeleton() { +function ModelPreferencesSkeleton({ title }: { title: string }) { return ( - Available Models + {title}
@@ -47,21 +50,27 @@ function ModelPreferencesSkeleton() { ); } -function EmptyModelsState() { +function EmptyModelsState({ + title, + noModels, + hint, +}: { + title: string; + noModels: string; + hint: string; +}) { return ( - Available Models + {title}
-

No models available

-

- Start a CLIProxy session to fetch available models -

+

{noModels}

+

{hint}

@@ -69,6 +78,7 @@ function EmptyModelsState() { } export function ModelPreferencesGrid() { + const { t } = useTranslation(); const { data: modelsData, isLoading, isError } = useCliproxyModels(); const { data: updateCheck } = useCliproxyUpdateCheck(); const backendLabel = updateCheck?.backendLabel ?? 'CLIProxy'; @@ -86,11 +96,17 @@ export function ModelPreferencesGrid() { }, [modelsData]); if (isLoading) { - return ; + return ; } if (isError || !modelsData || modelsData.totalCount === 0) { - return ; + return ( + + ); } return ( @@ -98,13 +114,13 @@ export function ModelPreferencesGrid() { - Available Models + {t('cliproxyOverviewComponents.availableModels')} - {modelsData.totalCount} total + {t('cliproxyOverviewComponents.totalCount', { count: modelsData.totalCount })} - Models available through {backendLabel}, grouped by provider + {t('cliproxyOverviewComponents.availableThroughGroupedByProvider', { backendLabel })} @@ -115,7 +131,9 @@ export function ModelPreferencesGrid() { className={cn('p-4 rounded-lg border', display.bgColor, 'border-transparent')} >
-

{display.name}

+

+ {t(`cliproxyModelCategory.${display.key}`)} +

{models.length} @@ -132,7 +150,7 @@ export function ModelPreferencesGrid() { ))} {models.length > 5 && (
- +{models.length - 5} more + {t('cliproxyOverviewComponents.moreCount', { count: models.length - 5 })}
)}
diff --git a/ui/src/components/cliproxy/overview/quick-stats-row.tsx b/ui/src/components/cliproxy/overview/quick-stats-row.tsx index 7e533d96..4e8931db 100644 --- a/ui/src/components/cliproxy/overview/quick-stats-row.tsx +++ b/ui/src/components/cliproxy/overview/quick-stats-row.tsx @@ -4,6 +4,7 @@ */ import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; import { Card, CardContent } from '@/components/ui/card'; import { Activity, CheckCircle2, Coins, Cpu } from 'lucide-react'; import { useCliproxyStats } from '@/hooks/use-cliproxy'; @@ -45,6 +46,7 @@ function StatsSkeleton() { } export function QuickStatsRow() { + const { t } = useTranslation(); const { data: stats, isLoading } = useCliproxyStats(); if (isLoading) { @@ -71,16 +73,24 @@ export function QuickStatsRow() {
} - label="Total Requests" + label={t('quickStats.totalRequests')} value={totalRequests} /> } - label="Success Rate" + label={t('quickStats.successRate')} value={`${successRate}%`} /> - } label="Total Tokens" value={tokenDisplay} /> - } label="Active Models" value={modelCount} /> + } + label={t('quickStats.totalTokens')} + value={tokenDisplay} + /> + } + label={t('quickStats.activeModels')} + value={modelCount} + />
); } diff --git a/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx index 07dbfd46..6a494631 100644 --- a/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx +++ b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx @@ -18,6 +18,7 @@ import { import { Sparkles, Loader2, Star, Zap } from 'lucide-react'; import { FlexibleModelSelector } from '../provider-model-selector'; import type { CustomPresetDialogProps, ModelMappingValues } from './types'; +import { useTranslation } from 'react-i18next'; export function CustomPresetDialog({ open, @@ -29,6 +30,7 @@ export function CustomPresetDialog({ catalog, allModels, }: CustomPresetDialogProps) { + const { t } = useTranslation(); const [values, setValues] = useState(currentValues); const [presetName, setPresetName] = useState(''); @@ -48,54 +50,54 @@ export function CustomPresetDialog({ - Custom Preset + {t('customPresetDialog.title')}
- + setPresetName(e.target.value)} - placeholder="e.g., My Custom Config" + placeholder={t('customPresetDialog.presetNamePlaceholder')} className="text-sm" />
{catalog?.provider === 'codex' && (

- Codex tip: suffixes -medium, -high, and -xhigh{' '} - pin effort. Unsuffixed models use Thinking settings. + {t('customPresetDialog.codexTipPrefix')} -medium, -high, and{' '} + -xhigh {t('customPresetDialog.codexTipSuffix')}

)} setValues({ ...values, default: model })} catalog={catalog} allModels={allModels} /> setValues({ ...values, opus: model })} catalog={catalog} allModels={allModels} /> setValues({ ...values, sonnet: model })} catalog={catalog} allModels={allModels} /> setValues({ ...values, haiku: model })} catalog={catalog} @@ -104,7 +106,7 @@ export function CustomPresetDialog({
{onSave && ( )} diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index c02ff4f6..b290dbdb 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -17,6 +17,7 @@ import { useDeletePreset, } from '@/hooks/use-cliproxy'; import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; +import i18n from '@/lib/i18n'; import { usePrivacy } from '@/contexts/privacy-context'; import { useProviderEditor } from './use-provider-editor'; import { CustomPresetDialog } from './custom-preset-dialog'; @@ -155,7 +156,7 @@ export function ProviderEditor({ const handleCustomPresetSave = (values: ModelMappingValues, presetName?: string) => { if (!presetName) { - toast.error('Please enter a preset name to save'); + toast.error(i18n.t('commonToast.enterPresetName')); return; } createPresetMutation.mutate({ profile: provider, data: { name: presetName, ...values } }); diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index 2bc9d138..5b8d5ec6 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -19,6 +19,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { AlertTriangle, AlertCircle, Check } from 'lucide-react'; import { cn } from '@/lib/utils'; import { getCodexEffortDisplay } from '@/lib/codex-effort'; +import { useTranslation } from 'react-i18next'; /** Model entry from catalog */ export interface ModelEntry { @@ -72,9 +73,12 @@ export function ProviderModelSelector({ value, onChange, disabled, - placeholder = 'Select a model', + placeholder, className, }: ProviderModelSelectorProps) { + const { t } = useTranslation(); + const resolvedPlaceholder = placeholder ?? t('providerModelSelector.selectModel'); + // Group models by tier const groupedModels = useMemo(() => { if (!catalog?.models) return { free: [], paid: [] }; @@ -95,7 +99,7 @@ export function ProviderModelSelector({ if (!catalog || catalog.models.length === 0) { return (
- No models available for this provider + {t('providerModelSelector.noModelsForProvider')}
); } @@ -110,12 +114,12 @@ export function ProviderModelSelector({ {model.name} {model.broken && ( - BROKEN + {t('providerModelSelector.broken')} )} {model.deprecated && ( - DEPRECATED + {t('providerModelSelector.deprecated')} )} {value === model.id && } @@ -127,13 +131,13 @@ export function ProviderModelSelector({
- + {value && {value}} @@ -230,7 +239,7 @@ export function ModelMappingSelector({ {model.name} {model.tier === 'paid' && ( - PAID + {t('providerModelSelector.paid')} )}
@@ -262,6 +271,7 @@ export function FlexibleModelSelector({ allModels, disabled, }: FlexibleModelSelectorProps) { + const { t } = useTranslation(); // Combine catalog models (recommended) with all available models const catalogModelIds = new Set(catalog?.models.map((m) => m.id) || []); const isCodexProvider = catalog?.provider === 'codex'; @@ -275,7 +285,7 @@ export function FlexibleModelSelector({
diff --git a/ui/src/components/cliproxy/sync/sync-dialog.tsx b/ui/src/components/cliproxy/sync/sync-dialog.tsx index 46a63270..4e106a36 100644 --- a/ui/src/components/cliproxy/sync/sync-dialog.tsx +++ b/ui/src/components/cliproxy/sync/sync-dialog.tsx @@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Loader2, Upload, CheckCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { useSyncPreview, useExecuteSync } from '@/hooks/use-cliproxy-sync'; @@ -23,6 +24,7 @@ interface SyncDialogProps { } export function SyncDialog({ open, onOpenChange }: SyncDialogProps) { + const { t } = useTranslation(); const { data: preview, isLoading: previewLoading } = useSyncPreview(); const { mutate: executeSync, isPending: isSyncing, isSuccess, reset } = useExecuteSync(); @@ -45,11 +47,9 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) { - Sync Profiles to Local CLIProxy + {t('syncDialog.title')} - - Sync your CCS API profiles to the local CLIProxy config.yaml. - + {t('syncDialog.description')}
@@ -59,8 +59,8 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
) : preview?.count === 0 ? (
-

No profiles configured to sync.

-

Create API profiles first using the Profiles tab.

+

{t('syncDialog.noProfiles')}

+

{t('syncDialog.createProfilesFirst')}

) : ( @@ -74,7 +74,7 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
{profile.name}
{profile.modelName && (
- Model: {profile.modelName} + {t('syncDialog.modelLabel')} {profile.modelName}
)} {profile.baseUrl && ( @@ -84,7 +84,7 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) { )}
- Ready + {t('syncDialog.ready')}
))} @@ -94,11 +94,11 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
- {preview?.count ?? 0} profile{(preview?.count ?? 0) !== 1 ? 's' : ''} to sync + {t('syncDialog.profilesToSync', { count: preview?.count ?? 0 })}
diff --git a/ui/src/components/cliproxy/sync/sync-status-card.tsx b/ui/src/components/cliproxy/sync/sync-status-card.tsx index 4c220292..e4054a1b 100644 --- a/ui/src/components/cliproxy/sync/sync-status-card.tsx +++ b/ui/src/components/cliproxy/sync/sync-status-card.tsx @@ -9,10 +9,12 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Loader2, RefreshCw, FileDown, Check, AlertCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; import { SyncDialog } from './sync-dialog'; import { useSyncStatus, useExecuteSync } from '@/hooks/use-cliproxy-sync'; export function SyncStatusCard() { + const { t } = useTranslation(); const [dialogOpen, setDialogOpen] = useState(false); const { data: status, isLoading, refetch } = useSyncStatus(); const { mutate: executeSync, isPending: isSyncing } = useExecuteSync(); @@ -31,7 +33,7 @@ export function SyncStatusCard() { - Profile Sync + {t('syncStatusCard.profileSync')} @@ -50,7 +52,7 @@ export function SyncStatusCard() {
- Profile Sync + {t('syncStatusCard.profileSync')} {isConfigured ? : } - {isConfigured ? 'Ready' : 'No Config'} + {isConfigured ? t('syncStatusCard.ready') : t('syncStatusCard.noConfig')}
{isConfigured && (
- Syncs API profiles to local CLIProxy config + {t('syncStatusCard.syncsProfilesDesc')}
)} {!isConfigured && ( -

- Run ccs doctor --fix to generate - config. -

+

)} {status?.error &&

{status.error}

} @@ -89,7 +93,7 @@ export function SyncStatusCard() { className="flex-1" onClick={() => setDialogOpen(true)} > - Details + {t('syncStatusCard.details')}
diff --git a/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx b/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx index 3b0cf8bf..6eec468b 100644 --- a/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx +++ b/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx @@ -4,6 +4,7 @@ import { Copy, FileCode2, Loader2, RefreshCw, Save } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { CodeEditor } from '@/components/shared/code-editor'; +import i18n from '@/lib/i18n'; import { cn } from '@/lib/utils'; interface RawJsonSettingsEditorPanelProps { @@ -39,7 +40,7 @@ export function RawJsonSettingsEditorPanel({ if (!value) return; await navigator.clipboard.writeText(value); setCopied(true); - toast.success('Settings copied to clipboard'); + toast.success(i18n.t('commonToast.settingsCopied')); window.setTimeout(() => setCopied(false), 1500); }; diff --git a/ui/src/components/copilot/config-form/model-selector.tsx b/ui/src/components/copilot/config-form/model-selector.tsx index 27638b42..1f3a91f0 100644 --- a/ui/src/components/copilot/config-form/model-selector.tsx +++ b/ui/src/components/copilot/config-form/model-selector.tsx @@ -16,6 +16,7 @@ import { import { Check } from 'lucide-react'; import type { FlexibleModelSelectorProps } from './types'; import { getPlanBadgeStyle, getMultiplierDisplay } from './utils'; +import { useTranslation } from 'react-i18next'; export function FlexibleModelSelector({ label, @@ -25,6 +26,7 @@ export function FlexibleModelSelector({ models, disabled, }: FlexibleModelSelectorProps) { + const { t } = useTranslation(); // Find current model for display const currentModel = models.find((m) => m.id === value); @@ -36,7 +38,7 @@ export function FlexibleModelSelector({
@@ -108,18 +117,18 @@ export function SettingsTab({ {/* Rate Limiting */}
-

Rate Limiting

+

{t('copilotSettings.rateLimiting')}

onUpdateRateLimit(e.target.value)} - placeholder="No limit" + placeholder={t('copilotSettings.noLimit')} min={0} className="max-w-[150px] h-8" /> @@ -128,10 +137,10 @@ export function SettingsTab({

- Wait instead of error when limit hit + {t('copilotSettings.waitOnRateLimitDesc')}

-

Daemon Settings

+

{t('copilotSettings.daemonSettings')}

- Start copilot-api when using profile + {t('copilotSettings.autoStartDaemonDesc')}

diff --git a/ui/src/components/health/health-check-item.tsx b/ui/src/components/health/health-check-item.tsx index 88268d64..6370bdc5 100644 --- a/ui/src/components/health/health-check-item.tsx +++ b/ui/src/components/health/health-check-item.tsx @@ -5,6 +5,7 @@ import { useFixHealth, type HealthCheck } from '@/hooks/use-health'; import { cn } from '@/lib/utils'; import { useState } from 'react'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; const statusConfig = { ok: { dot: 'bg-green-500', label: 'OK', labelColor: 'text-green-500' }, @@ -14,6 +15,7 @@ const statusConfig = { }; export function HealthCheckItem({ check }: { check: HealthCheck }) { + const { t } = useTranslation(); const fixMutation = useFixHealth(); const config = statusConfig[check.status]; const [isOpen, setIsOpen] = useState(false); @@ -21,7 +23,7 @@ export function HealthCheckItem({ check }: { check: HealthCheck }) { const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text); - toast.success('Copied to clipboard'); + toast.success(t('health.copied')); }; // Compact single-line display for items without expandable content @@ -62,7 +64,7 @@ export function HealthCheckItem({ check }: { check: HealthCheck }) { className="h-6 px-2 text-xs opacity-0 group-hover:opacity-100 transition-opacity" > - Fix + {t('health.fix')} )}
@@ -149,7 +151,7 @@ export function HealthCheckItem({ check }: { check: HealthCheck }) { className="h-7 px-3 text-xs" > - Apply Fix + {t('health.applyFix')} )}
diff --git a/ui/src/components/health/health-gauge.tsx b/ui/src/components/health/health-gauge.tsx index 2936b726..04c7a003 100644 --- a/ui/src/components/health/health-gauge.tsx +++ b/ui/src/components/health/health-gauge.tsx @@ -1,4 +1,5 @@ import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; interface HealthGaugeProps { passed: number; @@ -20,6 +21,7 @@ const statusColors = { }; export function HealthGauge({ passed, total, status, size = 'md' }: HealthGaugeProps) { + const { t } = useTranslation(); const config = sizeConfig[size]; const colors = statusColors[status]; const percentage = total > 0 ? Math.round((passed / total) * 100) : 0; @@ -83,7 +85,7 @@ export function HealthGauge({ passed, total, status, size = 'md' }: HealthGaugeP config.labelSize )} > - health + {t('health.healthLabel')}
diff --git a/ui/src/components/health/health-stats-bar.tsx b/ui/src/components/health/health-stats-bar.tsx index 042a3f89..bf5c84a1 100644 --- a/ui/src/components/health/health-stats-bar.tsx +++ b/ui/src/components/health/health-stats-bar.tsx @@ -1,4 +1,5 @@ import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; interface HealthStatsBarProps { total: number; @@ -28,6 +29,7 @@ function StatItem({ label, value, color, bgColor }: StatItemProps) { } export function HealthStatsBar({ total, passed, warnings, errors, info }: HealthStatsBarProps) { + const { t } = useTranslation(); // Calculate percentages for the progress bar const passedPct = (passed / total) * 100; const warningPct = (warnings / total) * 100; @@ -68,7 +70,7 @@ export function HealthStatsBar({ total, passed, warnings, errors, info }: Health
- Checks + {t('health.checks')} {total}
diff --git a/ui/src/components/layout/app-sidebar.tsx b/ui/src/components/layout/app-sidebar.tsx index 2f81dbf7..d596687c 100644 --- a/ui/src/components/layout/app-sidebar.tsx +++ b/ui/src/components/layout/app-sidebar.tsx @@ -35,6 +35,7 @@ import { useSidebar } from '@/hooks/use-sidebar'; import { useCliproxyUpdateCheck } from '@/hooks/use-cliproxy'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useTranslation } from 'react-i18next'; interface SidebarBadge { text: string; @@ -62,66 +63,69 @@ interface SidebarGroupDef { items: SidebarItem[]; } -// Define navigation groups -const navGroups: SidebarGroupDef[] = [ - { - title: 'General', - items: [ - { path: '/', icon: Home, label: 'Home' }, - { path: '/analytics', icon: BarChart3, label: 'Analytics' }, - ], - }, - { - title: 'Identity & Access', - items: [ - { - path: '/providers', - icon: Key, - label: 'API Profiles', - badge: { text: 'OpenRouter', icon: '/icons/openrouter.svg' }, - }, - { - path: '/cliproxy', - icon: Zap, - label: 'CLIProxy Plus', - isCollapsible: true, - children: [ - { path: '/cliproxy', label: 'Overview' }, - { path: '/cliproxy/control-panel', icon: Gauge, label: 'Control Panel' }, - ], - }, - { path: '/copilot', icon: Github, label: 'GitHub Copilot' }, - { path: '/cursor', iconSrc: '/assets/sidebar/cursor.svg', label: 'Cursor IDE' }, - { - path: '/accounts', - icon: Users, - label: 'Accounts', - isCollapsible: true, - children: [ - { path: '/accounts', label: 'All Accounts' }, - { path: '/shared', icon: FolderOpen, label: 'Shared Data' }, - ], - }, - ], - }, - { - title: 'Compatible CLIs', - items: [{ path: '/droid', icon: TerminalSquare, label: 'Factory Droid' }], - }, - { - title: 'System', - items: [ - { path: '/health', icon: Activity, label: 'Health' }, - { path: '/settings', icon: Settings, label: 'Settings' }, - ], - }, -]; +function buildNavGroups(t: (key: string) => string): SidebarGroupDef[] { + return [ + { + title: t('nav.general'), + items: [ + { path: '/', icon: Home, label: t('nav.home') }, + { path: '/analytics', icon: BarChart3, label: t('nav.analytics') }, + ], + }, + { + title: t('nav.identityAccess'), + items: [ + { + path: '/providers', + icon: Key, + label: t('nav.apiProfiles'), + badge: { text: 'OpenRouter', icon: '/icons/openrouter.svg' }, + }, + { + path: '/cliproxy', + icon: Zap, + label: t('nav.cliproxyPlus'), + isCollapsible: true, + children: [ + { path: '/cliproxy', label: t('nav.cliproxyOverview') }, + { path: '/cliproxy/control-panel', icon: Gauge, label: t('nav.controlPanel') }, + ], + }, + { path: '/copilot', icon: Github, label: t('nav.githubCopilot') }, + { path: '/cursor', iconSrc: '/assets/sidebar/cursor.svg', label: t('nav.cursorIde') }, + { + path: '/accounts', + icon: Users, + label: t('nav.accounts'), + isCollapsible: true, + children: [ + { path: '/accounts', label: t('nav.allAccounts') }, + { path: '/shared', icon: FolderOpen, label: t('nav.sharedData') }, + ], + }, + ], + }, + { + title: t('nav.compatibleClis'), + items: [{ path: '/droid', icon: TerminalSquare, label: t('nav.factoryDroid') }], + }, + { + title: t('nav.system'), + items: [ + { path: '/health', icon: Activity, label: t('nav.health') }, + { path: '/settings', icon: Settings, label: t('nav.settings') }, + ], + }, + ]; +} export function AppSidebar() { + const { t } = useTranslation(); const location = useLocation(); const navigate = useNavigate(); const { state } = useSidebar(); const { data: updateCheck } = useCliproxyUpdateCheck(); + const navGroups = buildNavGroups(t); // Dynamic label for CLIProxy based on backend const cliproxyLabel = updateCheck?.backendLabel ?? 'CLIProxy'; @@ -236,7 +240,7 @@ export function AppSidebar() { -

349+ models via OpenRouter

+

{t('nav.openrouterTooltip')}

)} diff --git a/ui/src/components/layout/language-switcher.tsx b/ui/src/components/layout/language-switcher.tsx new file mode 100644 index 00000000..8ab4fbbe --- /dev/null +++ b/ui/src/components/layout/language-switcher.tsx @@ -0,0 +1,38 @@ +import { Globe } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { normalizeLocale, persistLocale, SUPPORTED_LOCALES } from '@/lib/locales'; + +export function LanguageSwitcher() { + const { t, i18n } = useTranslation(); + const currentLocale = normalizeLocale(i18n.resolvedLanguage ?? i18n.language ?? 'en'); + + const handleLocaleChange = (nextLocale: string) => { + const normalized = persistLocale(nextLocale); + void i18n.changeLanguage(normalized); + }; + + return ( +
+ + +
+ ); +} diff --git a/ui/src/components/layout/layout.tsx b/ui/src/components/layout/layout.tsx index 03adb74b..0e69960d 100644 --- a/ui/src/components/layout/layout.tsx +++ b/ui/src/components/layout/layout.tsx @@ -14,6 +14,7 @@ import { SponsorButton } from '@/components/shared/sponsor-button'; import { ProjectSelectionDialog } from '@/components/shared/project-selection-dialog'; import { DeviceCodeDialog } from '@/components/shared/device-code-dialog'; import { UserMenu } from '@/components/auth/user-menu'; +import { LanguageSwitcher } from './language-switcher'; import { useProjectSelection } from '@/hooks/use-project-selection'; import { useDeviceCode } from '@/hooks/use-device-code'; import { storeLastRoute } from '@/lib/last-route'; @@ -47,6 +48,7 @@ export function Layout() {
+ diff --git a/ui/src/components/monitoring/auth-monitor/index.tsx b/ui/src/components/monitoring/auth-monitor/index.tsx index 93202ba3..1e40e6d0 100644 --- a/ui/src/components/monitoring/auth-monitor/index.tsx +++ b/ui/src/components/monitoring/auth-monitor/index.tsx @@ -5,6 +5,7 @@ */ import { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { STATUS_COLORS } from '@/lib/utils'; import { Skeleton } from '@/components/ui/skeleton'; import { AccountFlowViz } from '@/components/account-flow-viz'; @@ -20,6 +21,7 @@ import { SummaryCard } from './components/summary-card'; const STORAGE_KEY = 'auth-monitor-selected-provider'; export function AuthMonitor() { + const { t } = useTranslation(); const { accounts, totalSuccess, @@ -117,7 +119,7 @@ export function AuthMonitor() { Updated {timeSinceUpdate || 'now'}
| - {accounts.length} accounts + {t('authMonitor.accountsCount', { count: accounts.length })} {totalRequests.toLocaleString()} req
@@ -126,25 +128,25 @@ export function AuthMonitor() {
} - label="Accounts" + label={t('authMonitor.accounts')} value={accounts.length} color="var(--accent)" /> } - label="Success" + label={t('authMonitor.success')} value={totalSuccess.toLocaleString()} color={STATUS_COLORS.success} /> } - label="Failed" + label={t('authMonitor.failed')} value={totalFailure.toLocaleString()} color={totalFailure > 0 ? STATUS_COLORS.failed : undefined} /> } - label="Success Rate" + label={t('authMonitor.successRate')} value={`${overallSuccessRate}%`} color={ overallSuccessRate === 100 diff --git a/ui/src/components/monitoring/error-logs/index.tsx b/ui/src/components/monitoring/error-logs/index.tsx index 8f8cea22..2b5c6501 100644 --- a/ui/src/components/monitoring/error-logs/index.tsx +++ b/ui/src/components/monitoring/error-logs/index.tsx @@ -14,10 +14,12 @@ import { useCliproxyStatus } from '@/hooks/use-cliproxy-stats'; import { Skeleton } from '@/components/ui/skeleton'; import { ScrollArea } from '@/components/ui/scroll-area'; import { AlertTriangle, FileWarning, GripVertical, GripHorizontal } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { ErrorLogItem } from './error-log-item'; import { LogContentPanel } from './log-content-panel'; export function ErrorLogsMonitor() { + const { t } = useTranslation(); const { data: status, isLoading: isStatusLoading } = useCliproxyStatus(); const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running ?? false); @@ -142,14 +144,14 @@ export function ErrorLogsMonitor() {
- Error Logs + {t('errorLogs.title')} - {errorCount} failed request{errorCount !== 1 ? 's' : ''} + {t('errorLogs.failedRequests', { count: errorCount })}
- CLIProxy Diagnostics + {t('errorLogs.cliproxyDiagnostics')}
@@ -175,7 +177,7 @@ export function ErrorLogsMonitor() {
{logs.length > 50 && (
- Showing 50 of {logs.length} logs + {t('errorLogs.showingCount', { total: logs.length })}
)} diff --git a/ui/src/components/monitoring/error-logs/log-content-panel.tsx b/ui/src/components/monitoring/error-logs/log-content-panel.tsx index 45804590..46fc5a72 100644 --- a/ui/src/components/monitoring/error-logs/log-content-panel.tsx +++ b/ui/src/components/monitoring/error-logs/log-content-panel.tsx @@ -8,12 +8,14 @@ import { useCliproxyErrorLogContent } from '@/hooks/use-cliproxy-stats'; import { Skeleton } from '@/components/ui/skeleton'; import { CopyButton } from '@/components/ui/copy-button'; import { Terminal, Info, Code, ArrowUpRight, ArrowDownLeft, FileText } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { parseErrorLog } from '@/lib/error-log-parser'; import type { TabType, LogContentPanelProps } from './types'; import { TabButton, StatusBadge } from './ui-primitives'; import { OverviewTab, HeadersTab, BodyTab, RawTab } from './tab-components'; export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) { + const { t } = useTranslation(); const [activeTab, setActiveTab] = useState('overview'); const { data: content, isLoading, error } = useCliproxyErrorLogContent(name); @@ -29,7 +31,7 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
-

Select a log to view details

+

{t('errorLogs.selectLog')}

); @@ -51,7 +53,7 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) { if (error || !content || !parsed) { return (
-

Failed to load log content

+

{t('errorLogs.failedLoadContent')}

); } @@ -69,7 +71,7 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) { {name && ( @@ -80,7 +82,7 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) { {content && ( setActiveTab('overview')} icon={Info} > - Overview + {t('errorLogs.tabOverview')} setActiveTab('headers')} icon={Code} > - Headers + {t('errorLogs.tabHeaders')} setActiveTab('request')} icon={ArrowUpRight} > - Request + {t('errorLogs.tabRequest')} setActiveTab('response')} icon={ArrowDownLeft} > - Response + {t('errorLogs.tabResponse')} setActiveTab('raw')} icon={FileText}> - Raw + {t('errorLogs.tabRaw')}
diff --git a/ui/src/components/monitoring/error-logs/tab-components.tsx b/ui/src/components/monitoring/error-logs/tab-components.tsx index bba80c37..da2374b8 100644 --- a/ui/src/components/monitoring/error-logs/tab-components.tsx +++ b/ui/src/components/monitoring/error-logs/tab-components.tsx @@ -5,6 +5,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Info, Clock, Cpu, AlertTriangle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { getErrorTypeLabel, @@ -16,9 +17,11 @@ import { StatusBadge } from './ui-primitives'; /** Overview tab content */ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) { + const { t } = useTranslation(); const quotaResetDisplay = formatQuotaResetDelay(parsed.quotaResetDelay) || formatQuotaResetTimestamp(parsed.quotaResetTimestamp); + const na = t('errorLogs.na'); return ( @@ -37,7 +40,7 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
- Model: + {t('errorLogs.modelLabel')} {parsed.model} @@ -50,7 +53,7 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
- Quota resets in + {t('errorLogs.quotaResetsIn')} {quotaResetDisplay} @@ -61,37 +64,37 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) { {/* Key metrics grid */}
-
Method
-
{parsed.method || 'N/A'}
+
{t('errorLogs.method')}
+
{parsed.method || na}
-
Provider
-
{parsed.provider || 'N/A'}
+
{t('errorLogs.provider')}
+
{parsed.provider || na}
-
Version
-
{parsed.version || 'N/A'}
+
{t('errorLogs.version')}
+
{parsed.version || na}
-
Endpoint
+
{t('errorLogs.endpoint')}
- {parsed.endpoint || 'N/A'} + {parsed.endpoint || na}
{/* URL */}
-
URL
+
{t('errorLogs.url')}
- {parsed.url || 'N/A'} + {parsed.url || na}
{/* Timestamp */}
-
Timestamp
-
{parsed.timestamp || 'N/A'}
+
{t('errorLogs.timestamp')}
+
{parsed.timestamp || na}
{/* Actionable suggestion based on error type */} @@ -123,27 +126,14 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) { : 'text-blue-600 dark:text-blue-400' )} > - {parsed.errorType === 'rate_limit' && ( - <> - Rate Limited. Switch to a different account or wait for quota - reset. - {parsed.model && ( - <> - {' '} - Model {parsed.model} has - exhausted quota. - - )} - - )} - {parsed.errorType === 'auth' && - 'Authentication failed. Re-authenticate via CLIProxy Settings or check API key.'} - {parsed.errorType === 'not_found' && - 'Endpoint not found. This endpoint may not exist on this provider.'} - {parsed.errorType === 'server' && - 'Server error from upstream. Retry later or check provider status page.'} - {parsed.errorType === 'timeout' && - 'Request timed out. Check network connection or increase timeout settings.'} + {parsed.errorType === 'rate_limit' && + (parsed.model + ? t('errorLogs.rateLimitedMsg', { model: parsed.model }) + : t('errorLogs.rateLimitedMsgNoModel'))} + {parsed.errorType === 'auth' && t('errorLogs.authFailedMsg')} + {parsed.errorType === 'not_found' && t('errorLogs.notFoundMsg')} + {parsed.errorType === 'server' && t('errorLogs.serverErrorMsg')} + {parsed.errorType === 'timeout' && t('errorLogs.timeoutMsg')}
)} @@ -178,8 +168,10 @@ export function HeadersTab({ headers }: { headers: Record }) { /** JSON/Body tab content */ export function BodyTab({ content, label }: { content: string; label: string }) { + const { t } = useTranslation(); if (!content || content.trim() === '') { - return
No {label.toLowerCase()} body
; + const key = label === 'Request' ? 'errorLogs.noRequestBody' : 'errorLogs.noResponseBody'; + return
{t(key)}
; } // Try to format as JSON diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index 043a3c5f..62ed0b7c 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -51,6 +51,7 @@ import { import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useQuery, useIsMutating } from '@tanstack/react-query'; import { api, type CliproxyServerConfig } from '@/lib/api-client'; +import { useTranslation } from 'react-i18next'; import { useProxyStatus, useStartProxy, @@ -89,15 +90,17 @@ function formatUptime(startedAt?: string): string { return `${minutes}m`; } -function formatTimeAgo(timestamp?: number): string { +function formatTimeAgo( + timestamp: number | undefined, + t: (key: string, opts?: { count?: number }) => string +): string { if (!timestamp) return ''; const diff = Date.now() - timestamp; const minutes = Math.floor(diff / (1000 * 60)); const hours = Math.floor(diff / (1000 * 60 * 60)); - - if (minutes < 1) return 'just now'; - if (minutes < 60) return `${minutes}m ago`; - return `${hours}h ago`; + if (minutes < 1) return t('proxyStatusWidget.justNow'); + if (minutes < 60) return t('proxyStatusWidget.minutesAgo', { count: minutes }); + return t('proxyStatusWidget.hoursAgo', { count: hours }); } /** Icon button with tooltip wrapper */ @@ -148,6 +151,7 @@ function IconButton({ } export function ProxyStatusWidget() { + const { t } = useTranslation(); const { data: status, isLoading } = useProxyStatus(); const { data: updateCheck } = useCliproxyUpdateCheck(); const { data: versionsData, isLoading: versionsLoading } = useCliproxyVersions(); @@ -196,7 +200,9 @@ export function ProxyStatusWidget() { // Sync status const isSyncConfigured = syncStatus?.configured ?? false; - const syncStatusText = isSyncConfigured ? 'Sync Ready' : 'No Config'; + const syncStatusText = isSyncConfigured + ? t('proxyStatusWidget.syncReady') + : t('proxyStatusWidget.noConfig'); // Target version for update/downgrade badge const targetVersion = isUnstable @@ -257,12 +263,12 @@ export function ProxyStatusWidget() {
- Remote Proxy + {t('proxyStatusWidget.remoteProxy')} - Active + {t('proxyStatusWidget.active')}
@@ -273,7 +279,7 @@ export function ProxyStatusWidget() { {remoteDisplayHost}

- Traffic auto-routed to remote server + {t('proxyStatusWidget.trafficAutoRouted')}

@@ -310,21 +316,21 @@ export function ProxyStatusWidget() { <> executeSync()} disabled={!isSyncConfigured || isActioning} isPending={isSyncing} /> restartProxy.mutate()} disabled={isActioning} isPending={restartProxy.isPending} /> stopProxy.mutate()} disabled={isActioning} isPending={stopProxy.isPending} @@ -335,7 +341,11 @@ export function ProxyStatusWidget() { {/* Settings button always visible */} setIsExpanded(!isExpanded)} className={isExpanded ? 'bg-muted' : undefined} /> @@ -363,7 +373,11 @@ export function ProxyStatusWidget() { : 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50' )} onClick={() => handleInstallVersion(targetVersion)} - title={`Click to ${isUnstable ? 'downgrade' : 'update'}`} + title={ + isUnstable + ? t('proxyStatusWidget.clickToDowngrade') + : t('proxyStatusWidget.clickToUpdate') + } > {isUnstable ? ( @@ -379,11 +393,13 @@ export function ProxyStatusWidget() { {/* Stats row when running */} {isRunning && status && (
- Port {status.port} + + {t('proxyStatusWidget.port', { port: status.port })} + {status.sessionCount !== undefined && status.sessionCount > 0 && ( - {status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''} + {t('proxyStatusWidget.sessionCount', { count: status.sessionCount })} )} {status.startedAt && ( @@ -416,7 +432,9 @@ export function ProxyStatusWidget() { {/* Section header */} -

Version Management

+

+ {t('proxyStatusWidget.versionManagement')} +

{/* Version picker row */}
@@ -427,7 +445,7 @@ export function ProxyStatusWidget() { disabled={versionsLoading} > - + {versionsData?.versions.slice(0, 20).map((v) => { @@ -439,7 +457,9 @@ export function ProxyStatusWidget() { v{v} {v === versionsData.latestStable && ( - (stable) + + {t('proxyStatusWidget.stable')} + )} {vIsUnstable && ( @@ -464,7 +484,7 @@ export function ProxyStatusWidget() { ) : ( )} - Install + {t('proxyStatusWidget.install')}
@@ -474,14 +494,20 @@ export function ProxyStatusWidget() { isNewerVersionClient(selectedVersion, versionsData.maxStableVersion) && (
- Versions above {versionsData.maxStableVersion} have known issues + + {t('proxyStatusWidget.versionsAboveUnstable', { + version: versionsData.maxStableVersion, + })} +
)} {/* Sync time */} {updateCheck?.checkedAt && (
- Last checked {formatTimeAgo(updateCheck.checkedAt)} + {t('proxyStatusWidget.lastChecked', { + time: formatTimeAgo(updateCheck.checkedAt, t), + })}
)}
@@ -490,7 +516,9 @@ export function ProxyStatusWidget() { {/* Not running state */} {!isRunning && (
- Not running + + {t('proxyStatusWidget.notRunning')} +
)} @@ -514,27 +542,32 @@ export function ProxyStatusWidget() { - Install Unstable Version? + {t('proxyStatusWidget.installUnstableTitle')} -

- You are about to install v{pendingInstallVersion}, which is above - the maximum stable version{' '} - v{versionsData?.maxStableVersion || '6.6.80'}. -

+

- This version has known stability issues and may cause unexpected behavior. + {t('proxyStatusWidget.installUnstableWarning')}

-

Are you sure you want to proceed?

+

{t('proxyStatusWidget.installUnstableConfirm')}

- Cancel + + {t('proxyStatusWidget.cancel')} + - Install Anyway + {t('proxyStatusWidget.installAnyway')} diff --git a/ui/src/components/profiles/editor/env-editor-section.tsx b/ui/src/components/profiles/editor/env-editor-section.tsx index f9ff8c30..1a42f09f 100644 --- a/ui/src/components/profiles/editor/env-editor-section.tsx +++ b/ui/src/components/profiles/editor/env-editor-section.tsx @@ -12,6 +12,7 @@ import { MaskedInput } from '@/components/ui/masked-input'; import { Plus } from 'lucide-react'; import { isSensitiveKey } from './utils'; import type { Settings } from './types'; +import { useTranslation } from 'react-i18next'; interface EnvEditorSectionProps { currentSettings: Settings | undefined; @@ -32,6 +33,7 @@ export function EnvEditorSection({ onEnvValueChange, onAddEnvVar, }: EnvEditorSectionProps) { + const { t } = useTranslation(); return ( <> {/* Scrollable Environment Variables List */} @@ -45,7 +47,7 @@ export function EnvEditorSection({ {key} {isSensitiveKey(key) && ( - sensitive + {t('envEditor.sensitive')} )} @@ -67,10 +69,8 @@ export function EnvEditorSection({ ) : (
-

No environment variables configured.

-

- Add variables using the input below or edit the JSON directly. -

+

{t('envEditor.none')}

+

{t('envEditor.noneHint')}

)}
@@ -79,18 +79,18 @@ export function EnvEditorSection({ {/* Fixed Add Input at Bottom */}
onNewEnvKeyChange(e.target.value.toUpperCase())} className="font-mono text-sm h-8 w-2/5" onKeyDown={(e) => e.key === 'Enter' && newEnvKey.trim() && onAddEnvVar()} /> onNewEnvValueChange(e.target.value)} className="font-mono text-sm h-8 flex-1" diff --git a/ui/src/components/profiles/editor/friendly-ui-section.tsx b/ui/src/components/profiles/editor/friendly-ui-section.tsx index 7a5bdeed..10cec625 100644 --- a/ui/src/components/profiles/editor/friendly-ui-section.tsx +++ b/ui/src/components/profiles/editor/friendly-ui-section.tsx @@ -19,6 +19,7 @@ import { ChevronRight, Settings2, Plus } from 'lucide-react'; import { isOpenRouterProfile, extractTierMapping, applyTierMapping } from './utils'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; +import i18n from '@/lib/i18n'; import type { Settings, SettingsResponse } from './types'; import type { CliTarget } from '@/lib/api-client'; @@ -81,7 +82,7 @@ export function FriendlyUISection({ onEnvValueChange('ANTHROPIC_DEFAULT_HAIKU_MODEL', modelId); } // Show feedback toast - toast.success('Applied model to all tiers', { duration: 2000 }); + toast.success(i18n.t('commonToast.appliedModelAllTiers'), { duration: 2000 }); }; // Handle tier mapping change diff --git a/ui/src/components/profiles/editor/index.tsx b/ui/src/components/profiles/editor/index.tsx index 25bb4d37..d13c4353 100644 --- a/ui/src/components/profiles/editor/index.tsx +++ b/ui/src/components/profiles/editor/index.tsx @@ -16,6 +16,7 @@ import { FriendlyUISection } from './friendly-ui-section'; import { RawEditorSection } from './raw-editor-section'; import type { ProfileEditorProps, Settings, SettingsResponse } from './types'; import { api, type CliTarget } from '@/lib/api-client'; +import i18n from '@/lib/i18n'; export function ProfileEditor({ profileName, @@ -143,7 +144,7 @@ export function ProfileEditor({ queryClient.invalidateQueries({ queryKey: ['profiles'] }); setLocalEdits({}); setRawJsonEdits(null); - toast.success('Settings saved'); + toast.success(i18n.t('commonToast.settingsSaved')); }, onError: (error: Error) => { if (error.message === 'CONFLICT') setConflictDialog(true); @@ -158,12 +159,12 @@ export function ProfileEditor({ }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['profiles'] }); - toast.success('Default target updated'); + toast.success(i18n.t('commonToast.defaultTargetUpdated')); }, onError: (error: Error, target: CliTarget) => { const targetLabel = target === 'droid' ? 'Factory Droid' : 'Claude Code'; const suffix = error.message.trim() ? `: ${error.message}` : ''; - toast.error(`Failed to update default target to ${targetLabel}${suffix}`); + toast.error(i18n.t('commonToast.failedUpdateDefaultTarget', { target: targetLabel, suffix })); }, }); diff --git a/ui/src/components/profiles/editor/info-section.tsx b/ui/src/components/profiles/editor/info-section.tsx index acfc6b1c..38ee6916 100644 --- a/ui/src/components/profiles/editor/info-section.tsx +++ b/ui/src/components/profiles/editor/info-section.tsx @@ -3,6 +3,7 @@ * Displays profile information and usage commands */ +import { useTranslation } from 'react-i18next'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Label } from '@/components/ui/label'; import { CopyButton } from '@/components/ui/copy-button'; @@ -17,6 +18,7 @@ interface InfoSectionProps { } export function InfoSection({ profileName, target, data }: InfoSectionProps) { + const { t } = useTranslation(); const isDroidTarget = target === 'droid'; return ( @@ -26,17 +28,21 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {

- Profile Information + {t('profileEditor.profileInfo')}

{data && ( <>
- Profile Name + + {t('profileEditor.profileName')} + {data.profile}
- File Path + + {t('profileEditor.filePath')} +
{data.path} @@ -45,11 +51,15 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
- Last Modified + + {t('profileEditor.lastModified')} + {new Date(data.mtime).toLocaleString()}
- Default Target + + {t('profileEditor.defaultTarget')} + {target}
@@ -59,10 +69,12 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) { {/* Usage */}
-

Quick Usage

+

{t('profileEditor.quickUsage')}

- +
ccs {profileName} "prompt" @@ -72,7 +84,9 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
@@ -93,7 +107,9 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
@@ -107,7 +123,9 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
- +
ccs default {profileName} diff --git a/ui/src/components/profiles/model-tier-mapping.tsx b/ui/src/components/profiles/model-tier-mapping.tsx index 712e43c0..2cd87fbc 100644 --- a/ui/src/components/profiles/model-tier-mapping.tsx +++ b/ui/src/components/profiles/model-tier-mapping.tsx @@ -12,6 +12,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models'; import { suggestTierMappings } from '@/lib/openrouter-utils'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; export interface TierMapping { opus?: string; @@ -32,6 +33,7 @@ export function ModelTierMapping({ onChange, className, }: ModelTierMappingProps) { + const { t } = useTranslation(); const { models } = useOpenRouterCatalog(); const suggestions = useMemo(() => { @@ -53,18 +55,18 @@ export function ModelTierMapping({ - Model Tier Mapping - (Advanced) + {t('modelTierMapping.title')} + + ({t('modelTierMapping.advanced')}) + -

- Configure different models for Claude Code's opus/sonnet/haiku tiers. -

+

{t('modelTierMapping.description')}

{hasSuggestions && ( )} @@ -77,7 +79,7 @@ export function ModelTierMapping({ id="tier-opus" value={value.opus ?? ''} onChange={(e) => updateTier('opus', e.target.value)} - placeholder="e.g., anthropic/claude-opus-4" + placeholder={t('modelTierMapping.opusPlaceholder')} />
@@ -88,7 +90,7 @@ export function ModelTierMapping({ id="tier-sonnet" value={value.sonnet ?? ''} onChange={(e) => updateTier('sonnet', e.target.value)} - placeholder="e.g., anthropic/claude-sonnet-4" + placeholder={t('modelTierMapping.sonnetPlaceholder')} />
@@ -99,15 +101,12 @@ export function ModelTierMapping({ id="tier-haiku" value={value.haiku ?? ''} onChange={(e) => updateTier('haiku', e.target.value)} - placeholder="e.g., anthropic/claude-3.5-haiku" + placeholder={t('modelTierMapping.haikuPlaceholder')} />
-

- These set ANTHROPIC_DEFAULT_OPUS_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL, - ANTHROPIC_DEFAULT_HAIKU_MODEL. -

+

{t('modelTierMapping.footer')}

); diff --git a/ui/src/components/profiles/openrouter-quick-start.tsx b/ui/src/components/profiles/openrouter-quick-start.tsx index 3d9260dc..5cdf62c7 100644 --- a/ui/src/components/profiles/openrouter-quick-start.tsx +++ b/ui/src/components/profiles/openrouter-quick-start.tsx @@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge'; import { Separator } from '@/components/ui/separator'; import { useOpenRouterReady } from '@/hooks/use-openrouter-models'; import { Sparkles, ExternalLink, ArrowRight, Zap } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; interface OpenRouterQuickStartProps { onOpenRouterClick: () => void; @@ -19,6 +20,7 @@ export function OpenRouterQuickStart({ onOpenRouterClick, onCustomClick, }: OpenRouterQuickStartProps) { + const { t } = useTranslation(); const { modelCount, isLoading } = useOpenRouterReady(); return ( @@ -35,13 +37,14 @@ export function OpenRouterQuickStart({ variant="secondary" className="bg-accent/10 text-accent dark:bg-accent/20 dark:text-accent-foreground" > - Recommended + {t('openrouterQuickStart.recommended')}
- Start with OpenRouter + {t('openrouterQuickStart.title')} - Access {isLoading ? '300+' : `${modelCount}+`} models from OpenAI, Anthropic, Google, - Meta and more - all through one API. + {t('openrouterQuickStart.description', { + modelCountLabel: isLoading ? '300+' : `${modelCount}+`, + })} @@ -49,11 +52,11 @@ export function OpenRouterQuickStart({
- One API, all providers + {t('openrouterQuickStart.featureOneApi')}
- Model tier mapping + {t('openrouterQuickStart.featureTierMapping')}
@@ -62,12 +65,12 @@ export function OpenRouterQuickStart({ className="w-full bg-accent hover:bg-accent/90 text-white" size="lg" > - Create OpenRouter Profile + {t('openrouterQuickStart.createOpenRouterProfile')}

- Get your API key at{' '} + {t('openrouterQuickStart.getApiKeyAt')}{' '} - or + {t('openrouterQuickStart.or')}

{/* Custom Option */}
diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index 510e165a..b5a00388 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -32,6 +32,7 @@ import { Badge } from '@/components/ui/badge'; import { useCreateProfile } from '@/hooks/use-profiles'; import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models'; import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff, Settings2, Sparkles } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import { @@ -49,6 +50,7 @@ import { } from '@/lib/openrouter-utils'; import type { CategorizedModel } from '@/lib/openrouter-types'; import type { CliTarget } from '@/lib/api-client'; +import i18n from '@/lib/i18n'; const schema = z.object({ name: z @@ -104,6 +106,7 @@ export function ProfileCreateDialog({ onSuccess, initialMode = 'openrouter', }: ProfileCreateDialogProps) { + const { t } = useTranslation(); const createMutation = useCreateProfile(); const [activeTab, setActiveTab] = useState('basic'); const [urlWarning, setUrlWarning] = useState(null); @@ -242,7 +245,7 @@ export function ProfileCreateDialog({ const onSubmit = async (data: FormData) => { // Validate API key - required unless preset has requiresApiKey: false if (currentPreset?.requiresApiKey !== false && !data.apiKey) { - toast.error('API key is required'); + toast.error(i18n.t('commonToast.apiKeyRequired')); return; } // Use user-provided baseUrl (allows customization of preset URLs) @@ -290,7 +293,9 @@ export function ProfileCreateDialog({
{/* Main Options: OpenRouter + Custom */}
- +
{RECOMMENDED_PRESETS.map((preset) => ( - Custom + {t('profileEditor.custom')}
diff --git a/ui/src/components/profiles/profile-dialog.tsx b/ui/src/components/profiles/profile-dialog.tsx index f02ed87b..b6873580 100644 --- a/ui/src/components/profiles/profile-dialog.tsx +++ b/ui/src/components/profiles/profile-dialog.tsx @@ -16,6 +16,7 @@ import { Label } from '@/components/ui/label'; import { useCreateProfile, useUpdateProfile } from '@/hooks/use-profiles'; import type { Profile } from '@/lib/api-client'; import { ChevronDown, ChevronRight } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929'; @@ -41,6 +42,7 @@ interface ProfileDialogProps { } export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { + const { t } = useTranslation(); const createMutation = useCreateProfile(); const updateMutation = useUpdateProfile(); const [showModelMapping, setShowModelMapping] = useState(false); @@ -115,34 +117,45 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { - {profile ? 'Edit Profile' : 'Create API Profile'} + + {profile ? t('profileDialog.editTitle') : t('profileDialog.createTitle')} +
- - + + {errors.name && {errors.name.message}}
- - + + {errors.baseUrl && ( {errors.baseUrl.message} )}
- + {errors.apiKey && {errors.apiKey.message}}
- +

- Leave blank to use: {DEFAULT_MODEL} + {t('profileDialog.defaultModelHint', { model: DEFAULT_MODEL })}

@@ -153,7 +166,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { className="w-full flex items-center justify-between p-3 text-sm font-medium hover:bg-muted/50 transition-colors" onClick={() => setShowModelMapping(!showModelMapping)} > - Model Mapping (Opus/Sonnet/Haiku) + {t('profileDialog.modelMappingTitle')} {showModelMapping ? ( ) : ( @@ -164,13 +177,12 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { {showModelMapping && (

- Configure different model IDs for each tier. Useful for API proxies that route - different model types to different backends. + {t('profileDialog.modelMappingDesc')}

diff --git a/ui/src/components/setup/wizard/index.tsx b/ui/src/components/setup/wizard/index.tsx index 0b894eff..16576155 100644 --- a/ui/src/components/setup/wizard/index.tsx +++ b/ui/src/components/setup/wizard/index.tsx @@ -24,6 +24,7 @@ import { import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; import type { CLIProxyProvider } from '@/lib/provider-config'; import { applyDefaultPreset } from '@/lib/preset-utils'; +import i18n from '@/lib/i18n'; import { usePrivacy } from '@/contexts/privacy-context'; import { toast } from 'sonner'; @@ -100,7 +101,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { if (result.success && result.presetName) { toast.success(`Applied "${result.presetName}" preset`); } else if (!result.success) { - toast.warning('Account added, but failed to apply default preset'); + toast.warning(i18n.t('commonToast.accountAddedPresetFailed')); } } diff --git a/ui/src/components/setup/wizard/steps/variant-step.tsx b/ui/src/components/setup/wizard/steps/variant-step.tsx index 97e99166..15befb3c 100644 --- a/ui/src/components/setup/wizard/steps/variant-step.tsx +++ b/ui/src/components/setup/wizard/steps/variant-step.tsx @@ -18,6 +18,7 @@ import { cn } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { MODEL_CATALOGS } from '@/lib/model-catalogs'; import type { VariantStepProps } from '../types'; +import { useTranslation } from 'react-i18next'; const CUSTOM_MODEL_VALUE = '__custom__'; @@ -34,6 +35,7 @@ export function VariantStep({ onSkip, onCreate, }: VariantStepProps) { + const { t } = useTranslation(); // Track if user selected custom model option const catalogModels = MODEL_CATALOGS[selectedProvider]?.models || []; const isCustomModel = modelName && !catalogModels.some((m) => m.id === modelName); @@ -55,7 +57,7 @@ export function VariantStep({
- Using:{' '} + {t('setupVariant.using')}{' '} {selectedAccount.email || selectedAccount.id} @@ -64,33 +66,33 @@ export function VariantStep({ )}
- + onVariantNameChange(e.target.value)} - placeholder="e.g., my-gemini, g3, flash" + placeholder={t('setupVariant.variantNamePlaceholder')} />
- Use this name to invoke: ccs {variantName || ''} "prompt" + {t('setupVariant.invokeHintPrefix')} ccs {variantName || ''} "prompt"
- + {showCustomInput ? (
onModelChange(e.target.value)} - placeholder="e.g., gemini-2.5-pro, claude-opus-4-5" + placeholder={t('setupVariant.modelPlaceholder')} />
) : ( @@ -99,7 +101,7 @@ export function VariantStep({ onValueChange={handleModelSelect} > - + {catalogModels.map((m) => ( @@ -113,35 +115,37 @@ export function VariantStep({ ))} - Custom model name... + {t('setupVariant.customModelName')} )}
{showCustomInput - ? 'Enter any model name supported by CLIProxy' - : `Default: ${MODEL_CATALOGS[selectedProvider]?.defaultModel || 'provider default'}`} + ? t('setupVariant.enterAnyModel') + : t('setupVariant.defaultModel', { + model: + MODEL_CATALOGS[selectedProvider]?.defaultModel || + t('setupVariant.providerDefault'), + })}
-

- Skip if you just wanted to add an account without creating a variant -

+

{t('setupVariant.skipHint')}

); } diff --git a/ui/src/components/shared/device-code-dialog.tsx b/ui/src/components/shared/device-code-dialog.tsx index 809f4a43..9183be6a 100644 --- a/ui/src/components/shared/device-code-dialog.tsx +++ b/ui/src/components/shared/device-code-dialog.tsx @@ -21,6 +21,7 @@ import { getDeviceCodeProviderDisplayName, getDeviceCodeProviderInstruction, } from '@/lib/provider-config'; +import i18n from '@/lib/i18n'; interface DeviceCodeDialogProps { open: boolean; @@ -72,10 +73,10 @@ export function DeviceCodeDialog({ try { await navigator.clipboard.writeText(userCode); setHasCopied(true); - toast.success('Code copied to clipboard'); + toast.success(i18n.t('commonToast.codeCopied')); setTimeout(() => setHasCopied(false), 2000); } catch { - toast.error('Failed to copy code'); + toast.error(i18n.t('commonToast.copyCodeFailed')); } }, [userCode]); diff --git a/ui/src/components/shared/settings-dialog.tsx b/ui/src/components/shared/settings-dialog.tsx index f0f6c760..faeffb54 100644 --- a/ui/src/components/shared/settings-dialog.tsx +++ b/ui/src/components/shared/settings-dialog.tsx @@ -20,6 +20,7 @@ import { MaskedInput } from '@/components/ui/masked-input'; import { ConfirmDialog } from './confirm-dialog'; import { Save, X, Loader2, Code2 } from 'lucide-react'; import { toast } from 'sonner'; +import i18n from '@/lib/i18n'; // Lazy load CodeEditor to reduce initial bundle size const CodeEditor = lazy(() => import('./code-editor').then((m) => ({ default: m.CodeEditor }))); @@ -154,7 +155,7 @@ function SettingsDialogContent({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['settings', profileName] }); queryClient.invalidateQueries({ queryKey: ['profiles'] }); - toast.success('Settings saved'); + toast.success(i18n.t('commonToast.settingsSaved')); onClose(); }, onError: (error: Error) => { diff --git a/ui/src/components/updates/notice-progress-badge.tsx b/ui/src/components/updates/notice-progress-badge.tsx index 5b6c77ec..fa53c37c 100644 --- a/ui/src/components/updates/notice-progress-badge.tsx +++ b/ui/src/components/updates/notice-progress-badge.tsx @@ -1,33 +1,7 @@ import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import { type NoticeProgressState } from '@/lib/updates-notice-state'; - -const NOTICE_PROGRESS_META: Record< - NoticeProgressState, - { label: string; className: string; showDot?: boolean } -> = { - new: { - label: 'Needs Action', - className: - 'border-amber-300/70 bg-amber-100/70 text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-300', - showDot: true, - }, - seen: { - label: 'In Review', - className: - 'border-blue-300/70 bg-blue-100/70 text-blue-800 dark:border-blue-500/40 dark:bg-blue-500/15 dark:text-blue-300', - }, - done: { - label: 'Done', - className: - 'border-emerald-300/70 bg-emerald-100/70 text-emerald-800 dark:border-emerald-500/40 dark:bg-emerald-500/15 dark:text-emerald-300', - }, - dismissed: { - label: 'Dismissed', - className: - 'border-muted-foreground/20 bg-muted text-muted-foreground dark:border-muted-foreground/30', - }, -}; +import { useTranslation } from 'react-i18next'; export function NoticeProgressBadge({ state, @@ -36,7 +10,30 @@ export function NoticeProgressBadge({ state: NoticeProgressState; className?: string; }) { - const meta = NOTICE_PROGRESS_META[state]; + const { t } = useTranslation(); + const meta = { + new: { + label: t('updates.progressNeedsAction'), + className: + 'border-amber-300/70 bg-amber-100/70 text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-300', + showDot: true, + }, + seen: { + label: t('updates.progressInReview'), + className: + 'border-blue-300/70 bg-blue-100/70 text-blue-800 dark:border-blue-500/40 dark:bg-blue-500/15 dark:text-blue-300', + }, + done: { + label: t('updates.progressDone'), + className: + 'border-emerald-300/70 bg-emerald-100/70 text-emerald-800 dark:border-emerald-500/40 dark:bg-emerald-500/15 dark:text-emerald-300', + }, + dismissed: { + label: t('updates.progressDismissed'), + className: + 'border-muted-foreground/20 bg-muted text-muted-foreground dark:border-muted-foreground/30', + }, + }[state] as { label: string; className: string; showDot?: boolean }; return ( = { target: @@ -27,6 +22,13 @@ const SCOPE_STYLES: Record = { }; export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) { + const { t } = useTranslation(); + const pillarLabels: { key: keyof CliSupportEntry['pillars']; label: string }[] = [ + { key: 'baseUrl', label: 'Base URL' }, + { key: 'auth', label: 'Auth' }, + { key: 'model', label: 'Model' }, + ]; + return ( @@ -45,7 +47,7 @@ export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {
- {PILLAR_LABELS.map((pillar) => ( + {pillarLabels.map((pillar) => (

{pillar.label} @@ -57,7 +59,7 @@ export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {

- Dashboard + {t('updates.dashboard')}

{entry.routes.map((route) => ( @@ -75,7 +77,7 @@ export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {

- CLI Usage + {t('updates.cliUsage')}

{entry.commands.slice(0, 2).map((command) => ( diff --git a/ui/src/components/updates/support-status-badge.tsx b/ui/src/components/updates/support-status-badge.tsx index 2ab10cd8..a150912c 100644 --- a/ui/src/components/updates/support-status-badge.tsx +++ b/ui/src/components/updates/support-status-badge.tsx @@ -1,12 +1,7 @@ import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import type { SupportStatus } from '@/lib/support-updates-catalog'; - -const STATUS_LABELS: Record = { - new: 'New', - stable: 'Stable', - planned: 'Planned', -}; +import { useTranslation } from 'react-i18next'; const STATUS_STYLES: Record = { new: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-300', @@ -23,9 +18,16 @@ export function SupportStatusBadge({ status: SupportStatus; className?: string; }) { + const { t } = useTranslation(); + const labels: Record = { + new: t('updates.statusNew'), + stable: t('updates.statusStable'), + planned: t('updates.statusPlanned'), + }; + return ( - {STATUS_LABELS[status]} + {labels[status]} ); } diff --git a/ui/src/components/updates/updates-details-panel.tsx b/ui/src/components/updates/updates-details-panel.tsx index 8a7c107f..566b9b36 100644 --- a/ui/src/components/updates/updates-details-panel.tsx +++ b/ui/src/components/updates/updates-details-panel.tsx @@ -15,6 +15,7 @@ import { type SupportNotice, } from '@/lib/support-updates-catalog'; import { type NoticeProgressState } from '@/lib/updates-notice-state'; +import { useTranslation } from 'react-i18next'; type UpdatableNoticeProgress = 'new' | 'seen' | 'done' | 'dismissed'; @@ -29,10 +30,11 @@ export function UpdatesDetailsPanel({ relatedEntries: CliSupportEntry[]; onUpdateProgress: (nextState: UpdatableNoticeProgress) => void; }) { + const { t } = useTranslation(); if (!notice) { return (
-

No updates available.

+

{t('updates.noUpdates')}

); } @@ -53,21 +55,21 @@ export function UpdatesDetailsPanel({
- Published {formatCatalogDate(notice.publishedAt)} + {t('updates.published', { date: formatCatalogDate(notice.publishedAt) })}
@@ -78,7 +80,7 @@ export function UpdatesDetailsPanel({
- Do Next + {t('updates.doNext')}
{notice.primaryAction}
@@ -96,8 +98,8 @@ export function UpdatesDetailsPanel({
- Impacted Integrations - Related areas based on update scope and routing. + {t('updates.impactedIntegrations')} + {t('updates.impactedDesc')} @@ -134,10 +136,8 @@ export function UpdatesDetailsPanel({ - Why It Matters - - Short context only, no wall-of-text release notes. - + {t('updates.whyMatters')} + {t('updates.whyMattersDesc')} diff --git a/ui/src/components/updates/updates-notice-action-row.tsx b/ui/src/components/updates/updates-notice-action-row.tsx index 0ac130f0..e2f27146 100644 --- a/ui/src/components/updates/updates-notice-action-row.tsx +++ b/ui/src/components/updates/updates-notice-action-row.tsx @@ -3,8 +3,10 @@ import { ArrowUpRight, Terminal } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { CopyButton } from '@/components/ui/copy-button'; import { type SupportNoticeAction } from '@/lib/support-updates-catalog'; +import { useTranslation } from 'react-i18next'; export function UpdatesNoticeActionRow({ action }: { action: SupportNoticeAction }) { + const { t } = useTranslation(); return (
@@ -16,7 +18,7 @@ export function UpdatesNoticeActionRow({ action }: { action: SupportNoticeAction {action.type === 'route' && action.path && ( diff --git a/ui/src/lib/error-log-parser.ts b/ui/src/lib/error-log-parser.ts index ff2116b9..455c5ce5 100644 --- a/ui/src/lib/error-log-parser.ts +++ b/ui/src/lib/error-log-parser.ts @@ -1,3 +1,5 @@ +import { getFormattingLocale } from '@/lib/locales'; + /** * Error Log Parser Utility * @@ -394,7 +396,9 @@ function getStatusText(code: number): string { /** * Format Unix timestamp (seconds) to relative time string */ -export function formatRelativeTime(modifiedSeconds: number): string { +export function formatRelativeTime(modifiedSeconds: number, locale?: string): string { + const formatLocale = getFormattingLocale(locale); + const isZh = formatLocale === 'zh-CN'; const now = Date.now(); const modified = modifiedSeconds * 1000; // Convert to milliseconds const diff = now - modified; @@ -404,14 +408,14 @@ export function formatRelativeTime(modifiedSeconds: number): string { const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); - if (seconds < 60) return 'just now'; - if (minutes < 60) return `${minutes}m ago`; - if (hours < 24) return `${hours}h ago`; - if (days < 7) return `${days}d ago`; + if (seconds < 60) return isZh ? '刚刚' : 'just now'; + if (minutes < 60) return isZh ? `${minutes} 分钟前` : `${minutes}m ago`; + if (hours < 24) return isZh ? `${hours} 小时前` : `${hours}h ago`; + if (days < 7) return isZh ? `${days} 天前` : `${days}d ago`; // Format as date for older logs const date = new Date(modified); - return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + return date.toLocaleDateString(formatLocale, { month: 'short', day: 'numeric' }); } /** @@ -436,16 +440,26 @@ export function getStatusColor(code: number): string { /** * Get error type label */ -export function getErrorTypeLabel(type: ParsedErrorLog['errorType']): string { - const labels: Record = { - rate_limit: 'Rate Limited', - auth: 'Auth Error', - not_found: 'Not Found', - server: 'Server Error', - timeout: 'Timeout', - unknown: 'Error', - }; - return labels[type] || 'Error'; +export function getErrorTypeLabel(type: ParsedErrorLog['errorType'], locale?: string): string { + const isZh = getFormattingLocale(locale) === 'zh-CN'; + const labels: Record = isZh + ? { + rate_limit: '限流', + auth: '认证错误', + not_found: '未找到', + server: '服务错误', + timeout: '超时', + unknown: '错误', + } + : { + rate_limit: 'Rate Limited', + auth: 'Auth Error', + not_found: 'Not Found', + server: 'Server Error', + timeout: 'Timeout', + unknown: 'Error', + }; + return labels[type] || (isZh ? '错误' : 'Error'); } /** @@ -467,22 +481,27 @@ export function formatQuotaResetDelay(seconds: number | null): string | null { /** * Format quota reset timestamp as relative time */ -export function formatQuotaResetTimestamp(timestamp: string | null): string | null { +export function formatQuotaResetTimestamp( + timestamp: string | null, + locale?: string +): string | null { if (!timestamp) return null; + const isZh = getFormattingLocale(locale) === 'zh-CN'; try { const resetDate = new Date(timestamp); const now = new Date(); const diff = resetDate.getTime() - now.getTime(); - if (diff <= 0) return 'now'; + if (diff <= 0) return isZh ? '现在' : 'now'; const seconds = Math.floor(diff / 1000); - if (seconds < 60) return `${seconds}s`; + if (seconds < 60) return isZh ? `${seconds}秒` : `${seconds}s`; if (seconds < 3600) { const mins = Math.floor(seconds / 60); - return `${mins}m`; + return isZh ? `${mins}分钟` : `${mins}m`; } const hours = Math.floor(seconds / 3600); const mins = Math.floor((seconds % 3600) / 60); + if (isZh) return mins > 0 ? `${hours}小时 ${mins}分钟` : `${hours}小时`; return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; } catch { return null; diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts new file mode 100644 index 00000000..22eec49a --- /dev/null +++ b/ui/src/lib/i18n.ts @@ -0,0 +1,2237 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import { getInitialLocale } from '@/lib/locales'; + +const resources = { + en: { + translation: { + locale: { + en: 'English', + 'zh-CN': 'Simplified Chinese', + }, + layout: { + languageSwitcher: 'Language', + }, + nav: { + general: 'General', + home: 'Home', + analytics: 'Analytics', + identityAccess: 'Identity & Access', + apiProfiles: 'API Profiles', + cliproxyPlus: 'CLIProxy Plus', + cliproxyOverview: 'Overview', + controlPanel: 'Control Panel', + githubCopilot: 'GitHub Copilot', + cursorIde: 'Cursor IDE', + accounts: 'Accounts', + allAccounts: 'All Accounts', + sharedData: 'Shared Data', + compatibleClis: 'Compatible CLIs', + factoryDroid: 'Factory Droid', + system: 'System', + health: 'Health', + settings: 'Settings', + openrouterTooltip: '349+ models via OpenRouter', + }, + home: { + profiles: 'Profiles', + cliproxy: 'CLIProxy', + accounts: 'Accounts', + health: 'Health', + configurationRequired: 'Configuration Required', + }, + auth: { + dashboardTitle: 'CCS Dashboard', + loginDescription: 'Enter your credentials to access the dashboard', + username: 'Username', + password: 'Password', + usernamePlaceholder: 'Enter username', + passwordPlaceholder: 'Enter password', + signIn: 'Sign In', + signingIn: 'Signing in...', + loginFailed: 'Login failed', + }, + commonToast: { + apiKeyRequired: 'API key is required', + accountAddedPresetFailed: 'Account added, but failed to apply default preset', + settingsCopied: 'Settings copied to clipboard', + enterPresetName: 'Please enter a preset name to save', + codeCopied: 'Code copied to clipboard', + copyCodeFailed: 'Failed to copy code', + appliedModelAllTiers: 'Applied model to all tiers', + settingsSaved: 'Settings saved', + defaultTargetUpdated: 'Default target updated', + failedUpdateDefaultTarget: 'Failed to update default target to {{target}}{{suffix}}', + }, + profileDialog: { + editTitle: 'Edit Profile', + createTitle: 'Create API Profile', + name: 'Name', + namePlaceholder: 'my-api', + baseUrl: 'Base URL', + baseUrlPlaceholder: 'https://api.example.com', + apiKey: 'API Key', + defaultModel: 'Default Model (ANTHROPIC_MODEL)', + defaultModelHint: 'Leave blank to use: {{model}}', + modelMappingTitle: 'Model Mapping (Opus/Sonnet/Haiku)', + modelMappingDesc: + 'Configure different model IDs for each tier. Useful for API proxies that route different model types to different backends.', + opusModel: 'Opus Model (ANTHROPIC_DEFAULT_OPUS_MODEL)', + sonnetModel: 'Sonnet Model (ANTHROPIC_DEFAULT_SONNET_MODEL)', + haikuModel: 'Haiku Model (ANTHROPIC_DEFAULT_HAIKU_MODEL)', + cancel: 'Cancel', + saving: 'Saving...', + update: 'Update', + create: 'Create', + }, + modelTierMapping: { + title: 'Model Tier Mapping', + advanced: 'Advanced', + description: "Configure different models for Claude Code's opus/sonnet/haiku tiers.", + autoSuggest: 'Auto-suggest based on {{provider}}', + opusPlaceholder: 'e.g., anthropic/claude-opus-4', + sonnetPlaceholder: 'e.g., anthropic/claude-sonnet-4', + haikuPlaceholder: 'e.g., anthropic/claude-3.5-haiku', + footer: + 'These set ANTHROPIC_DEFAULT_OPUS_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL, ANTHROPIC_DEFAULT_HAIKU_MODEL.', + }, + envEditor: { + sensitive: 'sensitive', + none: 'No environment variables configured.', + noneHint: 'Add variables using the input below or edit the JSON directly.', + addVariable: 'Add Environment Variable', + keyPlaceholder: 'VARIABLE_NAME', + valuePlaceholder: 'value', + }, + setupVariant: { + using: 'Using:', + variantNameRequired: 'Variant Name *', + variantNamePlaceholder: 'e.g., my-gemini, g3, flash', + invokeHintPrefix: 'Use this name to invoke:', + model: 'Model', + modelPlaceholder: 'e.g., gemini-2.5-pro, claude-opus-4-5', + choosePresetInstead: 'Choose from presets instead', + selectModel: 'Select a model', + customModelName: 'Custom model name...', + enterAnyModel: 'Enter any model name supported by CLIProxy', + defaultModel: 'Default: {{model}}', + providerDefault: 'provider default', + back: 'Back', + skip: 'Skip', + creating: 'Creating...', + createVariant: 'Create Variant', + skipHint: 'Skip if you just wanted to add an account without creating a variant', + }, + customPresetDialog: { + title: 'Custom Preset', + presetNameOptional: 'Preset Name (optional)', + presetNamePlaceholder: 'e.g., My Custom Config', + codexTipPrefix: 'Codex tip: suffixes', + codexTipSuffix: 'pin effort. Unsuffixed models use Thinking settings.', + defaultModel: 'Default Model', + defaultModelDesc: 'Used when no specific tier is requested', + opusModel: 'Opus (Most capable)', + opusModelDesc: 'For complex reasoning tasks', + sonnetModel: 'Sonnet (Balanced)', + sonnetModelDesc: 'Balance of speed and capability', + haikuModel: 'Haiku (Fast)', + haikuModelDesc: 'Quick responses for simple tasks', + cancel: 'Cancel', + savePreset: 'Save Preset', + applyPreset: 'Apply Preset', + }, + componentModelSelector: { + selectModel: 'Select model', + availableModelsCount: 'Available Models ({{count}})', + preview: 'Preview', + }, + copilotSettings: { + enableCopilot: 'Enable Copilot', + enableCopilotDesc: 'Allow using GitHub Copilot subscription', + basicSettings: 'Basic Settings', + accountType: 'Account Type', + accountTypeIndividual: 'Individual', + accountTypeBusiness: 'Business', + accountTypeEnterprise: 'Enterprise', + rateLimiting: 'Rate Limiting', + rateLimitSeconds: 'Rate Limit (seconds)', + noLimit: 'No limit', + waitOnRateLimit: 'Wait on Rate Limit', + waitOnRateLimitDesc: 'Wait instead of error when limit hit', + daemonSettings: 'Daemon Settings', + autoStartDaemon: 'Auto-start Daemon', + autoStartDaemonDesc: 'Start copilot-api when using profile', + }, + providerModelSelector: { + selectModel: 'Select model', + noModelsForProvider: 'No models available for this provider', + broken: 'BROKEN', + deprecated: 'DEPRECATED', + paid: 'PAID', + freeTier: 'Free Tier', + paidTier: 'Paid Tier', + modelKnownIssues: 'This model has known issues', + viewIssueDetails: 'View issue details', + modelDeprecated: 'This model is deprecated', + recommended: 'Recommended', + allModelsCount: 'All Models ({{count}})', + noModelsAvailable: 'No models available', + }, + createAuthProfileDialog: { + title: 'Create New Account', + description: + 'Auth profiles require Claude CLI login. Run the command below in your terminal. You can edit sync mode, group, and continuity depth later from the Accounts table.', + profileName: 'Profile Name', + profileNamePlaceholder: 'e.g., work, personal, client', + invalidProfileName: + 'Name must start with a letter and contain only letters, numbers, dashes, or underscores.', + enableSharedHistory: 'Enable shared history sync with other ccs auth accounts', + historySyncGroupOptional: 'History Sync Group (optional)', + historySyncGroupPlaceholder: 'default, sprint-a, client-x', + historySyncGroupHint: + 'Leave empty to use the default shared group. Spaces are normalized to dashes.', + deeperContinuity: 'Advanced: deeper continuity mode', + deeperContinuityHint: + 'Adds sync for session-env, file-history, shell-snapshots, and todos. Credentials stay isolated.', + invalidContextGroup: + 'Group must start with a letter and use only letters, numbers, dashes, or underscores (max {{max}} chars).', + command: 'Command', + commandFallback: 'ccs auth create ', + afterRunPrefix: 'After running the command:', + afterRunStep1: 'Complete the Claude login in your browser', + afterRunStep2: 'Return here and refresh to see the new account', + poolingHint: + 'Prefer pooled Claude OAuth routing instead? Use CLIProxy Claude pool from the Accounts page action button.', + close: 'Close', + copied: 'Copied!', + copyCommand: 'Copy Command', + }, + cliproxyModelCategory: { + google: 'Google (Gemini)', + openai: 'OpenAI (GPT)', + anthropic: 'Anthropic (Claude)', + antigravity: 'Antigravity', + other: 'Other', + }, + cliproxyOverviewComponents: { + availableModels: 'Available Models', + noModelsAvailable: 'No models available', + startSessionHint: 'Start a CLIProxy session to fetch available models', + totalCount: '{{count}} total', + availableThroughGroupedByProvider: + 'Models available through {{backendLabel}}, grouped by provider', + moreCount: '+{{count}} more', + }, + categorizedModelSelector: { + selectModel: 'Select a model', + noModelsAvailable: 'No models available', + modelPlaceholder: 'Model...', + }, + openrouterQuickStart: { + recommended: 'Recommended', + title: 'Start with OpenRouter', + description: + 'Access {{modelCountLabel}} models from OpenAI, Anthropic, Google, Meta and more - all through one API.', + featureOneApi: 'One API, all providers', + featureTierMapping: 'Model tier mapping', + createOpenRouterProfile: 'Create OpenRouter Profile', + getApiKeyAt: 'Get your API key at', + or: 'or', + createCustomProfile: 'Create Custom API Profile', + }, + credentialHealth: { + title: 'Credential Health', + ready: 'Ready', + noCredentialsConfigured: + 'No credentials configured. Use the login buttons above to authenticate.', + neverUsed: 'Never used', + justNow: 'Just now', + unknown: 'Unknown', + daysAgo: '{{count}}d ago', + hoursAgo: '{{count}}h ago', + minutesAgo: '{{count}}m ago', + }, + proxyStatusWidget: { + justNow: 'just now', + minutesAgo: '{{count}}m ago', + hoursAgo: '{{count}}h ago', + syncReady: 'Sync Ready', + noConfig: 'No Config', + remoteProxy: 'Remote Proxy', + active: 'Active', + trafficAutoRouted: 'Traffic auto-routed to remote server', + tooltipSync: 'Sync profiles to CLIProxy', + tooltipRestart: 'Restart', + tooltipStop: 'Stop', + tooltipClose: 'Close', + tooltipVersionSettings: 'Version settings', + versionManagement: 'Version Management', + selectVersionPlaceholder: 'Select version to install...', + stable: '(stable)', + install: 'Install', + versionsAboveUnstable: 'Versions above {{version}} have known issues', + lastChecked: 'Last checked {{time}}', + notRunning: 'Not running', + start: 'Start', + port: 'Port {{port}}', + sessionCount: '{{count}} session', + sessionCount_other: '{{count}} sessions', + installUnstableTitle: 'Install Unstable Version?', + installUnstableDesc: + 'You are about to install v{{version}}, which is above the maximum stable version v{{maxStable}}.', + installUnstableWarning: + 'This version has known stability issues and may cause unexpected behavior.', + installUnstableConfirm: 'Are you sure you want to proceed?', + cancel: 'Cancel', + installAnyway: 'Install Anyway', + clickToDowngrade: 'Click to downgrade', + clickToUpdate: 'Click to update', + }, + syncStatusCard: { + profileSync: 'Profile Sync', + ready: 'Ready', + noConfig: 'No Config', + syncsProfilesDesc: 'Syncs API profiles to local CLIProxy config', + runDoctorHint: 'Run ccs doctor --fix to generate config.', + details: 'Details', + syncNow: 'Sync Now', + }, + syncDialog: { + title: 'Sync Profiles to Local CLIProxy', + description: 'Sync your CCS API profiles to the local CLIProxy config.yaml.', + noProfiles: 'No profiles configured to sync.', + createProfilesFirst: 'Create API profiles first using the Profiles tab.', + modelLabel: 'Model:', + ready: 'Ready', + profilesToSync_one: '{{count}} profile to sync', + profilesToSync_other: '{{count}} profiles to sync', + cancel: 'Cancel', + syncing: 'Syncing...', + synced: 'Synced!', + syncNow: 'Sync Now', + }, + errorLogs: { + title: 'Error Logs', + failedRequests_one: '{{count}} failed request', + failedRequests_other: '{{count}} failed requests', + cliproxyDiagnostics: 'CLIProxy Diagnostics', + showingCount: 'Showing 50 of {{total}} logs', + selectLog: 'Select a log to view details', + failedLoadContent: 'Failed to load log content', + copyAbsolutePath: 'Copy absolute path', + copyRawContent: 'Copy raw log content', + tabOverview: 'Overview', + tabHeaders: 'Headers', + tabRequest: 'Request', + tabResponse: 'Response', + tabRaw: 'Raw', + modelLabel: 'Model:', + quotaResetsIn: 'Quota resets in ', + method: 'Method', + provider: 'Provider', + version: 'Version', + endpoint: 'Endpoint', + url: 'URL', + timestamp: 'Timestamp', + na: 'N/A', + noHeaders: 'No headers available', + noRequestBody: 'No request body', + noResponseBody: 'No response body', + rateLimitedMsg: + 'Rate Limited. Switch to a different account or wait for quota reset. Model {{model}} has exhausted quota.', + rateLimitedMsgNoModel: + 'Rate Limited. Switch to a different account or wait for quota reset.', + authFailedMsg: + 'Authentication failed. Re-authenticate via CLIProxy Settings or check API key.', + notFoundMsg: 'Endpoint not found. This endpoint may not exist on this provider.', + serverErrorMsg: 'Server error from upstream. Retry later or check provider status page.', + timeoutMsg: 'Request timed out. Check network connection or increase timeout settings.', + unknown: 'unknown', + }, + editAccountContext: { + title: 'Edit History Sync', + description: + 'Configure how "{{name}}" shares history and continuity with other ccs auth accounts.', + syncMode: 'Sync Mode', + selectContextMode: 'Select context mode', + isolatedOption: 'isolated (no sync)', + sharedOption: 'shared (sync enabled)', + sharedModeHint: + 'Shared mode reuses workspace context for accounts in the same history sync group.', + isolatedModeHint: + 'Isolated mode keeps this account fully separate from other ccs auth accounts.', + historySyncGroup: 'History Sync Group', + groupPlaceholder: 'default', + groupHint: + 'Normalized to lowercase (spaces become dashes). Allowed: letters, numbers, _ , - (max {{max}} chars).', + invalidGroup: 'Enter a valid group name that starts with a letter.', + continuityDepth: 'Continuity Depth', + selectContinuityDepth: 'Select continuity depth', + standardOption: 'standard (projects only)', + deeperOption: 'deeper continuity (advanced)', + deeperHint: + 'Advanced mode also syncs session-env, file-history, shell-snapshots, and todos.', + standardHint: 'Standard mode syncs project workspace context only.', + credentialsIsolated: 'Credentials and .anthropic remain isolated per account in all modes.', + cancel: 'Cancel', + save: 'Save', + saving: 'Saving...', + }, + historySyncLearningMap: { + title: 'How History Sync Works', + description: 'Isolated -> Shared -> Deeper. Use Sync per row for all changes.', + learningMap: 'Learning Map', + isolated: 'Isolated', + shared: 'Shared', + deeper: 'Deeper', + cliproxyManaged_one: + '{{count}} CLIProxy Claude pool account is managed in Action Center / CLIProxy page.', + cliproxyManaged_other: + '{{count}} CLIProxy Claude pool accounts are managed in Action Center / CLIProxy page.', + showDetails: 'Show details: groups, switching, and legacy policy', + modeSwitch: 'Mode Switch', + modeSwitchDesc: + 'Sync dialog lets users move between isolated/shared and choose deeper continuity.', + historySyncGroup: 'History Sync Group', + historySyncGroupDesc: + 'Same group means shared project context lane. Default fallback is default.', + }, + accountsTable: { + name: 'Name', + type: 'Type', + defaultBadge: 'default', + created: 'Created', + lastUsed: 'Last Used', + historySync: 'History Sync', + actions: 'Actions', + syncTitle: 'Edit sync mode, group, and continuity depth', + confirmLegacyTitle: "Confirm this legacy account's current mode as explicit", + sync: 'Sync', + confirm: 'Confirm', + active: 'Active', + setDefault: 'Set Default', + deleteAccount: 'Delete account', + cannotDeleteDefault: 'Cannot delete default account', + noAccounts: 'No CCS auth accounts found. Use ccs auth create to add accounts.', + resetToDefault: 'Reset to CCS Default', + deleteDialogTitle: 'Delete Account', + deleteDialogDesc: + 'Are you sure you want to delete the account "{{name}}"? This will remove the profile and all its session data. This action cannot be undone.', + cancel: 'Cancel', + delete: 'Delete', + sharedGroupStandard: 'shared ({{group}}, standard)', + sharedGroupDeeper: 'shared ({{group}}, deeper)', + sharedGroupLegacy: 'shared ({{group}}, standard legacy)', + isolatedLegacy: 'isolated (legacy default)', + isolated: 'isolated', + }, + addAccountDialog: { + title: 'Add {{displayName}} Account', + descKiro: + 'Choose a Kiro auth method, then authenticate via browser or import from Kiro IDE.', + descDeviceCode: + 'Click Authenticate. A verification code will appear for you to enter on the provider website.', + descOauth: 'Click Authenticate to get an OAuth URL. Open it in any browser to sign in.', + powerUserEnabled: 'Power user mode enabled', + powerUserSkipped: + 'AGY responsibility checklist is skipped from Settings > Proxy. You accept full responsibility for OAuth/account risk.', + authMethod: 'Auth Method', + selectKiroAuthMethod: 'Select Kiro auth method', + nicknameRequired: 'Nickname (required)', + nicknameOptional: 'Nickname (optional)', + nicknamePlaceholder: 'e.g., work, personal', + nicknameRequiredHint: + 'Required for this provider. Use a unique friendly name (e.g., work, personal).', + nicknameOptionalHint: + 'A friendly name to identify this account. Auto-generated from email if left empty.', + waitingForAuth: 'Waiting for authentication...', + deviceCodeHint: + 'A verification code dialog will appear shortly. Enter the code on the provider website.', + browserHint: + 'Complete the authentication in your browser. This dialog closes automatically.', + openUrlLabel: 'Open this URL in any browser to sign in:', + copied: 'Copied', + copy: 'Copy', + open: 'Open', + popupBlocked: 'Popup blocked. Copy the URL above and open it manually in your browser.', + redirectPasteLabel: "Redirect didn't work? Paste the callback URL:", + callbackPlaceholder: 'Paste the redirect URL here...', + submitCallback: 'Submit Callback', + submitting: 'Submitting...', + preparingUrl: 'Preparing sign-in URL...', + cancel: 'Cancel', + importingToken: 'Importing token from Kiro IDE...', + authenticate: 'Authenticate', + importFromIde: 'Import from IDE', + importing: 'Importing...', + }, + flowViz: { + backToProviders: 'Back to providers', + showDetails: 'Show Details', + hideDetails: 'Hide Details', + resetLayout: 'Reset layout', + provider: 'Provider', + totalRequests: 'Total Requests', + accounts: 'Accounts', + connectionTimeline: 'Connection Timeline', + noRecentConnections: 'No recent connections', + }, + quickStats: { + totalRequests: 'Total Requests', + successRate: 'Success Rate', + totalTokens: 'Total Tokens', + activeModels: 'Active Models', + }, + authMonitor: { + accounts: 'Accounts', + accountsCount: '{{count}} accounts', + success: 'Success', + failed: 'Failed', + successRate: 'Success Rate', + }, + accountCard: { + resumeAccount: 'Resume account', + pauseAccount: 'Pause account', + quotaLoading: 'Quota...', + quota: 'Quota', + quotaUnavailable: 'Quota limits unavailable', + reauthNeeded: 'Reauth needed', + removeAndReadd: 'Remove and re-add account', + autoRefreshFailed: 'Auto-refresh failed', + }, + antigravityChecklist: { + title: 'Antigravity OAuth Responsibility', + subtitle: 'Complete all 4 steps before you can authenticate.', + mandatory: 'Mandatory', + completion: 'Completion', + stepsCount: '{{current}}/4 steps', + }, + accountSafetyWarning: { + title: 'OAuth Account Safety Warning', + subtitle: 'Issue #509 · Gemini + AGY OAuth risk', + firstLine: + 'Issue #509 documents suspension/ban reports tied to ccs agy and shared-account usage between ccs gemini and ccs agy.', + secondLine: + 'Continue only if you accept full responsibility for OAuth and account-access risk.', + issueLabel: 'Read issue #509', + proxySettingsLabel: 'Gemini + AGY controls: Settings > Proxy', + }, + cliproxyDialog: { + name: 'Name', + provider: 'Provider', + account: 'Account', + accountOptional: 'Account (optional)', + model: 'Model', + modelOptional: 'Model (optional)', + defaultTarget: 'Default Target', + defaultTier: 'Default Tier', + tierConfig: 'Tier Configuration', + selectProvider: 'Select provider...', + useDefaultAccount: 'Use default account', + defaultSuffix: '(default)', + opus: 'Opus', + sonnet: 'Sonnet', + haiku: 'Haiku', + claudeCode: 'Claude Code', + factoryDroid: 'Factory Droid', + cancel: 'Cancel', + create: 'Create', + creating: 'Creating...', + saveChanges: 'Save Changes', + saving: 'Saving...', + placeholderName: 'my-gemini', + placeholderModel: 'gemini-2.5-pro', + placeholderComposite: 'my-composite', + placeholderModelId: 'model-id', + placeholderAccountId: 'account-id', + editCompositeTitle: 'Edit Composite Variant: {{name}}', + editSingleTitle: 'Edit Single Variant: {{name}}', + }, + profileEditor: { + profileInfo: 'Profile Information', + profileName: 'Profile Name', + filePath: 'File Path', + lastModified: 'Last Modified', + defaultTarget: 'Default Target', + quickUsage: 'Quick Usage', + runWithProfile: 'Run with profile', + runOnDroid: 'Run on Droid', + droidAliasExplicit: 'Droid alias (explicit)', + overrideToClaude: 'Override to Claude', + overrideToClaudeExplicit: 'Override to Claude (explicit)', + setAsDefault: 'Set as default', + provider: 'Provider', + custom: 'Custom', + }, + settings: { + sectionLoadFailed: 'Failed to load section', + unknownError: 'Unknown error occurred', + reloadPage: 'Reload page', + refresh: 'Refresh', + copy: 'Copy', + copied: 'Copied', + saved: 'Saved', + loading: 'Loading...', + configFileNotFound: 'Config file not found', + }, + settingsTabs: { + web: 'Web', + env: 'Env', + think: 'Think', + proxy: 'Proxy', + auth: 'Auth', + backup: 'Backup', + }, + settingsAuth: { + failedFetchTokens: 'Failed to fetch auth tokens', + failedSaveTokens: 'Failed to save tokens', + tokensUpdated: 'Tokens updated. Restart CLIProxy to apply.', + failedRegenerate: 'Failed to regenerate secret', + secretRegenerated: 'New management secret generated. Restart CLIProxy to apply.', + failedReset: 'Failed to reset tokens', + tokensReset: 'Tokens reset to defaults. Restart CLIProxy to apply.', + description: 'Configure CLIProxy authentication tokens. Changes require CLIProxy restart.', + apiKey: 'API Key', + custom: 'Custom', + apiKeyDesc: 'Used by Claude Code to authenticate with CLIProxy', + apiKeyPlaceholder: 'API key', + managementSecret: 'Management Secret', + managementSecretDesc: 'Used by CCS dashboard to access CLIProxy management APIs', + managementSecretPlaceholder: 'Management secret', + generateSecret: 'Generate new secure secret', + resetDefaults: 'Reset to Defaults', + resetDesc: 'Resets both API key and management secret to their default values.', + saving: 'Saving...', + save: 'Save', + }, + settingsGlobalEnv: { + description: + 'Environment variables injected into all non-Claude subscription profiles (gemini, codex, agy, ghcp, etc.)', + enabled: 'Global Env enabled', + disabled: 'Global Env disabled', + enabledDesc: 'Env vars will be injected into third-party profiles', + disabledDesc: 'Env vars will not be injected', + envVars: 'Environment Variables', + noneConfigured: 'No environment variables configured', + addNew: 'Add New Variable', + keyName: 'KEY_NAME', + value: 'value', + add: 'Add', + quickAdd: 'Quick Add Common Variables', + allConfigured: 'All common variables are configured', + }, + settingsBackups: { + failedFetch: 'Failed to fetch backups', + failedRestore: 'Failed to restore backup', + restoreSuccess: 'Backup restored successfully', + title: 'Settings Backups', + description: + 'Restore previous versions of your settings.json file. Backups are created automatically when settings are modified.', + none: 'No backups available', + noneHint: 'Backups will appear here when you modify settings', + latest: 'Latest', + restoring: 'Restoring...', + restore: 'Restore', + restoreConfirmTitle: 'Restore Backup?', + restoreConfirmPrefix: 'This will replace your current settings with backup from', + restoreConfirmSuffix: 'This action cannot be undone.', + cancel: 'Cancel', + }, + settingsWebsearch: { + description: 'CLI-based web search for third-party profiles (gemini, codex, agy, etc.)', + enabled: 'WebSearch enabled', + disabled: 'WebSearch disabled', + checking: 'Checking status...', + providers: 'Providers', + noneConfigured: 'No providers configured', + noneConfiguredHint: 'Install CLI tools to enable web search providers', + checkProviders: 'Check for providers', + installed: 'installed', + notInstalled: 'not installed', + model: 'Model:', + howInstall: 'How to install {{name}} CLI', + installGlobally: 'Install globally', + requiresKey: '(requires xAI API key)', + freeTier: '(FREE tier available)', + viewDocs: 'View documentation', + }, + settingsProxy: { + failedLoadAgyMode: 'Failed to load AGY power user mode', + failedUpdateAgyMode: 'Failed to update AGY power user mode', + failedVerifyAgyMode: 'Failed to verify AGY power user mode persistence', + notPersistedAgyMode: + 'AGY power user mode was not persisted. Config may have been modified by another process.', + agyModeEnabled: 'AGY power user mode enabled.', + agyModeDisabled: 'AGY power user mode disabled.', + typePhraseToContinue: 'Type "{{value}}" to continue.', + description: 'Configure local or remote {{backend}} connection for proxy-based profiles', + backendPlus: 'CLIProxy Plus', + backend: 'CLIProxy', + instanceStatus: 'Instance Status', + connectionMode: 'Connection Mode', + local: 'Local', + remote: 'Remote', + localDesc: 'Run {{backend}} binary on this machine', + remoteDesc: 'Connect to a remote {{backend}} server', + backendBinary: 'Backend Binary', + stopProxyToSwitch: 'Stop the running proxy in Instance Status to switch backend.', + default: 'Default', + plusDesc: 'Full provider support including Kiro and GitHub Copilot', + originalDesc: 'Original binary (Gemini, Codex, Antigravity only)', + variantsIncompatible: + 'Existing Kiro/Copilot variants will not work with CLIProxyAPI. Switch to CLIProxyAPIPlus or remove those variants.', + safety: 'Safety', + agyModeTitle: 'Antigravity Power User Mode', + agyModeDesc: 'Skip AGY responsibility checklist in Add Account and `ccs agy` flows.', + agyWarning: + 'Use only if you fully understand the OAuth suspension/ban risk pattern (#509). CCS cannot assume responsibility for account loss.', + finalConfirm: 'Final confirmation required', + finalConfirmDesc: + 'Enabling this will skip AGY safety checkpoints in both dashboard and CLI. Review issue #509 and type the exact phrase to proceed.', + step1: 'Step 1', + readIssue: 'Read issue #509', + step2: 'Step 2', + typePrefix: 'Type', + typeSuffix: 'to enable.', + typePhraseAria: 'Type I ACCEPT RISK to enable Antigravity power user mode', + exactPhrase: 'Exact phrase required.', + enableAgyMode: 'Enable Power User Mode', + toggleAgyMode: 'Toggle AGY power user mode', + fallbackSettings: 'Fallback Settings', + enableFallback: 'Enable fallback to local', + enableFallbackDesc: 'Use local proxy if remote is unreachable', + autoStartLocal: 'Auto-start local proxy', + autoStartLocalDesc: 'Automatically start local proxy on fallback', + advanced: 'Advanced', + debugMode: 'Debug Mode', + debugModeDesc: 'Enable developer diagnostics in browser console', + debugModeEnabled: 'Debug mode enabled. Check browser console for detailed logs.', + localProxy: 'Local Proxy', + port: 'Port', + autoStart: 'Auto-start', + localAutoStartDesc: 'Start local proxy automatically when needed', + remoteConfigTitle: 'Remote Server Configuration', + host: 'Host', + hostPlaceholder: '192.168.1.100 or proxy.example.com', + defaultPort: 'default: {{value}}', + portPlaceholder: 'Leave empty for {{value}}', + protocol: 'Protocol', + http: 'HTTP', + https: 'HTTPS', + apiKeyOptional: 'API Key (optional)', + apiKeyPlaceholder: 'For /v1/* API endpoints', + apiKeyDesc: 'Used for API requests to /v1/chat/completions', + managementKeyOptional: 'Management Key (optional)', + managementKeyPlaceholder: 'For /v0/management/* endpoints', + managementKeyDesc: 'Used for dashboard management APIs. Falls back to API Key if not set.', + testing: 'Testing...', + testConnection: 'Test Connection', + connectedLatency: 'Connected ({{value}}ms)', + connectionFailed: 'Connection failed', + invalidPortOrEmpty: 'Port must be an integer between 1 and 65535, or empty for default', + invalidLocalPort: 'Local port must be an integer between 1 and 65535', + backendPlusApi: 'CLIProxyAPIPlus', + backendApi: 'CLIProxyAPI', + }, + settingsThinking: { + description: 'Configure extended thinking/reasoning for supported models.', + supportedProviders: 'Supported Providers', + modeTitle: 'Thinking Mode', + modeAutoDesc: 'Automatically set thinking based on model tier', + modeOffDesc: 'Disable extended thinking', + modeManualDesc: 'Set a persistent override level or use CLI flags per run', + tierDefaults: 'Tier Defaults', + tierDefaultsDesc: 'Default thinking level for each model tier when in auto mode.', + persistentOverride: 'Persistent Override', + persistentOverrideDesc: 'Applied to all sessions. CLI flags still take priority.', + enterCustomBudget: 'Enter custom budget', + apply: 'Apply', + providerOverrides: 'Provider Overrides ({{count}})', + providerOverridesDesc: + 'Override tier defaults for specific providers. Add custom provider keys as needed.', + addProviderPlaceholder: 'Add provider key (e.g. qwen)', + showWarnings: 'Show Warnings', + showWarningsDesc: 'Display warnings when thinking values are clamped or adjusted', + cliEnvOverride: 'CLI & Env Override', + supportLine1Prefix: 'Thinking budget:', + supportLine1Suffix: '(token-based)', + supportLine2Prefix: 'Reasoning effort:', + supportLine2SuffixPrefix: '(suffix or ', + supportLine2SuffixPostfix: ': medium/high/xhigh)', + supportLine3Prefix: 'Codex suffixes pin effort (for example ', + supportLine3Suffix: '); unsuffixed models use Thinking mode.', + }, + analytics: { + title: 'Analytics', + subtitle: 'Track usage and insights', + month: 'Month', + allTime: 'All Time', + updated: 'Updated {{value}}', + }, + analyticsSummary: { + totalTokens: 'Total Tokens', + totalTokensSubtitle: '{{input}} in / {{output}} out', + totalCost: 'Total Cost', + totalCostSubtitle: '${{value}}/day avg', + cacheTokens: 'Cache Tokens', + cacheTokensSubtitle: '${{cost}} ({{percent}}% of cost)', + inputCost: 'Input Cost', + outputCost: 'Output Cost', + tokensSubtitle: '{{value}} tokens', + }, + apiProfiles: { + title: 'API Profiles', + new: 'New', + searchPlaceholder: 'Search profiles...', + loadingProfiles: 'Loading profiles...', + failedLoadTitle: 'Failed to load profiles', + failedLoadDesc: 'Unable to fetch API profiles. Please try again.', + retry: 'Retry', + noProfilesYet: 'No API profiles yet', + noProfilesDesc: 'Create your first profile to connect to custom API endpoints', + createProfile: 'Create Profile', + noProfileMatch: 'No profiles match "{{query}}"', + profileCount: '{{count}} profile', + profileCount_other: '{{count}} profiles', + configuredCount: '{{count}} configured', + deleteProfileTitle: 'Delete Profile', + deleteProfileDesc: + 'Are you sure you want to delete "{{name}}"? This will remove the settings file and cannot be undone.', + delete: 'Delete', + unsavedChangesTitle: 'Unsaved Changes', + unsavedChangesDesc: + 'You have unsaved changes in "{{current}}". Discard and switch to "{{next}}"?', + discardSwitch: 'Discard and Switch', + }, + accountsPage: { + title: 'Accounts', + managePrefix: 'Manage', + manageSuffix: 'accounts and pool onboarding from one panel.', + mobileManageSuffix: 'continuity per account.', + primaryActions: 'Primary Actions', + createAccount: 'Create Account', + authClaudeInPool: 'Authenticate Claude in Pool', + openClaudePoolSettings: 'Open Claude Pool Settings', + openCliProxyClaudePool: 'Open CLIProxy Claude Pool', + migrationFollowup: 'Migration Follow-up', + legacyContextPending: '{{count}} account still needs first-time mode confirmation.', + legacyContextPending_other: '{{count}} accounts still need first-time mode confirmation.', + legacyContinuityPending: + '{{count}} shared account remains on standard legacy continuity depth.', + legacyContinuityPending_other: + '{{count}} shared accounts remain on standard legacy continuity depth.', + confirmingLegacy: 'Confirming Legacy Policies...', + confirmLegacy: 'Confirm Legacy Policies ({{count}})', + noLegacyFollowup: 'No legacy follow-up pending.', + continuityGuide: 'Continuity Guide', + expandWhenNeeded: 'Expand only when needed.', + sharedStandard: 'Shared Standard', + sharedStandardDesc: 'Project workspace sync only. Best default for most teams.', + sharedDeeper: 'Shared Deeper', + sharedDeeperPrefix: 'Adds', + isolated: 'Isolated', + isolatedDesc: 'No link. Best for strict separation.', + quickCommands: 'Quick Commands', + quickCommandsDesc: 'Copy and run in terminal.', + workspaceBadge: 'ccs auth Workspace', + historySyncBadge: 'History Sync Controls', + authAccounts: 'Auth Accounts', + tableScopePrefix: 'This table is intentionally scoped to', + tableScopeMiddle: 'accounts. Use', + tableScopeSuffix: 'for mode/group/depth changes.', + accountMatrix: 'Account Matrix', + sharedTotalDesc: + 'Shared total: {{count}}. Actions include Sync settings and legacy confirmation.', + loadingAccounts: 'Loading accounts...', + }, + health: { + justNow: 'just now', + secondsAgo: '{{count}}s ago', + minutesAgo: '{{count}}m ago', + hoursAgo: '{{count}}h ago', + systemHealth: 'System Health', + build: 'build {{version}}', + lastScan: 'Last scan:', + autoRefresh: 'Auto-refresh:', + refresh: 'Refresh', + checks: 'Checks', + version: 'Version', + platform: 'Platform', + liveMonitoring: 'Live monitoring active', + copied: 'Copied to clipboard', + refreshing: 'Refreshing health checks...', + fix: 'Fix', + applyFix: 'Apply Fix', + healthLabel: 'health', + }, + updates: { + actionRequired: 'Action Required', + done: 'Done', + all: 'All', + inboxTitle: 'Updates Inbox', + inboxSubtitle: 'Focus on actions, then mark updates done or dismissed.', + needsAction: 'Needs Action', + doneCount: 'Done', + searchPlaceholder: 'Search actions or commands', + noNotices: 'No notices match this view.', + noUpdates: 'No updates available.', + published: 'Published {{date}}', + markDone: 'Mark done', + dismiss: 'Dismiss', + reopen: 'Reopen', + doNext: 'Do Next', + impactedIntegrations: 'Impacted Integrations', + impactedDesc: 'Related areas based on update scope and routing.', + whyMatters: 'Why It Matters', + whyMattersDesc: 'Short context only, no wall-of-text release notes.', + open: 'Open', + progressNeedsAction: 'Needs Action', + progressInReview: 'In Review', + progressDone: 'Done', + progressDismissed: 'Dismissed', + statusNew: 'New', + statusStable: 'Stable', + statusPlanned: 'Planned', + dashboard: 'Dashboard', + cliUsage: 'CLI Usage', + }, + cliproxyPage: { + connected: 'Connected', + notConnected: 'Not connected', + variant: 'variant', + parentNotConnected: 'Parent not connected', + viaProvider: 'via {{provider}}', + emptyTitle: 'CCS Profile Manager', + emptyDesc: + 'Manage OAuth authentication, account preferences, and model selection for CLIProxy providers. Configure how CCS routes requests to different AI backends.', + emptyControlPanelPrefix: 'For live usage stats and real-time monitoring, visit the', + controlPanel: 'Control Panel', + quickSetup: 'Quick Setup', + accountManagement: 'CCS-level account management', + providers: 'Providers', + variants: 'Variants', + providerCount: '{{count}} provider', + providerCount_other: '{{count}} providers', + connectedCount: '{{count}} connected', + variantDisplay: '{{name}} ({{provider}} variant)', + }, + copilotPage: { + title: 'Copilot', + subtitle: 'GitHub Copilot proxy', + setup: 'Setup', + auth: 'Auth', + daemon: 'Daemon', + unofficialTitle: 'Unofficial API - Use at Your Own Risk', + unofficialItem1: 'Reverse-engineered API - may break anytime', + unofficialItem2: 'Excessive use may trigger account restrictions', + unofficialItem3: 'No warranty, no responsibility from CCS', + yes: 'Yes', + no: 'No', + installed: 'Installed', + missing: 'Missing', + integration: 'Integration', + enabled: 'Enabled', + disabled: 'Disabled', + github: 'GitHub', + connected: 'Connected', + notConnected: 'Not Connected', + authenticate: 'Authenticate', + authenticating: 'Authenticating...', + status: 'Status', + running: 'Running', + stopped: 'Stopped', + port: 'Port', + stopping: 'Stopping...', + stop: 'Stop', + starting: 'Starting...', + start: 'Start', + installing: 'Installing...', + installCopilotApi: 'Install copilot-api', + proxy: 'Proxy', + active: 'Active', + inactive: 'Inactive', + }, + cursorPage: { + title: 'Cursor', + beta: 'Beta', + subtitle: 'Dedicated Cursor integration controls', + unofficialTitle: 'Unofficial API - Use at Your Own Risk', + unofficialItem1: 'Reverse-engineered integration may break anytime', + unofficialItem2: 'Abuse or excessive usage may risk account restrictions', + unofficialItem3: 'No warranty, no responsibility from CCS', + integration: 'Integration', + authentication: 'Authentication', + daemon: 'Daemon', + enabled: 'Enabled', + disabled: 'Disabled', + expired: 'Expired', + notConnected: 'Not connected', + running: 'Running', + stopped: 'Stopped', + actions: 'Actions', + disableIntegration: 'Disable Integration', + enableIntegration: 'Enable Integration', + autoDetectAuth: 'Auto-detect Auth', + manualAuthImport: 'Manual Auth Import', + stopDaemon: 'Stop Daemon', + startDaemon: 'Start Daemon', + port: 'Port', + configuration: 'Cursor Configuration', + lastModified: 'Last modified:', + neverSaved: 'Never saved', + saving: 'Saving...', + save: 'Save', + modelConfig: 'Model Config', + settings: 'Settings', + info: 'Info', + presets: 'Presets', + presetsDesc: 'Apply pre-configured model mappings', + modelMapping: 'Model Mapping', + modelMappingDesc: 'Configure which models to use for each tier', + defaultModel: 'Default Model', + defaultModelDesc: 'Used when no specific tier is requested.', + opusModel: 'Opus (Most capable)', + opusModelDesc: 'Complex reasoning and highest quality responses.', + sonnetModel: 'Sonnet (Balanced)', + sonnetModelDesc: 'General coding and day-to-day chat workloads.', + haikuModel: 'Haiku (Fast)', + haikuModelDesc: 'Low-latency and lightweight request paths.', + runtimeSettings: 'Runtime Settings', + autoStartDaemon: 'Auto-start Daemon', + autoStartDesc: 'Start Cursor daemon automatically when integration is used.', + ghostMode: 'Ghost Mode', + ghostModeDesc: 'Request x-ghost-mode to reduce telemetry.', + provider: 'Provider', + filePath: 'File Path', + availableModels: 'Available Models', + loadingModels: 'Loading models...', + noModels: 'No model metadata available yet. Start daemon and refresh.', + default: 'Default', + rawConfiguration: 'Raw Configuration (JSON)', + manualImportTitle: 'Manual Cursor Auth Import', + manualImportDesc: + 'Provide Cursor access token and machine ID if auto-detect is unavailable.', + accessToken: 'Access Token', + accessTokenPlaceholder: 'Paste Cursor access token', + machineId: 'Machine ID', + machineIdPlaceholder: '32-char hex (UUID without hyphens)', + cancel: 'Cancel', + import: 'Import', + selectModel: 'Select model', + useDefaultModel: 'Use Default Model', + availableModelCount: 'Available Models ({{count}})', + invalidPort: 'Port must be an integer between 1 and 65535', + defaultModelRequired: 'Default model is required', + savedConfig: 'Cursor config and model mapping saved', + failedSaveConfig: 'Failed to save config', + modelsLoadingWait: 'Models are still loading. Please wait before applying a preset.', + noModelsAvailable: 'No models available yet. Start the daemon and refresh first.', + appliedCodexPreset: 'Applied GPT-5.3 Codex preset', + appliedClaudePreset: 'Applied Claude 4.6 preset', + appliedGeminiPreset: 'Applied Gemini 3 preset', + integrationEnabled: 'Cursor integration enabled', + integrationDisabled: 'Cursor integration disabled', + failedUpdateIntegration: 'Failed to update integration state', + credentialsImported: 'Cursor credentials imported', + autoDetectFailed: 'Auto-detect failed', + manualRequired: 'Token and machine ID are required', + manualImportFailed: 'Manual import failed', + failedStartDaemon: 'Failed to start daemon', + daemonStarted: 'Daemon started', + daemonStartedWithPid: 'Daemon started (PID: {{pid}})', + failedStopDaemon: 'Failed to stop daemon', + daemonStopped: 'Daemon stopped', + rawLoading: 'Raw settings are still loading. Please wait and try again.', + invalidJson: 'Invalid JSON', + rawSaved: 'Raw settings saved', + rawChanged: 'Raw settings changed externally. Refresh and retry.', + failedSaveRaw: 'Failed to save raw settings', + savedAll: 'Cursor configuration saved', + }, + droidPage: { + loadingDiagnostics: 'Loading Droid diagnostics...', + failedDiagnostics: 'Failed to load Droid diagnostics.', + overview: 'Overview', + byok: 'BYOK', + docs: 'Docs', + runtimeInstall: 'Runtime and Installation', + status: 'Status', + detected: 'Detected', + notFound: 'Not Found', + detectionSource: 'Detection source', + binaryPath: 'Binary path', + installDirectory: 'Install directory', + version: 'Version', + overridePath: 'Override (CCS_DROID_PATH)', + configFiles: 'Config Files', + path: 'Path', + resolved: 'Resolved', + size: 'Size', + lastModified: 'Last modified', + parseWarning: 'Parse warning: {{value}}', + readWarning: 'Read warning: {{value}}', + warnings: 'Warnings', + byokSummary: 'BYOK Summary', + activeModelSelector: 'Active model selector', + customModels: 'Custom models', + ccsManaged: 'CCS-managed', + userManaged: 'User-managed', + malformedEntries: 'Malformed entries', + providers: 'Providers', + none: 'none', + customModelsTitle: 'Custom Models', + modelName: 'Name / Model', + provider: 'Provider', + baseUrl: 'Base URL', + noCustomModels: 'No custom models', + docsAlignedNotes: 'Docs-Aligned Notes', + factoryDocs: 'Factory Docs', + providerFactCheckDocs: 'Provider Fact-Check Docs', + providerValues: 'Provider values: {{value}}', + settingsHierarchy: 'Settings hierarchy: {{value}}', + settingsTitle: 'Droid BYOK Settings', + fixJsonBeforeQuickSettings: 'Fix JSON syntax before using quick settings controls.', + fixJsonBeforeReasoning: 'Fix JSON syntax before updating BYOK reasoning settings.', + unableUpdateReasoning: 'Unable to update selected BYOK model reasoning setting.', + fixJsonBeforeBudget: 'Fix JSON syntax before updating thinking budget.', + anthropicOnlyBudget: 'Thinking budget is only available for Anthropic BYOK models.', + invalidJson: 'Invalid JSON: {{value}}', + saved: 'Droid settings saved', + changedExternally: 'Droid settings changed externally. Refresh and retry.', + failedSave: 'Failed to save Droid settings', + }, + sharedPage: { + commands: 'Commands', + skills: 'Skills', + agents: 'Agents', + title: 'Shared Data', + subtitle: 'Commands, skills, and agents shared across Claude instances', + totalShared: 'Total Shared', + visible: 'Visible', + markdownDetail: 'Markdown detail view', + filterPrefix: 'Filter:', + configurationRequired: 'Configuration Required', + countsUnavailable: 'Counts unavailable', + retryCounts: 'Retry counts', + filterPlaceholder: 'Filter {{tab}} by name, description, or path', + showing: 'Showing {{visible}} of {{total}} {{tab}}', + showingQuery: ' for "{{query}}"', + refreshing: ' (refreshing...)', + loadingShared: 'Loading shared {{tab}}...', + failedLoadShared: 'Failed to load shared {{tab}}', + retry: 'Retry', + noSharedFound: + 'No shared {{tab}} found. Run `ccs sync` or add items in your shared directory.', + noMatch: 'No {{tab}} match "{{query}}".', + selectOne: 'Select a {{tab}} to view full content.', + pathLabel: 'Path', + resolvedSource: 'Resolved Source', + loadingMarkdown: 'Loading markdown content...', + failedLoadContent: 'Failed to load content', + retryContent: 'Retry content', + noMarkdown: 'No markdown content available.', + }, + }, + }, + 'zh-CN': { + translation: { + locale: { + en: 'English', + 'zh-CN': '简体中文', + }, + layout: { + languageSwitcher: '语言', + }, + nav: { + general: '通用', + home: '首页', + analytics: '分析', + identityAccess: '身份与访问', + apiProfiles: 'API 配置', + cliproxyPlus: 'CLIProxy Plus', + cliproxyOverview: '概览', + controlPanel: '控制面板', + githubCopilot: 'GitHub Copilot', + cursorIde: 'Cursor IDE', + accounts: '账号', + allAccounts: '全部账号', + sharedData: '共享数据', + compatibleClis: '兼容 CLI', + factoryDroid: 'Factory Droid', + system: '系统', + health: '健康', + settings: '设置', + openrouterTooltip: '通过 OpenRouter 提供 349+ 模型', + }, + home: { + profiles: '配置', + cliproxy: 'CLIProxy', + accounts: '账号', + health: '健康', + configurationRequired: '需要配置', + }, + auth: { + dashboardTitle: 'CCS 控制台', + loginDescription: '请输入凭据以访问控制台', + username: '用户名', + password: '密码', + usernamePlaceholder: '请输入用户名', + passwordPlaceholder: '请输入密码', + signIn: '登录', + signingIn: '登录中...', + loginFailed: '登录失败', + }, + commonToast: { + apiKeyRequired: 'API key 为必填项', + accountAddedPresetFailed: '账号已添加,但应用默认预设失败', + settingsCopied: '设置已复制到剪贴板', + enterPresetName: '请先输入预设名称再保存', + codeCopied: '验证码已复制到剪贴板', + copyCodeFailed: '复制验证码失败', + appliedModelAllTiers: '已将模型应用到全部层级', + settingsSaved: '设置已保存', + defaultTargetUpdated: '默认目标已更新', + failedUpdateDefaultTarget: '更新默认目标到 {{target}} 失败{{suffix}}', + }, + profileDialog: { + editTitle: '编辑配置', + createTitle: '创建 API 配置', + name: '名称', + namePlaceholder: 'my-api', + baseUrl: 'Base URL', + baseUrlPlaceholder: 'https://api.example.com', + apiKey: 'API Key', + defaultModel: '默认模型(ANTHROPIC_MODEL)', + defaultModelHint: '留空则使用:{{model}}', + modelMappingTitle: '模型映射(Opus/Sonnet/Haiku)', + modelMappingDesc: '为不同档位配置不同模型 ID,适合按模型类型路由到不同后端的 API 代理。', + opusModel: 'Opus 模型(ANTHROPIC_DEFAULT_OPUS_MODEL)', + sonnetModel: 'Sonnet 模型(ANTHROPIC_DEFAULT_SONNET_MODEL)', + haikuModel: 'Haiku 模型(ANTHROPIC_DEFAULT_HAIKU_MODEL)', + cancel: '取消', + saving: '保存中...', + update: '更新', + create: '创建', + }, + modelTierMapping: { + title: '模型档位映射', + advanced: '高级', + description: '为 Claude Code 的 opus/sonnet/haiku 档位配置不同模型。', + autoSuggest: '基于 {{provider}} 自动建议', + opusPlaceholder: '例如:anthropic/claude-opus-4', + sonnetPlaceholder: '例如:anthropic/claude-sonnet-4', + haikuPlaceholder: '例如:anthropic/claude-3.5-haiku', + footer: + '这些字段对应 ANTHROPIC_DEFAULT_OPUS_MODEL、ANTHROPIC_DEFAULT_SONNET_MODEL、ANTHROPIC_DEFAULT_HAIKU_MODEL。', + }, + envEditor: { + sensitive: '敏感', + none: '尚未配置环境变量。', + noneHint: '可在下方输入添加,或直接编辑 JSON。', + addVariable: '添加环境变量', + keyPlaceholder: 'VARIABLE_NAME', + valuePlaceholder: 'value', + }, + setupVariant: { + using: '当前使用:', + variantNameRequired: '变体名称 *', + variantNamePlaceholder: '例如:my-gemini、g3、flash', + invokeHintPrefix: '可通过该名称调用:', + model: '模型', + modelPlaceholder: '例如:gemini-2.5-pro、claude-opus-4-5', + choosePresetInstead: '改为从预设中选择', + selectModel: '选择模型', + customModelName: '自定义模型名称...', + enterAnyModel: '可输入 CLIProxy 支持的任意模型名', + defaultModel: '默认:{{model}}', + providerDefault: '提供商默认值', + back: '返回', + skip: '跳过', + creating: '创建中...', + createVariant: '创建变体', + skipHint: '如果你只想添加账号而不创建变体,可以直接跳过', + }, + customPresetDialog: { + title: '自定义预设', + presetNameOptional: '预设名称(可选)', + presetNamePlaceholder: '例如:我的自定义配置', + codexTipPrefix: 'Codex 提示:后缀', + codexTipSuffix: '会固定 effort。无后缀模型使用 Thinking 设置。', + defaultModel: '默认模型', + defaultModelDesc: '未指定档位时使用', + opusModel: 'Opus(最强)', + opusModelDesc: '用于复杂推理任务', + sonnetModel: 'Sonnet(均衡)', + sonnetModelDesc: '兼顾速度与能力', + haikuModel: 'Haiku(快速)', + haikuModelDesc: '适合简单任务的快速响应', + cancel: '取消', + savePreset: '保存预设', + applyPreset: '应用预设', + }, + componentModelSelector: { + selectModel: '选择模型', + availableModelsCount: '可用模型({{count}})', + preview: '预览', + }, + copilotSettings: { + enableCopilot: '启用 Copilot', + enableCopilotDesc: '允许使用 GitHub Copilot 订阅', + basicSettings: '基础设置', + accountType: '账户类型', + accountTypeIndividual: '个人', + accountTypeBusiness: '企业', + accountTypeEnterprise: '组织', + rateLimiting: '速率限制', + rateLimitSeconds: '速率限制(秒)', + noLimit: '不限制', + waitOnRateLimit: '触发限速时等待', + waitOnRateLimitDesc: '达到限制时等待而不是报错', + daemonSettings: '守护进程设置', + autoStartDaemon: '自动启动守护进程', + autoStartDaemonDesc: '使用配置时自动启动 copilot-api', + }, + providerModelSelector: { + selectModel: '选择模型', + noModelsForProvider: '该提供商暂无可用模型', + broken: '故障', + deprecated: '已弃用', + paid: '付费', + freeTier: '免费档', + paidTier: '付费档', + modelKnownIssues: '该模型存在已知问题', + viewIssueDetails: '查看问题详情', + modelDeprecated: '该模型已弃用', + recommended: '推荐', + allModelsCount: '全部模型({{count}})', + noModelsAvailable: '暂无可用模型', + }, + createAuthProfileDialog: { + title: '创建新账号', + description: + 'Auth 配置需要先完成 Claude CLI 登录。请在终端执行下方命令。稍后你仍可在 Accounts 表中编辑同步模式、分组与连续性深度。', + profileName: '配置名称', + profileNamePlaceholder: '例如:work、personal、client', + invalidProfileName: '名称必须以字母开头,且只能包含字母、数字、短横线或下划线。', + enableSharedHistory: '与其他 ccs auth 账号启用共享历史同步', + historySyncGroupOptional: '历史同步分组(可选)', + historySyncGroupPlaceholder: 'default、sprint-a、client-x', + historySyncGroupHint: '留空将使用默认共享分组。空格会自动规范为短横线。', + deeperContinuity: '高级:更深连续性模式', + deeperContinuityHint: + '会同步 session-env、file-history、shell-snapshots 与 todos。凭据仍保持隔离。', + invalidContextGroup: + '分组必须以字母开头,且只能包含字母、数字、短横线或下划线(最多 {{max}} 个字符)。', + command: '命令', + commandFallback: 'ccs auth create ', + afterRunPrefix: '执行命令后:', + afterRunStep1: '在浏览器中完成 Claude 登录', + afterRunStep2: '返回此处并刷新,即可看到新账号', + poolingHint: + '如果更倾向于共享池化 Claude OAuth 路由,可在 Accounts 页面操作按钮中使用 CLIProxy Claude pool。', + close: '关闭', + copied: '已复制!', + copyCommand: '复制命令', + }, + cliproxyModelCategory: { + google: 'Google(Gemini)', + openai: 'OpenAI(GPT)', + anthropic: 'Anthropic(Claude)', + antigravity: 'Antigravity', + other: '其他', + }, + cliproxyOverviewComponents: { + availableModels: '可用模型', + noModelsAvailable: '暂无可用模型', + startSessionHint: '启动 CLIProxy 会话后即可拉取可用模型', + totalCount: '共 {{count}} 个', + availableThroughGroupedByProvider: '通过 {{backendLabel}} 提供的模型,按提供商分组', + moreCount: '另外 {{count}} 个', + }, + categorizedModelSelector: { + selectModel: '选择模型', + noModelsAvailable: '暂无可用模型', + modelPlaceholder: '模型...', + }, + openrouterQuickStart: { + recommended: '推荐', + title: '从 OpenRouter 开始', + description: + '通过一个 API 即可访问来自 OpenAI、Anthropic、Google、Meta 等的 {{modelCountLabel}} 个模型。', + featureOneApi: '一个 API,接入全部提供商', + featureTierMapping: '模型档位映射', + createOpenRouterProfile: '创建 OpenRouter 配置', + getApiKeyAt: '在此获取 API Key:', + or: '或', + createCustomProfile: '创建自定义 API 配置', + }, + credentialHealth: { + title: '凭据健康状态', + ready: '就绪', + noCredentialsConfigured: '尚未配置凭据。请使用上方登录按钮完成认证。', + neverUsed: '从未使用', + justNow: '刚刚', + unknown: '未知', + daysAgo: '{{count}} 天前', + hoursAgo: '{{count}} 小时前', + minutesAgo: '{{count}} 分钟前', + }, + proxyStatusWidget: { + justNow: '刚刚', + minutesAgo: '{{count}} 分钟前', + hoursAgo: '{{count}} 小时前', + syncReady: '同步就绪', + noConfig: '未配置', + remoteProxy: '远程代理', + active: '已连接', + trafficAutoRouted: '流量已自动转发到远程服务器', + tooltipSync: '同步配置到 CLIProxy', + tooltipRestart: '重启', + tooltipStop: '停止', + tooltipClose: '关闭', + tooltipVersionSettings: '版本设置', + versionManagement: '版本管理', + selectVersionPlaceholder: '选择要安装的版本...', + stable: '(稳定版)', + install: '安装', + versionsAboveUnstable: '高于 {{version}} 的版本存在已知问题', + lastChecked: '上次检查 {{time}}', + notRunning: '未运行', + start: '启动', + port: '端口 {{port}}', + sessionCount: '{{count}} 个会话', + sessionCount_other: '{{count}} 个会话', + installUnstableTitle: '安装非稳定版本?', + installUnstableDesc: + '即将安装 v{{version}},该版本高于当前最大稳定版 v{{maxStable}}。', + installUnstableWarning: '该版本存在已知稳定性问题,可能导致异常行为。', + installUnstableConfirm: '确定要继续吗?', + cancel: '取消', + installAnyway: '仍要安装', + clickToDowngrade: '点击降级', + clickToUpdate: '点击更新', + }, + syncStatusCard: { + profileSync: '配置同步', + ready: '就绪', + noConfig: '未配置', + syncsProfilesDesc: '将 API 配置同步到本地 CLIProxy 配置', + runDoctorHint: '运行 ccs doctor --fix 生成配置。', + details: '详情', + syncNow: '立即同步', + }, + syncDialog: { + title: '同步配置到本地 CLIProxy', + description: '将 CCS API 配置同步到本地 CLIProxy 的 config.yaml。', + noProfiles: '没有可同步的配置。', + createProfilesFirst: '请先在「配置」页创建 API 配置。', + modelLabel: '模型:', + ready: '就绪', + profilesToSync_one: '{{count}} 个配置待同步', + profilesToSync_other: '{{count}} 个配置待同步', + cancel: '取消', + syncing: '同步中...', + synced: '已同步!', + syncNow: '立即同步', + }, + errorLogs: { + title: '错误日志', + failedRequests_one: '{{count}} 个失败请求', + failedRequests_other: '{{count}} 个失败请求', + cliproxyDiagnostics: 'CLIProxy 诊断', + showingCount: '显示 50 / {{total}} 条', + selectLog: '选择一条日志查看详情', + failedLoadContent: '加载日志内容失败', + copyAbsolutePath: '复制绝对路径', + copyRawContent: '复制原始日志', + tabOverview: '概览', + tabHeaders: '请求头', + tabRequest: '请求体', + tabResponse: '响应体', + tabRaw: '原始', + modelLabel: '模型:', + quotaResetsIn: '配额重置于 ', + method: '方法', + provider: '提供商', + version: '版本', + endpoint: '端点', + url: 'URL', + timestamp: '时间戳', + na: '无', + noHeaders: '无请求头', + noRequestBody: '无请求体内容', + noResponseBody: '无响应体内容', + rateLimitedMsg: '触发限速。请切换账号或等待配额重置。模型 {{model}} 配额已用尽。', + rateLimitedMsgNoModel: '触发限速。请切换账号或等待配额重置。', + authFailedMsg: '认证失败。请通过 CLIProxy 设置重新认证或检查 API Key。', + notFoundMsg: '端点未找到,该提供商可能不存在此端点。', + serverErrorMsg: '上游服务器错误。请稍后重试或查看提供商状态页。', + timeoutMsg: '请求超时。请检查网络或增大超时设置。', + unknown: '未知', + }, + editAccountContext: { + title: '编辑历史同步', + description: '配置「{{name}}」与其他 ccs auth 账号共享历史与连续性的方式。', + syncMode: '同步模式', + selectContextMode: '选择同步模式', + isolatedOption: '隔离(不同步)', + sharedOption: '共享(启用同步)', + sharedModeHint: '共享模式会为同一历史同步分组内的账号复用工作区上下文。', + isolatedModeHint: '隔离模式使该账号与其他 ccs auth 账号完全独立。', + historySyncGroup: '历史同步分组', + groupPlaceholder: 'default', + groupHint: '会规范为小写(空格变为短横线)。允许:字母、数字、_、-(最多 {{max}} 字符)。', + invalidGroup: '请输入以字母开头的有效分组名。', + continuityDepth: '连续性深度', + selectContinuityDepth: '选择连续性深度', + standardOption: '标准(仅项目)', + deeperOption: '更深连续性(高级)', + deeperHint: '高级模式还会同步 session-env、file-history、shell-snapshots 和 todos。', + standardHint: '标准模式仅同步项目工作区上下文。', + credentialsIsolated: '凭据与 .anthropic 在所有模式下均按账号隔离。', + cancel: '取消', + save: '保存', + saving: '保存中...', + }, + historySyncLearningMap: { + title: '历史同步说明', + description: '隔离 -> 共享 -> 更深。在每行使用「同步」进行所有更改。', + learningMap: '说明', + isolated: '隔离', + shared: '共享', + deeper: '更深', + cliproxyManaged_one: '{{count}} 个 CLIProxy Claude 池账号在操作中心 / CLIProxy 页面管理。', + cliproxyManaged_other: + '{{count}} 个 CLIProxy Claude 池账号在操作中心 / CLIProxy 页面管理。', + showDetails: '显示详情:分组、切换与旧策略', + modeSwitch: '模式切换', + modeSwitchDesc: '同步对话框可让用户在隔离/共享间切换并选择更深连续性。', + historySyncGroup: '历史同步分组', + historySyncGroupDesc: '同一分组即共享项目上下文。默认回退为 default。', + }, + accountsTable: { + name: '名称', + type: '类型', + defaultBadge: '默认', + created: '创建时间', + lastUsed: '最后使用', + historySync: '历史同步', + actions: '操作', + syncTitle: '编辑同步模式、分组与连续性深度', + confirmLegacyTitle: '确认此旧账号的当前模式为显式', + sync: '同步', + confirm: '确认', + active: '当前', + setDefault: '设为默认', + deleteAccount: '删除账号', + cannotDeleteDefault: '不能删除默认账号', + noAccounts: '未找到 CCS auth 账号。请使用 ccs auth create 添加。', + resetToDefault: '恢复为 CCS 默认', + deleteDialogTitle: '删除账号', + deleteDialogDesc: + '确定要删除账号「{{name}}」吗?将移除该配置及其所有会话数据,且无法撤销。', + cancel: '取消', + delete: '删除', + sharedGroupStandard: '共享({{group}},标准)', + sharedGroupDeeper: '共享({{group}},更深)', + sharedGroupLegacy: '共享({{group}},标准旧策略)', + isolatedLegacy: '隔离(旧默认)', + isolated: '隔离', + }, + addAccountDialog: { + title: '添加 {{displayName}} 账号', + descKiro: '选择 Kiro 认证方式,然后通过浏览器或从 Kiro IDE 导入完成认证。', + descDeviceCode: '点击认证后,验证码将显示,请在提供商网站输入。', + descOauth: '点击认证获取 OAuth URL,在任意浏览器中打开即可登录。', + powerUserEnabled: '已启用高级用户模式', + powerUserSkipped: '已在设置 > 代理 中跳过 AGY 责任清单,您需自行承担 OAuth/账号风险。', + authMethod: '认证方式', + selectKiroAuthMethod: '选择 Kiro 认证方式', + nicknameRequired: '昵称(必填)', + nicknameOptional: '昵称(选填)', + nicknamePlaceholder: '例如:工作、个人', + nicknameRequiredHint: '该提供商必填。请使用唯一易记名称(如工作、个人)。', + nicknameOptionalHint: '用于区分账号的友好名称。留空将根据邮箱自动生成。', + waitingForAuth: '等待认证中...', + deviceCodeHint: '验证码对话框即将出现,请在提供商网站输入验证码。', + browserHint: '在浏览器中完成认证后,本对话框将自动关闭。', + openUrlLabel: '在任意浏览器中打开此 URL 登录:', + copied: '已复制', + copy: '复制', + open: '打开', + popupBlocked: '弹窗被拦截。请复制上方 URL 在浏览器中手动打开。', + redirectPasteLabel: '重定向未生效?请粘贴回调 URL:', + callbackPlaceholder: '在此粘贴重定向 URL...', + submitCallback: '提交回调', + submitting: '提交中...', + preparingUrl: '正在准备登录 URL...', + cancel: '取消', + importingToken: '正在从 Kiro IDE 导入令牌...', + authenticate: '认证', + importFromIde: '从 IDE 导入', + importing: '导入中...', + }, + flowViz: { + backToProviders: '返回提供商', + showDetails: '显示详情', + hideDetails: '隐藏详情', + resetLayout: '重置布局', + provider: '提供商', + totalRequests: '总请求数', + accounts: '账号', + connectionTimeline: '连接时间线', + noRecentConnections: '暂无最近连接', + }, + quickStats: { + totalRequests: '总请求数', + successRate: '成功率', + totalTokens: '总 Token 数', + activeModels: '活跃模型数', + }, + authMonitor: { + accounts: '账号', + accountsCount: '{{count}} 个账号', + success: '成功', + failed: '失败', + successRate: '成功率', + }, + accountCard: { + resumeAccount: '恢复账号', + pauseAccount: '暂停账号', + quotaLoading: '配额...', + quota: '配额', + quotaUnavailable: '配额限制不可用', + reauthNeeded: '需要重新认证', + removeAndReadd: '移除并重新添加账号', + autoRefreshFailed: '自动刷新失败', + }, + antigravityChecklist: { + title: 'Antigravity OAuth 责任须知', + subtitle: '认证前请完成全部 4 步。', + mandatory: '必读', + completion: '完成度', + stepsCount: '{{current}}/4 步', + }, + accountSafetyWarning: { + title: 'OAuth 账号安全提示', + subtitle: 'Issue #509 · Gemini + AGY OAuth 风险', + firstLine: + 'Issue #509 记录了与 ccs agy 及 ccs gemini 与 ccs agy 共享账号使用相关的封禁/停用报告。', + secondLine: '仅在您愿意承担 OAuth 与账号访问风险的前提下继续。', + issueLabel: '查看 issue #509', + proxySettingsLabel: 'Gemini + AGY 控制:设置 > 代理', + }, + cliproxyDialog: { + name: '名称', + provider: '提供商', + account: '账号', + accountOptional: '账号(选填)', + model: '模型', + modelOptional: '模型(选填)', + defaultTarget: '默认目标', + defaultTier: '默认档位', + tierConfig: '档位配置', + selectProvider: '选择提供商...', + useDefaultAccount: '使用默认账号', + defaultSuffix: '(默认)', + opus: 'Opus', + sonnet: 'Sonnet', + haiku: 'Haiku', + claudeCode: 'Claude Code', + factoryDroid: 'Factory Droid', + cancel: '取消', + create: '创建', + creating: '创建中...', + saveChanges: '保存更改', + saving: '保存中...', + placeholderName: 'my-gemini', + placeholderModel: 'gemini-2.5-pro', + placeholderComposite: 'my-composite', + placeholderModelId: 'model-id', + placeholderAccountId: 'account-id', + editCompositeTitle: '编辑复合变体:{{name}}', + editSingleTitle: '编辑单一样式:{{name}}', + }, + profileEditor: { + profileInfo: '配置信息', + profileName: '配置名称', + filePath: '文件路径', + lastModified: '最后修改', + defaultTarget: '默认目标', + quickUsage: '快速使用', + runWithProfile: '使用配置运行', + runOnDroid: '在 Droid 上运行', + droidAliasExplicit: 'Droid 别名(显式)', + overrideToClaude: '切换为 Claude', + overrideToClaudeExplicit: '切换为 Claude(显式)', + setAsDefault: '设为默认', + provider: '提供商', + custom: '自定义', + }, + settings: { + sectionLoadFailed: '加载分区失败', + unknownError: '发生未知错误', + reloadPage: '重新加载页面', + refresh: '刷新', + copy: '复制', + copied: '已复制', + saved: '已保存', + loading: '加载中...', + configFileNotFound: '未找到配置文件', + }, + settingsTabs: { + web: '网页', + env: '环境', + think: '思考', + proxy: '代理', + auth: '认证', + backup: '备份', + }, + settingsAuth: { + failedFetchTokens: '获取认证令牌失败', + failedSaveTokens: '保存令牌失败', + tokensUpdated: '令牌已更新,请重启 CLIProxy 生效。', + failedRegenerate: '重新生成密钥失败', + secretRegenerated: '新的管理密钥已生成,请重启 CLIProxy 生效。', + failedReset: '重置令牌失败', + tokensReset: '令牌已重置为默认值,请重启 CLIProxy 生效。', + description: '配置 CLIProxy 认证令牌。修改后需要重启 CLIProxy。', + apiKey: 'API Key', + custom: '自定义', + apiKeyDesc: 'Claude Code 通过该值向 CLIProxy 鉴权', + apiKeyPlaceholder: 'API key', + managementSecret: '管理密钥', + managementSecretDesc: 'CCS Dashboard 使用该值访问 CLIProxy 管理接口', + managementSecretPlaceholder: '管理密钥', + generateSecret: '生成新的安全密钥', + resetDefaults: '重置为默认值', + resetDesc: '将 API key 和管理密钥都恢复为默认值。', + saving: '保存中...', + save: '保存', + }, + settingsGlobalEnv: { + description: '向所有非 Claude 订阅型配置注入环境变量(gemini、codex、agy、ghcp 等)', + enabled: '全局环境变量已启用', + disabled: '全局环境变量已禁用', + enabledDesc: '环境变量将注入第三方配置', + disabledDesc: '环境变量不会被注入', + envVars: '环境变量', + noneConfigured: '尚未配置环境变量', + addNew: '新增变量', + keyName: 'KEY_NAME', + value: 'value', + add: '添加', + quickAdd: '快速添加常用变量', + allConfigured: '常用变量已全部配置', + }, + settingsBackups: { + failedFetch: '获取备份失败', + failedRestore: '恢复备份失败', + restoreSuccess: '备份恢复成功', + title: '设置备份', + description: '恢复 settings.json 的历史版本。每次修改设置时会自动创建备份。', + none: '暂无备份', + noneHint: '修改设置后,备份会显示在这里', + latest: '最新', + restoring: '恢复中...', + restore: '恢复', + restoreConfirmTitle: '确认恢复备份?', + restoreConfirmPrefix: '这将用以下备份替换当前设置:', + restoreConfirmSuffix: '此操作不可撤销。', + cancel: '取消', + }, + settingsWebsearch: { + description: '面向第三方配置(gemini、codex、agy 等)的 CLI 网页搜索', + enabled: 'WebSearch 已启用', + disabled: 'WebSearch 已禁用', + checking: '正在检查状态...', + providers: '提供商', + noneConfigured: '未配置任何提供商', + noneConfiguredHint: '请先安装 CLI 工具以启用网页搜索提供商', + checkProviders: '检查可用提供商', + installed: '已安装', + notInstalled: '未安装', + model: '模型:', + howInstall: '如何安装 {{name}} CLI', + installGlobally: '全局安装', + requiresKey: '(需要 xAI API key)', + freeTier: '(提供免费额度)', + viewDocs: '查看文档', + }, + settingsProxy: { + failedLoadAgyMode: '加载 AGY 高级模式失败', + failedUpdateAgyMode: '更新 AGY 高级模式失败', + failedVerifyAgyMode: '校验 AGY 高级模式持久化失败', + notPersistedAgyMode: 'AGY 高级模式未成功持久化,配置可能被其他进程修改。', + agyModeEnabled: 'AGY 高级模式已启用。', + agyModeDisabled: 'AGY 高级模式已关闭。', + typePhraseToContinue: '请输入“{{value}}”后继续。', + description: '为基于代理的配置设置本地或远程 {{backend}} 连接', + backendPlus: 'CLIProxy Plus', + backend: 'CLIProxy', + instanceStatus: '实例状态', + connectionMode: '连接模式', + local: '本地', + remote: '远程', + localDesc: '在本机运行 {{backend}} 二进制', + remoteDesc: '连接到远程 {{backend}} 服务器', + backendBinary: '后端二进制', + stopProxyToSwitch: '请先在实例状态中停止正在运行的代理,再切换后端。', + default: '默认', + plusDesc: '完整支持包括 Kiro 和 GitHub Copilot 在内的提供商', + originalDesc: '原版二进制(仅 Gemini、Codex、Antigravity)', + variantsIncompatible: + '现有 Kiro/Copilot 变体与 CLIProxyAPI 不兼容。请切换到 CLIProxyAPIPlus 或移除这些变体。', + safety: '安全', + agyModeTitle: 'Antigravity 高级模式', + agyModeDesc: '在 Add Account 与 `ccs agy` 流程中跳过 AGY 责任确认清单。', + agyWarning: '仅在你充分理解 OAuth 封禁风险模式(#509)后使用。CCS 不对账号损失承担责任。', + finalConfirm: '需要最终确认', + finalConfirmDesc: + '启用后会跳过 Dashboard 与 CLI 的 AGY 安全检查。请先阅读 issue #509,并输入精确短语后继续。', + step1: '步骤 1', + readIssue: '阅读 issue #509', + step2: '步骤 2', + typePrefix: '输入', + typeSuffix: '以启用。', + typePhraseAria: '输入 I ACCEPT RISK 以启用 Antigravity 高级模式', + exactPhrase: '必须输入完全一致的短语。', + enableAgyMode: '启用高级模式', + toggleAgyMode: '切换 AGY 高级模式', + fallbackSettings: '回退设置', + enableFallback: '启用本地回退', + enableFallbackDesc: '远程不可达时使用本地代理', + autoStartLocal: '自动启动本地代理', + autoStartLocalDesc: '发生回退时自动启动本地代理', + advanced: '高级', + debugMode: '调试模式', + debugModeDesc: '在浏览器控制台输出开发诊断信息', + debugModeEnabled: '调试模式已开启,请在浏览器控制台查看详细日志。', + localProxy: '本地代理', + port: '端口', + autoStart: '自动启动', + localAutoStartDesc: '需要时自动启动本地代理', + remoteConfigTitle: '远程服务器配置', + host: '主机', + hostPlaceholder: '192.168.1.100 或 proxy.example.com', + defaultPort: '默认:{{value}}', + portPlaceholder: '留空使用 {{value}}', + protocol: '协议', + http: 'HTTP', + https: 'HTTPS', + apiKeyOptional: 'API Key(可选)', + apiKeyPlaceholder: '用于 /v1/* API 接口', + apiKeyDesc: '用于 /v1/chat/completions 接口请求', + managementKeyOptional: '管理密钥(可选)', + managementKeyPlaceholder: '用于 /v0/management/* 接口', + managementKeyDesc: '用于 Dashboard 管理接口。未设置时回退使用 API Key。', + testing: '测试中...', + testConnection: '测试连接', + connectedLatency: '已连接({{value}}ms)', + connectionFailed: '连接失败', + invalidPortOrEmpty: '端口必须是 1 到 65535 的整数,或留空使用默认值', + invalidLocalPort: '本地端口必须是 1 到 65535 的整数', + backendPlusApi: 'CLIProxyAPIPlus', + backendApi: 'CLIProxyAPI', + }, + settingsThinking: { + description: '为支持的模型配置扩展思考/推理。', + supportedProviders: '支持的提供商', + modeTitle: '思考模式', + modeAutoDesc: '根据模型档位自动设置思考级别', + modeOffDesc: '关闭扩展思考', + modeManualDesc: '设置持久覆盖级别,或按次使用 CLI 参数', + tierDefaults: '档位默认值', + tierDefaultsDesc: '自动模式下各模型档位的默认思考级别。', + persistentOverride: '持久覆盖', + persistentOverrideDesc: '应用到所有会话。CLI 参数仍具有更高优先级。', + enterCustomBudget: '输入自定义预算', + apply: '应用', + providerOverrides: '提供商覆盖({{count}})', + providerOverridesDesc: '为特定提供商覆盖档位默认值。可按需添加自定义提供商键。', + addProviderPlaceholder: '添加提供商键(例如 qwen)', + showWarnings: '显示告警', + showWarningsDesc: '当思考值被限制或调整时显示告警', + cliEnvOverride: 'CLI 与环境变量覆盖', + supportLine1Prefix: '思考预算:', + supportLine1Suffix: '(基于 token)', + supportLine2Prefix: '推理强度:', + supportLine2SuffixPrefix: '(后缀或 ', + supportLine2SuffixPostfix: ':medium/high/xhigh)', + supportLine3Prefix: 'Codex 后缀会固定强度(例如 ', + supportLine3Suffix: ');无后缀模型使用 Thinking mode。', + }, + analytics: { + title: '分析', + subtitle: '追踪使用情况与洞察', + month: '本月', + allTime: '全部时间', + updated: '更新于 {{value}}', + }, + analyticsSummary: { + totalTokens: '总 Token', + totalTokensSubtitle: '输入 {{input}} / 输出 {{output}}', + totalCost: '总成本', + totalCostSubtitle: '日均 ${{value}}', + cacheTokens: '缓存 Token', + cacheTokensSubtitle: '${{cost}}(占成本 {{percent}}%)', + inputCost: '输入成本', + outputCost: '输出成本', + tokensSubtitle: '{{value}} 个 token', + }, + apiProfiles: { + title: 'API 配置', + new: '新建', + searchPlaceholder: '搜索配置...', + loadingProfiles: '正在加载配置...', + failedLoadTitle: '加载配置失败', + failedLoadDesc: '无法获取 API 配置,请稍后重试。', + retry: '重试', + noProfilesYet: '还没有 API 配置', + noProfilesDesc: '创建你的第一个配置以连接自定义 API 端点', + createProfile: '创建配置', + noProfileMatch: '没有匹配“{{query}}”的配置', + profileCount: '{{count}} 个配置', + configuredCount: '{{count}} 个已配置', + deleteProfileTitle: '删除配置', + deleteProfileDesc: '确定要删除“{{name}}”吗?这会移除设置文件且无法恢复。', + delete: '删除', + unsavedChangesTitle: '未保存的更改', + unsavedChangesDesc: '你在“{{current}}”中有未保存内容,是否丢弃并切换到“{{next}}”?', + discardSwitch: '丢弃并切换', + }, + accountsPage: { + title: '账号', + managePrefix: '统一管理', + manageSuffix: '账号与池化接入流程。', + mobileManageSuffix: '的连续性配置。', + primaryActions: '主要操作', + createAccount: '创建账号', + authClaudeInPool: '在 Pool 中认证 Claude', + openClaudePoolSettings: '打开 Claude Pool 设置', + openCliProxyClaudePool: '打开 CLIProxy Claude Pool', + migrationFollowup: '迁移后续处理', + legacyContextPending: '{{count}} 个账号仍需首次模式确认。', + legacyContinuityPending: '{{count}} 个共享账号仍处于标准旧版连续性深度。', + confirmingLegacy: '正在确认旧版策略...', + confirmLegacy: '确认旧版策略({{count}})', + noLegacyFollowup: '暂无待处理的迁移后续项。', + continuityGuide: '连续性指南', + expandWhenNeeded: '按需展开查看。', + sharedStandard: '共享标准', + sharedStandardDesc: '仅同步项目工作区。适合大多数团队。', + sharedDeeper: '共享更深', + sharedDeeperPrefix: '额外包含', + isolated: '隔离', + isolatedDesc: '不建立链接。适合严格隔离场景。', + quickCommands: '快捷命令', + quickCommandsDesc: '复制后在终端运行。', + workspaceBadge: 'ccs auth 工作区', + historySyncBadge: '历史同步控制', + authAccounts: '认证账号', + tableScopePrefix: '该表仅展示', + tableScopeMiddle: '账号。模式/分组/深度调整请使用', + tableScopeSuffix: '。', + accountMatrix: '账号矩阵', + sharedTotalDesc: '共享总数:{{count}}。可执行 Sync 设置和旧版确认操作。', + loadingAccounts: '正在加载账号...', + }, + health: { + justNow: '刚刚', + secondsAgo: '{{count}} 秒前', + minutesAgo: '{{count}} 分钟前', + hoursAgo: '{{count}} 小时前', + systemHealth: '系统健康', + build: '构建 {{version}}', + lastScan: '上次扫描:', + autoRefresh: '自动刷新:', + refresh: '刷新', + checks: '检查项', + version: '版本', + platform: '平台', + liveMonitoring: '实时监控已启用', + copied: '已复制到剪贴板', + refreshing: '正在刷新健康检查...', + fix: '修复', + applyFix: '应用修复', + healthLabel: '健康', + }, + updates: { + actionRequired: '待处理', + done: '已完成', + all: '全部', + inboxTitle: '更新收件箱', + inboxSubtitle: '先处理操作,再将更新标记为完成或忽略。', + needsAction: '需处理', + doneCount: '已完成', + searchPlaceholder: '搜索操作或命令', + noNotices: '当前视图下没有通知。', + noUpdates: '暂无更新。', + published: '发布于 {{date}}', + markDone: '标记完成', + dismiss: '忽略', + reopen: '重新打开', + doNext: '下一步', + impactedIntegrations: '受影响的集成', + impactedDesc: '根据更新范围与路由匹配的相关区域。', + whyMatters: '为什么重要', + whyMattersDesc: '仅保留简要背景,不展示冗长发布说明。', + open: '打开', + progressNeedsAction: '需处理', + progressInReview: '审阅中', + progressDone: '已完成', + progressDismissed: '已忽略', + statusNew: '新', + statusStable: '稳定', + statusPlanned: '计划中', + dashboard: 'Dashboard', + cliUsage: 'CLI 用法', + }, + cliproxyPage: { + connected: '已连接', + notConnected: '未连接', + variant: '变体', + parentNotConnected: '上游未连接', + viaProvider: '通过 {{provider}}', + emptyTitle: 'CCS 配置管理', + emptyDesc: '管理 CLIProxy 提供商的 OAuth 认证、账号偏好与模型选择,配置 CCS 的请求路由。', + emptyControlPanelPrefix: '如需查看实时用量和监控,请前往', + controlPanel: '控制面板', + quickSetup: '快速设置', + accountManagement: 'CCS 级账号管理', + providers: '提供商', + variants: '变体', + providerCount: '{{count}} 个提供商', + connectedCount: '{{count}} 个已连接', + variantDisplay: '{{name}}({{provider}} 变体)', + }, + copilotPage: { + title: 'Copilot', + subtitle: 'GitHub Copilot 代理', + setup: '安装', + auth: '认证', + daemon: '守护进程', + unofficialTitle: '非官方 API - 风险自负', + unofficialItem1: '逆向接口,可能随时失效', + unofficialItem2: '过度使用可能触发账号限制', + unofficialItem3: 'CCS 不提供担保,也不承担责任', + yes: '是', + no: '否', + installed: '已安装', + missing: '缺失', + integration: '集成', + enabled: '已启用', + disabled: '已禁用', + github: 'GitHub', + connected: '已连接', + notConnected: '未连接', + authenticate: '去认证', + authenticating: '认证中...', + status: '状态', + running: '运行中', + stopped: '已停止', + port: '端口', + stopping: '停止中...', + stop: '停止', + starting: '启动中...', + start: '启动', + installing: '安装中...', + installCopilotApi: '安装 copilot-api', + proxy: '代理', + active: '活跃', + inactive: '未激活', + }, + cursorPage: { + title: 'Cursor', + beta: 'Beta', + subtitle: 'Cursor 集成专用控制面板', + unofficialTitle: '非官方 API - 风险自负', + unofficialItem1: '逆向集成可能随时失效', + unofficialItem2: '滥用或过量使用可能导致账号受限', + unofficialItem3: 'CCS 不提供担保,也不承担责任', + integration: '集成', + authentication: '认证', + daemon: '守护进程', + enabled: '已启用', + disabled: '已禁用', + expired: '已过期', + notConnected: '未连接', + running: '运行中', + stopped: '已停止', + actions: '操作', + disableIntegration: '禁用集成', + enableIntegration: '启用集成', + autoDetectAuth: '自动检测认证', + manualAuthImport: '手动导入认证', + stopDaemon: '停止守护进程', + startDaemon: '启动守护进程', + port: '端口', + configuration: 'Cursor 配置', + lastModified: '最后修改:', + neverSaved: '从未保存', + saving: '保存中...', + save: '保存', + modelConfig: '模型配置', + settings: '设置', + info: '信息', + presets: '预设', + presetsDesc: '应用预设模型映射', + modelMapping: '模型映射', + modelMappingDesc: '为不同档位配置使用模型', + defaultModel: '默认模型', + defaultModelDesc: '未指定档位时使用。', + opusModel: 'Opus(最强)', + opusModelDesc: '复杂推理与最高质量输出。', + sonnetModel: 'Sonnet(均衡)', + sonnetModelDesc: '日常编码和通用对话。', + haikuModel: 'Haiku(快速)', + haikuModelDesc: '低延迟与轻量请求。', + runtimeSettings: '运行时设置', + autoStartDaemon: '自动启动守护进程', + autoStartDesc: '在使用集成时自动启动 Cursor 守护进程。', + ghostMode: 'Ghost 模式', + ghostModeDesc: '请求时附带 x-ghost-mode 以减少遥测。', + provider: '提供商', + filePath: '文件路径', + availableModels: '可用模型', + loadingModels: '加载模型中...', + noModels: '暂无模型元数据,请先启动守护进程后刷新。', + default: '默认', + rawConfiguration: '原始配置(JSON)', + manualImportTitle: '手动导入 Cursor 认证', + manualImportDesc: '若无法自动检测,请提供 Cursor access token 与 machine ID。', + accessToken: '访问令牌', + accessTokenPlaceholder: '粘贴 Cursor access token', + machineId: '机器 ID', + machineIdPlaceholder: '32 位十六进制(无连字符 UUID)', + cancel: '取消', + import: '导入', + selectModel: '选择模型', + useDefaultModel: '使用默认模型', + availableModelCount: '可用模型({{count}})', + invalidPort: '端口必须是 1 到 65535 之间的整数', + defaultModelRequired: '默认模型必填', + savedConfig: 'Cursor 配置与模型映射已保存', + failedSaveConfig: '保存配置失败', + modelsLoadingWait: '模型仍在加载中,请稍后再应用预设。', + noModelsAvailable: '暂无可用模型,请先启动守护进程并刷新。', + appliedCodexPreset: '已应用 GPT-5.3 Codex 预设', + appliedClaudePreset: '已应用 Claude 4.6 预设', + appliedGeminiPreset: '已应用 Gemini 3 预设', + integrationEnabled: 'Cursor 集成已启用', + integrationDisabled: 'Cursor 集成已禁用', + failedUpdateIntegration: '更新集成状态失败', + credentialsImported: 'Cursor 凭据已导入', + autoDetectFailed: '自动检测失败', + manualRequired: 'Token 和 machine ID 为必填项', + manualImportFailed: '手动导入失败', + failedStartDaemon: '启动守护进程失败', + daemonStarted: '守护进程已启动', + daemonStartedWithPid: '守护进程已启动(PID: {{pid}})', + failedStopDaemon: '停止守护进程失败', + daemonStopped: '守护进程已停止', + rawLoading: '原始设置仍在加载,请稍后重试。', + invalidJson: 'JSON 无效', + rawSaved: '原始设置已保存', + rawChanged: '原始设置已被外部修改,请刷新后重试。', + failedSaveRaw: '保存原始设置失败', + savedAll: 'Cursor 配置已保存', + }, + droidPage: { + loadingDiagnostics: '加载 Droid 诊断中...', + failedDiagnostics: '加载 Droid 诊断失败。', + overview: '概览', + byok: 'BYOK', + docs: '文档', + runtimeInstall: '运行时与安装', + status: '状态', + detected: '已检测', + notFound: '未找到', + detectionSource: '检测来源', + binaryPath: '二进制路径', + installDirectory: '安装目录', + version: '版本', + overridePath: '覆盖路径(CCS_DROID_PATH)', + configFiles: '配置文件', + path: '路径', + resolved: '解析结果', + size: '大小', + lastModified: '最后修改', + parseWarning: '解析告警:{{value}}', + readWarning: '读取告警:{{value}}', + warnings: '告警', + byokSummary: 'BYOK 概览', + activeModelSelector: '当前模型选择器', + customModels: '自定义模型', + ccsManaged: 'CCS 管理', + userManaged: '用户管理', + malformedEntries: '异常条目', + providers: '提供商', + none: '无', + customModelsTitle: '自定义模型', + modelName: '名称 / 模型', + provider: '提供商', + baseUrl: 'Base URL', + noCustomModels: '没有自定义模型', + docsAlignedNotes: '文档对齐说明', + factoryDocs: 'Factory 文档', + providerFactCheckDocs: 'Provider 校验文档', + providerValues: 'Provider 值:{{value}}', + settingsHierarchy: 'Settings 层级:{{value}}', + settingsTitle: 'Droid BYOK 设置', + fixJsonBeforeQuickSettings: '请先修复 JSON 语法,再使用快速设置。', + fixJsonBeforeReasoning: '请先修复 JSON 语法,再更新 BYOK 推理设置。', + unableUpdateReasoning: '无法更新所选 BYOK 模型的推理设置。', + fixJsonBeforeBudget: '请先修复 JSON 语法,再更新思考预算。', + anthropicOnlyBudget: '思考预算仅适用于 Anthropic BYOK 模型。', + invalidJson: 'JSON 无效:{{value}}', + saved: 'Droid 设置已保存', + changedExternally: 'Droid 设置已被外部修改,请刷新后重试。', + failedSave: '保存 Droid 设置失败', + }, + sharedPage: { + commands: '命令', + skills: '技能', + agents: '代理', + title: '共享数据', + subtitle: '在 Claude 实例间共享的命令、技能和代理', + totalShared: '共享总数', + visible: '可见', + markdownDetail: 'Markdown 详情视图', + filterPrefix: '筛选:', + configurationRequired: '需要配置', + countsUnavailable: '统计不可用', + retryCounts: '重试统计', + filterPlaceholder: '按名称、描述或路径筛选 {{tab}}', + showing: '显示 {{visible}} / {{total}} 个{{tab}}', + showingQuery: '(关键字“{{query}}”)', + refreshing: '(刷新中...)', + loadingShared: '正在加载共享{{tab}}...', + failedLoadShared: '加载共享{{tab}}失败', + retry: '重试', + noSharedFound: '未找到共享{{tab}}。请运行 `ccs sync` 或在共享目录添加内容。', + noMatch: '没有匹配“{{query}}”的{{tab}}。', + selectOne: '选择一个{{tab}}查看完整内容。', + pathLabel: '路径', + resolvedSource: '解析后来源', + loadingMarkdown: '正在加载 Markdown 内容...', + failedLoadContent: '加载内容失败', + retryContent: '重试内容加载', + noMarkdown: '暂无 Markdown 内容。', + }, + }, + }, +} as const; + +if (!i18n.isInitialized) { + i18n.use(initReactI18next).init({ + resources, + lng: getInitialLocale(), + fallbackLng: 'en', + interpolation: { + escapeValue: false, + }, + }); +} + +export default i18n; diff --git a/ui/src/lib/locales.ts b/ui/src/lib/locales.ts new file mode 100644 index 00000000..bf6ff60d --- /dev/null +++ b/ui/src/lib/locales.ts @@ -0,0 +1,52 @@ +export const LOCALE_STORAGE_KEY = 'ccs-ui-locale'; + +export const SUPPORTED_LOCALES = ['en', 'zh-CN'] as const; + +export type AppLocale = (typeof SUPPORTED_LOCALES)[number]; + +export function isSupportedLocale(locale: string): locale is AppLocale { + return SUPPORTED_LOCALES.includes(locale as AppLocale); +} + +export function normalizeLocale(locale: string | null | undefined): AppLocale { + if (!locale) return 'en'; + if (locale.toLowerCase().startsWith('zh')) return 'zh-CN'; + return 'en'; +} + +export function getStoredLocale(): AppLocale | null { + if (typeof window === 'undefined') return null; + try { + const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY); + return raw ? normalizeLocale(raw) : null; + } catch { + return null; + } +} + +export function getInitialLocale(): AppLocale { + const stored = getStoredLocale(); + if (stored) return stored; + if (typeof navigator !== 'undefined') return normalizeLocale(navigator.language); + return 'en'; +} + +export function getFormattingLocale(locale?: string): string { + if (locale) return normalizeLocale(locale); + const stored = getStoredLocale(); + if (stored) return stored; + if (typeof navigator !== 'undefined') return normalizeLocale(navigator.language); + return 'en'; +} + +export function persistLocale(locale: string): AppLocale { + const normalized = normalizeLocale(locale); + if (typeof window !== 'undefined') { + try { + window.localStorage.setItem(LOCALE_STORAGE_KEY, normalized); + } catch { + // Ignore storage errors and keep runtime locale only. + } + } + return normalized; +} diff --git a/ui/src/pages/accounts.tsx b/ui/src/pages/accounts.tsx index b0c5efe1..6b517db5 100644 --- a/ui/src/pages/accounts.tsx +++ b/ui/src/pages/accounts.tsx @@ -17,8 +17,10 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component import { ScrollArea } from '@/components/ui/scroll-area'; import { useAccounts, useConfirmLegacyAccountPolicies } from '@/hooks/use-accounts'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; export function AccountsPage() { + const { t } = useTranslation(); const navigate = useNavigate(); const { data, isLoading } = useAccounts(); const confirmLegacyMutation = useConfirmLegacyAccountPolicies(); @@ -59,12 +61,12 @@ export function AccountsPage() {
-

Accounts

+

{t('accountsPage.title')}

- Manage + {t('accountsPage.managePrefix')} ccs auth - accounts and pool onboarding from one panel. + {t('accountsPage.manageSuffix')}

@@ -72,7 +74,7 @@ export function AccountsPage() {

- Primary Actions + {t('accountsPage.primaryActions')}

@@ -104,7 +106,7 @@ export function AccountsPage() { {hasLegacyFollowUp ? (

- Migration Follow-up + {t('accountsPage.migrationFollowup')}

@@ -112,16 +114,14 @@ export function AccountsPage() {
{legacyContextCount > 0 && (

- {legacyContextCount} account - {legacyContextCount > 1 ? 's still need' : ' still needs'} first-time - mode confirmation. + {t('accountsPage.legacyContextPending', { count: legacyContextCount })}

)} {legacyContinuityCount > 0 && (

- {legacyContinuityCount} shared account - {legacyContinuityCount > 1 ? 's remain' : ' remains'} on standard legacy - continuity depth. + {t('accountsPage.legacyContinuityPending', { + count: legacyContinuityCount, + })}

)}
@@ -134,14 +134,14 @@ export function AccountsPage() { disabled={confirmLegacyMutation.isPending || legacyTargetCount === 0} > {confirmLegacyMutation.isPending - ? 'Confirming Legacy Policies...' - : `Confirm Legacy Policies (${legacyTargetCount})`} + ? t('accountsPage.confirmingLegacy') + : t('accountsPage.confirmLegacy', { count: legacyTargetCount })}
) : (
- No legacy follow-up pending. + {t('accountsPage.noLegacyFollowup')}
)} @@ -151,9 +151,11 @@ export function AccountsPage() { @@ -306,11 +314,11 @@ export function AccountsPage() { - Account Matrix + {t('accountsPage.accountMatrix')} {isLoading ? ( -
Loading accounts...
+
{t('accountsPage.loadingAccounts')}
) : ( )} diff --git a/ui/src/pages/analytics/components/analytics-header.tsx b/ui/src/pages/analytics/components/analytics-header.tsx index cb69b29e..4c5f3380 100644 --- a/ui/src/pages/analytics/components/analytics-header.tsx +++ b/ui/src/pages/analytics/components/analytics-header.tsx @@ -9,6 +9,7 @@ import { subDays, startOfMonth } from 'date-fns'; import { Button } from '@/components/ui/button'; import { DateRangeFilter } from '@/components/analytics/date-range-filter'; import { RefreshCw } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; interface AnalyticsHeaderProps { dateRange: DateRange | undefined; @@ -29,11 +30,13 @@ export function AnalyticsHeader({ lastUpdatedText, viewMode, }: AnalyticsHeaderProps) { + const { t } = useTranslation(); + return (
-

Analytics

-

Track usage & insights

+

{t('analytics.title')}

+

{t('analytics.subtitle')}

@@ -122,7 +124,7 @@ export function ApiPage() {
setSearchQuery(e.target.value)} @@ -133,20 +135,22 @@ export function ApiPage() { {/* Profile List */} {isLoading ? ( -
Loading profiles...
+
+ {t('apiProfiles.loadingProfiles')} +
) : isError ? (
-

Failed to load profiles

+

{t('apiProfiles.failedLoadTitle')}

- Unable to fetch API profiles. Please try again. + {t('apiProfiles.failedLoadDesc')}

@@ -156,9 +160,9 @@ export function ApiPage() {
-

No API profiles yet

+

{t('apiProfiles.noProfilesYet')}

- Create your first profile to connect to custom API endpoints + {t('apiProfiles.noProfilesDesc')}

) : (

- No profiles match "{searchQuery}" + {t('apiProfiles.noProfileMatch', { query: searchQuery })}

)}
@@ -197,12 +201,12 @@ export function ApiPage() { {profiles.length > 0 && (
- - {profiles.length} profile{profiles.length !== 1 ? 's' : ''} - + {t('apiProfiles.profileCount', { count: profiles.length })} - {profiles.filter((p) => p.configured).length} configured + {t('apiProfiles.configuredCount', { + count: profiles.filter((p) => p.configured).length, + })}
@@ -253,9 +257,9 @@ export function ApiPage() { {/* Delete Confirmation */} deleteConfirm && handleDelete(deleteConfirm)} onCancel={() => setDeleteConfirm(null)} @@ -264,9 +268,12 @@ export function ApiPage() { {/* Unsaved Changes Confirmation */} { setEditorHasChanges(false); diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 345f0fd0..8de213b5 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -34,6 +34,7 @@ import type { AuthStatus, Variant } from '@/lib/api-client'; import { MODEL_CATALOGS } from '@/lib/model-catalogs'; import { getProviderDisplayName, isValidProvider } from '@/lib/provider-config'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; // Sidebar provider item function ProviderSidebarItem({ @@ -45,6 +46,7 @@ function ProviderSidebarItem({ isSelected: boolean; onSelect: () => void; }) { + const { t } = useTranslation(); const accountCount = status.accounts?.length || 0; return ( @@ -71,12 +73,14 @@ function ProviderSidebarItem({ {status.authenticated ? ( <> - Connected + {t('cliproxyPage.connected')} ) : ( <> - Not connected + + {t('cliproxyPage.notConnected')} + )}
@@ -101,6 +105,7 @@ function VariantSidebarItem({ onDelete: () => void; isDeleting?: boolean; }) { + const { t } = useTranslation(); return (
@@ -157,27 +166,25 @@ function VariantSidebarItem({ // Empty state for right panel function EmptyProviderState({ onSetup }: { onSetup: () => void }) { + const { t } = useTranslation(); return (
-

CCS Profile Manager

-

- Manage OAuth authentication, account preferences, and model selection for CLIProxy - providers. Configure how CCS routes requests to different AI backends. -

+

{t('cliproxyPage.emptyTitle')}

+

{t('cliproxyPage.emptyDesc')}

- For live usage stats and real-time monitoring, visit the{' '} + {t('cliproxyPage.emptyControlPanelPrefix')}{' '} - Control Panel + {t('cliproxyPage.controlPanel')} .

@@ -185,6 +192,7 @@ function EmptyProviderState({ onSetup }: { onSetup: () => void }) { } export function CliproxyPage() { + const { t } = useTranslation(); const queryClient = useQueryClient(); const { data: authData, isLoading: authLoading } = useCliproxyAuth(); const { data: variantsData, isFetching } = useCliproxy(); @@ -337,7 +345,9 @@ export function CliproxyPage() {
-

CCS-level account management

+

+ {t('cliproxyPage.accountManagement')} +

@@ -354,7 +364,7 @@ export function CliproxyPage() {
- Providers + {t('cliproxyPage.providers')}
{authLoading ? (
@@ -380,7 +390,7 @@ export function CliproxyPage() { <>
- Variants + {t('cliproxyPage.variants')}
{variants.map((variant) => ( @@ -408,12 +418,12 @@ export function CliproxyPage() { {/* Footer Stats */}
- - {providers.length} provider{providers.length !== 1 ? 's' : ''} - + {t('cliproxyPage.providerCount', { count: providers.length })} - {providers.filter((p) => p.authenticated).length} connected + {t('cliproxyPage.connectedCount', { + count: providers.filter((p) => p.authenticated).length, + })}
@@ -429,7 +439,10 @@ export function CliproxyPage() { // Variant selected - show ProviderEditor with variant profile name
-

Copilot

+

{t('copilotPage.title')}

-

GitHub Copilot proxy

+

{t('copilotPage.subtitle')}

{/* Status Overview */} @@ -148,18 +150,18 @@ export function CopilotPage() {
- Unofficial API - Use at Your Own Risk + {t('copilotPage.unofficialTitle')}
    -
  • Reverse-engineered API - may break anytime
  • -
  • Excessive use may trigger account restrictions
  • -
  • No warranty, no responsibility from CCS
  • +
  • {t('copilotPage.unofficialItem1')}
  • +
  • {t('copilotPage.unofficialItem2')}
  • +
  • {t('copilotPage.unofficialItem3')}
{/* Setup - Binary first, then enabled status */} - + {!status?.installed && ( @@ -182,12 +184,12 @@ export function CopilotPage() { {isInstalling ? ( <> - Installing... + {t('copilotPage.installing')} ) : ( <> - Install copilot-api + {t('copilotPage.installCopilotApi')} )} @@ -195,21 +197,27 @@ export function CopilotPage() { {status?.installed && ( )} {/* Authentication - only show after binary installed */} {status?.installed && ( - + {!status?.authenticated && ( )} @@ -226,15 +236,17 @@ export function CopilotPage() { {/* Daemon - only show after authenticated */} {status?.authenticated && ( - +
- Port: {status?.port ?? 4141} + {t('copilotPage.port')}: {status?.port ?? 4141}
{status?.daemon_running ? ( @@ -246,7 +258,7 @@ export function CopilotPage() { disabled={isStoppingDaemon} > - {isStoppingDaemon ? 'Stopping...' : 'Stop'} + {isStoppingDaemon ? t('copilotPage.stopping') : t('copilotPage.stop')} ) : ( )}
@@ -270,16 +282,16 @@ export function CopilotPage() { {/* Footer */}
- Proxy + {t('copilotPage.proxy')} {status?.daemon_running ? ( - Active + {t('copilotPage.active')} ) : ( - Inactive + {t('copilotPage.inactive')} )}
diff --git a/ui/src/pages/cursor.tsx b/ui/src/pages/cursor.tsx index fcc0e08e..2268fb3e 100644 --- a/ui/src/pages/cursor.tsx +++ b/ui/src/pages/cursor.tsx @@ -52,6 +52,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { useTranslation } from 'react-i18next'; interface CursorConfigDraft { port: string; @@ -156,6 +157,7 @@ function CursorModelSelector({ allowDefaultFallback?: boolean; onChange: (value: string) => void; }) { + const { t } = useTranslation(); const selectorValue = value || (allowDefaultFallback ? '__default' : ''); const selected = models.find((model) => model.id === value); @@ -177,12 +179,12 @@ function CursorModelSelector({ disabled={disabled} > - + {selectorValue && (
{allowDefaultFallback && selectorValue === '__default' - ? 'Use Default Model' + ? t('cursorPage.useDefaultModel') : selected?.name || selectorValue} {selected?.provider && ( @@ -197,9 +199,11 @@ function CursorModelSelector({ - Available Models ({models.length}) + {t('cursorPage.availableModelCount', { count: models.length })} - {allowDefaultFallback && Use Default Model} + {allowDefaultFallback && ( + {t('cursorPage.useDefaultModel')} + )} {models.map((model) => (
@@ -252,6 +256,7 @@ function StatusItem({ } export function CursorPage() { + const { t } = useTranslation(); const { status, statusLoading, @@ -342,8 +347,13 @@ export function CursorPage() { const canStart = Boolean(status?.enabled && status?.authenticated && !status?.token_expired); const integrationBadge = useMemo( - () => (status?.enabled ? Enabled : Disabled), - [status?.enabled] + () => + status?.enabled ? ( + {t('cursorPage.enabled')} + ) : ( + {t('cursorPage.disabled')} + ), + [status?.enabled, t] ); const handleSaveConfig = async ({ @@ -351,11 +361,11 @@ export function CursorPage() { }: { suppressSuccessToast?: boolean } = {}) => { const parsedPort = Number(effectivePort); if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) { - toast.error('Port must be an integer between 1 and 65535'); + toast.error(t('cursorPage.invalidPort')); return false; } if (!effectiveModel.trim()) { - toast.error('Default model is required'); + toast.error(t('cursorPage.defaultModelRequired')); return false; } @@ -382,23 +392,23 @@ export function CursorPage() { }) ); if (!suppressSuccessToast) { - toast.success('Cursor config and model mapping saved'); + toast.success(t('cursorPage.savedConfig')); } return true; } catch (error) { - toast.error((error as Error).message || 'Failed to save config'); + toast.error((error as Error).message || t('cursorPage.failedSaveConfig')); return false; } }; const applyPreset = (preset: 'codex53' | 'claude46' | 'gemini3') => { if (modelsLoading) { - toast.error('Models are still loading. Please wait before applying a preset.'); + toast.error(t('cursorPage.modelsLoadingWait')); return; } if (models.length === 0) { - toast.error('No models available yet. Start the daemon and refresh first.'); + toast.error(t('cursorPage.noModelsAvailable')); return; } @@ -457,7 +467,7 @@ export function CursorPage() { sonnet_model: codex53, haiku_model: codexMini, })); - toast.success('Applied GPT-5.3 Codex preset'); + toast.success(t('cursorPage.appliedCodexPreset')); return; } @@ -469,7 +479,7 @@ export function CursorPage() { sonnet_model: sonnet45, haiku_model: haiku45, })); - toast.success('Applied Claude 4.6 preset'); + toast.success(t('cursorPage.appliedClaudePreset')); return; } @@ -480,30 +490,32 @@ export function CursorPage() { sonnet_model: gemini3Pro, haiku_model: gemini3Flash, })); - toast.success('Applied Gemini 3 preset'); + toast.success(t('cursorPage.appliedGeminiPreset')); }; const handleToggleEnabled = async (enabled: boolean) => { try { await updateConfigAsync({ enabled }); - toast.success(enabled ? 'Cursor integration enabled' : 'Cursor integration disabled'); + toast.success( + enabled ? t('cursorPage.integrationEnabled') : t('cursorPage.integrationDisabled') + ); } catch (error) { - toast.error((error as Error).message || 'Failed to update integration state'); + toast.error((error as Error).message || t('cursorPage.failedUpdateIntegration')); } }; const handleAutoDetectAuth = async () => { try { await autoDetectAuthAsync(); - toast.success('Cursor credentials imported'); + toast.success(t('cursorPage.credentialsImported')); } catch (error) { - toast.error((error as Error).message || 'Auto-detect failed'); + toast.error((error as Error).message || t('cursorPage.autoDetectFailed')); } }; const handleManualAuthImport = async () => { if (!manualToken.trim() || !manualMachineId.trim()) { - toast.error('Token and machine ID are required'); + toast.error(t('cursorPage.manualRequired')); return; } @@ -512,12 +524,12 @@ export function CursorPage() { accessToken: manualToken.trim(), machineId: manualMachineId.trim(), }); - toast.success('Cursor credentials imported'); + toast.success(t('cursorPage.credentialsImported')); setManualAuthOpen(false); setManualToken(''); setManualMachineId(''); } catch (error) { - toast.error((error as Error).message || 'Manual import failed'); + toast.error((error as Error).message || t('cursorPage.manualImportFailed')); } }; @@ -525,12 +537,16 @@ export function CursorPage() { try { const result = await startDaemonAsync(); if (!result.success) { - toast.error(result.error || 'Failed to start daemon'); + toast.error(result.error || t('cursorPage.failedStartDaemon')); return; } - toast.success(`Daemon started${result.pid ? ` (PID: ${result.pid})` : ''}`); + toast.success( + result.pid + ? t('cursorPage.daemonStartedWithPid', { pid: result.pid }) + : t('cursorPage.daemonStarted') + ); } catch (error) { - toast.error((error as Error).message || 'Failed to start daemon'); + toast.error((error as Error).message || t('cursorPage.failedStartDaemon')); } }; @@ -538,12 +554,12 @@ export function CursorPage() { try { const result = await stopDaemonAsync(); if (!result.success) { - toast.error(result.error || 'Failed to stop daemon'); + toast.error(result.error || t('cursorPage.failedStopDaemon')); return; } - toast.success('Daemon stopped'); + toast.success(t('cursorPage.daemonStopped')); } catch (error) { - toast.error((error as Error).message || 'Failed to stop daemon'); + toast.error((error as Error).message || t('cursorPage.failedStopDaemon')); } }; @@ -551,12 +567,12 @@ export function CursorPage() { suppressSuccessToast = false, }: { suppressSuccessToast?: boolean } = {}) => { if (!rawSettingsReady) { - toast.error('Raw settings are still loading. Please wait and try again.'); + toast.error(t('cursorPage.rawLoading')); return false; } if (!rawParseResult.isValid || !rawParseResult.settings) { - toast.error(rawParseResult.error || 'Invalid JSON'); + toast.error(rawParseResult.error || t('cursorPage.invalidJson')); return false; } @@ -567,14 +583,14 @@ export function CursorPage() { }); setRawConfigDirty(false); if (!suppressSuccessToast) { - toast.success('Raw settings saved'); + toast.success(t('cursorPage.rawSaved')); } return true; } catch (error) { if (isApiConflictError(error)) { - toast.error('Raw settings changed externally. Refresh and retry.'); + toast.error(t('cursorPage.rawChanged')); } else { - toast.error((error as Error).message || 'Failed to save raw settings'); + toast.error((error as Error).message || t('cursorPage.failedSaveRaw')); } return false; } @@ -601,7 +617,7 @@ export function CursorPage() { } if (saveConfig && saveRawSettings) { - toast.success('Cursor configuration saved'); + toast.success(t('cursorPage.savedAll')); } }; @@ -623,12 +639,12 @@ export function CursorPage() { alt="" className="w-5 h-5 object-contain shrink-0" /> -

Cursor

+

{t('cursorPage.title')}

- Beta + {t('cursorPage.beta')} {integrationBadge}
@@ -642,7 +658,7 @@ export function CursorPage() {
-

Dedicated Cursor integration controls

+

{t('cursorPage.subtitle')}

@@ -651,46 +667,48 @@ export function CursorPage() {
- Unofficial API - Use at Your Own Risk + {t('cursorPage.unofficialTitle')}
    -
  • Reverse-engineered integration may break anytime
  • -
  • Abuse or excessive usage may risk account restrictions
  • -
  • No warranty, no responsibility from CCS
  • +
  • {t('cursorPage.unofficialItem1')}
  • +
  • {t('cursorPage.unofficialItem2')}
  • +
  • {t('cursorPage.unofficialItem3')}

- Actions + {t('cursorPage.actions')}

{status?.enabled ? ( @@ -702,7 +720,7 @@ export function CursorPage() { disabled={isUpdatingConfig} > - Disable Integration + {t('cursorPage.disableIntegration')} ) : ( )} @@ -728,7 +746,7 @@ export function CursorPage() { ) : ( )} - Auto-detect Auth + {t('cursorPage.autoDetectAuth')} {status?.daemon_running ? ( @@ -754,7 +772,7 @@ export function CursorPage() { ) : ( )} - Stop Daemon + {t('cursorPage.stopDaemon')} ) : ( )}
@@ -777,7 +795,7 @@ export function CursorPage() {
- Port + {t('cursorPage.port')} {status?.port ?? config?.port ?? DEFAULT_CURSOR_PORT}
@@ -789,7 +807,7 @@ export function CursorPage() {
-

Cursor Configuration

+

{t('cursorPage.configuration')}

{rawSettings && ( cursor.settings.json @@ -798,10 +816,10 @@ export function CursorPage() {
{rawSettings && (

- Last modified:{' '} + {t('cursorPage.lastModified')}{' '} {rawSettings.exists ? new Date(rawSettings.mtime).toLocaleString() - : 'Never saved'} + : t('cursorPage.neverSaved')}

)}
@@ -828,12 +846,12 @@ export function CursorPage() { {isUpdatingConfig || isSavingRawSettings ? ( <> - Saving... + {t('cursorPage.saving')} ) : ( <> - Save + {t('cursorPage.save')} )} @@ -846,13 +864,13 @@ export function CursorPage() {
- Model Config + {t('cursorPage.modelConfig')} - Settings + {t('cursorPage.settings')} - Info + {t('cursorPage.info')}
@@ -867,10 +885,10 @@ export function CursorPage() {

- Presets + {t('cursorPage.presets')}

- Apply pre-configured model mappings + {t('cursorPage.presetsDesc')}

diff --git a/ui/src/pages/droid.tsx b/ui/src/pages/droid.tsx index 0a1dea5d..382791c3 100644 --- a/ui/src/pages/droid.tsx +++ b/ui/src/pages/droid.tsx @@ -27,6 +27,7 @@ import { Separator } from '@/components/ui/separator'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; import { applyAnthropicBudgetTokensToDroidByokModel, applyReasoningEffortToDroidByokModel, @@ -166,6 +167,7 @@ function DetailRow({ } export function DroidPage() { + const { t } = useTranslation(); const { diagnostics, diagnosticsLoading, @@ -201,7 +203,7 @@ export function DroidPage() { const updateSettingsField = (key: string, value: unknown | null) => { if (!rawEditorParsed.valid) { - toast.error('Fix JSON syntax before using quick settings controls.'); + toast.error(t('droidPage.fixJsonBeforeQuickSettings')); return; } @@ -248,7 +250,7 @@ export function DroidPage() { const handleSaveRawSettings = async () => { if (!rawEditorValidation.valid) { - toast.error(`Invalid JSON: ${rawEditorValidation.error}`); + toast.error(t('droidPage.invalidJson', { value: rawEditorValidation.error })); return; } @@ -258,12 +260,12 @@ export function DroidPage() { expectedMtime: rawSettings?.exists ? rawSettings.mtime : undefined, }); setRawDraftText(null); - toast.success('Droid settings saved'); + toast.success(t('droidPage.saved')); } catch (error) { if (isApiConflictError(error)) { - toast.error('Droid settings changed externally. Refresh and retry.'); + toast.error(t('droidPage.changedExternally')); } else { - toast.error((error as Error).message || 'Failed to save Droid settings'); + toast.error((error as Error).message || t('droidPage.failedSave')); } } }; @@ -279,7 +281,7 @@ export function DroidPage() { return (
- Loading Droid diagnostics... + {t('droidPage.loadingDiagnostics')}
); } @@ -287,7 +289,7 @@ export function DroidPage() { if (diagnosticsError || !diagnostics) { return (
- Failed to load Droid diagnostics. + {t('droidPage.failedDiagnostics')}
); } @@ -314,9 +316,9 @@ export function DroidPage() {
- Overview - BYOK - Docs + {t('droidPage.overview')} + {t('droidPage.byok')} + {t('droidPage.docs')}
@@ -328,34 +330,40 @@ export function DroidPage() { - Runtime & Installation + {t('droidPage.runtimeInstall')}
- Status + {t('droidPage.status')} - {diagnostics.binary.installed ? 'Detected' : 'Not Found'} + {diagnostics.binary.installed + ? t('droidPage.detected') + : t('droidPage.notFound')}
- + @@ -366,7 +374,7 @@ export function DroidPage() { - Config Files + {t('droidPage.configFiles')} @@ -380,15 +388,25 @@ export function DroidPage() { )}
- - - - + + + + {file.parseError && ( -

Parse warning: {file.parseError}

+

+ {t('droidPage.parseWarning', { value: file.parseError })} +

)} {file.readError && ( -

Read warning: {file.readError}

+

+ {t('droidPage.readWarning', { value: file.readError })} +

)}
))} @@ -400,7 +418,7 @@ export function DroidPage() { - Warnings + {t('droidPage.warnings')} @@ -444,11 +462,11 @@ export function DroidPage() { disabledReason={ rawEditorParsed.valid ? null - : `BYOK reasoning controls disabled: ${rawEditorParsed.error}` + : `${t('droidPage.byok')}: ${rawEditorParsed.error}` } onEffortChange={(modelId, effort) => { if (!rawEditorParsed.valid) { - toast.error('Fix JSON syntax before updating BYOK reasoning settings.'); + toast.error(t('droidPage.fixJsonBeforeReasoning')); return; } @@ -458,7 +476,7 @@ export function DroidPage() { effort ); if (!nextSettings) { - toast.error('Unable to update selected BYOK model reasoning setting.'); + toast.error(t('droidPage.unableUpdateReasoning')); return; } @@ -466,7 +484,7 @@ export function DroidPage() { }} onAnthropicBudgetChange={(modelId, budgetTokens) => { if (!rawEditorParsed.valid) { - toast.error('Fix JSON syntax before updating thinking budget.'); + toast.error(t('droidPage.fixJsonBeforeBudget')); return; } @@ -476,7 +494,7 @@ export function DroidPage() { budgetTokens ); if (!nextSettings) { - toast.error('Thinking budget is only available for Anthropic BYOK models.'); + toast.error(t('droidPage.anthropicOnlyBudget')); return; } @@ -488,38 +506,38 @@ export function DroidPage() { - BYOK Summary + {t('droidPage.byokSummary')}
-

Providers

+

{t('droidPage.providers')}

{providerRows.length === 0 && ( - none + {t('droidPage.none')} )} {providerRows.map(([provider, count]) => ( @@ -534,20 +552,20 @@ export function DroidPage() { - Custom Models + {t('droidPage.customModelsTitle')}
- Name / Model - Provider - Base URL + {t('droidPage.modelName')} + {t('droidPage.provider')} + {t('droidPage.baseUrl')}
{customModels.length === 0 && (
- No custom models + {t('droidPage.noCustomModels')}
)} {customModels.map((model) => ( @@ -593,7 +611,7 @@ export function DroidPage() { - Docs-Aligned Notes + {t('droidPage.docsAlignedNotes')} @@ -605,7 +623,7 @@ export function DroidPage() {

- Factory Docs + {t('droidPage.factoryDocs')}

{docsLinks.map((link) => ( @@ -633,7 +651,7 @@ export function DroidPage() {

- Provider Fact-Check Docs + {t('droidPage.providerFactCheckDocs')}

{providerDocs.map((providerDoc) => ( @@ -660,10 +678,10 @@ export function DroidPage() {

- Provider values: {providerValues.join(', ')} + {t('droidPage.providerValues', { value: providerValues.join(', ') })}

- Settings hierarchy: {settingsHierarchy.join(' -> ')} + {t('droidPage.settingsHierarchy', { value: settingsHierarchy.join(' -> ') })}

@@ -686,7 +704,7 @@ export function DroidPage() { 0) return 'error'; @@ -16,14 +17,17 @@ function getOverallStatus(summary: { passed: number; warnings: number; errors: n return 'ok'; } -function formatRelativeTime(timestamp: number): string { +function formatRelativeTime( + timestamp: number, + t: (key: string, options?: Record) => string +): string { const seconds = Math.floor((Date.now() - timestamp) / 1000); - if (seconds < 5) return 'just now'; - if (seconds < 60) return `${seconds}s ago`; + if (seconds < 5) return t('health.justNow'); + if (seconds < 60) return t('health.secondsAgo', { count: seconds }); const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; + if (minutes < 60) return t('health.minutesAgo', { count: minutes }); const hours = Math.floor(minutes / 60); - return `${hours}h ago`; + return t('health.hoursAgo', { count: hours }); } function sortGroupsByIssues(groups: HealthGroup[]): HealthGroup[] { @@ -75,6 +79,7 @@ function LoadingSkeleton() { } export function HealthPage() { + const { t } = useTranslation(); const { data, isLoading, refetch, dataUpdatedAt } = useHealth(); // Use dataUpdatedAt directly instead of storing in state @@ -91,12 +96,12 @@ export function HealthPage() { const copyDoctorCommand = () => { navigator.clipboard.writeText('ccs doctor'); - toast.success('Copied to clipboard'); + toast.success(t('health.copied')); }; const handleRefresh = () => { refetch(); - toast.info('Refreshing health checks...'); + toast.info(t('health.refreshing')); }; if (isLoading && !data) { @@ -154,10 +159,12 @@ export function HealthPage() { {/* Main title */}
-

System Health

+

+ {t('health.systemHealth')} +

{data?.version && ( - build {data.version} + {t('health.build', { version: data.version })} )}
@@ -165,12 +172,12 @@ export function HealthPage() { {/* Status message */}
- Last scan: + {t('health.lastScan')} - {lastRefresh ? formatRelativeTime(lastRefresh) : '--'} + {lastRefresh ? formatRelativeTime(lastRefresh, t) : '--'} | - Auto-refresh: + {t('health.autoRefresh')} 30s
@@ -195,7 +202,7 @@ export function HealthPage() { className="gap-2" > - Refresh + {t('health.refresh')} R @@ -237,10 +244,10 @@ export function HealthPage() {
- Version {data?.version ?? '--'} + {t('health.version')} {data?.version ?? '--'} - Platform{' '} + {t('health.platform')}{' '} {typeof navigator !== 'undefined' ? navigator.platform : 'linux'} @@ -248,7 +255,7 @@ export function HealthPage() {
- Live monitoring active + {t('health.liveMonitoring')}
diff --git a/ui/src/pages/home.tsx b/ui/src/pages/home.tsx index 2b192021..a60041ae 100644 --- a/ui/src/pages/home.tsx +++ b/ui/src/pages/home.tsx @@ -9,6 +9,7 @@ import { useOverview } from '@/hooks/use-overview'; import { useSharedSummary } from '@/hooks/use-shared'; import { cn } from '@/lib/utils'; import type { LucideIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; const HEALTH_VARIANTS = { ok: 'success', @@ -62,6 +63,7 @@ function InlineStat({ } export function HomePage() { + const { t } = useTranslation(); const navigate = useNavigate(); const { data: overview, isLoading: isOverviewLoading } = useOverview(); const { data: shared, isLoading: isSharedLoading } = useSharedSummary(); @@ -134,28 +136,28 @@ export function HomePage() { {/* Right: Inline Stats */}
navigate('/providers')} /> navigate('/cliproxy')} /> navigate('/accounts')} /> - Configuration Required + {t('home.configurationRequired')} {shared.symlinkStatus.message} )} diff --git a/ui/src/pages/login.tsx b/ui/src/pages/login.tsx index 0550e9a3..87fb1e37 100644 --- a/ui/src/pages/login.tsx +++ b/ui/src/pages/login.tsx @@ -12,8 +12,10 @@ import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { AlertCircle, Loader2, Lock } from 'lucide-react'; import { Alert, AlertDescription } from '@/components/ui/alert'; +import { useTranslation } from 'react-i18next'; export function LoginPage() { + const { t } = useTranslation(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(null); @@ -47,7 +49,7 @@ export function LoginPage() { await login(username, password); navigate(from, { replace: true }); } catch (err) { - setError(err instanceof Error ? err.message : 'Login failed'); + setError(err instanceof Error ? err.message : t('auth.loginFailed')); } finally { setLoading(false); } @@ -60,8 +62,8 @@ export function LoginPage() {
- CCS Dashboard - Enter your credentials to access the dashboard + {t('auth.dashboardTitle')} + {t('auth.loginDescription')}
@@ -72,26 +74,26 @@ export function LoginPage() { )}
- + setUsername(e.target.value)} - placeholder="Enter username" + placeholder={t('auth.usernamePlaceholder')} autoComplete="username" disabled={loading} required />
- + setPassword(e.target.value)} - placeholder="Enter password" + placeholder={t('auth.passwordPlaceholder')} autoComplete="current-password" disabled={loading} required @@ -101,10 +103,10 @@ export function LoginPage() { {loading ? ( <> - Signing in... + {t('auth.signingIn')} ) : ( - 'Sign In' + t('auth.signIn') )} diff --git a/ui/src/pages/settings/components/tab-navigation.tsx b/ui/src/pages/settings/components/tab-navigation.tsx index 536140c0..d0d919c6 100644 --- a/ui/src/pages/settings/components/tab-navigation.tsx +++ b/ui/src/pages/settings/components/tab-navigation.tsx @@ -6,22 +6,24 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Globe, Settings2, Server, KeyRound, Brain, Archive } from 'lucide-react'; import type { SettingsTab } from '../types'; +import { useTranslation } from 'react-i18next'; interface TabNavigationProps { activeTab: SettingsTab; onTabChange: (tab: SettingsTab) => void; } -const tabs = [ - { value: 'websearch' as const, label: 'Web', icon: Globe }, - { value: 'globalenv' as const, label: 'Env', icon: Settings2 }, - { value: 'thinking' as const, label: 'Think', icon: Brain }, - { value: 'proxy' as const, label: 'Proxy', icon: Server }, - { value: 'auth' as const, label: 'Auth', icon: KeyRound }, - { value: 'backups' as const, label: 'Backup', icon: Archive }, -] as const; - export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) { + const { t } = useTranslation(); + const tabs = [ + { value: 'websearch' as const, label: t('settingsTabs.web'), icon: Globe }, + { value: 'globalenv' as const, label: t('settingsTabs.env'), icon: Settings2 }, + { value: 'thinking' as const, label: t('settingsTabs.think'), icon: Brain }, + { value: 'proxy' as const, label: t('settingsTabs.proxy'), icon: Server }, + { value: 'auth' as const, label: t('settingsTabs.auth'), icon: KeyRound }, + { value: 'backups' as const, label: t('settingsTabs.backup'), icon: Archive }, + ] as const; + return ( onTabChange(v as SettingsTab)}> diff --git a/ui/src/pages/settings/index.tsx b/ui/src/pages/settings/index.tsx index 1571fdde..57da2d80 100644 --- a/ui/src/pages/settings/index.tsx +++ b/ui/src/pages/settings/index.tsx @@ -21,6 +21,8 @@ import { useSettingsTab, useRawConfig } from './hooks'; import { TabNavigation } from './components/tab-navigation'; import { SectionSkeleton } from './components/section-skeleton'; import type { SettingsTab } from './types'; +import { useTranslation } from 'react-i18next'; +import i18n from '@/lib/i18n'; /** * Retry wrapper for dynamic imports with exponential backoff @@ -72,15 +74,19 @@ class SectionErrorBoundary extends Component<
-

Failed to load section

-

{this.state.error?.message || 'Unknown error occurred'}

+

+ {i18n.t('settings.sectionLoadFailed')} +

+

+ {this.state.error?.message || i18n.t('settings.unknownError')} +

@@ -93,6 +99,7 @@ class SectionErrorBoundary extends Component< // Inner component that uses context function SettingsPageInner() { + const { t } = useTranslation(); const { activeTab, setActiveTab } = useSettingsTab(); const { rawConfig, @@ -178,12 +185,12 @@ function SettingsPageInner() { {copied ? ( <> - Copied + {t('settings.copied')} ) : ( <> - Copy + {t('settings.copy')} )} @@ -203,7 +210,7 @@ function SettingsPageInner() { {rawConfigLoading ? (
- Loading... + {t('settings.loading')}
) : rawConfig ? (
-

Config file not found

+

{t('settings.configFileNotFound')}

ccs migrate diff --git a/ui/src/pages/settings/sections/auth-section.tsx b/ui/src/pages/settings/sections/auth-section.tsx index bfbbb59a..7a083599 100644 --- a/ui/src/pages/settings/sections/auth-section.tsx +++ b/ui/src/pages/settings/sections/auth-section.tsx @@ -23,6 +23,7 @@ import { Save, } from 'lucide-react'; import { useRawConfig } from '../hooks'; +import { useTranslation } from 'react-i18next'; interface TokenInfo { value: string; @@ -35,6 +36,7 @@ interface AuthTokens { } export default function AuthSection() { + const { t } = useTranslation(); const { fetchRawConfig } = useRawConfig(); // State @@ -60,16 +62,16 @@ export default function AuthSection() { // Use /raw to get unmasked values for editing const response = await fetch('/api/settings/auth/tokens/raw'); if (!response.ok) { - throw new Error('Failed to fetch auth tokens'); + throw new Error(t('settingsAuth.failedFetchTokens')); } const data = await response.json(); setTokens(data); } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); + setError(err instanceof Error ? err.message : t('settings.unknownError')); } finally { setLoading(false); } - }, []); + }, [t]); // Load on mount useEffect(() => { @@ -117,16 +119,16 @@ export default function AuthSection() { if (!response.ok) { const data = await response.json(); - throw new Error(data.error || 'Failed to save tokens'); + throw new Error(data.error || t('settingsAuth.failedSaveTokens')); } - setSuccess('Tokens updated. Restart CLIProxy to apply.'); + setSuccess(t('settingsAuth.tokensUpdated')); setEditedApiKey(null); setEditedSecret(null); await fetchTokens(); await fetchRawConfig(); } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); + setError(err instanceof Error ? err.message : t('settings.unknownError')); } finally { setSaving(false); } @@ -143,14 +145,14 @@ export default function AuthSection() { if (!response.ok) { const data = await response.json(); - throw new Error(data.error || 'Failed to regenerate secret'); + throw new Error(data.error || t('settingsAuth.failedRegenerate')); } - setSuccess('New management secret generated. Restart CLIProxy to apply.'); + setSuccess(t('settingsAuth.secretRegenerated')); await fetchTokens(); await fetchRawConfig(); } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); + setError(err instanceof Error ? err.message : t('settings.unknownError')); } finally { setSaving(false); } @@ -167,16 +169,16 @@ export default function AuthSection() { if (!response.ok) { const data = await response.json(); - throw new Error(data.error || 'Failed to reset tokens'); + throw new Error(data.error || t('settingsAuth.failedReset')); } - setSuccess('Tokens reset to defaults. Restart CLIProxy to apply.'); + setSuccess(t('settingsAuth.tokensReset')); setEditedApiKey(null); setEditedSecret(null); await fetchTokens(); await fetchRawConfig(); } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); + setError(err instanceof Error ? err.message : t('settings.unknownError')); } finally { setSaving(false); } @@ -209,7 +211,7 @@ export default function AuthSection() {
- Loading... + {t('settings.loading')}
); @@ -251,31 +253,27 @@ export default function AuthSection() { {/* Scrollable Content */}
-

- Configure CLIProxy authentication tokens. Changes require CLIProxy restart. -

+

{t('settingsAuth.description')}

{/* API Key Section */}
-

API Key

+

{t('settingsAuth.apiKey')}

{tokens.apiKey.isCustom && ( - Custom + {t('settingsAuth.custom')} )}
-

- Used by Claude Code to authenticate with CLIProxy -

+

{t('settingsAuth.apiKeyDesc')}

setEditedApiKey(e.target.value)} - placeholder="API key" + placeholder={t('settingsAuth.apiKeyPlaceholder')} disabled={saving} className="pr-20 font-mono text-sm" /> @@ -310,15 +308,15 @@ export default function AuthSection() {
-

Management Secret

+

{t('settingsAuth.managementSecret')}

{tokens.managementSecret.isCustom && ( - Custom + {t('settingsAuth.custom')} )}

- Used by CCS dashboard to access CLIProxy management APIs + {t('settingsAuth.managementSecretDesc')}

@@ -326,7 +324,7 @@ export default function AuthSection() { type={showSecret ? 'text' : 'password'} value={displaySecret} onChange={(e) => setEditedSecret(e.target.value)} - placeholder="Management secret" + placeholder={t('settingsAuth.managementSecretPlaceholder')} disabled={saving} className="pr-20 font-mono text-sm" /> @@ -359,7 +357,7 @@ export default function AuthSection() { size="sm" onClick={regenerateSecret} disabled={saving} - title="Generate new secure secret" + title={t('settingsAuth.generateSecret')} > @@ -375,11 +373,9 @@ export default function AuthSection() { className="gap-2" > - Reset to Defaults + {t('settingsAuth.resetDefaults')} -

- Resets both API key and management secret to their default values. -

+

{t('settingsAuth.resetDesc')}

@@ -394,7 +390,7 @@ export default function AuthSection() { className="flex-1" > - Refresh + {t('settings.refresh')}
diff --git a/ui/src/pages/settings/sections/backups-section.tsx b/ui/src/pages/settings/sections/backups-section.tsx index 9f22ed71..2222c5b5 100644 --- a/ui/src/pages/settings/sections/backups-section.tsx +++ b/ui/src/pages/settings/sections/backups-section.tsx @@ -22,6 +22,7 @@ import { } from '@/components/ui/alert-dialog'; import { RefreshCw, CheckCircle2, AlertCircle, RotateCcw, Clock, Archive } from 'lucide-react'; import { useRawConfig } from '../hooks'; +import { useTranslation } from 'react-i18next'; /** Duration in ms before success toast auto-dismisses */ const SUCCESS_DISPLAY_DURATION_MS = 3000; @@ -39,6 +40,7 @@ interface BackupsResponse { } export default function BackupsSection() { + const { t } = useTranslation(); const { fetchRawConfig } = useRawConfig(); // AbortController refs for cleanup @@ -66,18 +68,18 @@ export default function BackupsSection() { signal: abortControllerRef.current.signal, }); if (!response.ok) { - throw new Error('Failed to fetch backups'); + throw new Error(t('settingsBackups.failedFetch')); } const data: BackupsResponse = await response.json(); setBackups(data.backups || []); } catch (err) { // Ignore abort errors if (err instanceof Error && err.name === 'AbortError') return; - setError(err instanceof Error ? err.message : 'Unknown error'); + setError(err instanceof Error ? err.message : t('settings.unknownError')); } finally { setLoading(false); } - }, []); + }, [t]); // Restore backup (wrapped in useCallback for callback stability) const restoreBackup = useCallback( @@ -98,21 +100,21 @@ export default function BackupsSection() { if (!response.ok) { const data = await response.json(); - throw new Error(data.error || 'Failed to restore backup'); + throw new Error(data.error || t('settingsBackups.failedRestore')); } - setSuccess('Backup restored successfully'); + setSuccess(t('settingsBackups.restoreSuccess')); await fetchBackups(); await fetchRawConfig(); } catch (err) { // Ignore abort errors if (err instanceof Error && err.name === 'AbortError') return; - setError(err instanceof Error ? err.message : 'Unknown error'); + setError(err instanceof Error ? err.message : t('settings.unknownError')); } finally { setRestoring(null); } }, - [fetchBackups, fetchRawConfig] + [fetchBackups, fetchRawConfig, t] ); // Load on mount @@ -205,12 +207,9 @@ export default function BackupsSection() {
-

Settings Backups

+

{t('settingsBackups.title')}

-

- Restore previous versions of your settings.json file. Backups are created - automatically when settings are modified. -

+

{t('settingsBackups.description')}

{/* Backups List */} @@ -218,9 +217,9 @@ export default function BackupsSection() {
-

No backups available

+

{t('settingsBackups.none')}

- Backups will appear here when you modify settings + {t('settingsBackups.noneHint')}

@@ -236,7 +235,7 @@ export default function BackupsSection() {

{backup.timestamp}

{index === 0 && ( - Latest + {t('settingsBackups.latest')} )}
@@ -253,7 +252,9 @@ export default function BackupsSection() { - {restoring === backup.timestamp ? 'Restoring...' : 'Restore'} + {restoring === backup.timestamp + ? t('settingsBackups.restoring') + : t('settingsBackups.restore')}
@@ -276,7 +277,7 @@ export default function BackupsSection() { className="w-full" > - Refresh + {t('settings.refresh')}
@@ -284,17 +285,17 @@ export default function BackupsSection() { setConfirmRestore(null)}> - Restore Backup? + {t('settingsBackups.restoreConfirmTitle')} - This will replace your current settings with backup from{' '} + {t('settingsBackups.restoreConfirmPrefix')}{' '} {confirmRestore} - . This action cannot be undone. + . {t('settingsBackups.restoreConfirmSuffix')} - Cancel + {t('settingsBackups.cancel')} { if (confirmRestore) { @@ -303,7 +304,7 @@ export default function BackupsSection() { setConfirmRestore(null); }} > - Restore + {t('settingsBackups.restore')} diff --git a/ui/src/pages/settings/sections/globalenv-section.tsx b/ui/src/pages/settings/sections/globalenv-section.tsx index a055f577..e7f8bd9b 100644 --- a/ui/src/pages/settings/sections/globalenv-section.tsx +++ b/ui/src/pages/settings/sections/globalenv-section.tsx @@ -11,8 +11,10 @@ import { Switch } from '@/components/ui/switch'; import { Input } from '@/components/ui/input'; import { RefreshCw, CheckCircle2, AlertCircle, Plus, Trash2 } from 'lucide-react'; import { useGlobalEnvConfig, useRawConfig } from '../hooks'; +import { useTranslation } from 'react-i18next'; export default function GlobalEnvSection() { + const { t } = useTranslation(); const { config, loading, @@ -46,7 +48,7 @@ export default function GlobalEnvSection() {
- Loading... + {t('settings.loading')}
); @@ -71,7 +73,7 @@ export default function GlobalEnvSection() { {success && (
- Saved + {t('settings.saved')}
)}
@@ -79,21 +81,18 @@ export default function GlobalEnvSection() { {/* Scrollable Content */}
-

- Environment variables injected into all non-Claude subscription profiles (gemini, codex, - agy, ghcp, etc.) -

+

{t('settingsGlobalEnv.description')}

{/* Enable/Disable Toggle */}

- {config?.enabled ? 'Global Env enabled' : 'Global Env disabled'} + {config?.enabled ? t('settingsGlobalEnv.enabled') : t('settingsGlobalEnv.disabled')}

{config?.enabled - ? 'Env vars will be injected into third-party profiles' - : 'Env vars will not be injected'} + ? t('settingsGlobalEnv.enabledDesc') + : t('settingsGlobalEnv.disabledDesc')}

@@ -101,7 +100,7 @@ export default function GlobalEnvSection() { {/* Current Environment Variables */}
-

Environment Variables

+

{t('settingsGlobalEnv.envVars')}

{config?.env && Object.keys(config.env).length > 0 ? (
@@ -127,18 +126,18 @@ export default function GlobalEnvSection() {
) : (
-

No environment variables configured

+

{t('settingsGlobalEnv.noneConfigured')}

)} {/* Add New Variable */}
-

Add New Variable

+

{t('settingsGlobalEnv.addNew')}

setNewEnvKey(e.target.value.toUpperCase())} - placeholder="KEY_NAME" + placeholder={t('settingsGlobalEnv.keyName')} className="flex-1 font-mono text-sm h-9" disabled={saving} /> @@ -146,7 +145,7 @@ export default function GlobalEnvSection() { setNewEnvValue(e.target.value)} - placeholder="value" + placeholder={t('settingsGlobalEnv.value')} className="flex-1 font-mono text-sm h-9" disabled={saving} /> @@ -157,14 +156,14 @@ export default function GlobalEnvSection() { className="h-9" > - Add + {t('settingsGlobalEnv.add')}
{/* Common Variables Quick Add */}
-

Quick Add Common Variables

+

{t('settingsGlobalEnv.quickAdd')}

{[ { key: 'DISABLE_BUG_COMMAND', value: '1' }, @@ -192,7 +191,7 @@ export default function GlobalEnvSection() { (k) => config.env[k] ) && ( - All common variables are configured + {t('settingsGlobalEnv.allConfigured')} )}
@@ -214,7 +213,7 @@ export default function GlobalEnvSection() { className="w-full" > - Refresh + {t('settings.refresh')}
diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index 391a865d..ce90a1d1 100644 --- a/ui/src/pages/settings/sections/proxy/index.tsx +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -30,6 +30,7 @@ import { api } from '@/lib/api-client'; import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; /** LocalStorage key for debug mode preference */ const DEBUG_MODE_KEY = 'ccs_debug_mode'; @@ -42,6 +43,7 @@ function normalizeRiskAckPhrase(value: string): string { } export default function ProxySection() { + const { t } = useTranslation(); const { config, loading, @@ -98,17 +100,17 @@ export default function ProxySection() { setAgyAckBypassLoading(true); const response = await fetch('/api/settings/auth/antigravity-risk'); if (!response.ok) { - throw new Error('Failed to load AGY power user mode'); + throw new Error(t('settingsProxy.failedLoadAgyMode')); } const data = (await response.json()) as { antigravityAckBypass?: boolean }; setAgyAckBypass(data.antigravityAckBypass === true); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to load AGY power user mode'); + toast.error(err instanceof Error ? err.message : t('settingsProxy.failedLoadAgyMode')); setAgyAckBypass(false); } finally { setAgyAckBypassLoading(false); } - }, []); + }, [t]); const persistAgyAckBypass = useCallback( async (nextValue: boolean) => { @@ -130,7 +132,7 @@ export default function ProxySection() { }; if (!response.ok) { - throw new Error(payload.error || 'Failed to update AGY power user mode'); + throw new Error(payload.error || t('settingsProxy.failedUpdateAgyMode')); } const persistedValue = payload.antigravityAckBypass === true; @@ -139,29 +141,29 @@ export default function ProxySection() { cache: 'no-store', }); if (!verifyResponse.ok) { - throw new Error('Failed to verify AGY power user mode persistence'); + throw new Error(t('settingsProxy.failedVerifyAgyMode')); } const verifyData = (await verifyResponse.json()) as { antigravityAckBypass?: boolean }; const verifiedValue = verifyData.antigravityAckBypass === true; if (verifiedValue !== nextValue) { - throw new Error( - 'AGY power user mode was not persisted. Config may have been modified by another process.' - ); + throw new Error(t('settingsProxy.notPersistedAgyMode')); } setAgyAckBypass(verifiedValue && persistedValue); setShowAgyEnableConfirm(false); setAgyEnableConfirmPhrase(''); - toast.success(nextValue ? 'AGY power user mode enabled.' : 'AGY power user mode disabled.'); + toast.success( + nextValue ? t('settingsProxy.agyModeEnabled') : t('settingsProxy.agyModeDisabled') + ); await fetchRawConfig(); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to update AGY power user mode'); + toast.error(err instanceof Error ? err.message : t('settingsProxy.failedUpdateAgyMode')); } finally { agyAckBypassSavingRef.current = false; setAgyAckBypassSaving(false); } }, - [agyAckBypassSaving, fetchRawConfig, saving] + [agyAckBypassSaving, fetchRawConfig, saving, t] ); const handleAgyAckBypassChange = useCallback( @@ -182,11 +184,11 @@ export default function ProxySection() { const confirmAgyEnable = useCallback(() => { if (!isAgyConfirmPhraseValid) { - toast.error(`Type "${RISK_ACK_PHRASE}" to continue.`); + toast.error(t('settingsProxy.typePhraseToContinue', { value: RISK_ACK_PHRASE })); return; } void persistAgyAckBypass(true); - }, [isAgyConfirmPhraseValid, persistAgyAckBypass]); + }, [isAgyConfirmPhraseValid, persistAgyAckBypass, t]); // Backend state (loaded from API) + mutation hook for proper query invalidation const [backend, setBackend] = useState<'original' | 'plus'>('plus'); @@ -262,7 +264,7 @@ export default function ProxySection() {
- Loading... + {t('settings.loading')}
); @@ -308,7 +310,7 @@ export default function ProxySection() { const parsedPort = Number(portStr); if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) { - toast.error('Port must be an integer between 1 and 65535, or empty for default'); + toast.error(t('settingsProxy.invalidPortOrEmpty')); setEditedPort(null); return; } @@ -340,7 +342,7 @@ export default function ProxySection() { const parsedPort = localPortStr === '' ? CLIPROXY_DEFAULT_PORT : Number(localPortStr); if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) { - toast.error('Local port must be an integer between 1 and 65535'); + toast.error(t('settingsProxy.invalidLocalPort')); setEditedLocalPort(null); return; } @@ -379,7 +381,7 @@ export default function ProxySection() { {success && (
- Saved + {t('settings.saved')}
)}
@@ -388,21 +390,23 @@ export default function ProxySection() {

- Configure local or remote {backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'} connection - for proxy-based profiles + {t('settingsProxy.description', { + backend: + backend === 'plus' ? t('settingsProxy.backendPlus') : t('settingsProxy.backend'), + })}

{/* Proxy Status Widget - Quick access to start/stop controls */} {!isRemoteMode && (
-

Instance Status

+

{t('settingsProxy.instanceStatus')}

)} {/* Mode Toggle - Card based selection */}
-

Connection Mode

+

{t('settingsProxy.connectionMode')}

{/* Local Mode Card */}

- Run {backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'} binary on this machine + {t('settingsProxy.localDesc', { + backend: + backend === 'plus' + ? t('settingsProxy.backendPlus') + : t('settingsProxy.backend'), + })}

@@ -439,10 +448,15 @@ export default function ProxySection() { - Remote + {t('settingsProxy.remote')}

- Connect to a remote {backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'} server + {t('settingsProxy.remoteDesc', { + backend: + backend === 'plus' + ? t('settingsProxy.backendPlus') + : t('settingsProxy.backend'), + })}

@@ -452,14 +466,14 @@ export default function ProxySection() {

- Backend Binary + {t('settingsProxy.backendBinary')}

{/* Warning when local proxy is running - must stop to change backend (not applicable in remote mode) */} {!isRemoteMode && isProxyRunning && ( - Stop the running proxy in Instance Status to switch backend. + {t('settingsProxy.stopProxyToSwitch')} )} @@ -475,14 +489,12 @@ export default function ProxySection() { } ${isBackendSwitchBlocked ? 'opacity-60 cursor-not-allowed' : ''}`} >
- CLIProxyAPIPlus + {t('settingsProxy.backendPlusApi')} - Default + {t('settingsProxy.default')}
-

- Full provider support including Kiro and GitHub Copilot -

+

{t('settingsProxy.plusDesc')}

{/* Original Backend Card */} @@ -496,21 +508,16 @@ export default function ProxySection() { } ${isBackendSwitchBlocked ? 'opacity-60 cursor-not-allowed' : ''}`} >
- CLIProxyAPI + {t('settingsProxy.backendApi')}
-

- Original binary (Gemini, Codex, Antigravity only) -

+

{t('settingsProxy.originalDesc')}

{/* Warning when original backend selected with Kiro/ghcp variants */} {backend === 'original' && hasKiroGhcpVariants && ( - - Existing Kiro/Copilot variants will not work with CLIProxyAPI. Switch to - CLIProxyAPIPlus or remove those variants. - + {t('settingsProxy.variantsIncompatible')} )}
@@ -519,15 +526,13 @@ export default function ProxySection() {

- Safety + {t('settingsProxy.safety')}

-

Antigravity Power User Mode

-

- Skip AGY responsibility checklist in Add Account and `ccs agy` flows. -

+

{t('settingsProxy.agyModeTitle')}

+

{t('settingsProxy.agyModeDesc')}

- Use only if you fully understand the OAuth suspension/ban risk pattern (#509). CCS - cannot assume responsibility for account loss. + {t('settingsProxy.agyWarning')}

{showAgyEnableConfirm && (

- Final confirmation required + {t('settingsProxy.finalConfirm')}

- Enabling this will skip AGY safety checkpoints in both dashboard and CLI. - Review issue #509 and type the exact phrase to proceed. + {t('settingsProxy.finalConfirmDesc')}

- Step 1 + {t('settingsProxy.step1')}

- Read issue #509 + {t('settingsProxy.readIssue')}

- Step 2 + {t('settingsProxy.step2')}

- Type{' '} + {t('settingsProxy.typePrefix')}{' '} {RISK_ACK_PHRASE} {' '} - to enable. + {t('settingsProxy.typeSuffix')}

@@ -590,10 +593,10 @@ export default function ProxySection() { placeholder={RISK_ACK_PHRASE} disabled={agyAckBypassSaving || saving} className="font-mono text-xs" - aria-label="Type I ACCEPT RISK to enable Antigravity power user mode" + aria-label={t('settingsProxy.typePhraseAria')} />

- Exact phrase required. + {t('settingsProxy.exactPhrase')}

@@ -606,7 +609,7 @@ export default function ProxySection() { }} disabled={agyAckBypassSaving || saving} > - Cancel + {t('settingsBackups.cancel')}
)} - Toggle AGY power user mode + {t('settingsProxy.toggleAgyMode')}
@@ -651,14 +654,14 @@ export default function ProxySection() { {/* Fallback Settings */}
-

Fallback Settings

+

{t('settingsProxy.fallbackSettings')}

{/* Enable Fallback */}
-

Enable fallback to local

+

{t('settingsProxy.enableFallback')}

- Use local proxy if remote is unreachable + {t('settingsProxy.enableFallbackDesc')}

-

Auto-start local proxy

+

{t('settingsProxy.autoStartLocal')}

- Automatically start local proxy on fallback + {t('settingsProxy.autoStartLocalDesc')}

- Advanced + {t('settingsProxy.advanced')}

{/* Debug Mode Toggle */}
-

Debug Mode

+

{t('settingsProxy.debugMode')}

- Enable developer diagnostics in browser console + {t('settingsProxy.debugModeDesc')}

{debugMode && (

- Debug mode enabled. Check browser console for detailed logs. + {t('settingsProxy.debugModeEnabled')}

)}
@@ -748,7 +751,7 @@ export default function ProxySection() { className="w-full" > - Refresh + {t('settings.refresh')}
diff --git a/ui/src/pages/settings/sections/proxy/local-proxy-card.tsx b/ui/src/pages/settings/sections/proxy/local-proxy-card.tsx index 9f942f4c..d9d25185 100644 --- a/ui/src/pages/settings/sections/proxy/local-proxy-card.tsx +++ b/ui/src/pages/settings/sections/proxy/local-proxy-card.tsx @@ -7,6 +7,7 @@ import { Switch } from '@/components/ui/switch'; import { Input } from '@/components/ui/input'; import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; import type { CliproxyServerConfig } from '../../types'; +import { useTranslation } from 'react-i18next'; interface LocalProxyCardProps { config: CliproxyServerConfig; @@ -25,15 +26,16 @@ export function LocalProxyCard({ onSaveLocalPort, onSaveConfig, }: LocalProxyCardProps) { + const { t } = useTranslation(); const localConfig = config.local; return (
-

Local Proxy

+

{t('settingsProxy.localProxy')}

{/* Port */}
- +
-

Auto-start

-

- Start local proxy automatically when needed -

+

{t('settingsProxy.autoStart')}

+

{t('settingsProxy.localAutoStartDesc')}

- Remote Server Configuration + {t('settingsProxy.remoteConfigTitle')}

{/* Host */}
- + setEditedHost(e.target.value)} onBlur={onSaveHost} - placeholder="192.168.1.100 or proxy.example.com" + placeholder={t('settingsProxy.hostPlaceholder')} className="font-mono" disabled={saving} /> @@ -87,9 +89,13 @@ export function RemoteProxyCard({
setEditedPort(e.target.value.replace(/\D/g, ''))} onBlur={onSavePort} - placeholder={`Leave empty for ${getDefaultPort(config.remote.protocol || 'http')}`} + placeholder={t('settingsProxy.portPlaceholder', { + value: getDefaultPort(config.remote.protocol || 'http'), + })} className="font-mono" disabled={saving} />
- +
@@ -125,36 +133,34 @@ export function RemoteProxyCard({ {/* Auth Token (API Key) */}
- + setEditedAuthToken(e.target.value)} onBlur={onSaveAuthToken} - placeholder="For /v1/* API endpoints" + placeholder={t('settingsProxy.apiKeyPlaceholder')} className="font-mono" disabled={saving} /> -

- Used for API requests to /v1/chat/completions -

+

{t('settingsProxy.apiKeyDesc')}

{/* Management Key */}
- + setEditedManagementKey(e.target.value)} onBlur={onSaveManagementKey} - placeholder="For /v0/management/* endpoints" + placeholder={t('settingsProxy.managementKeyPlaceholder')} className="font-mono" disabled={saving} /> -

- Used for dashboard management APIs. Falls back to API Key if not set. -

+

{t('settingsProxy.managementKeyDesc')}

{/* Test Connection */} @@ -168,12 +174,12 @@ export function RemoteProxyCard({ {testing ? ( <> - Testing... + {t('settingsProxy.testing')} ) : ( <> - Test Connection + {t('settingsProxy.testConnection')} )} @@ -192,14 +198,14 @@ export function RemoteProxyCard({ <> - Connected ({testResult.latencyMs}ms) + {t('settingsProxy.connectedLatency', { value: testResult.latencyMs })} ) : ( <> - {testResult.error || 'Connection failed'} + {testResult.error || t('settingsProxy.connectionFailed')} )} diff --git a/ui/src/pages/settings/sections/thinking/index.tsx b/ui/src/pages/settings/sections/thinking/index.tsx index a4b09ea2..bfe4f13e 100644 --- a/ui/src/pages/settings/sections/thinking/index.tsx +++ b/ui/src/pages/settings/sections/thinking/index.tsx @@ -20,6 +20,7 @@ import { import { RefreshCw, CheckCircle2, AlertCircle, Brain, Info, ChevronDown } from 'lucide-react'; import { useThinkingConfig } from '../../hooks'; import type { ThinkingMode } from '../../types'; +import { useTranslation } from 'react-i18next'; const THINKING_LEVELS = [ { value: 'minimal', label: 'Minimal (512 tokens)' }, @@ -42,6 +43,7 @@ const THINKING_BUDGET_MIN = 0; const THINKING_BUDGET_MAX = 100000; export default function ThinkingSection() { + const { t } = useTranslation(); const { config, loading, @@ -125,7 +127,7 @@ export default function ThinkingSection() {
- Loading... + {t('settings.loading')}
); @@ -156,7 +158,7 @@ export default function ThinkingSection() { className="h-7 px-2 text-xs border-destructive/50 hover:bg-destructive/10" > - Retry + {t('sharedPage.retry')}
@@ -164,7 +166,7 @@ export default function ThinkingSection() { {success && (
- Saved + {t('settings.saved')}
)}
@@ -174,27 +176,28 @@ export default function ThinkingSection() {
-

- Configure extended thinking/reasoning for supported models. -

+

{t('settingsThinking.description')}

{/* U4: Provider support indicator */}
-

Supported Providers

+

{t('settingsThinking.supportedProviders')}

  • - Thinking budget: agy, gemini (token-based) + {t('settingsThinking.supportLine1Prefix')} agy,{' '} + gemini {t('settingsThinking.supportLine1Suffix')}
  • - Reasoning effort: codex (suffix or --effort: - medium/high/xhigh) + {t('settingsThinking.supportLine2Prefix')} codex{' '} + {t('settingsThinking.supportLine2SuffixPrefix')} + --effort + {t('settingsThinking.supportLine2SuffixPostfix')}
  • - Codex suffixes pin effort (for example -high); unsuffixed models use - Thinking mode. + {t('settingsThinking.supportLine3Prefix')} -high + {t('settingsThinking.supportLine3Suffix')}
@@ -202,7 +205,7 @@ export default function ThinkingSection() { {/* Mode Selection */}
-

Thinking Mode

+

{t('settingsThinking.modeTitle')}

{(['auto', 'off', 'manual'] as ThinkingMode[]).map((mode) => (

{mode}

- {mode === 'auto' && 'Automatically set thinking based on model tier'} - {mode === 'off' && 'Disable extended thinking'} - {mode === 'manual' && - 'Set a persistent override level or use CLI flags per run'} + {mode === 'auto' && t('settingsThinking.modeAutoDesc')} + {mode === 'off' && t('settingsThinking.modeOffDesc')} + {mode === 'manual' && t('settingsThinking.modeManualDesc')}

-

Tier Defaults

+

{t('settingsThinking.tierDefaults')}

- Default thinking level for each model tier when in auto mode. + {t('settingsThinking.tierDefaultsDesc')}

@@ -276,9 +278,9 @@ export default function ThinkingSection() { {/* Manual Override (shown when mode is manual) */} {config.mode === 'manual' && (
-

Persistent Override

+

{t('settingsThinking.persistentOverride')}

- Applied to all sessions. CLI flags still take priority. + {t('settingsThinking.persistentOverrideDesc')}

setCustomProviderInput(event.target.value)} disabled={saving} - placeholder="Add provider key (e.g. qwen)" + placeholder={t('settingsThinking.addProviderPlaceholder')} />
{providerKeys.map((provider) => ( @@ -404,7 +408,9 @@ export default function ThinkingSection() { - Default + + {t('cursorPage.default')} + {THINKING_LEVELS.map((level) => ( {level.label} @@ -425,9 +431,9 @@ export default function ThinkingSection() { {/* Show Warnings Toggle */}
-

Show Warnings

+

{t('settingsThinking.showWarnings')}

- Display warnings when thinking values are clamped or adjusted + {t('settingsThinking.showWarningsDesc')}

-

CLI & Env Override

+

{t('settingsThinking.cliEnvOverride')}

Override per session with flags or{' '} CCS_THINKING env var. @@ -465,7 +471,7 @@ export default function ThinkingSection() { className="w-full" > - Refresh + {t('settings.refresh')}

diff --git a/ui/src/pages/settings/sections/websearch/index.tsx b/ui/src/pages/settings/sections/websearch/index.tsx index 7b061fd8..537b38fa 100644 --- a/ui/src/pages/settings/sections/websearch/index.tsx +++ b/ui/src/pages/settings/sections/websearch/index.tsx @@ -10,8 +10,10 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { RefreshCw, CheckCircle2, AlertCircle, Package } from 'lucide-react'; import { useWebSearchConfig, useRawConfig } from '../../hooks'; import { ProviderCard } from './provider-card'; +import { useTranslation } from 'react-i18next'; export default function WebSearchSection() { + const { t } = useTranslation(); const { config, status, @@ -83,7 +85,7 @@ export default function WebSearchSection() {
- Loading... + {t('settings.loading')}
); @@ -108,7 +110,7 @@ export default function WebSearchSection() { {success && (
- Saved + {t('settings.saved')}
)}
@@ -116,18 +118,16 @@ export default function WebSearchSection() { {/* Scrollable Content */}
-

- CLI-based web search for third-party profiles (gemini, codex, agy, etc.) -

+

{t('settingsWebsearch.description')}

{/* Status Summary */}

- {isGeminiEnabled ? 'WebSearch enabled' : 'WebSearch disabled'} + {isGeminiEnabled ? t('settingsWebsearch.enabled') : t('settingsWebsearch.disabled')}

{statusLoading ? ( -

Checking status...

+

{t('settingsWebsearch.checking')}

) : status?.readiness ? (

{status.readiness.message}

) : null} @@ -139,19 +139,21 @@ export default function WebSearchSection() { {/* CLI Providers */}
-

Providers

+

{t('settingsWebsearch.providers')}

{/* Empty state when no providers available */} {!status?.geminiCli && !status?.opencodeCli && !status?.grokCli && !statusLoading && (
-

No providers configured

+

+ {t('settingsWebsearch.noneConfigured')} +

- Install CLI tools to enable web search providers + {t('settingsWebsearch.noneConfiguredHint')}

)} @@ -233,7 +235,7 @@ export default function WebSearchSection() { className="w-full" > - Refresh + {t('settings.refresh')}
diff --git a/ui/src/pages/settings/sections/websearch/provider-card.tsx b/ui/src/pages/settings/sections/websearch/provider-card.tsx index 5792d680..c28977de 100644 --- a/ui/src/pages/settings/sections/websearch/provider-card.tsx +++ b/ui/src/pages/settings/sections/websearch/provider-card.tsx @@ -6,6 +6,7 @@ import { Switch } from '@/components/ui/switch'; import { Input } from '@/components/ui/input'; import { Terminal, ExternalLink, ChevronDown, ChevronUp, Check } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; export interface ProviderCardProps { name: string; @@ -50,6 +51,7 @@ export function ProviderCard({ docsUrl, hintColor, }: ProviderCardProps) { + const { t } = useTranslation(); const badgeClass = badgeColor === 'green' ? 'bg-green-500/10 text-green-600' @@ -98,11 +100,11 @@ export function ProviderCard({ {installed ? ( - installed + {t('settingsWebsearch.installed')} ) : ( - not installed + {t('settingsWebsearch.notInstalled')} )}
@@ -116,7 +118,9 @@ export function ProviderCard({ {enabled && modelInput !== undefined && setModelInput && onModelBlur && (
- + setModelInput(e.target.value)} @@ -128,7 +132,7 @@ export function ProviderCard({ {modelSaved && ( - Saved + {t('settings.saved')} )}
@@ -143,13 +147,16 @@ export function ProviderCard({ className={`flex items-center gap-2 text-sm hover:underline w-full py-2 ${hintTextClass}`} > {showHint ? : } - How to install {displayName} CLI + {t('settingsWebsearch.howInstall', { name: displayName })} {showHint && (

- Install globally{' '} - {badge === 'GROK_API_KEY' ? '(requires xAI API key)' : '(FREE tier available)'}: + {t('settingsWebsearch.installGlobally')}{' '} + {badge === 'GROK_API_KEY' + ? t('settingsWebsearch.requiresKey') + : t('settingsWebsearch.freeTier')} + :

{installCmd} @@ -161,7 +168,7 @@ export function ProviderCard({ className="hover:underline inline-flex items-center gap-1" > - View documentation + {t('settingsWebsearch.viewDocs')}
)} diff --git a/ui/src/pages/shared.tsx b/ui/src/pages/shared.tsx index 836816d9..8a848519 100644 --- a/ui/src/pages/shared.tsx +++ b/ui/src/pages/shared.tsx @@ -8,6 +8,8 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useSharedItemContent, useSharedItems, useSharedSummary } from '@/hooks/use-shared'; import { cn } from '@/lib/utils'; +import '@/lib/i18n'; +import { useTranslation } from 'react-i18next'; import { AlertCircle, AlertTriangle, @@ -21,14 +23,15 @@ import { type TabType = 'commands' | 'skills' | 'agents'; -const tabLabels: Record = { - commands: 'Commands', - skills: 'Skills', - agents: 'Agents', -}; - export function SharedPage() { + const { t } = useTranslation(); const [tab, setTab] = useState('commands'); + const tabLabels: Record = { + commands: t('sharedPage.commands'), + skills: t('sharedPage.skills'), + agents: t('sharedPage.agents'), + }; + const [query, setQuery] = useState(''); const [selectedItemPath, setSelectedItemPath] = useState(null); @@ -105,10 +108,8 @@ export function SharedPage() {
-

Shared Data

-

- Commands, skills, and agents shared across Claude instances -

+

{t('sharedPage.title')}

+

{t('sharedPage.subtitle')}

- + - +
- Markdown detail view - {activeQuery ? Filter: {activeQuery} : null} + {t('sharedPage.markdownDetail')} + {activeQuery ? ( + + {t('sharedPage.filterPrefix')} {activeQuery} + + ) : null}
@@ -148,7 +153,7 @@ export function SharedPage() { {summary && !summary.symlinkStatus.valid && ( - Configuration Required + {t('sharedPage.configurationRequired')} {summary.symlinkStatus.message}. Run `ccs sync` to configure. @@ -158,7 +163,7 @@ export function SharedPage() { {isSummaryError && ( - Counts unavailable + {t('sharedPage.countsUnavailable')}

{summaryErrorMessage}

@@ -170,7 +175,7 @@ export function SharedPage() { }} > - Retry counts + {t('sharedPage.retryCounts')}
@@ -200,30 +205,38 @@ export function SharedPage() { setQuery(event.target.value)} - placeholder={`Filter ${tab} by name, description, or path`} - aria-label={`Filter ${tab} by name, description, or path`} + placeholder={t('sharedPage.filterPlaceholder', { tab })} + aria-label={t('sharedPage.filterPlaceholder', { tab })} className="pl-8 h-9" />
{!isLoading && !isError && (

- Showing {filteredItems.length} of {allItems.length} {tab} - {activeQuery ? ` for "${activeQuery}"` : ''} - {isFetching ? ' (refreshing...)' : ''} + {t('sharedPage.showing', { + visible: filteredItems.length, + total: allItems.length, + tab, + })} + {activeQuery ? t('sharedPage.showingQuery', { query: activeQuery }) : ''} + {isFetching ? t('sharedPage.refreshing') : ''}

)}
{isLoading ? ( -
Loading shared {tab}...
+
+ {t('sharedPage.loadingShared', { tab })} +
) : isError ? (
-

Failed to load shared {tab}

+

+ {t('sharedPage.failedLoadShared', { tab })} +

{itemsErrorMessage}

) : hasNoItems ? (
- No shared {tab} found. Run `ccs sync` or add items in your shared directory. + {t('sharedPage.noSharedFound', { tab })}
) : hasNoMatches ? (
- No {tab} match "{activeQuery}". + {t('sharedPage.noMatch', { tab, query: activeQuery })}
) : (
@@ -277,7 +290,7 @@ export function SharedPage() {
{!selectedItem ? (
- Select a {tab.slice(0, -1)} to view full content. + {t('sharedPage.selectOne', { tab: tab.slice(0, -1) })}
) : ( <> @@ -293,11 +306,15 @@ export function SharedPage() {
- + {selectedItemContent?.contentPath && selectedItemContent.contentPath !== selectedItem.path && ( @@ -310,12 +327,12 @@ export function SharedPage() { {isContentLoading ? (

- Loading markdown content... + {t('sharedPage.loadingMarkdown')}

) : isContentError ? ( - Failed to load content + {t('sharedPage.failedLoadContent')}

{contentErrorMessage}

@@ -327,7 +344,7 @@ export function SharedPage() { }} > - Retry content + {t('sharedPage.retryContent')}
@@ -433,10 +450,11 @@ interface ParsedMarkdownDocument { } function MarkdownViewer({ content }: { content: string }) { + const { t } = useTranslation(); const parsedDocument = useMemo(() => parseMarkdownDocument(content), [content]); if (parsedDocument.blocks.length === 0 && parsedDocument.frontmatter.length === 0) { - return

No markdown content available.

; + return

{t('sharedPage.noMarkdown')}

; } return ( diff --git a/ui/src/pages/updates.tsx b/ui/src/pages/updates.tsx index 2ce8c9d1..770c3d61 100644 --- a/ui/src/pages/updates.tsx +++ b/ui/src/pages/updates.tsx @@ -17,15 +17,10 @@ import { writeNoticeProgressMap, type NoticeProgressMap, } from '@/lib/updates-notice-state'; +import { useTranslation } from 'react-i18next'; type NoticeViewMode = 'inbox' | 'done' | 'all'; -const NOTICE_VIEW_MODES: { id: NoticeViewMode; label: string }[] = [ - { id: 'inbox', label: 'Action Required' }, - { id: 'done', label: 'Done' }, - { id: 'all', label: 'All' }, -]; - function noticeMatchesQuery(notice: SupportNotice, queryValue: string): boolean { if (!queryValue) { return true; @@ -49,6 +44,7 @@ function noticeMatchesQuery(notice: SupportNotice, queryValue: string): boolean } export function UpdatesPage() { + const { t } = useTranslation(); const notices = useMemo( () => [...SUPPORT_NOTICES].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)), [] @@ -112,20 +108,18 @@ export function UpdatesPage() {
-

Updates Inbox

+

{t('updates.inboxTitle')}

-

- Focus on actions, then mark updates done or dismissed. -

+

{t('updates.inboxSubtitle')}

-

Needs Action

+

{t('updates.needsAction')}

{pendingCount}

-

Done

+

{t('updates.doneCount')}

{doneCount}

@@ -135,13 +129,17 @@ export function UpdatesPage() { setQuery(event.target.value)} - placeholder="Search actions or commands" + placeholder={t('updates.searchPlaceholder')} className="h-9 pl-8" />
- {NOTICE_VIEW_MODES.map((mode) => ( + {[ + { id: 'inbox' as const, label: t('updates.actionRequired') }, + { id: 'done' as const, label: t('updates.done') }, + { id: 'all' as const, label: t('updates.all') }, + ].map((mode) => (