diff --git a/ui/package-lock.json b/ui/package-lock.json index ae8ec9a..3730f92 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -12,9 +12,11 @@ "@fontsource-variable/jetbrains-mono": "^5.1.1", "@fontsource-variable/public-sans": "^5.1.0", "@tanstack/react-query": "^5.51.1", + "i18next": "^26.3.6", "lucide-react": "^0.408.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^17.0.11", "react-router-dom": "^6.24.0" }, "devDependencies": { @@ -2556,6 +2558,15 @@ "node": ">= 0.4" } }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, "node_modules/html2canvas": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", @@ -2569,6 +2580,34 @@ "node": ">=8.0.0" } }, + "node_modules/i18next": { + "version": "26.3.6", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", + "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -3160,6 +3199,33 @@ "react": "^18.3.1" } }, + "node_modules/react-i18next": { + "version": "17.0.11", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz", + "integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3567,7 +3633,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -3608,6 +3674,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/ui/package.json b/ui/package.json index 9ced4db..77990bb 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,9 +14,11 @@ "@fontsource-variable/jetbrains-mono": "^5.1.1", "@fontsource-variable/public-sans": "^5.1.0", "@tanstack/react-query": "^5.51.1", + "i18next": "^26.3.6", "lucide-react": "^0.408.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^17.0.11", "react-router-dom": "^6.24.0" }, "devDependencies": { diff --git a/ui/src/auth/ChangePasswordForm.tsx b/ui/src/auth/ChangePasswordForm.tsx index 87d630c..9b4c90c 100644 --- a/ui/src/auth/ChangePasswordForm.tsx +++ b/ui/src/auth/ChangePasswordForm.tsx @@ -1,5 +1,7 @@ import { useState, type FormEvent } from 'react' +import { useTranslation } from 'react-i18next' import { ApiError } from '../api/client' +import { apiErrorMessage } from '../i18n/apiError' import { Button, Field, Input } from '../components/ui' import { useAuth } from './AuthContext' @@ -11,12 +13,13 @@ const MIN_LENGTH = 12 * and the voluntary change modal. */ export function ChangePasswordForm({ - submitLabel = 'Update password', + submitLabel, onSuccess, }: { submitLabel?: string onSuccess?: () => void }) { + const { t } = useTranslation() const { changePassword } = useAuth() const [current, setCurrent] = useState('') const [next, setNext] = useState('') @@ -37,14 +40,14 @@ export function ChangePasswordForm({ await changePassword(current, next) onSuccess?.() } catch (err) { - setError(err instanceof ApiError ? err.message : 'Could not change the password.') + setError(err instanceof ApiError ? apiErrorMessage(err, t) : t('auth.password.failed')) setBusy(false) } } return (
) diff --git a/ui/src/auth/ChangePasswordModal.tsx b/ui/src/auth/ChangePasswordModal.tsx index ee854e4..5bb4d54 100644 --- a/ui/src/auth/ChangePasswordModal.tsx +++ b/ui/src/auth/ChangePasswordModal.tsx @@ -1,15 +1,17 @@ +import { useTranslation } from 'react-i18next' import { Modal, useToast } from '../components/ui' import { ChangePasswordForm } from './ChangePasswordForm' /** Voluntary password change, launched from the Topbar (dismissable, unlike the forced screen). */ export function ChangePasswordModal({ open, onClose }: { open: boolean; onClose: () => void }) { + const { t } = useTranslation() const toast = useToast() return ( -- {currentUser?.username ? `Signed in as ${currentUser.username}. ` : ''} - Choose a new password to finish signing in. + {currentUser?.username + ? t('auth.password.signedInAs', { name: currentUser.username }) + : ''} + {t('auth.password.chooseNew')}
Sign in to your account.
+{t('auth.login.subtitle')}
@@ -96,7 +98,7 @@ export function LoginScreen() { onClick={() => setUseMaster(true)} className="mt-4 w-full text-center text-xs text-ink-faint hover:text-ink-muted" > - Use master key instead + {t('auth.login.useMaster')}- Enter your master key to unlock the console. -
+{t('auth.unlock.subtitle')}
@@ -107,7 +105,7 @@ export function UnlockScreen({ onBack }: { onBack?: () => void }) { onClick={onBack} className="mt-4 w-full text-center text-xs text-ink-faint hover:text-ink-muted" > - Back to sign in + {t('auth.unlock.back')} )} @@ -119,11 +117,12 @@ export function UnlockScreen({ onBack }: { onBack?: () => void }) { /** Subtle API-reachability footer beneath the unlock/login form. */ export function ApiReachLine({ reach, health }: { reach: ApiReach; health: Health | null }) { + const { t } = useTranslation() if (reach === 'checking') { return (
{error.message}
+ +{error.message}
- -
- Fewer results than requested{topK ? ` (top_k ${topK})` : ''}. A selective filter or a small - candidate pool left some slots unfilled. + {topK + ? t('search.underDelivered.withTopK', { topK }) + : t('search.underDelivered.noTopK')}
No workspaces yet.
+ return{t('search.tree.noWorkspaces')}
} return (No collections
+{t('search.tree.noCollections')}
) : ( cols.map((col) => (
- Where uploaded files are stored. The active backend is chosen in{' '}
- config.yaml / .env. Retention is set{' '}
- per upload (1–30 days, or kept permanently) on the Documents page —
- there's no global retention setting.
+
{accel.data?.device === 'cuda'
- ? `Your GPU (${accel.data?.name}, capability ${accel.data?.capability}) is older than Ampere — `
- : 'No GPU detected — '}
- docling runs on CPU and is very slow (minutes per document, and large
- PDFs can hit the ingestion time limit). PyMuPDF is recommended on this hardware.
+ ? t('settings.parser.gpuOlderThanAmpere', {
+ name: accel.data?.name,
+ capability: accel.data?.capability,
+ })
+ : t('settings.parser.noGpuDetected')}
+
Detecting GPU…
+ return ( ++ {t('settings.parser.detectingGpu')} +
+ ) } const compatible = accel.data?.compatible ?? false return ( @@ -168,17 +181,25 @@ function AcceleratorNote({
- The embedding model turns documents into vectors. Changing the provider,
- model, or output dimensions changes the vector size — existing collections
- must be re-indexed or search will break. Changing only an API key is safe.
+
- The reranker reorders retrieved chunks by true query relevance before the final cut — the
- biggest precision win. It's optional and safe: if the model or a remote
- provider is unavailable, search falls back to its normal ranking.
+
- Adjacency expansion pulls the chunks next to each result and merges them into one span, so
- a section that runs across pages comes back whole instead of cut at the top-K boundary.
- It's a plain lookup — no extra model calls — and safe: if it can't fetch,
- search returns the un-expanded results.
+
- The MCP endpoint (/api/mcp) throttles each API key to this many requests per
- minute; the next request in the same minute returns 429. Each key gets its own
- budget, and the new limit applies to the next MCP request
+
{error?.message ?? 'Could not reach Ollama'}
++ {error?.message ?? t('settings.field.ollamaUnreachable')} +