From 09fd877a1d0866c8d635cebc49bbe332685df840 Mon Sep 17 00:00:00 2001
From: Jayme Klein
Date: Thu, 13 Aug 2026 17:15:57 -0300
Subject: [PATCH] feat(ui): internationalize the console with i18next (en +
pt-BR)
Adds react-i18next + i18next with browser-locale detection, a LanguageSwitcher,
and en/pt-BR translation bundles, wiring the auth screens, pages, and shared
components through t().
---
ui/package-lock.json | 77 +-
ui/package.json | 2 +
ui/src/auth/ChangePasswordForm.tsx | 21 +-
ui/src/auth/ChangePasswordModal.tsx | 8 +-
ui/src/auth/ChangePasswordScreen.tsx | 16 +-
ui/src/auth/LoginScreen.tsx | 28 +-
ui/src/auth/UnlockScreen.tsx | 35 +-
ui/src/components/Pager.tsx | 18 +-
.../collections/CollectionFormModal.tsx | 20 +-
.../components/documents/DocumentFilters.tsx | 44 +-
ui/src/components/documents/UploadZone.tsx | 10 +-
ui/src/components/graph/GraphCanvas.tsx | 13 +-
ui/src/components/layout/ErrorBoundary.tsx | 35 +-
ui/src/components/layout/LanguageSwitcher.tsx | 37 +
ui/src/components/layout/Sidebar.tsx | 34 +-
ui/src/components/layout/Topbar.tsx | 21 +-
ui/src/components/search/ResultCard.tsx | 10 +-
ui/src/components/search/SearchResults.tsx | 60 +-
ui/src/components/search/WorkspaceTree.tsx | 24 +-
ui/src/components/settings/ConfigPanel.tsx | 240 +++---
ui/src/components/settings/McpPanel.tsx | 86 +-
ui/src/components/tags/TagChip.tsx | 4 +-
ui/src/components/tags/TagFilterBar.tsx | 16 +-
ui/src/components/tags/TagPicker.tsx | 28 +-
ui/src/components/ui/ConfirmDialog.tsx | 8 +-
ui/src/components/ui/CopyButton.tsx | 8 +-
ui/src/components/ui/Modal.tsx | 4 +-
ui/src/components/ui/QueryError.tsx | 8 +-
ui/src/components/ui/StatusBadge.tsx | 25 +-
ui/src/components/ui/Toast.tsx | 18 +-
ui/src/components/users/PermissionsModal.tsx | 122 +--
ui/src/components/users/RevealOncePanel.tsx | 6 +-
ui/src/components/users/UserFormModal.tsx | 36 +-
ui/src/components/users/UserKeyModal.tsx | 42 +-
.../workspaces/WorkspaceFormModal.tsx | 20 +-
ui/src/i18n/apiError.ts | 34 +
ui/src/i18n/detect.ts | 57 ++
ui/src/i18n/i18next.d.ts | 15 +
ui/src/i18n/index.ts | 45 +
ui/src/i18n/locales/en/translation.json | 782 ++++++++++++++++++
ui/src/i18n/locales/pt-BR/translation.json | 782 ++++++++++++++++++
ui/src/i18n/parity.ts | 16 +
ui/src/lib/format.ts | 36 +-
ui/src/main.tsx | 1 +
ui/src/pages/Collections.tsx | 69 +-
ui/src/pages/Dashboard.tsx | 49 +-
ui/src/pages/Documents.tsx | 101 +--
ui/src/pages/Graph.tsx | 52 +-
ui/src/pages/Indexing.tsx | 45 +-
ui/src/pages/IngestionQueue.tsx | 112 +--
ui/src/pages/Search.tsx | 52 +-
ui/src/pages/Settings.tsx | 65 +-
ui/src/pages/Tags.tsx | 112 +--
ui/src/pages/Users.tsx | 84 +-
ui/src/pages/Workspaces.tsx | 53 +-
55 files changed, 2904 insertions(+), 842 deletions(-)
create mode 100644 ui/src/components/layout/LanguageSwitcher.tsx
create mode 100644 ui/src/i18n/apiError.ts
create mode 100644 ui/src/i18n/detect.ts
create mode 100644 ui/src/i18n/i18next.d.ts
create mode 100644 ui/src/i18n/index.ts
create mode 100644 ui/src/i18n/locales/en/translation.json
create mode 100644 ui/src/i18n/locales/pt-BR/translation.json
create mode 100644 ui/src/i18n/parity.ts
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 (
-
+
{
- toast.success('Password updated.')
+ toast.success(t('auth.password.updated'))
onClose()
}}
/>
diff --git a/ui/src/auth/ChangePasswordScreen.tsx b/ui/src/auth/ChangePasswordScreen.tsx
index b09f50b..ee42389 100644
--- a/ui/src/auth/ChangePasswordScreen.tsx
+++ b/ui/src/auth/ChangePasswordScreen.tsx
@@ -1,4 +1,5 @@
import { KeyRound } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import { ChangePasswordForm } from './ChangePasswordForm'
import { useAuth } from './AuthContext'
@@ -8,6 +9,7 @@ import { useAuth } from './AuthContext'
* `changePassword` clears the flag and the app renders.
*/
export function ChangePasswordScreen() {
+ const { t } = useTranslation()
const { currentUser, logout } = useAuth()
return (
@@ -16,19 +18,23 @@ export function ChangePasswordScreen() {
-
Set a new password
+
+ {t('auth.password.setTitle')}
+
- {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 out
+ {t('topbar.signOut')}
diff --git a/ui/src/auth/LoginScreen.tsx b/ui/src/auth/LoginScreen.tsx
index 0977ccf..b86aefb 100644
--- a/ui/src/auth/LoginScreen.tsx
+++ b/ui/src/auth/LoginScreen.tsx
@@ -1,17 +1,18 @@
import { useEffect, useRef, useState, type FormEvent } from 'react'
import { LogIn } from 'lucide-react'
+import type { TFunction } from 'i18next'
+import { useTranslation } from 'react-i18next'
import { ApiError } from '../api/client'
+import { apiErrorMessage } from '../i18n/apiError'
import { Button } from '../components/ui/Button'
import { Input } from '../components/ui/Input'
import { useAuth } from './AuthContext'
import { ApiReachLine, UnlockScreen, useApiReach } from './UnlockScreen'
/** Maps a login failure to a coarse, credential-safe message. */
-function loginError(err: unknown): string {
- if (err instanceof ApiError) {
- return err.status === 401 ? 'Invalid username or password.' : err.message
- }
- return 'Could not reach the API. Is the stack running?'
+function loginError(err: unknown, t: TFunction): string {
+ if (err instanceof ApiError && err.status === 401) return t('auth.login.invalid')
+ return apiErrorMessage(err, t)
}
/**
@@ -19,6 +20,7 @@ function loginError(err: unknown): string {
* swaps in the bootstrap {@link UnlockScreen} (break-glass admin access).
*/
export function LoginScreen() {
+ const { t } = useTranslation()
const { login } = useAuth()
const { reach, health } = useApiReach()
const [useMaster, setUseMaster] = useState(false)
@@ -42,7 +44,7 @@ export function LoginScreen() {
try {
await login(username.trim(), password)
} catch (err) {
- setError(loginError(err))
+ setError(loginError(err, t))
setBusy(false)
}
}
@@ -55,23 +57,23 @@ export function LoginScreen() {
EmbedBase
- 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')}
diff --git a/ui/src/auth/UnlockScreen.tsx b/ui/src/auth/UnlockScreen.tsx
index 380ea42..edd55b4 100644
--- a/ui/src/auth/UnlockScreen.tsx
+++ b/ui/src/auth/UnlockScreen.tsx
@@ -1,6 +1,9 @@
import { useEffect, useRef, useState, type FormEvent } from 'react'
import { KeyRound, Loader2, ShieldCheck, ShieldAlert } from 'lucide-react'
+import type { TFunction } from 'i18next'
+import { useTranslation } from 'react-i18next'
import { api, ApiError } from '../api/client'
+import { apiErrorMessage } from '../i18n/apiError'
import type { Health } from '../api/types'
import { useAuth } from './AuthContext'
import { Button } from '../components/ui/Button'
@@ -26,13 +29,9 @@ export function useApiReach(): { reach: ApiReach; health: Health | null } {
}
/** Maps an unlock failure to a key-safe message (never echoes the key). */
-function unlockError(err: unknown): string {
- if (err instanceof ApiError) {
- return err.status === 401
- ? 'That key was rejected. Check it and try again.'
- : err.message
- }
- return 'Could not reach the API. Is the stack running?'
+function unlockError(err: unknown, t: TFunction): string {
+ if (err instanceof ApiError && err.status === 401) return t('auth.unlock.rejected')
+ return apiErrorMessage(err, t)
}
/**
@@ -41,6 +40,7 @@ function unlockError(err: unknown): string {
* toggle. Submitting verifies the key against `/workspaces`.
*/
export function UnlockScreen({ onBack }: { onBack?: () => void }) {
+ const { t } = useTranslation()
const { unlock } = useAuth()
const { reach, health } = useApiReach()
const [key, setKey] = useState('')
@@ -60,7 +60,7 @@ export function UnlockScreen({ onBack }: { onBack?: () => void }) {
try {
await unlock(key.trim())
} catch (err) {
- setError(unlockError(err))
+ setError(unlockError(err, t))
setBusy(false)
}
}
@@ -73,9 +73,7 @@ export function UnlockScreen({ onBack }: { onBack?: () => void }) {
EmbedBase
-
- Enter your master key to unlock the console.
-
+ {t('auth.unlock.subtitle')}
)}
- {busy ? 'Verifying…' : 'Unlock'}
+ {busy ? t('auth.unlock.submitting') : t('auth.unlock.submit')}
@@ -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 (
- Checking API…
+ {t('auth.api.checking')}
)
}
@@ -131,14 +130,14 @@ export function ApiReachLine({ reach, health }: { reach: ApiReach; health: Healt
return (
- API unreachable
+ {t('auth.api.unreachable')}
)
}
return (
- API ready · {health?.embedding_model ?? 'model'}
+ {t('auth.api.ready', { model: health?.embedding_model ?? t('auth.api.modelFallback') })}
)
}
diff --git a/ui/src/components/Pager.tsx b/ui/src/components/Pager.tsx
index cb37343..a8c60c8 100644
--- a/ui/src/components/Pager.tsx
+++ b/ui/src/components/Pager.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from 'react-i18next'
import { cn } from '../lib/cn'
import { Button } from './ui'
@@ -21,6 +22,7 @@ export function Pager({
* end of it first. Assumes the page gutter both callers share (see the class note below). */
sticky?: boolean
}) {
+ const { t } = useTranslation()
if (total === 0) return null
const start = page * pageSize + 1
const end = Math.min((page + 1) * pageSize, total)
@@ -34,13 +36,7 @@ export function Pager({
sticky && 'sticky bottom-0 z-10 -mx-8 border-t border-border bg-canvas px-8 py-3',
)}
>
-
- Showing{' '}
-
- {start}–{end}
- {' '}
- of {total}
-
+ {t('ui.pager.range', { from: start, to: end, total })}
onPage(page - 1)}
>
- Previous
+ {t('ui.pager.previous')}
-
- Page {page + 1} of {lastPage + 1}
-
+ {t('ui.pager.page', { page: page + 1, pages: lastPage + 1 })}
= lastPage || loading}
onClick={() => onPage(page + 1)}
>
- Next
+ {t('ui.pager.next')}
diff --git a/ui/src/components/collections/CollectionFormModal.tsx b/ui/src/components/collections/CollectionFormModal.tsx
index dbca492..d1c7c41 100644
--- a/ui/src/components/collections/CollectionFormModal.tsx
+++ b/ui/src/components/collections/CollectionFormModal.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
import type { Collection } from '../../api/types'
import {
Button,
@@ -54,6 +55,7 @@ export function CollectionFormModal({
onSubmit: (values: CollectionFormValues) => void
onClose: () => void
}) {
+ const { t } = useTranslation()
const [values, setValues] = useState(DEFAULTS)
// Reseed every time the modal opens so stale edits never leak between rows.
@@ -75,20 +77,20 @@ export function CollectionFormModal({
- Cancel
+ {t('common.cancel')}
- {editing ? 'Save changes' : 'Create'}
+ {editing ? t('common.saveChanges') : t('common.create')}
>
}
>
-
+
{
if (e.key === 'Enter') submit()
}}
- placeholder="e.g. Q3 Reports"
+ placeholder={t('collections.namePlaceholder')}
/>
-
+
-
+
set('color', c)} />
-
+
set('icon', i)} />
diff --git a/ui/src/components/documents/DocumentFilters.tsx b/ui/src/components/documents/DocumentFilters.tsx
index 24b40f0..f088283 100644
--- a/ui/src/components/documents/DocumentFilters.tsx
+++ b/ui/src/components/documents/DocumentFilters.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { Search, SlidersHorizontal, X } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import {
FILE_TYPES,
filterInputCls as inputCls,
@@ -33,6 +34,7 @@ export function DocumentFilters({
value: DocumentFilterValues
onChange: (next: DocumentFilterValues) => void
}) {
+ const { t } = useTranslation()
const [expanded, setExpanded] = useState(false)
const set = (key: K, v: DocumentFilterValues[K]) =>
onChange({ ...value, [key]: v })
@@ -46,48 +48,48 @@ export function DocumentFilters({
set('filename', text(e.target.value))}
/>
set('status', text(e.target.value))}
>
- Any status
+ {t('documents.filters.anyStatus')}
{STATUSES.map((s) => (
- {s}
+ {t(`status.${s}`)}
))}
set('file_type', text(e.target.value))}
>
- Any type
- {FILE_TYPES.map((t) => (
-
- {t}
+ {t('documents.filters.anyType')}
+ {FILE_TYPES.map((ft) => (
+
+ {ft}
))}
set('indexed', e.target.value === '' ? undefined : e.target.value === 'true')
}
>
- Indexed: any
- Indexed
- Not indexed
+ {t('documents.filters.indexedAny')}
+ {t('documents.filters.indexed')}
+ {t('documents.filters.notIndexed')}
- More
+ {t('common.more')}
{active && (
- Clear
+ {t('common.clear')}
)}
@@ -115,23 +117,23 @@ export function DocumentFilters({
set('storage_backend', text(e.target.value))}
/>
set('embedding_model', text(e.target.value))}
/>
- Size (bytes)
+ {t('documents.filters.size')}
set('min_size', num(e.target.value))}
/>
@@ -140,13 +142,13 @@ export function DocumentFilters({
type="number"
min={0}
className={`${inputCls} w-24`}
- placeholder="max"
+ placeholder={t('common.max')}
value={value.max_size ?? ''}
onChange={(e) => set('max_size', num(e.target.value))}
/>
- Created
+ {t('documents.filters.created')}
(null)
const [dragging, setDragging] = useState(false)
@@ -63,20 +65,18 @@ export function UploadZone({
)}
- {busy ? 'Uploading…' : 'Drag files here, or '}
+ {busy ? t('documents.upload.uploading') : t('documents.upload.drag')}
{!busy && (
inputRef.current?.click()}
className="font-medium text-accent hover:underline"
>
- browse
+ {t('documents.upload.browse')}
)}
-
- PDF, text, Markdown · DOCX/PPTX with docling · up to {maxSizeMb} MB each
-
+ {t('documents.upload.hint', { max: maxSizeMb })}
)
}
diff --git a/ui/src/components/graph/GraphCanvas.tsx b/ui/src/components/graph/GraphCanvas.tsx
index 19a7cd4..d9165d8 100644
--- a/ui/src/components/graph/GraphCanvas.tsx
+++ b/ui/src/components/graph/GraphCanvas.tsx
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Graph } from '@antv/g6'
import { Magnet, Maximize2, ZoomIn, ZoomOut } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import type { GraphResponse } from '../../api/types'
import { toG6 } from '../../lib/toG6'
import { Button } from '../ui'
@@ -100,6 +101,7 @@ export function GraphCanvas({
selected?: string | null
fitNonce?: number
}) {
+ const { t } = useTranslation()
const containerRef = useRef(null)
const graphRef = useRef(null)
// Keep the latest onSelect without forcing a graph rebuild on identity change.
@@ -200,13 +202,13 @@ export function GraphCanvas({
- void graphRef.current?.zoomBy(1.2, anim)}>
+ void graphRef.current?.zoomBy(1.2, anim)}>
- void graphRef.current?.zoomBy(0.8, anim)}>
+ void graphRef.current?.zoomBy(0.8, anim)}>
- void graphRef.current?.fitView()}>
+ void graphRef.current?.fitView()}>
@@ -226,10 +228,11 @@ function RepelControl({
onChange: (v: number) => void
onCommit: (v: number) => void
}) {
+ const { t } = useTranslation()
return (
onChange(Number(e.target.value))}
onPointerUp={() => onCommit(value)}
onKeyUp={() => onCommit(value)}
diff --git a/ui/src/components/layout/ErrorBoundary.tsx b/ui/src/components/layout/ErrorBoundary.tsx
index 5dfe10e..59c8033 100644
--- a/ui/src/components/layout/ErrorBoundary.tsx
+++ b/ui/src/components/layout/ErrorBoundary.tsx
@@ -1,4 +1,5 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
+import { useTranslation } from 'react-i18next'
interface Props {
children: ReactNode
@@ -8,6 +9,27 @@ interface State {
error: Error | null
}
+/**
+ * Fallback UI for a caught render error. Split out as a function component so it can
+ * localize its labels via `useTranslation` — a hook the class boundary itself can't use.
+ * The raw `error.message` is shown untranslated: it's the caught JS error's own text.
+ */
+function ErrorFallback({ error, onReset }: { error: Error; onReset: () => void }): ReactNode {
+ const { t } = useTranslation()
+ return (
+
+
{t('ui.errorBoundary.title')}
+
{error.message}
+
+ {t('common.retry')}
+
+
+ )
+}
+
/** Catches render-time errors and shows a recoverable fallback. */
export class ErrorBoundary extends Component
{
state: State = { error: null }
@@ -24,18 +46,7 @@ export class ErrorBoundary extends Component {
render(): ReactNode {
const { error } = this.state
if (error) {
- return (
-
-
Something went wrong
-
{error.message}
-
this.setState({ error: null })}
- className="rounded-control border border-border bg-surface px-4 py-2 text-sm font-medium text-ink transition-colors hover:bg-canvas"
- >
- Try again
-
-
- )
+ return this.setState({ error: null })} />
}
return this.props.children
}
diff --git a/ui/src/components/layout/LanguageSwitcher.tsx b/ui/src/components/layout/LanguageSwitcher.tsx
new file mode 100644
index 0000000..f087191
--- /dev/null
+++ b/ui/src/components/layout/LanguageSwitcher.tsx
@@ -0,0 +1,37 @@
+import { Check, Languages } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { DropdownItem, DropdownMenu } from '../ui/DropdownMenu'
+import { setLanguage } from '../../i18n'
+import { SUPPORTED, type Lang } from '../../i18n/detect'
+
+/** Endonyms — a language is always named in its own tongue, so never translated. */
+const LANG_LABELS: Record = {
+ en: 'English',
+ 'pt-BR': 'Português (BR)',
+}
+
+/**
+ * Topbar control to switch the console language between the supported locales.
+ * Persistence + `` / date-locale syncing are handled by the i18n layer
+ * (`i18n/index.ts`), so this only has to call `changeLanguage`.
+ */
+export function LanguageSwitcher() {
+ const { i18n, t } = useTranslation()
+ const active = i18n.resolvedLanguage
+ return (
+ }
+ >
+ {SUPPORTED.map((lang) => (
+ : undefined}
+ onSelect={() => setLanguage(lang)}
+ >
+ {LANG_LABELS[lang]}
+
+ ))}
+
+ )
+}
diff --git a/ui/src/components/layout/Sidebar.tsx b/ui/src/components/layout/Sidebar.tsx
index 5a8203d..57c8631 100644
--- a/ui/src/components/layout/Sidebar.tsx
+++ b/ui/src/components/layout/Sidebar.tsx
@@ -1,11 +1,15 @@
import { NavLink } from 'react-router-dom'
import { DatabaseZap, FolderKanban, LayoutDashboard, ListChecks, Search, Settings, Users, Workflow, type LucideIcon } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import { cn } from '../../lib/cn'
import { useAuth } from '../../auth/AuthContext'
+/** The nav labels are translation keys under `nav.*`, resolved at render. */
+type NavLabelKey = `nav.${'dashboard' | 'workspaces' | 'graph' | 'search' | 'indexing' | 'ingestionQueue' | 'users' | 'settings'}`
+
interface NavItem {
to: string
- label: string
+ labelKey: NavLabelKey
icon: LucideIcon
end?: boolean
}
@@ -13,31 +17,32 @@ interface NavItem {
// Admins see the full console; a non-admin sees only the data plane they're scoped
// to (browse their permitted workspaces + search + the ingestion views below).
const ADMIN_MAIN: NavItem[] = [
- { to: '/', label: 'Dashboard', icon: LayoutDashboard, end: true },
- { to: '/workspaces', label: 'Workspaces', icon: FolderKanban },
- { to: '/graph', label: 'Graph', icon: Workflow },
- { to: '/search', label: 'Search', icon: Search },
+ { to: '/', labelKey: 'nav.dashboard', icon: LayoutDashboard, end: true },
+ { to: '/workspaces', labelKey: 'nav.workspaces', icon: FolderKanban },
+ { to: '/graph', labelKey: 'nav.graph', icon: Workflow },
+ { to: '/search', labelKey: 'nav.search', icon: Search },
]
const USER_MAIN: NavItem[] = [
- { to: '/workspaces', label: 'Workspaces', icon: FolderKanban },
- { to: '/search', label: 'Search', icon: Search },
+ { to: '/workspaces', labelKey: 'nav.workspaces', icon: FolderKanban },
+ { to: '/search', labelKey: 'nav.search', icon: Search },
]
// Ingestion views — everyone sees them; the backend scopes their contents to the
// caller's grants (a non-admin sees only their permitted collections' jobs/coverage).
const INGESTION_ITEMS: NavItem[] = [
- { to: '/indexing', label: 'Indexing', icon: DatabaseZap },
- { to: '/ingestion-queue', label: 'Ingestion Queue', icon: ListChecks },
+ { to: '/indexing', labelKey: 'nav.indexing', icon: DatabaseZap },
+ { to: '/ingestion-queue', labelKey: 'nav.ingestionQueue', icon: ListChecks },
]
// Grouped under the "Admin" heading in the sidebar (admins only).
const ADMIN_ITEMS: NavItem[] = [
- { to: '/users', label: 'Users', icon: Users },
- { to: '/settings', label: 'Settings', icon: Settings },
+ { to: '/users', labelKey: 'nav.users', icon: Users },
+ { to: '/settings', labelKey: 'nav.settings', icon: Settings },
]
-function NavItemLink({ to, label, icon: Icon, end }: NavItem) {
+function NavItemLink({ to, labelKey, icon: Icon, end }: NavItem) {
+ const { t } = useTranslation()
return (
- {label}
+ {t(labelKey)}
)
}
export function Sidebar() {
+ const { t } = useTranslation()
const { isAdmin } = useAuth()
const mainItems = [...(isAdmin ? ADMIN_MAIN : USER_MAIN), ...INGESTION_ITEMS]
return (
@@ -75,7 +81,7 @@ export function Sidebar() {
{isAdmin && (
<>
- Admin
+ {t('nav.admin')}
{ADMIN_ITEMS.map((item) => (
diff --git a/ui/src/components/layout/Topbar.tsx b/ui/src/components/layout/Topbar.tsx
index 7ead544..376a678 100644
--- a/ui/src/components/layout/Topbar.tsx
+++ b/ui/src/components/layout/Topbar.tsx
@@ -1,13 +1,17 @@
import { KeyRound, LogOut } from 'lucide-react'
import { useState, type ReactNode } from 'react'
+import { useTranslation } from 'react-i18next'
import { useAuth } from '../../auth/AuthContext'
import { ChangePasswordModal } from '../../auth/ChangePasswordModal'
+import { LanguageSwitcher } from './LanguageSwitcher'
/**
- * Top bar with a breadcrumb slot and the account controls: who's signed in, an
- * optional change-password action (session users only), and Sign out.
+ * Top bar with a breadcrumb slot and the account controls: the language switcher,
+ * who's signed in, an optional change-password action (session users only), and
+ * Sign out.
*/
export function Topbar({ children }: { children?: ReactNode }) {
+ const { t } = useTranslation()
const { currentUser, isAdmin, logout } = useAuth()
const [changing, setChanging] = useState(false)
@@ -15,29 +19,30 @@ export function Topbar({ children }: { children?: ReactNode }) {
{children}
+
- {currentUser ? currentUser.username : 'Master key'}
- {isAdmin ? ' · admin' : ''}
+ {currentUser ? currentUser.username : t('topbar.masterKey')}
+ {isAdmin ? t('topbar.adminSuffix') : ''}
{currentUser && (
setChanging(true)}
- title="Change your password"
+ title={t('topbar.changePasswordTitle')}
className="flex items-center gap-1.5 rounded-full border border-border bg-canvas px-3 py-1 text-xs font-medium text-ink-muted transition-colors hover:border-ink-faint hover:text-ink"
>
- Change password
+ {t('topbar.changePassword')}
)}
- Sign out
+ {t('topbar.signOut')}
setChanging(false)} />
diff --git a/ui/src/components/search/ResultCard.tsx b/ui/src/components/search/ResultCard.tsx
index c31ab1b..e36a2ad 100644
--- a/ui/src/components/search/ResultCard.tsx
+++ b/ui/src/components/search/ResultCard.tsx
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import { FileText, Layers } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import type { SearchResult } from '../../api/types'
import { Badge, Card } from '../ui'
@@ -33,6 +34,7 @@ function highlight(text: string, query: string): ReactNode {
/** A single ranked search hit: rank, score, provenance, and emphasized text. */
export function ResultCard({ result, query }: { result: SearchResult; query: string }) {
+ const { t } = useTranslation()
const src = result.source
return (
@@ -49,20 +51,20 @@ export function ResultCard({ result, query }: { result: SearchResult; query: str
{src.workspace_name}
)}
-
+
{result.score.toFixed(3)}
{src && (src.filename || src.page_number !== null || src.page_range) && (
- {src.filename ?? 'Unknown file'}
+ {src.filename ?? t('search.unknownFile')}
{/* A span merged across pages advertises its full range; a single hit shows its page. */}
{src.page_range ? (
- · pp.{src.page_range}
+ {t('search.pages', { range: src.page_range })}
) : (
src.page_number !== null && (
- · p.{src.page_number}
+ {t('search.page', { page: src.page_number })}
)
)}
diff --git a/ui/src/components/search/SearchResults.tsx b/ui/src/components/search/SearchResults.tsx
index 8f97dcc..b31a581 100644
--- a/ui/src/components/search/SearchResults.tsx
+++ b/ui/src/components/search/SearchResults.tsx
@@ -1,18 +1,18 @@
import type { UseMutationResult } from '@tanstack/react-query'
import { AlertTriangle, SearchX, Telescope } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import type { CollectionStat, SearchMode, SearchRequest, SearchResponse } from '../../api/types'
import { Card, EmptyState, QueryError, Skeleton } from '../ui'
+import { apiErrorMessage } from '../../i18n/apiError'
import { ResultCard } from './ResultCard'
-const MODE: Record = {
- hybrid: { label: 'Hybrid', cls: 'border-accent/40 text-accent', hint: 'BM25 + semantic' },
- semantic: { label: 'Semantic', cls: 'border-border text-ink-muted', hint: 'Vector similarity' },
- bm25: { label: 'BM25', cls: 'border-border text-ink-muted', hint: 'Keyword ranking' },
- semantic_only: {
- label: 'Semantic only',
- cls: 'border-warn/40 text-warn',
- hint: 'BM25 index unavailable — fell back to semantic',
- },
+/** Per-mode chip styling; the label and hint are resolved from `search.mode.*` /
+ * `search.modeHint.*` at render time. */
+const MODE_CLS: Record = {
+ hybrid: 'border-accent/40 text-accent',
+ semantic: 'border-border text-ink-muted',
+ bm25: 'border-border text-ink-muted',
+ semantic_only: 'border-warn/40 text-warn',
}
/** Results pane: drives off the search mutation across all of its states. */
@@ -21,6 +21,7 @@ export function SearchResults({
}: {
mutation: UseMutationResult
}) {
+ const { t } = useTranslation()
if (mutation.isPending) {
return (
@@ -31,15 +32,15 @@ export function SearchResults({
)
}
if (mutation.isError) {
- return
+ return
}
const res = mutation.data
if (!res) {
return (
}
- title="Search your collections"
- description="Pick one or more collections on the left, type a query, and run it."
+ title={t('search.empty.title')}
+ description={t('search.empty.description')}
/>
)
}
@@ -51,8 +52,8 @@ export function SearchResults({
{res.results.length === 0 ? (
}
- title="No matches"
- description="No chunks matched this query in the selected collections. Try broadening the query or relaxing filters."
+ title={t('search.noMatches.title')}
+ description={t('search.noMatches.description')}
/>
) : (
res.results.map((r) =>
)
@@ -64,17 +65,23 @@ export function SearchResults({
/** Mode chip + millisecond timing line. */
function ResultsHeader({ res }: { res: SearchResponse }) {
- const mode = MODE[res.search_mode] ?? MODE.semantic
+ const { t } = useTranslation()
+ const mode = res.search_mode
+ const cls = MODE_CLS[mode] ?? MODE_CLS.semantic
return (
- {mode.label}
+ {t(`search.mode.${mode}`)}
- embed {res.query_embedding_ms}ms · search {res.search_ms}ms · total {res.total_ms}ms
+ {t('search.timing', {
+ embed: res.query_embedding_ms,
+ search: res.search_ms,
+ total: res.total_ms,
+ })}
)
@@ -82,12 +89,14 @@ function ResultsHeader({ res }: { res: SearchResponse }) {
/** Warns that filters or available matches starved the requested `top_k`. */
function UnderDeliveredBanner({ topK }: { topK?: number }) {
+ const { t } = useTranslation()
return (
- 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')}
)
@@ -95,12 +104,13 @@ function UnderDeliveredBanner({ topK }: { topK?: number }) {
/** Per-collection retrieved / returned / contributed breakdown. */
function CollectionStatsPanel({ stats }: { stats: Record
}) {
+ const { t } = useTranslation()
const entries = Object.values(stats)
if (entries.length === 0) return null
return (
- Per-collection breakdown
+ {t('search.breakdown.title')}
{entries.map((s) => (
@@ -110,7 +120,11 @@ function CollectionStatsPanel({ stats }: { stats: Record
· {s.workspace_name}
- {s.retrieved_before_filter} → {s.returned_after_filter} · {s.contributed_to_top_k} in top-k
+ {t('search.breakdown.stats', {
+ retrieved: s.retrieved_before_filter,
+ returned: s.returned_after_filter,
+ contributed: s.contributed_to_top_k,
+ })}
))}
diff --git a/ui/src/components/search/WorkspaceTree.tsx b/ui/src/components/search/WorkspaceTree.tsx
index 7a7a539..42599de 100644
--- a/ui/src/components/search/WorkspaceTree.tsx
+++ b/ui/src/components/search/WorkspaceTree.tsx
@@ -1,8 +1,10 @@
import { useState } from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import { useCollections, useWorkspaces } from '../../api/hooks'
import type { Collection, Workspace } from '../../api/types'
import { EntityIcon, QueryError, Skeleton } from '../ui'
+import { apiErrorMessage } from '../../i18n/apiError'
/** Selection callbacks shared down the tree. */
interface TreeActions {
@@ -17,6 +19,7 @@ interface TreeActions {
* select-all toggle.
*/
export function WorkspaceTree(actions: TreeActions) {
+ const { t } = useTranslation()
const { data, isLoading, isError, error, refetch } = useWorkspaces()
if (isLoading) {
return (
@@ -28,10 +31,16 @@ export function WorkspaceTree(actions: TreeActions) {
)
}
if (isError) {
- return void refetch()} />
+ return (
+ void refetch()}
+ />
+ )
}
if (!data || data.length === 0) {
- return No workspaces yet.
+ return {t('search.tree.noWorkspaces')}
}
return (
@@ -44,6 +53,7 @@ export function WorkspaceTree(actions: TreeActions) {
/** One workspace and its collections, collapsible, with a select-all control. */
function WorkspaceNode({ ws, selected, onToggle, onToggleMany }: { ws: Workspace } & TreeActions) {
+ const { t } = useTranslation()
const { data, isLoading } = useCollections(ws.id)
const [open, setOpen] = useState(true)
const cols = data ?? []
@@ -57,7 +67,11 @@ function WorkspaceNode({ ws, selected, onToggle, onToggleMany }: { ws: Workspace
type="button"
onClick={() => setOpen((o) => !o)}
className="text-ink-faint transition-colors hover:text-ink"
- aria-label={open ? `Collapse ${ws.name}` : `Expand ${ws.name}`}
+ aria-label={
+ open
+ ? t('search.tree.collapse', { name: ws.name })
+ : t('search.tree.expand', { name: ws.name })
+ }
>
{open ? : }
@@ -71,7 +85,7 @@ function WorkspaceNode({ ws, selected, onToggle, onToggleMany }: { ws: Workspace
onClick={() => onToggleMany(ids, !allSelected)}
className="text-xs text-accent hover:underline"
>
- {allSelected ? 'Clear' : 'All'}
+ {allSelected ? t('common.clear') : t('search.tree.all')}
)}
@@ -80,7 +94,7 @@ function WorkspaceNode({ ws, selected, onToggle, onToggleMany }: { ws: Workspace
{isLoading ? (
) : cols.length === 0 ? (
- No collections
+ {t('search.tree.noCollections')}
) : (
cols.map((col) => (
if (isError || !data) {
return (
void refetch()}
/>
@@ -48,6 +51,7 @@ export function ConfigPanel() {
* no longer read by any upload path, so the panel no longer offers it.
*/
function StorageForm({ config }: { config: AppConfig }) {
+ const { t } = useTranslation()
const storage = config.storage
const activeName = storage.default
const activeType = storage.backends?.[activeName]?.type
@@ -57,15 +61,15 @@ function StorageForm({ config }: { config: AppConfig }) {
- 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.
+ , strong: }}
+ />
-
-
+
+
toast.success('PDF backend saved. Services are reloading.'),
- onError: (e) => toast.error(e.message),
+ onSuccess: () => toast.success(t('settings.toast.pdfSaved')),
+ onError: (e) => toast.error(apiErrorMessage(e, t)),
},
)
}
return (
-
-
+
+
setBackend(e.target.value)}>
- PyMuPDF — fast, one chunk per page
+ {t('settings.parser.optionPymupdf')}
- docling — section-aware + tables/OCR{compatible ? '' : ' (needs a GPU)'}
+ {t('settings.parser.optionDocling')}
+ {compatible ? '' : t('settings.parser.needsGpuSuffix')}
@@ -131,17 +137,19 @@ function ParserForm({ config }: { config: AppConfig }) {
{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')}
+ }} />
)}
- {update.isPending ? 'Saving…' : 'Save PDF backend'}
+ {update.isPending ? t('common.saving') : t('settings.parser.save')}
@@ -156,8 +164,13 @@ function AcceleratorNote({
accel: ReturnType
recommended: string
}) {
+ const { t } = useTranslation()
if (accel.isLoading) {
- return Detecting GPU…
+ return (
+
+ {t('settings.parser.detectingGpu')}
+
+ )
}
const compatible = accel.data?.compatible ?? false
return (
@@ -168,17 +181,25 @@ function AcceleratorNote({
)}
{compatible
- ? `Compatible GPU detected (${accel.data?.name}, capability ${accel.data?.capability}).`
+ ? t('settings.parser.compatibleGpu', {
+ name: accel.data?.name,
+ capability: accel.data?.capability,
+ })
: accel.data?.device === 'cuda'
- ? `GPU ${accel.data?.name} (capability ${accel.data?.capability}) is below Ampere.`
- : 'No compatible GPU detected.'}{' '}
- Recommended: {recommended} .
+ ? t('settings.parser.gpuBelowAmpere', {
+ name: accel.data?.name,
+ capability: accel.data?.capability,
+ })
+ : t('settings.parser.noCompatibleGpu')}{' '}
+ {t('settings.parser.recommendedLabel')}{' '}
+ {recommended} .
)
}
/** Editable form for the embedding section; every other config section round-trips. */
function EmbeddingForm({ config }: { config: AppConfig }) {
+ const { t } = useTranslation()
const toast = useToast()
const update = useUpdateConfig()
const emb = config.embedding
@@ -225,8 +246,8 @@ function EmbeddingForm({ config }: { config: AppConfig }) {
update.mutate(
{ ...config, embedding },
{
- onSuccess: () => toast.success('Embedding config saved. Services are reloading.'),
- onError: (e) => toast.error(e.message),
+ onSuccess: () => toast.success(t('settings.toast.embeddingSaved')),
+ onError: (e) => toast.error(apiErrorMessage(e, t)),
},
)
}
@@ -236,28 +257,28 @@ function EmbeddingForm({ config }: { config: AppConfig }) {
- 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.
+ }} />
-
-
+
+
changeProvider(e.target.value)}>
- Ollama (local)
- Sentence-Transformers (in-process)
- OpenAI-compatible
- Google Gemini
+ {t('settings.provider.ollamaLocal')}
+
+ {t('settings.provider.sentenceTransformers')}
+
+ {t('settings.provider.openaiCompat')}
+ {t('settings.provider.gemini')}
{provider === 'ollama' ? (
) : (
)}
{needsKey && (
-
+
setApiKey(e.target.value)}
placeholder={
- keyIsSet ? 'key set — leave blank to keep' : isGemini ? 'AI Studio key' : 'API key'
+ keyIsSet
+ ? t('settings.field.keySetPlaceholder')
+ : isGemini
+ ? t('settings.field.aiStudioKey')
+ : t('settings.field.apiKey')
}
/>
)}
{isGemini && (
setMaxRpm(e.target.value)}
- placeholder="0 (unlimited)"
+ placeholder={t('settings.embedding.maxRpmPlaceholder')}
/>
)}
@@ -340,11 +369,11 @@ function EmbeddingForm({ config }: { config: AppConfig }) {
{changesDimensions && (
- Re-index required after saving
+ {t('settings.embedding.reindexRequired')}
)}
- {update.isPending ? 'Saving…' : 'Save embedding config'}
+ {update.isPending ? t('common.saving') : t('settings.embedding.save')}
@@ -358,6 +387,7 @@ function EmbeddingForm({ config }: { config: AppConfig }) {
* Optional + graceful: a load or remote failure degrades to RRF-only ranking, never a 500.
*/
function RerankerForm({ config }: { config: AppConfig }) {
+ const { t } = useTranslation()
const toast = useToast()
const update = useUpdateConfig()
const rr = config.reranker
@@ -404,8 +434,8 @@ function RerankerForm({ config }: { config: AppConfig }) {
update.mutate(
{ ...config, reranker },
{
- onSuccess: () => toast.success('Reranker config saved. Services are reloading.'),
- onError: (e) => toast.error(e.message),
+ onSuccess: () => toast.success(t('settings.toast.rerankerSaved')),
+ onError: (e) => toast.error(apiErrorMessage(e, t)),
},
)
}
@@ -415,13 +445,11 @@ function RerankerForm({ config }: { config: AppConfig }) {
- 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.
+ }} />
-
+
setEnabled(e.target.checked)}
className="h-5 w-5 accent-accent"
/>
- Enable reranking
+ {t('settings.reranker.enable')}
-
+
changeProvider(e.target.value)}>
- Cross-encoder (local, no key)
- Hosted rerank API (Cohere / Jina / Voyage)
- LLM-as-reranker
+ {t('settings.reranker.providerCrossEncoder')}
+ {t('settings.reranker.providerRerankApi')}
+ {t('settings.reranker.providerLlm')}
{isLlm && (
-
+
setLlmProvider(e.target.value)}
>
- Google Gemini (AI Studio)
- OpenAI-compatible
- Ollama (local)
+ {t('settings.provider.geminiAiStudio')}
+ {t('settings.provider.openaiCompat')}
+ {t('settings.provider.ollamaLocal')}
)}
@@ -455,16 +483,16 @@ function RerankerForm({ config }: { config: AppConfig }) {
) : (
@@ -485,14 +513,14 @@ function RerankerForm({ config }: { config: AppConfig }) {
)}
{needsBaseUrl && (
)}
{needsKey && (
-
+
setApiKey(e.target.value)}
placeholder={
- keyIsSet ? 'key set — leave blank to keep' : isLlmGemini ? 'AI Studio key' : 'API key'
+ keyIsSet
+ ? t('settings.field.keySetPlaceholder')
+ : isLlmGemini
+ ? t('settings.field.aiStudioKey')
+ : t('settings.field.apiKey')
}
/>
)}
- {update.isPending ? 'Saving…' : 'Save reranker config'}
+ {update.isPending ? t('common.saving') : t('settings.reranker.save')}
@@ -552,6 +588,7 @@ function RerankerForm({ config }: { config: AppConfig }) {
* top_k onto the next page(s) comes back whole. 0 disables it; the other search knobs round-trip.
*/
function SearchForm({ config }: { config: AppConfig }) {
+ const { t } = useTranslation()
const toast = useToast()
const update = useUpdateConfig()
const search = config.search
@@ -567,8 +604,8 @@ function SearchForm({ config }: { config: AppConfig }) {
update.mutate(
{ ...config, search: next },
{
- onSuccess: () => toast.success('Search config saved. Services are reloading.'),
- onError: (e) => toast.error(e.message),
+ onSuccess: () => toast.success(t('settings.toast.searchSaved')),
+ onError: (e) => toast.error(apiErrorMessage(e, t)),
},
)
}
@@ -578,18 +615,15 @@ function SearchForm({ config }: { config: AppConfig }) {
- 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.
+ }} />
-
+
- {update.isPending ? 'Saving…' : 'Save search config'}
+ {update.isPending ? t('common.saving') : t('settings.search.save')}
@@ -618,6 +652,7 @@ function SearchForm({ config }: { config: AppConfig }) {
* so a save binds from the next MCP call. `enabled` and `max_results` round-trip untouched.
*/
function MCPForm({ config }: { config: AppConfig }) {
+ const { t } = useTranslation()
const toast = useToast()
const update = useUpdateConfig()
const mcp = config.mcp
@@ -638,8 +673,8 @@ function MCPForm({ config }: { config: AppConfig }) {
update.mutate(
{ ...config, mcp: next },
{
- onSuccess: () => toast.success('MCP config saved. Services are reloading.'),
- onError: (e) => toast.error(e.message),
+ onSuccess: () => toast.success(t('settings.toast.mcpSaved')),
+ onError: (e) => toast.error(apiErrorMessage(e, t)),
},
)
}
@@ -649,17 +684,15 @@ function MCPForm({ config }: { config: AppConfig }) {
- 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
+ , strong: }} />
-
+
- {update.isPending ? 'Saving…' : 'Save MCP config'}
+ {update.isPending ? t('common.saving') : t('settings.mcp.save')}
@@ -696,6 +729,7 @@ function OllamaModelField({
setModel: (m: string) => void
baseUrl: string
}) {
+ const { t } = useTranslation()
const { data: models, isLoading, isError, error, refetch } = useOllamaModels(baseUrl, true)
useEffect(() => {
@@ -703,12 +737,14 @@ function OllamaModelField({
}, [models, model, setModel])
return (
-
+
{isError ? (
-
{error?.message ?? 'Could not reach Ollama'}
+
+ {error?.message ?? t('settings.field.ollamaUnreachable')}
+
void refetch()}>
- Retry
+ {t('common.retry')}
) : (
@@ -718,8 +754,8 @@ function OllamaModelField({
onChange={(e) => setModel(e.target.value)}
disabled={isLoading || !models?.length}
>
- {isLoading && Loading models… }
- {!isLoading && !models?.length && No models installed }
+ {isLoading && {t('settings.field.loadingModels')} }
+ {!isLoading && !models?.length && {t('settings.field.noModelsInstalled')} }
{models?.map((m) => (
{m}
diff --git a/ui/src/components/settings/McpPanel.tsx b/ui/src/components/settings/McpPanel.tsx
index dd074c9..da903e3 100644
--- a/ui/src/components/settings/McpPanel.tsx
+++ b/ui/src/components/settings/McpPanel.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { Download, ExternalLink } from 'lucide-react'
+import { Trans, useTranslation } from 'react-i18next'
import { api } from '../../api/client'
import { useMcpTools } from '../../api/hooks'
import type { McpToolGroup } from '../../api/types'
@@ -191,6 +192,7 @@ ${TAGS_NOTE}
/** A labelled monospace value row with a copy button. */
function ValueRow({ label, value }: { label: string; value: string }) {
+ const { t } = useTranslation()
return (
{label}
@@ -198,7 +200,7 @@ function ValueRow({ label, value }: { label: string; value: string }) {
{value}
-
+
)
@@ -212,6 +214,7 @@ function ValueRow({ label, value }: { label: string; value: string }) {
* they track the registered tools automatically.
*/
export function McpPanel() {
+ const { t } = useTranslation()
// Address an MCP client uses to reach this server. Defaults to how the console
// was reached; when that's localhost it only works on this machine, so we swap
// in the LAN IP the server reports (same protocol/port) — reachable by other
@@ -247,16 +250,12 @@ export function McpPanel() {
return (
- Connection
+ {t('mcp.connection.title')}
-
+
- Transport
+ {t('mcp.connection.transport')}
HTTP (streamable)
- Auth header
+ {t('mcp.connection.authHeader')}
Authorization: Bearer …
- Send an API key on every request as{' '}
- Authorization: Bearer <key> or{' '}
- X-API-Key: <key> — the master key, or a{' '}
- per-user key for scoped access (each tool respects that user's
- read/write grants). Substitute your own in the snippets below; nothing is embedded here.
+ ,
+ authcode: Authorization: Bearer <key>,
+ apikeycode: X-API-Key: <key>,
+ }}
+ />
- Client setup
+ {t('mcp.clientSetup.title')}
-
- Add this to your MCP client's server config (replace the placeholder key):
-
+ {t('mcp.clientSetup.instructions')}
-
+
-
Agent skill
+
{t('mcp.skill.title')}
{skill && (
- Download .zip
+ {t('mcp.skill.download')}
)}
- A small skill folder (SKILL.md +{' '}
- references/). Unzip it into your agent's skills directory
- (e.g. .claude/skills/embedbase/) — the agent loads{' '}
- SKILL.md up front and opens a reference file only when the
- task needs it, so a read-only session never pulls in the upload rules.
+ }}
+ />
{toolsQuery.isLoading ? (
@@ -326,7 +325,7 @@ export function McpPanel() {
- Bundled:{' '}
+ {t('mcp.skill.bundled')}{' '}
{bundle
.filter((f) => f.name !== 'SKILL.md')
.map((f) => f.name)
@@ -334,24 +333,22 @@ export function McpPanel() {
>
) : (
-
- The skill is unavailable — the tool catalogue could not be loaded.
-
+ {t('mcp.skill.unavailable')}
)}
-
Tools
+ {t('mcp.tools.title')}
{groups.length > 0 && (
- {groups.reduce((n, g) => n + g.tools.length, 0)} tools · each respects your grants
+ {t('mcp.tools.count', { count: groups.reduce((n, g) => n + g.tools.length, 0) })}
)}
{toolsQuery.isError ? (
void toolsQuery.refetch()}
/>
@@ -366,31 +363,28 @@ export function McpPanel() {
{g.group}
- {g.tools.map((t) => (
-
+ {g.tools.map((tool) => (
+
- {t.name}
- {t.signature}
+ {tool.name}
+ {tool.signature}
-
{t.summary}
+
{tool.summary}
))}
))}
- {TAGS_NOTE}
+ {t('mcp.tagsNote')}
>
)}
- REST API reference
+ {t('mcp.rest.title')}
-
- A standalone OpenAPI reference of just the integration endpoints — search and
- workspace/collection/document access — for the AI to read as the API standard.
-
+ {t('mcp.rest.blurb')}
diff --git a/ui/src/components/tags/TagChip.tsx b/ui/src/components/tags/TagChip.tsx
index dd4eadb..19f5052 100644
--- a/ui/src/components/tags/TagChip.tsx
+++ b/ui/src/components/tags/TagChip.tsx
@@ -1,4 +1,5 @@
import { X } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import { cn } from '../../lib/cn'
const DEFAULT_COLOR = '#5B6B7A'
@@ -18,6 +19,7 @@ export function TagChip({
onRemove?: () => void
className?: string
}) {
+ const { t } = useTranslation()
const c = color || DEFAULT_COLOR
return (
diff --git a/ui/src/components/tags/TagFilterBar.tsx b/ui/src/components/tags/TagFilterBar.tsx
index 852c36b..a6693a8 100644
--- a/ui/src/components/tags/TagFilterBar.tsx
+++ b/ui/src/components/tags/TagFilterBar.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from 'react-i18next'
import type { TagRef } from '../../api/types'
import { cn } from '../../lib/cn'
@@ -27,19 +28,20 @@ export function TagFilterBar({
selected: string[]
onToggle: (name: string) => void
}) {
+ const { t } = useTranslation()
if (tags.length === 0) return null
return (
-
Filter by tag:
- {tags.map((t) => {
- const on = selected.includes(t.name)
- const c = t.color || '#5B6B7A'
+
{t('tags.filterByTag')}
+ {tags.map((tag) => {
+ const on = selected.includes(tag.name)
+ const c = tag.color || '#5B6B7A'
return (
onToggle(t.name)}
+ onClick={() => onToggle(tag.name)}
aria-pressed={on}
className={cn(
'rounded-full px-2 py-0.5 text-xs font-medium transition-colors',
@@ -47,7 +49,7 @@ export function TagFilterBar({
)}
style={on ? { backgroundColor: c } : undefined}
>
- {t.name}
+ {tag.name}
)
})}
diff --git a/ui/src/components/tags/TagPicker.tsx b/ui/src/components/tags/TagPicker.tsx
index 9084b45..32e9c4a 100644
--- a/ui/src/components/tags/TagPicker.tsx
+++ b/ui/src/components/tags/TagPicker.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { Check, Plus } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import { useTags } from '../../api/hooks'
import type { TagRef } from '../../api/types'
import { Input } from '../ui'
@@ -33,14 +34,15 @@ export function TagPicker({
onCreate: (name: string) => void
busy?: boolean
}) {
+ const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const { data: tags } = useTags(wsId)
- const assignedIds = new Set(assigned.map((t) => t.id))
+ const assignedIds = new Set(assigned.map((tag) => tag.id))
const q = normalize(query)
- const candidates = (tags ?? []).filter((t) => !q || t.name.includes(q))
- const exists = (tags ?? []).some((t) => t.name === q)
+ const candidates = (tags ?? []).filter((tag) => !q || tag.name.includes(q))
+ const exists = (tags ?? []).some((tag) => tag.name === q)
const close = () => {
setOpen(false)
@@ -55,7 +57,7 @@ export function TagPicker({
className="inline-flex items-center gap-1 rounded-full border border-dashed border-border px-2 py-0.5 text-xs text-ink-muted transition-colors hover:border-accent hover:text-ink"
>
- Tag
+ {t('tags.picker.add')}
{open && (
@@ -66,26 +68,26 @@ export function TagPicker({
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
- placeholder="Filter or create…"
+ placeholder={t('tags.picker.placeholder')}
className="h-8 text-[13px]"
/>
- {candidates.map((t) => {
- const on = assignedIds.has(t.id)
+ {candidates.map((tag) => {
+ const on = assignedIds.has(tag.id)
return (
(on ? onUnassign(t.id) : onAssign(t.id))}
+ onClick={() => (on ? onUnassign(tag.id) : onAssign(tag.id))}
className="flex w-full items-center justify-between gap-2 rounded-control px-2 py-1.5 text-[13px] text-ink transition-colors hover:bg-canvas disabled:opacity-50"
>
- {t.name}
+ {tag.name}
{on && }
@@ -105,11 +107,11 @@ export function TagPicker({
)}
>
- Create “{q}”
+ {t('tags.picker.create', { name: q })}
)}
{candidates.length === 0 && !q && (
-
No tags yet.
+
{t('tags.picker.empty')}
)}
diff --git a/ui/src/components/ui/ConfirmDialog.tsx b/ui/src/components/ui/ConfirmDialog.tsx
index 9d97540..2a0f6a9 100644
--- a/ui/src/components/ui/ConfirmDialog.tsx
+++ b/ui/src/components/ui/ConfirmDialog.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from 'react-i18next'
import { Modal } from './Modal'
import { Button } from './Button'
@@ -6,7 +7,7 @@ export function ConfirmDialog({
open,
title,
message,
- confirmLabel = 'Delete',
+ confirmLabel,
loading,
onConfirm,
onClose,
@@ -19,6 +20,7 @@ export function ConfirmDialog({
onConfirm: () => void
onClose: () => void
}) {
+ const { t } = useTranslation()
return (
- Cancel
+ {t('common.cancel')}
- {confirmLabel}
+ {confirmLabel ?? t('common.confirm')}
>
}
diff --git a/ui/src/components/ui/CopyButton.tsx b/ui/src/components/ui/CopyButton.tsx
index 507d11a..353b902 100644
--- a/ui/src/components/ui/CopyButton.tsx
+++ b/ui/src/components/ui/CopyButton.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { Check, Copy } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import { cn } from '../../lib/cn'
import { Button, type ButtonProps } from './Button'
import { useToast } from './Toast'
@@ -23,6 +24,7 @@ export function CopyButton({
iconClassName?: string
}) {
const [copied, setCopied] = useState(false)
+ const { t } = useTranslation()
const toast = useToast()
const copy = async () => {
try {
@@ -30,7 +32,7 @@ export function CopyButton({
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
} catch {
- toast.error('Copy failed — select it and copy manually.')
+ toast.error(t('ui.copyButton.failed'))
}
}
return (
@@ -38,7 +40,7 @@ export function CopyButton({
type="button"
variant={variant}
size={size}
- aria-label={label ?? 'Copy'}
+ aria-label={label ?? t('common.copy')}
{...props}
onClick={copy}
>
@@ -47,7 +49,7 @@ export function CopyButton({
) : (
)}
- {label != null && {copied ? 'Copied' : label} }
+ {label != null && {copied ? t('common.copied') : label} }
)
}
diff --git a/ui/src/components/ui/Modal.tsx b/ui/src/components/ui/Modal.tsx
index 76e27bb..e2cd143 100644
--- a/ui/src/components/ui/Modal.tsx
+++ b/ui/src/components/ui/Modal.tsx
@@ -1,6 +1,7 @@
import { useEffect, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import { cn } from '../../lib/cn'
export interface ModalProps {
@@ -14,6 +15,7 @@ export interface ModalProps {
/** Centered overlay dialog. Closes on Esc and backdrop click; locks body scroll. */
export function Modal({ open, onClose, title, children, footer, className }: ModalProps) {
+ const { t } = useTranslation()
useEffect(() => {
if (!open) return
const { body } = document
@@ -49,7 +51,7 @@ export function Modal({ open, onClose, title, children, footer, className }: Mod
diff --git a/ui/src/components/ui/QueryError.tsx b/ui/src/components/ui/QueryError.tsx
index d957a69..45303d4 100644
--- a/ui/src/components/ui/QueryError.tsx
+++ b/ui/src/components/ui/QueryError.tsx
@@ -1,9 +1,10 @@
import { AlertTriangle } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import { Button } from './Button'
/** Inline error surface for a failed query, with an optional retry action. */
export function QueryError({
- title = 'Could not load this',
+ title,
message,
onRetry,
}: {
@@ -11,14 +12,15 @@ export function QueryError({
message?: string
onRetry?: () => void
}) {
+ const { t } = useTranslation()
return (
-
{title}
+
{title ?? t('ui.queryError.title')}
{message &&
{message}
}
{onRetry && (
- Try again
+ {t('common.retry')}
)}
diff --git a/ui/src/components/ui/StatusBadge.tsx b/ui/src/components/ui/StatusBadge.tsx
index 846efcd..44fc4c4 100644
--- a/ui/src/components/ui/StatusBadge.tsx
+++ b/ui/src/components/ui/StatusBadge.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from 'react-i18next'
import { cn } from '../../lib/cn'
export type DocStatus = 'uploading' | 'pending' | 'processing' | 'done' | 'failed' | 'deleting'
@@ -5,23 +6,27 @@ export type DocStatus = 'uploading' | 'pending' | 'processing' | 'done' | 'faile
/** Document states plus `rate_limited` — a job-only pause the ingestion queue surfaces. */
export type BadgeStatus = DocStatus | 'rate_limited'
-const MAP: Record = {
- uploading: { label: 'Uploading', dot: 'bg-accent animate-pulse', text: 'text-accent' },
- pending: { label: 'Pending', dot: 'bg-pending', text: 'text-ink-muted' },
- processing: { label: 'Processing', dot: 'bg-warn animate-pulse', text: 'text-warn' },
- done: { label: 'Done', dot: 'bg-ok', text: 'text-ok' },
- failed: { label: 'Failed', dot: 'bg-err', text: 'text-err' },
- deleting: { label: 'Deleting', dot: 'bg-warn animate-pulse', text: 'text-warn' },
- rate_limited: { label: 'Rate limited', dot: 'bg-warn animate-pulse', text: 'text-warn' },
+// Presentation only — the human label is resolved from `status.` at render
+// so it follows the active language (a module-const label never would).
+const STYLE: Record = {
+ uploading: { dot: 'bg-accent animate-pulse', text: 'text-accent' },
+ pending: { dot: 'bg-pending', text: 'text-ink-muted' },
+ processing: { dot: 'bg-warn animate-pulse', text: 'text-warn' },
+ done: { dot: 'bg-ok', text: 'text-ok' },
+ failed: { dot: 'bg-err', text: 'text-err' },
+ deleting: { dot: 'bg-warn animate-pulse', text: 'text-warn' },
+ rate_limited: { dot: 'bg-warn animate-pulse', text: 'text-warn' },
}
/** Ingestion status pill with a semantic color dot. */
export function StatusBadge({ status }: { status: BadgeStatus }) {
- const s = MAP[status] ?? MAP.pending
+ const { t } = useTranslation()
+ const safe: BadgeStatus = status in STYLE ? status : 'pending'
+ const s = STYLE[safe]
return (
- {s.label}
+ {t(`status.${safe}`)}
)
}
diff --git a/ui/src/components/ui/Toast.tsx b/ui/src/components/ui/Toast.tsx
index 74f7031..2f31f9b 100644
--- a/ui/src/components/ui/Toast.tsx
+++ b/ui/src/components/ui/Toast.tsx
@@ -8,6 +8,7 @@ import {
} from 'react'
import { createPortal } from 'react-dom'
import { AlertCircle, CheckCircle2, X } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import { cn } from '../../lib/cn'
type ToastKind = 'success' | 'error'
@@ -33,10 +34,11 @@ export function useToast(): ToastApi {
}
export function ToastProvider({ children }: { children: ReactNode }) {
+ const { t } = useTranslation()
const [toasts, setToasts] = useState([])
const remove = useCallback((id: number) => {
- setToasts((list) => list.filter((t) => t.id !== id))
+ setToasts((list) => list.filter((item) => item.id !== id))
}, [])
const push = useCallback(
@@ -61,24 +63,24 @@ export function ToastProvider({ children }: { children: ReactNode }) {
{children}
{createPortal(
- {toasts.map((t) => (
+ {toasts.map((item) => (
- {t.kind === 'success' ? (
+ {item.kind === 'success' ? (
) : (
)}
-
{t.message}
+
{item.message}
remove(t.id)}
+ onClick={() => remove(item.id)}
className="text-ink-faint transition-colors hover:text-ink"
- aria-label="Dismiss"
+ aria-label={t('common.dismiss')}
>
diff --git a/ui/src/components/users/PermissionsModal.tsx b/ui/src/components/users/PermissionsModal.tsx
index 1e51ba8..4c327a1 100644
--- a/ui/src/components/users/PermissionsModal.tsx
+++ b/ui/src/components/users/PermissionsModal.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { Plus, ShieldCheck, Trash2 } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import {
useCollections,
useGrantPermission,
@@ -21,21 +22,13 @@ import {
Skeleton,
useToast,
} from '../ui'
+import { apiErrorMessage } from '../../i18n/apiError'
/** Grantable capabilities — privileges not tied to a resource (backend `_CAPABILITIES`).
- * The `id` must match the backend capability string. */
-const CAPABILITIES = [
- {
- id: 'create_workspace',
- label: 'Create workspaces',
- help: 'Lets this user create new workspaces — they get write access to any they create.',
- },
- {
- id: 'manage_tags',
- label: 'Manage tags',
- help: 'Lets this user create, edit, delete, and assign tags in any workspace they can read.',
- },
-] as const
+ * Each id must match the backend capability string; its human label + help are resolved
+ * from `users.grants.capabilityLabel.
` / `capabilityHelp.` so they follow the language. */
+const CAPABILITY_IDS = ['create_workspace', 'manage_tags'] as const
+type CapabilityId = (typeof CAPABILITY_IDS)[number]
/**
* Permission editor for one user. Permissions SCOPE A USER DOWN: with none the user
@@ -54,17 +47,23 @@ export function PermissionsModal({
user: User | null
onClose: () => void
}) {
+ const { t } = useTranslation()
const userId = user?.id ?? ''
const { data, isLoading, isError, error, refetch } = usePermissions(userId, open && Boolean(userId))
return (
-
+
{user && (
- Permissions scope this user down . With none,
- they see and write everything. A workspace limits them to it; add a collection (or document) to
- narrow them to just that one.
+ {t('users.grants.intro.lead')}{' '}
+ {t('users.grants.intro.emphasis')}
+ {t('users.grants.intro.rest')}
{isLoading ? (
@@ -74,12 +73,16 @@ export function PermissionsModal({
))}
) : isError ? (
- void refetch()} />
+ void refetch()}
+ />
) : !data || data.length === 0 ? (
}
- title="No permissions yet"
- description="Grant this user read or write on a workspace, collection, or document above."
+ title={t('users.grants.empty.title')}
+ description={t('users.grants.empty.description')}
/>
) : (
@@ -104,11 +107,12 @@ export function PermissionsModal({
/** The add-a-grant form: resource type → resource picker → level → add. */
function GrantForm({ userId }: { userId: string }) {
+ const { t } = useTranslation()
const [resourceType, setResourceType] = useState
('collection')
const [wsId, setWsId] = useState('')
const [colId, setColId] = useState('')
const [docId, setDocId] = useState('')
- const [capabilityId, setCapabilityId] = useState(CAPABILITIES[0].id)
+ const [capabilityId, setCapabilityId] = useState(CAPABILITY_IDS[0])
const [level, setLevel] = useState('read')
const toast = useToast()
@@ -133,10 +137,10 @@ function GrantForm({ userId }: { userId: string }) {
{ resource_type: resourceType, resource_id: resourceId, level: grantLevel },
{
onSuccess: () => {
- toast.success('Permission granted.')
+ toast.success(t('users.grants.toast.granted'))
setDocId('')
},
- onError: (e) => toast.error(e.message),
+ onError: (e) => toast.error(apiErrorMessage(e, t)),
},
)
}
@@ -144,7 +148,7 @@ function GrantForm({ userId }: { userId: string }) {
return (
-
+
{
@@ -153,41 +157,44 @@ function GrantForm({ userId }: { userId: string }) {
setDocId('')
}}
>
- Workspace
- Collection
- Document
- Capability
+ {t('users.grants.type.workspace')}
+ {t('users.grants.type.collection')}
+ {t('users.grants.type.document')}
+ {t('users.grants.type.capability')}
{resourceType !== 'capability' && (
-
+
setLevel(e.target.value as PermissionLevel)}>
- Read — search, list, download
- Write — ingest, delete (and read)
+ {t('users.grants.readOption')}
+ {t('users.grants.writeOption')}
)}
{resourceType === 'capability' && (
-
- setCapabilityId(e.target.value)}>
- {CAPABILITIES.map((cap) => (
-
- {cap.label}
+
+ setCapabilityId(e.target.value as CapabilityId)}
+ >
+ {CAPABILITY_IDS.map((id) => (
+
+ {t(`users.grants.capabilityLabel.${id}`)}
))}
- {CAPABILITIES.find((cap) => cap.id === capabilityId)?.help}
+ {t(`users.grants.capabilityHelp.${capabilityId}`)}
)}
{resourceType === 'workspace' && (
-
+
setWsId(e.target.value)}>
- Select a workspace…
+ {t('users.grants.selectWorkspace')}
{(workspaces.data ?? []).map((ws) => (
{ws.name}
@@ -199,7 +206,7 @@ function GrantForm({ userId }: { userId: string }) {
{resourceType === 'collection' && (
@@ -260,13 +269,14 @@ function GrantRow({
resourceName: string | null
level: PermissionLevel
}) {
+ const { t } = useTranslation()
const revokeMut = useRevokePermission(userId)
const [confirming, setConfirming] = useState(false)
const toast = useToast()
const revoke = () =>
revokeMut.mutate(grantId, {
- onSuccess: () => toast.success('Permission revoked.'),
- onError: (e) => toast.error(e.message),
+ onSuccess: () => toast.success(t('users.grants.toast.revoked')),
+ onError: (e) => toast.error(apiErrorMessage(e, t)),
})
// Show the resource's name; the raw id becomes a click-to-copy chip. A deleted
@@ -276,8 +286,10 @@ function GrantRow({
return (
- {level === 'write' ? 'Write' : 'Read'}
- {resourceType}
+ {level === 'write' ? t('users.grants.write') : t('users.grants.read')}
+
+ {t(`users.grants.type.${resourceType}`)}
+
- {resourceName ?? 'deleted'}
+ {resourceName ?? t('users.grants.deleted')}
{confirming ? (
- Revoke
+ {t('users.revoke')}
setConfirming(false)} disabled={revokeMut.isPending}>
- Cancel
+ {t('common.cancel')}
) : (
setConfirming(true)}
className="h-10 w-10 shrink-0 px-0 hover:text-err"
>
diff --git a/ui/src/components/users/RevealOncePanel.tsx b/ui/src/components/users/RevealOncePanel.tsx
index 3e6975d..6dc7ddb 100644
--- a/ui/src/components/users/RevealOncePanel.tsx
+++ b/ui/src/components/users/RevealOncePanel.tsx
@@ -1,4 +1,5 @@
import { ShieldAlert } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import { Button, CopyButton } from '../ui'
/**
@@ -15,6 +16,7 @@ export function RevealOncePanel({
message: string
onDone: () => void
}) {
+ const { t } = useTranslation()
return (
@@ -25,10 +27,10 @@ export function RevealOncePanel({
{secret}
-
+
- Done
+ {t('users.reveal.done')}
)
diff --git a/ui/src/components/users/UserFormModal.tsx b/ui/src/components/users/UserFormModal.tsx
index 61ea3bd..b8bf542 100644
--- a/ui/src/components/users/UserFormModal.tsx
+++ b/ui/src/components/users/UserFormModal.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
import type { User } from '../../api/types'
import { Button, Field, Input, Modal } from '../ui'
@@ -55,6 +56,7 @@ export function UserFormModal({
onSubmit: (values: UserFormValues) => void
onClose: () => void
}) {
+ const { t } = useTranslation()
const [values, setValues] = useState(DEFAULTS)
// Reseed every time the modal opens so stale edits never leak between rows.
@@ -81,38 +83,38 @@ export function UserFormModal({
- Cancel
+ {t('common.cancel')}
- {editing ? 'Save changes' : 'Create'}
+ {editing ? t('common.saveChanges') : t('common.create')}
>
}
>
set('username', e.target.value)}
- placeholder="e.g. jane"
+ placeholder={t('users.form.usernamePlaceholder')}
/>
{
if (e.key === 'Enter') submit()
}}
- placeholder="e.g. jane@example.com"
+ placeholder={t('users.form.emailPlaceholder')}
/>
-
+
set('name', e.target.value)}
- placeholder="Optional"
+ placeholder={t('common.optional')}
/>
set('is_active', e.target.checked)}
className="h-4 w-4 rounded border-border accent-accent"
/>
- Active — an inactive user can't sign in and their API key stops working
+ {t('users.form.activeLabel')}
set('is_admin', e.target.checked)}
className="h-4 w-4 rounded border-border accent-accent"
/>
- Admin — full console access (manage users, settings, every workspace)
+ {t('users.form.adminLabel')}
diff --git a/ui/src/components/users/UserKeyModal.tsx b/ui/src/components/users/UserKeyModal.tsx
index dddeb81..99193be 100644
--- a/ui/src/components/users/UserKeyModal.tsx
+++ b/ui/src/components/users/UserKeyModal.tsx
@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react'
import { KeyRound, RefreshCw, Trash2 } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import { useMintUserKey, useRevokeUserKey } from '../../api/hooks'
import type { MintedUserKey, User } from '../../api/types'
import { Button, Modal, useToast } from '../ui'
+import { apiErrorMessage } from '../../i18n/apiError'
import { formatDate, timeAgo } from '../../lib/format'
import { RevealOncePanel } from './RevealOncePanel'
@@ -19,6 +21,7 @@ export function UserKeyModal({
user: User | null
onClose: () => void
}) {
+ const { t } = useTranslation()
const mintMut = useMintUserKey()
const revokeMut = useRevokeUserKey()
const [minted, setMinted] = useState(null)
@@ -43,28 +46,33 @@ export function UserKeyModal({
const mint = () =>
mintMut.mutate(
{ id: user.id, body: {} },
- { onSuccess: (key) => setMinted(key), onError: (e) => toast.error(e.message) },
+ { onSuccess: (key) => setMinted(key), onError: (e) => toast.error(apiErrorMessage(e, t)) },
)
const revoke = () =>
revokeMut.mutate(user.id, {
onSuccess: () => {
- toast.success('Key revoked.')
+ toast.success(t('users.key.toast.revoked'))
setConfirmingRevoke(false)
setRevoked(true)
},
- onError: (e) => toast.error(e.message),
+ onError: (e) => toast.error(apiErrorMessage(e, t)),
})
return (
-
+
{minted ? (
// Close on Done rather than returning to the management view: the `user`
// prop is a frozen snapshot, so its key metadata is stale post-rotation.
// Reopening reads the freshly-invalidated list.
) : (
@@ -74,14 +82,16 @@ export function UserKeyModal({
{existing.key_prefix}…
- Created {formatDate(existing.created_at)} ·{' '}
- {existing.last_used_at ? `used ${timeAgo(existing.last_used_at)}` : 'never used'}
+ {t('common.created', { date: formatDate(existing.created_at) })} ·{' '}
+ {existing.last_used_at
+ ? t('users.key.used', { ago: timeAgo(existing.last_used_at) })
+ : t('users.key.neverUsed')}
{confirmingRevoke ? (
- Revoke
+ {t('users.revoke')}
setConfirmingRevoke(false)}
disabled={revokeMut.isPending}
>
- Cancel
+ {t('common.cancel')}
) : (
setConfirmingRevoke(true)}
className="h-10 w-10 shrink-0 px-0 hover:text-err"
>
@@ -105,26 +115,22 @@ export function UserKeyModal({
)}
) : (
- This user has no API key yet.
+ {t('users.key.none')}
)}
{existing ? (
<>
- Rotate key
+ {t('users.key.rotate')}
>
) : (
<>
- Create key
+ {t('users.key.create')}
>
)}
- {existing && (
-
- Rotating replaces the current key — the old one stops working immediately.
-
- )}
+ {existing && {t('users.key.rotateHint')}
}
)}
diff --git a/ui/src/components/workspaces/WorkspaceFormModal.tsx b/ui/src/components/workspaces/WorkspaceFormModal.tsx
index eb35dd3..6d1c257 100644
--- a/ui/src/components/workspaces/WorkspaceFormModal.tsx
+++ b/ui/src/components/workspaces/WorkspaceFormModal.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
import type { Workspace } from '../../api/types'
import {
Button,
@@ -54,6 +55,7 @@ export function WorkspaceFormModal({
onSubmit: (values: WorkspaceFormValues) => void
onClose: () => void
}) {
+ const { t } = useTranslation()
const [values, setValues] = useState(DEFAULTS)
// Reseed every time the modal opens so stale edits never leak between rows.
@@ -75,20 +77,20 @@ export function WorkspaceFormModal({
- Cancel
+ {t('common.cancel')}
- {editing ? 'Save changes' : 'Create'}
+ {editing ? t('common.saveChanges') : t('common.create')}
>
}
>
-
+
{
if (e.key === 'Enter') submit()
}}
- placeholder="e.g. Research"
+ placeholder={t('workspaces.namePlaceholder')}
/>
-
+
-
+
set('color', c)} />
-
+
set('icon', i)} />
diff --git a/ui/src/i18n/apiError.ts b/ui/src/i18n/apiError.ts
new file mode 100644
index 0000000..8bf6fcf
--- /dev/null
+++ b/ui/src/i18n/apiError.ts
@@ -0,0 +1,34 @@
+import type { TFunction } from 'i18next'
+import { ApiError } from '../api/client'
+
+type ErrorKey =
+ | 'errors.network'
+ | 'errors.sessionExpired'
+ | 'errors.accountDeactivated'
+ | 'errors.adminRequired'
+ | 'errors.configNotLoaded'
+ | 'errors.passwordChangeRequired'
+
+// Curated map of known, *static* backend `detail` strings (and the sentinels the
+// API client throws) → localized keys. Interpolated / free-text details (anything
+// embedding `{exc}`) are deliberately left out — they fall through to the raw
+// message below rather than being string-matched.
+const DETAIL_KEYS: Record = {
+ 'Your account has been deactivated.': 'errors.accountDeactivated',
+ 'User is inactive': 'errors.accountDeactivated',
+ 'Administrator access required': 'errors.adminRequired',
+ 'Config not loaded yet': 'errors.configNotLoaded',
+ 'Password change required': 'errors.passwordChangeRequired',
+}
+
+/**
+ * Map an error thrown by the API client to a user-facing, localized message.
+ * Status rules come first (prose-independent and robust), then the curated detail
+ * map; otherwise the raw message passes through so nothing user-visible is lost.
+ */
+export function apiErrorMessage(err: unknown, t: TFunction): string {
+ if (!(err instanceof ApiError)) return t('errors.network')
+ if (err.status === 401) return t('errors.sessionExpired')
+ const key = DETAIL_KEYS[err.message]
+ return key ? t(key) : err.message
+}
diff --git a/ui/src/i18n/detect.ts b/ui/src/i18n/detect.ts
new file mode 100644
index 0000000..4e2a01d
--- /dev/null
+++ b/ui/src/i18n/detect.ts
@@ -0,0 +1,57 @@
+/**
+ * Language detection + persistence for the console UI, decoupled from React and
+ * i18next so boot code can resolve a language synchronously (mirrors the storage
+ * try/catch shape of `api/tokenStore.ts`).
+ *
+ * Precedence: an explicit saved choice → the browser's languages → `en`.
+ */
+
+export const SUPPORTED = ['en', 'pt-BR'] as const
+export type Lang = (typeof SUPPORTED)[number]
+export const DEFAULT_LANG: Lang = 'en'
+
+const STORAGE_KEY = 'embedbase.lang'
+
+/** Map a raw BCP-47 tag (e.g. `pt`, `pt-BR`, `pt-PT`, `en-US`) to a supported
+ * language, or `null` when it's neither Portuguese nor English. */
+export function normalize(raw: string | null | undefined): Lang | null {
+ if (!raw) return null
+ const tag = raw.toLowerCase()
+ if (tag.startsWith('pt')) return 'pt-BR'
+ if (tag.startsWith('en')) return 'en'
+ return null
+}
+
+/** The persisted explicit choice, or `null` (also `null` when storage is off). */
+export function readStoredLang(): Lang | null {
+ try {
+ return normalize(window.localStorage.getItem(STORAGE_KEY))
+ } catch {
+ // Private-mode / storage-disabled: fall back to detection.
+ return null
+ }
+}
+
+/** Persist the explicit choice; silently no-ops when storage is unavailable. */
+export function persistLang(lang: Lang): void {
+ try {
+ window.localStorage.setItem(STORAGE_KEY, lang)
+ } catch {
+ // Storage unavailable — the in-memory i18next language still applies.
+ }
+}
+
+/** Resolve the language to boot with: saved choice → browser scan → `en`. */
+export function detectInitialLang(): Lang {
+ const saved = readStoredLang()
+ if (saved) return saved
+ const candidates =
+ navigator.languages && navigator.languages.length > 0
+ ? navigator.languages
+ : [navigator.language]
+ for (const candidate of candidates) {
+ const match = normalize(candidate)
+ if (match) return match
+ }
+ return DEFAULT_LANG
+}
diff --git a/ui/src/i18n/i18next.d.ts b/ui/src/i18n/i18next.d.ts
new file mode 100644
index 0000000..a35a0b9
--- /dev/null
+++ b/ui/src/i18n/i18next.d.ts
@@ -0,0 +1,15 @@
+/**
+ * Type-safe translation keys: augment i18next's `CustomTypeOptions` with the `en`
+ * catalog as the source of truth, so every `t('…')` call is checked against the
+ * real key set at compile time. Catalog parity between locales is enforced
+ * separately in `parity.ts`.
+ */
+import 'i18next'
+import type en from './locales/en/translation.json'
+
+declare module 'i18next' {
+ interface CustomTypeOptions {
+ defaultNS: 'translation'
+ resources: { translation: typeof en }
+ }
+}
diff --git a/ui/src/i18n/index.ts b/ui/src/i18n/index.ts
new file mode 100644
index 0000000..c61c37b
--- /dev/null
+++ b/ui/src/i18n/index.ts
@@ -0,0 +1,45 @@
+/**
+ * i18next bootstrap — imported for its side effect from `main.tsx` before the app
+ * renders. Resources are bundled (no async backend), so `useTranslation()` works
+ * synchronously everywhere without a Suspense boundary or an ``.
+ *
+ * The active language mirrors to the document's `lang` attribute and the shared
+ * date formatter (`lib/format`). Persistence is deliberately NOT done here — it's
+ * tied to the explicit `setLanguage` action, so a browser-detected default is
+ * never frozen into storage on first load (which would stop it tracking the
+ * browser on later visits).
+ */
+import i18n from 'i18next'
+import { initReactI18next } from 'react-i18next'
+import { setDateLocale } from '../lib/format'
+import { DEFAULT_LANG, SUPPORTED, detectInitialLang, persistLang, type Lang } from './detect'
+import en from './locales/en/translation.json'
+import ptBR from './locales/pt-BR/translation.json'
+
+/** Keep the document + date formatter aligned with the active language. */
+function applyLang(lang: string): void {
+ document.documentElement.lang = lang
+ setDateLocale(lang)
+}
+
+const initial = detectInitialLang()
+
+void i18n.use(initReactI18next).init({
+ resources: { en: { translation: en }, 'pt-BR': { translation: ptBR } },
+ lng: initial,
+ fallbackLng: DEFAULT_LANG,
+ supportedLngs: [...SUPPORTED],
+ interpolation: { escapeValue: false }, // React already escapes rendered values
+ react: { useSuspense: false },
+})
+
+applyLang(initial)
+i18n.on('languageChanged', applyLang)
+
+/** Switch + remember the console language — the only path that persists a choice. */
+export function setLanguage(lang: Lang): void {
+ persistLang(lang)
+ void i18n.changeLanguage(lang)
+}
+
+export default i18n
diff --git a/ui/src/i18n/locales/en/translation.json b/ui/src/i18n/locales/en/translation.json
new file mode 100644
index 0000000..88f1ee8
--- /dev/null
+++ b/ui/src/i18n/locales/en/translation.json
@@ -0,0 +1,782 @@
+{
+ "common": {
+ "saving": "Saving…",
+ "cancel": "Cancel",
+ "create": "Create",
+ "saveChanges": "Save changes",
+ "name": "Name",
+ "description": "Description",
+ "optional": "Optional",
+ "color": "Color",
+ "icon": "Icon",
+ "tags": "Tags",
+ "noDescription": "No description",
+ "created": "Created {{date}}",
+ "editAria": "Edit {{name}}",
+ "deleteAria": "Delete {{name}}",
+ "more": "More",
+ "clear": "Clear",
+ "retry": "Retry",
+ "retrying": "Retrying…",
+ "min": "min",
+ "max": "max",
+ "openAria": "Open {{name}}",
+ "downloadAria": "Download {{name}}",
+ "close": "Close",
+ "removeAria": "Remove {{name}}",
+ "confirm": "Confirm",
+ "copy": "Copy",
+ "copied": "Copied",
+ "dismiss": "Dismiss"
+ },
+ "nav": {
+ "dashboard": "Dashboard",
+ "workspaces": "Workspaces",
+ "graph": "Graph",
+ "search": "Search",
+ "indexing": "Indexing",
+ "ingestionQueue": "Ingestion Queue",
+ "users": "Users",
+ "settings": "Settings",
+ "admin": "Admin"
+ },
+ "topbar": {
+ "masterKey": "Master key",
+ "adminSuffix": " · admin",
+ "changePassword": "Change password",
+ "changePasswordTitle": "Change your password",
+ "signOut": "Sign out",
+ "signOutTitle": "Sign out and clear your session",
+ "language": "Change language"
+ },
+ "status": {
+ "uploading": "Uploading",
+ "pending": "Pending",
+ "processing": "Processing",
+ "done": "Done",
+ "failed": "Failed",
+ "deleting": "Deleting",
+ "rate_limited": "Rate limited"
+ },
+ "workspaces": {
+ "title": "Workspaces",
+ "subtitle": "Top-level containers for your collections.",
+ "new": "New workspace",
+ "editTitle": "Edit workspace",
+ "namePlaceholder": "e.g. Research",
+ "descHint": "Optional — what lives in this workspace.",
+ "empty": {
+ "title": "No workspaces yet",
+ "canCreate": "Create your first workspace to start organising collections.",
+ "readonly": "No workspaces are shared with you yet."
+ },
+ "error": "Could not load workspaces",
+ "toast": {
+ "created": "Workspace “{{name}}” created.",
+ "updated": "Workspace updated.",
+ "deleted": "Workspace “{{name}}” deleted."
+ },
+ "delete": {
+ "title": "Delete workspace",
+ "message": "Delete “{{name}}”? Its collections, API keys, and documents are permanently removed. This cannot be undone."
+ }
+ },
+ "collections": {
+ "title": "Collections",
+ "subtitle": "Searchable document sets within this workspace.",
+ "new": "New collection",
+ "editTitle": "Edit collection",
+ "namePlaceholder": "e.g. Q3 Reports",
+ "descHint": "Optional — what this collection holds.",
+ "empty": {
+ "title": "No collections yet",
+ "canCreate": "Create a collection to start ingesting and searching documents.",
+ "readonly": "No collections here yet."
+ },
+ "error": "Could not load collections",
+ "toast": {
+ "created": "Collection “{{name}}” created.",
+ "updated": "Collection updated.",
+ "deleted": "Collection “{{name}}” deleted."
+ },
+ "delete": {
+ "title": "Delete collection",
+ "message": "Delete “{{name}}”? Its documents and indexed vectors are permanently removed. This cannot be undone."
+ }
+ },
+ "documents": {
+ "title": "Documents",
+ "subtitle": {
+ "canWrite": "Upload files to ingest them into this collection.",
+ "readonly": "Browse and search documents in this collection."
+ },
+ "error": "Could not load documents",
+ "empty": {
+ "none": {
+ "title": "No documents yet",
+ "description": "Drop files into the area above to start ingesting them."
+ },
+ "filtered": {
+ "title": "No matching documents",
+ "description": "No documents match the current filters. Adjust or clear them to see more."
+ }
+ },
+ "retention": {
+ "label": "Temporary — automatically delete after",
+ "aria": "Retention in days",
+ "day_one": "day",
+ "day_other": "days"
+ },
+ "delete": {
+ "title": "Delete document",
+ "message": "Delete “{{name}}”? Its chunks and indexed vectors are removed. This cannot be undone."
+ },
+ "toast": {
+ "queued_one": "{{count}} file queued for ingestion.",
+ "queued_other": "{{count}} files queued for ingestion.",
+ "uploadError": "{{name}}: {{error}}",
+ "tooLarge": "{{name}} is larger than {{max}} MB and was skipped.",
+ "deleted": "Deleted “{{name}}”.",
+ "reprocessing": "Reprocessing “{{name}}”.",
+ "indexing": "Indexing “{{name}}”."
+ },
+ "progress": {
+ "ingesting": "Ingesting"
+ },
+ "row": {
+ "chunk_one": "{{count}} chunk",
+ "chunk_other": "{{count}} chunks",
+ "updated": "updated {{ago}}",
+ "original": "original: {{name}}",
+ "why": "Why?",
+ "hide": "Hide",
+ "downloadOriginalAria": "Download original source file ({{name}})"
+ },
+ "index": {
+ "indexed": "Indexed",
+ "indexedTitle": "Indexed for BM25 / keyword search",
+ "notIndexedTitle": "Not in the keyword index — click to index",
+ "index": "Index",
+ "indexing": "Indexing…"
+ },
+ "failure": {
+ "loading": "Loading error…",
+ "loadError": "Could not load the error detail.",
+ "none": "No error detail recorded."
+ },
+ "filters": {
+ "searchPlaceholder": "Search filenames…",
+ "statusAria": "Status",
+ "anyStatus": "Any status",
+ "typeAria": "File type",
+ "anyType": "Any type",
+ "indexedAria": "Indexed",
+ "indexedAny": "Indexed: any",
+ "indexed": "Indexed",
+ "notIndexed": "Not indexed",
+ "storageBackend": "Storage backend",
+ "embeddingModel": "Embedding model",
+ "size": "Size (bytes)",
+ "created": "Created"
+ },
+ "upload": {
+ "uploading": "Uploading…",
+ "drag": "Drag files here, or ",
+ "browse": "browse",
+ "hint": "PDF, text, Markdown · DOCX/PPTX with docling · up to {{max}} MB each"
+ }
+ },
+ "auth": {
+ "field": {
+ "username": "Username",
+ "password": "Password"
+ },
+ "login": {
+ "subtitle": "Sign in to your account.",
+ "submit": "Sign in",
+ "submitting": "Signing in…",
+ "useMaster": "Use master key instead",
+ "invalid": "Invalid username or password."
+ },
+ "unlock": {
+ "subtitle": "Enter your master key to unlock the console.",
+ "masterKey": "Master key",
+ "submit": "Unlock",
+ "submitting": "Verifying…",
+ "back": "Back to sign in",
+ "rejected": "That key was rejected. Check it and try again."
+ },
+ "api": {
+ "checking": "Checking API…",
+ "unreachable": "API unreachable",
+ "ready": "API ready · {{model}}",
+ "modelFallback": "model"
+ },
+ "password": {
+ "current": "Current password",
+ "new": "New password",
+ "confirm": "Confirm new password",
+ "hint": "At least {{min}} characters.",
+ "tooShort": "Use at least {{min}} characters.",
+ "mismatch": "Passwords do not match.",
+ "update": "Update password",
+ "setContinue": "Set password & continue",
+ "changeTitle": "Change password",
+ "updated": "Password updated.",
+ "failed": "Could not change the password.",
+ "setTitle": "Set a new password",
+ "signedInAs": "Signed in as {{name}}. ",
+ "chooseNew": "Choose a new password to finish signing in."
+ }
+ },
+ "errors": {
+ "network": "Could not reach the API. Is the stack running?",
+ "sessionExpired": "Session expired. Please sign in again.",
+ "accountDeactivated": "Your account has been deactivated.",
+ "adminRequired": "Administrator access required.",
+ "configNotLoaded": "Config not loaded yet.",
+ "passwordChangeRequired": "Password change required."
+ },
+ "search": {
+ "title": "Search",
+ "subtitle": "Hybrid BM25 + semantic search across your collections.",
+ "queryPlaceholder": "Search your collections…",
+ "submit": "Search",
+ "selectPrompt": "Select at least one collection to search.",
+ "topK": "Top-K",
+ "modeLabel": "Mode",
+ "mode": {
+ "hybrid": "Hybrid",
+ "semantic": "Semantic",
+ "bm25": "BM25",
+ "semantic_only": "Semantic only"
+ },
+ "modeHint": {
+ "hybrid": "BM25 + semantic",
+ "semantic": "Vector similarity",
+ "bm25": "Keyword ranking",
+ "semantic_only": "BM25 index unavailable — fell back to semantic"
+ },
+ "alpha": "Alpha",
+ "alphaHint": "0 = BM25 only, 1 = semantic only",
+ "hideFilters": "Hide filters",
+ "filters": "Filters",
+ "filename": "Filename",
+ "filenamePlaceholder": "e.g. report.pdf",
+ "commaSeparated": "Comma-separated",
+ "tagsPlaceholder": "e.g. finance, q3",
+ "failed": "Search failed",
+ "empty": {
+ "title": "Search your collections",
+ "description": "Pick one or more collections on the left, type a query, and run it."
+ },
+ "noMatches": {
+ "title": "No matches",
+ "description": "No chunks matched this query in the selected collections. Try broadening the query or relaxing filters."
+ },
+ "timing": "embed {{embed}}ms · search {{search}}ms · total {{total}}ms",
+ "underDelivered": {
+ "withTopK": "Fewer results than requested (top_k {{topK}}). A selective filter or a small candidate pool left some slots unfilled.",
+ "noTopK": "Fewer results than requested. A selective filter or a small candidate pool left some slots unfilled."
+ },
+ "breakdown": {
+ "title": "Per-collection breakdown",
+ "stats": "{{retrieved}} → {{returned}} · {{contributed}} in top-k"
+ },
+ "relevanceScore": "Relevance score",
+ "unknownFile": "Unknown file",
+ "pages": "· pp.{{range}}",
+ "page": "· p.{{page}}",
+ "tree": {
+ "noWorkspaces": "No workspaces yet.",
+ "collapse": "Collapse {{name}}",
+ "expand": "Expand {{name}}",
+ "all": "All",
+ "noCollections": "No collections"
+ }
+ },
+ "tags": {
+ "subtitle": "Correlate and filter collections and documents across this workspace.",
+ "new": "New tag",
+ "error": "Could not load tags",
+ "empty": {
+ "title": "No tags yet",
+ "description": "Create a tag to correlate and filter collections and documents."
+ },
+ "count": {
+ "col": "{{count}} col",
+ "doc": "{{count}} doc"
+ },
+ "workspaceBadge": "workspace",
+ "mergeAria": "Merge {{name}}",
+ "itemsError": "Could not load items",
+ "notAssigned": "Not assigned to anything yet.",
+ "none": "None",
+ "editTitle": "Edit tag",
+ "nameHint": "Lowercased and trimmed on save.",
+ "namePlaceholder": "e.g. finance",
+ "mergeTitle": "Merge tag",
+ "merge": "Merge",
+ "mergeInto": "Merge into",
+ "mergeIntro": {
+ "before": "Move every assignment of ",
+ "after": " onto another tag, then delete it."
+ },
+ "noMergeTargets": "No other tags to merge into.",
+ "selectTag": "Select a tag…",
+ "filterByTag": "Filter by tag:",
+ "delete": {
+ "title": "Delete tag",
+ "message": "Delete “{{name}}”? It is removed from every collection and document it tags. This cannot be undone."
+ },
+ "toast": {
+ "created": "Tag “{{name}}” created.",
+ "updated": "Tag updated.",
+ "merged": "Tags merged.",
+ "deleted": "Tag “{{name}}” deleted."
+ },
+ "picker": {
+ "add": "Tag",
+ "placeholder": "Filter or create…",
+ "create": "Create “{{name}}”",
+ "empty": "No tags yet."
+ }
+ },
+ "graph": {
+ "title": "Graph",
+ "subtitle": "Files (the larger nodes) linked through the tags they share. Hotter tag nodes are more-reused. Drag a node to pin and highlight it, click it to release; tune the repel force or reset to re-center.",
+ "workspaceAria": "Workspace",
+ "collectionAria": "Collection",
+ "allCollections": "All collections",
+ "error": "Could not load the graph",
+ "empty": {
+ "title": "Nothing to graph yet",
+ "description": "Tag some documents in this scope and they'll appear here, linked through their shared tags."
+ },
+ "resetView": "Reset view",
+ "reset": "Reset",
+ "heat": {
+ "title": "Tag heat",
+ "files_one": "{{count}} file",
+ "files_other": "{{count}} files"
+ },
+ "open": "Open",
+ "download": "Download",
+ "files": "Files",
+ "node": {
+ "usedBy_one": "Used by {{count}} file.",
+ "usedBy_other": "Used by {{count}} files.",
+ "tags_one": "{{count}} tag.",
+ "tags_other": "{{count}} tags."
+ },
+ "zoomIn": "Zoom in",
+ "zoomOut": "Zoom out",
+ "fitToView": "Fit to view",
+ "repel": {
+ "title": "Repel force — how strongly nodes push each other apart",
+ "aria": "Repel force"
+ }
+ },
+ "dashboard": {
+ "title": "Dashboard",
+ "subtitle": "System health and recent activity at a glance.",
+ "health": {
+ "unreachable": "Could not reach the health endpoint.",
+ "system": "System",
+ "vectorStore": "Vector store",
+ "version": "Version",
+ "uptime": "Uptime"
+ },
+ "viewAll": "View all",
+ "goToWorkspaces": "Go to Workspaces",
+ "recentActivity": "Recent activity",
+ "noActivity": {
+ "title": "No recent activity",
+ "description": "Document ingestion across your collections will show up here."
+ }
+ },
+ "indexing": {
+ "title": "Indexing",
+ "subtitle": "Keyword-index coverage per collection. A document is searchable once its chunks are stored — the keyword index is maintained automatically, so there is nothing to rebuild. Anything short of full coverage is an ingestion that has not finished; retry the failed ones here.",
+ "error": "Could not load index status",
+ "empty": {
+ "title": "Nothing to index yet",
+ "description": "Upload documents to a collection and they'll appear here."
+ },
+ "unindexed_one": "{{count}} unindexed",
+ "unindexed_other": "{{count}} unindexed",
+ "coverage": "{{indexed}}/{{total}} indexed",
+ "pendingSuffix": " · {{count}} ingesting",
+ "failedSuffix": " · {{count}} failed",
+ "failedDocs_one": "{{count}} failed document",
+ "failedDocs_other": "{{count}} failed documents",
+ "retryFailed": "Retry failed",
+ "retryDialog": {
+ "title": "Retry failed ingestions",
+ "message": "Re-ingest the {{docs}} in “{{name}}”? Each is re-queued and embedded again from its stored file — resuming where the last attempt stopped — so this spends embedding-provider quota. Documents still ingesting are left alone."
+ },
+ "toast": {
+ "requeued_one": "Re-queued {{count}} document.",
+ "requeued_other": "Re-queued {{count}} documents."
+ }
+ },
+ "queue": {
+ "retryFailed": "Retry failed",
+ "retryAllFailed": "Retry all failed",
+ "queueEmpty": "Queue empty",
+ "paused": "· paused on the embedding provider's quota — retrying in {{time}}",
+ "live": "Live",
+ "connecting": "Connecting…",
+ "error": "Could not load the queue",
+ "openInCollectionTitle": "Open in collection to edit or delete",
+ "open": "Open",
+ "unknownCollection": "Unknown collection",
+ "chunks": "Chunks",
+ "chunksLatest": "(latest)",
+ "chunkProgress": "{{current}} / {{total}} chunks",
+ "retryIn": "retry in {{time}}",
+ "retrying": "retrying…",
+ "parts": {
+ "ingesting": "{{count}} ingesting",
+ "queued": "{{count}} queued",
+ "waiting": "{{count}} waiting on rate limit"
+ },
+ "filters": {
+ "collectionPlaceholder": "Collection name"
+ },
+ "empty": {
+ "filtered": {
+ "title": "No matching jobs",
+ "description": "No ingestion jobs match the current filters. Adjust or clear them to see more."
+ },
+ "none": {
+ "title": "No ingestions yet",
+ "description": "Upload a document and its chunks will stream here in real time as they're embedded."
+ }
+ },
+ "retryDialog": {
+ "title": "Retry failed documents",
+ "message": "Re-run ingestion for every failed document in the queue? Each is re-queued from where it left off.",
+ "messageFiltered": "Re-run ingestion for every failed document matching the current filters? Each is re-queued from where it left off."
+ },
+ "toast": {
+ "reenqueued_one": "Re-enqueued {{count}} failed document.",
+ "reenqueued_other": "Re-enqueued {{count}} failed documents.",
+ "noneMatched": "No failed documents matched the current filters."
+ },
+ "phase": {
+ "parsing": "parsing",
+ "embedding": "embedding",
+ "storing": "storing",
+ "rate_limited": "rate limited",
+ "done": "done",
+ "failed": "failed"
+ }
+ },
+ "ui": {
+ "queryError": {
+ "title": "Could not load this"
+ },
+ "copyButton": {
+ "failed": "Copy failed — select it and copy manually."
+ },
+ "errorBoundary": {
+ "title": "Something went wrong"
+ },
+ "pager": {
+ "range": "Showing {{from}}–{{to}} of {{total}}",
+ "previous": "Previous",
+ "page": "Page {{page}} of {{pages}}",
+ "next": "Next"
+ }
+ },
+ "users": {
+ "title": "Users",
+ "subtitle": "Each user signs in with a username + password (admins get the full console; others are scoped by their grants) and may hold one API key for MCP/programmatic access.",
+ "new": "New user",
+ "editTitle": "Edit user",
+ "admin": "Admin",
+ "error": "Could not load users",
+ "revoke": "Revoke",
+ "empty": {
+ "title": "No users yet",
+ "description": "Create a user, share their one-time password, and grant access to workspaces or collections."
+ },
+ "status": {
+ "active": "Active",
+ "inactive": "Inactive"
+ },
+ "actions": {
+ "permissions": "Permissions",
+ "apiKey": "API key",
+ "edit": "Edit",
+ "delete": "Delete"
+ },
+ "row": {
+ "key": "key {{prefix}}…",
+ "noKey": "no API key",
+ "created": "created {{date}}",
+ "rpm": "{{rpm}} rpm",
+ "activateAria": "Activate user",
+ "deactivateAria": "Deactivate user",
+ "manageAria": "Manage {{username}}"
+ },
+ "toast": {
+ "updated": "User updated.",
+ "deleted": "User “{{username}}” deleted."
+ },
+ "reveal": {
+ "done": "Done",
+ "created": {
+ "title": "User “{{username}}” created",
+ "message": "Share this one-time password with the user — they will be asked to change it on first sign-in. It is shown only once."
+ },
+ "reset": {
+ "title": "Password reset for “{{username}}”",
+ "message": "Share this one-time password with the user — their old password no longer works and they will set a new one on next sign-in. Shown only once."
+ }
+ },
+ "delete": {
+ "title": "Delete user",
+ "message": "Delete “{{username}}”? Their API key and all permission grants are permanently removed. This cannot be undone."
+ },
+ "reset": {
+ "title": "Reset password",
+ "message": "Reset the password for “{{username}}”? Their current password stops working immediately and any active session is signed out."
+ },
+ "form": {
+ "usernameHint": "Used to sign in. Letters, digits, and . _ + @ - (3–64 chars).",
+ "usernameError": "Enter a valid username (3–64 characters).",
+ "usernamePlaceholder": "e.g. jane",
+ "email": "Email",
+ "emailHint": "Optional — leave blank if the user has no email.",
+ "emailError": "Enter a valid email address.",
+ "emailPlaceholder": "e.g. jane@example.com",
+ "nameHint": "Optional — a display name.",
+ "rateLimit": "MCP rate limit (requests/min)",
+ "rateLimitHint": "Caps this user's key on /api/mcp. 0 = use the global default.",
+ "activeLabel": "Active — an inactive user can't sign in and their API key stops working",
+ "adminLabel": "Admin — full console access (manage users, settings, every workspace)"
+ },
+ "key": {
+ "title": "API key — {{name}}",
+ "revealMessage": "Copy this key now — it is shown in full only once and cannot be retrieved again.",
+ "used": "used {{ago}}",
+ "neverUsed": "never used",
+ "revokeAria": "Revoke key",
+ "none": "This user has no API key yet.",
+ "rotate": "Rotate key",
+ "create": "Create key",
+ "rotateHint": "Rotating replaces the current key — the old one stops working immediately.",
+ "toast": {
+ "revoked": "Key revoked."
+ }
+ },
+ "grants": {
+ "modalTitle": "Permissions — {{email}}",
+ "intro": {
+ "lead": "Permissions",
+ "emphasis": "scope this user down",
+ "rest": ". With none, they see and write everything. A workspace limits them to it; add a collection (or document) to narrow them to just that one."
+ },
+ "error": "Could not load grants",
+ "empty": {
+ "title": "No permissions yet",
+ "description": "Grant this user read or write on a workspace, collection, or document above."
+ },
+ "resourceType": "Resource type",
+ "type": {
+ "workspace": "Workspace",
+ "collection": "Collection",
+ "document": "Document",
+ "capability": "Capability"
+ },
+ "levelLabel": "Level",
+ "readOption": "Read — search, list, download",
+ "writeOption": "Write — ingest, delete (and read)",
+ "read": "Read",
+ "write": "Write",
+ "capabilityLabel": {
+ "create_workspace": "Create workspaces",
+ "manage_tags": "Manage tags"
+ },
+ "capabilityHelp": {
+ "create_workspace": "Lets this user create new workspaces — they get write access to any they create.",
+ "manage_tags": "Lets this user create, edit, delete, and assign tags in any workspace they can read."
+ },
+ "selectWorkspace": "Select a workspace…",
+ "select": "Select…",
+ "pickWorkspaceFirst": "Pick a workspace first",
+ "documentId": "Document ID",
+ "documentIdHint": "The document's id (e.g. doc_…), from its collection.",
+ "addPermission": "Add permission",
+ "deleted": "deleted",
+ "copyIdTitle": "Copy id: {{id}}",
+ "copyIdAria": "Copy id {{id}}",
+ "revokeAria": "Revoke permission",
+ "toast": {
+ "granted": "Permission granted.",
+ "revoked": "Permission revoked."
+ }
+ }
+ },
+ "settings": {
+ "title": "Settings",
+ "subtitle": "Runtime configuration, console security, and MCP access.",
+ "tabs": {
+ "general": "General",
+ "mcp": "MCP server"
+ },
+ "config": {
+ "title": "Configuration",
+ "loadError": "Could not load configuration"
+ },
+ "runtime": {
+ "title": "Runtime",
+ "unreachable": "API unreachable",
+ "unreachableDetail": "Could not reach the health endpoint.",
+ "service": "Service",
+ "version": "Version",
+ "vectorStore": "Vector store",
+ "embeddingProvider": "Embedding provider",
+ "embeddingModel": "Embedding model",
+ "uptime": "Uptime"
+ },
+ "security": {
+ "title": "Security",
+ "session": "Session",
+ "sessionNote": "Your credential is held in this browser only. Sign out to clear it.",
+ "signOut": "Sign out"
+ },
+ "field": {
+ "provider": "Provider",
+ "model": "Model",
+ "baseUrl": "Base URL",
+ "apiKey": "API key",
+ "apiKeyHint": "Write-only; never shown after saving.",
+ "keySetPlaceholder": "key set — leave blank to keep",
+ "aiStudioKey": "AI Studio key",
+ "baseUrlRequiredOpenrouter": "required — e.g. https://openrouter.ai/api",
+ "baseUrlOllamaDefault": "blank uses the Ollama default",
+ "modelIdPlaceholder": "model id",
+ "ollamaModelsHint": "Installed Ollama models",
+ "ollamaUnreachable": "Could not reach Ollama",
+ "loadingModels": "Loading models…",
+ "noModelsInstalled": "No models installed"
+ },
+ "provider": {
+ "ollamaLocal": "Ollama (local)",
+ "sentenceTransformers": "Sentence-Transformers (in-process)",
+ "openaiCompat": "OpenAI-compatible",
+ "gemini": "Google Gemini",
+ "geminiAiStudio": "Google Gemini (AI Studio)"
+ },
+ "storage": {
+ "blurb": "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.",
+ "title": "Object storage",
+ "activeBackend": "Active backend"
+ },
+ "parser": {
+ "title": "PDF parsing",
+ "backend": "Backend",
+ "optionPymupdf": "PyMuPDF — fast, one chunk per page",
+ "optionDocling": "docling — section-aware + tables/OCR",
+ "needsGpuSuffix": " (needs a GPU)",
+ "gpuOlderThanAmpere": "Your GPU ({{name}}, capability {{capability}}) is older than Ampere — ",
+ "noGpuDetected": "No GPU detected — ",
+ "doclingCpuWarn": "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.",
+ "save": "Save PDF backend",
+ "detectingGpu": "Detecting GPU…",
+ "compatibleGpu": "Compatible GPU detected ({{name}}, capability {{capability}}).",
+ "gpuBelowAmpere": "GPU {{name}} (capability {{capability}}) is below Ampere.",
+ "noCompatibleGpu": "No compatible GPU detected.",
+ "recommendedLabel": "Recommended:"
+ },
+ "embedding": {
+ "blurb": "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.",
+ "title": "Embedding model",
+ "modelHintGemini": "e.g. gemini-embedding-001",
+ "modelHint": "the exact model id on this server",
+ "outputDim": "Output dimensions",
+ "outputDimHint": "optional; blank = full 3072",
+ "maxRpm": "Max requests / min",
+ "maxRpmHint": "throttle embeds so bulk ingestion stays under the provider's quota (each text = one request); blank or 0 = unlimited",
+ "maxRpmPlaceholder": "0 (unlimited)",
+ "reindexRequired": "Re-index required after saving",
+ "save": "Save embedding config"
+ },
+ "reranker": {
+ "blurb": "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.",
+ "title": "Reranker",
+ "enable": "Enable reranking",
+ "providerCrossEncoder": "Cross-encoder (local, no key)",
+ "providerRerankApi": "Hosted rerank API (Cohere / Jina / Voyage)",
+ "providerLlm": "LLM-as-reranker",
+ "llmProvider": "LLM provider",
+ "modelHintRerankApi": "e.g. rerank-v3.5 (Cohere), jina-reranker-v2 (Jina)",
+ "modelHintGemini": "e.g. gemini-2.5-flash",
+ "modelHintLlm": "the exact chat model id",
+ "modelHintCrossEncoder": "a HuggingFace cross-encoder id; the baked default is offline-safe",
+ "baseUrlHintRerankApi": "required — the full /rerank endpoint",
+ "topN": "Candidates scored (top_n)",
+ "topNHint": "max candidates the reranker scores per collection",
+ "save": "Save reranker config"
+ },
+ "search": {
+ "blurb": "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.",
+ "title": "Retrieval",
+ "expand": "Adjacency expansion (neighbours)",
+ "expandHint": "chunks pulled on each side of a hit; 0 = off, max {{max}}",
+ "save": "Save search config"
+ },
+ "mcp": {
+ "blurb": "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 ",
+ "title": "MCP server",
+ "rateLimit": "Rate limit (requests / min per key)",
+ "rateLimitHint": "per API key on /api/mcp; the next request in the same minute returns 429",
+ "save": "Save MCP config"
+ },
+ "toast": {
+ "pdfSaved": "PDF backend saved. Services are reloading.",
+ "embeddingSaved": "Embedding config saved. Services are reloading.",
+ "rerankerSaved": "Reranker config saved. Services are reloading.",
+ "searchSaved": "Search config saved. Services are reloading.",
+ "mcpSaved": "MCP config saved. Services are reloading."
+ }
+ },
+ "mcp": {
+ "tagsNote": "Tag tools need the manage_tags permission; assigning to a collection/document also needs write on it.",
+ "connection": {
+ "title": "Connection",
+ "address": "Server address",
+ "addressHintLocal": "This address only works on this machine; the server couldn't determine a LAN IP for other devices to use.",
+ "addressHint": "Auto-detected from the server. Used to build the endpoint and snippets below.",
+ "endpoint": "Endpoint",
+ "transport": "Transport",
+ "authHeader": "Auth header",
+ "authNote": "Send an API key on every request as or — the master key, or a per-user key for scoped access (each tool respects that user's read/write grants). Substitute your own in the snippets below; nothing is embedded here."
+ },
+ "clientSetup": {
+ "title": "Client setup",
+ "instructions": "Add this to your MCP client's server config (replace the placeholder key):",
+ "copyConfig": "Copy config"
+ },
+ "skill": {
+ "title": "Agent skill",
+ "download": "Download .zip",
+ "folderNote": "A small skill folder (SKILL.md + references/). Unzip it into your agent's skills directory (e.g. .claude/skills/embedbase/) — the agent loads SKILL.md up front and opens a reference file only when the task needs it, so a read-only session never pulls in the upload rules.",
+ "bundled": "Bundled:",
+ "unavailable": "The skill is unavailable — the tool catalogue could not be loaded."
+ },
+ "tools": {
+ "title": "Tools",
+ "count_one": "{{count}} tool · each respects your grants",
+ "count_other": "{{count}} tools · each respects your grants",
+ "loadError": "Could not load the tool catalogue"
+ },
+ "rest": {
+ "title": "REST API reference",
+ "blurb": "A standalone OpenAPI reference of just the integration endpoints — search and workspace/collection/document access — for the AI to read as the API standard."
+ }
+ }
+}
diff --git a/ui/src/i18n/locales/pt-BR/translation.json b/ui/src/i18n/locales/pt-BR/translation.json
new file mode 100644
index 0000000..86a11ae
--- /dev/null
+++ b/ui/src/i18n/locales/pt-BR/translation.json
@@ -0,0 +1,782 @@
+{
+ "common": {
+ "saving": "Salvando…",
+ "cancel": "Cancelar",
+ "create": "Criar",
+ "saveChanges": "Salvar alterações",
+ "name": "Nome",
+ "description": "Descrição",
+ "optional": "Opcional",
+ "color": "Cor",
+ "icon": "Ícone",
+ "tags": "Tags",
+ "noDescription": "Sem descrição",
+ "created": "Criado em {{date}}",
+ "editAria": "Editar {{name}}",
+ "deleteAria": "Excluir {{name}}",
+ "more": "Mais",
+ "clear": "Limpar",
+ "retry": "Tentar novamente",
+ "retrying": "Tentando…",
+ "min": "mín",
+ "max": "máx",
+ "openAria": "Abrir {{name}}",
+ "downloadAria": "Baixar {{name}}",
+ "close": "Fechar",
+ "removeAria": "Remover {{name}}",
+ "confirm": "Confirmar",
+ "copy": "Copiar",
+ "copied": "Copiado",
+ "dismiss": "Dispensar"
+ },
+ "nav": {
+ "dashboard": "Painel",
+ "workspaces": "Espaços de trabalho",
+ "graph": "Grafo",
+ "search": "Busca",
+ "indexing": "Indexação",
+ "ingestionQueue": "Fila de ingestão",
+ "users": "Usuários",
+ "settings": "Configurações",
+ "admin": "Admin"
+ },
+ "topbar": {
+ "masterKey": "Chave mestra",
+ "adminSuffix": " · admin",
+ "changePassword": "Alterar senha",
+ "changePasswordTitle": "Alterar sua senha",
+ "signOut": "Sair",
+ "signOutTitle": "Sair e encerrar sua sessão",
+ "language": "Alterar idioma"
+ },
+ "status": {
+ "uploading": "Enviando",
+ "pending": "Pendente",
+ "processing": "Processando",
+ "done": "Concluído",
+ "failed": "Falhou",
+ "deleting": "Excluindo",
+ "rate_limited": "Limite atingido"
+ },
+ "workspaces": {
+ "title": "Espaços de trabalho",
+ "subtitle": "Contêineres de nível superior para suas coleções.",
+ "new": "Novo espaço de trabalho",
+ "editTitle": "Editar espaço de trabalho",
+ "namePlaceholder": "ex.: Pesquisa",
+ "descHint": "Opcional — o que vive neste espaço de trabalho.",
+ "empty": {
+ "title": "Nenhum espaço de trabalho ainda",
+ "canCreate": "Crie seu primeiro espaço de trabalho para começar a organizar coleções.",
+ "readonly": "Nenhum espaço de trabalho foi compartilhado com você ainda."
+ },
+ "error": "Não foi possível carregar os espaços de trabalho",
+ "toast": {
+ "created": "Espaço de trabalho “{{name}}” criado.",
+ "updated": "Espaço de trabalho atualizado.",
+ "deleted": "Espaço de trabalho “{{name}}” excluído."
+ },
+ "delete": {
+ "title": "Excluir espaço de trabalho",
+ "message": "Excluir “{{name}}”? Suas coleções, chaves de API e documentos serão removidos permanentemente. Isso não pode ser desfeito."
+ }
+ },
+ "collections": {
+ "title": "Coleções",
+ "subtitle": "Conjuntos de documentos pesquisáveis neste espaço de trabalho.",
+ "new": "Nova coleção",
+ "editTitle": "Editar coleção",
+ "namePlaceholder": "ex.: Relatórios do 3º tri",
+ "descHint": "Opcional — o que esta coleção contém.",
+ "empty": {
+ "title": "Nenhuma coleção ainda",
+ "canCreate": "Crie uma coleção para começar a ingerir e pesquisar documentos.",
+ "readonly": "Nenhuma coleção aqui ainda."
+ },
+ "error": "Não foi possível carregar as coleções",
+ "toast": {
+ "created": "Coleção “{{name}}” criada.",
+ "updated": "Coleção atualizada.",
+ "deleted": "Coleção “{{name}}” excluída."
+ },
+ "delete": {
+ "title": "Excluir coleção",
+ "message": "Excluir “{{name}}”? Seus documentos e vetores indexados serão removidos permanentemente. Isso não pode ser desfeito."
+ }
+ },
+ "documents": {
+ "title": "Documentos",
+ "subtitle": {
+ "canWrite": "Envie arquivos para ingeri-los nesta coleção.",
+ "readonly": "Navegue e pesquise documentos nesta coleção."
+ },
+ "error": "Não foi possível carregar os documentos",
+ "empty": {
+ "none": {
+ "title": "Nenhum documento ainda",
+ "description": "Solte arquivos na área acima para começar a ingeri-los."
+ },
+ "filtered": {
+ "title": "Nenhum documento correspondente",
+ "description": "Nenhum documento corresponde aos filtros atuais. Ajuste ou limpe-os para ver mais."
+ }
+ },
+ "retention": {
+ "label": "Temporário — excluir automaticamente após",
+ "aria": "Retenção em dias",
+ "day_one": "dia",
+ "day_other": "dias"
+ },
+ "delete": {
+ "title": "Excluir documento",
+ "message": "Excluir “{{name}}”? Seus chunks e vetores indexados serão removidos. Isso não pode ser desfeito."
+ },
+ "toast": {
+ "queued_one": "{{count}} arquivo na fila para ingestão.",
+ "queued_other": "{{count}} arquivos na fila para ingestão.",
+ "uploadError": "{{name}}: {{error}}",
+ "tooLarge": "{{name}} é maior que {{max}} MB e foi ignorado.",
+ "deleted": "“{{name}}” excluído.",
+ "reprocessing": "Reprocessando “{{name}}”.",
+ "indexing": "Indexando “{{name}}”."
+ },
+ "progress": {
+ "ingesting": "Ingerindo"
+ },
+ "row": {
+ "chunk_one": "{{count}} chunk",
+ "chunk_other": "{{count}} chunks",
+ "updated": "atualizado {{ago}}",
+ "original": "original: {{name}}",
+ "why": "Por quê?",
+ "hide": "Ocultar",
+ "downloadOriginalAria": "Baixar arquivo de origem original ({{name}})"
+ },
+ "index": {
+ "indexed": "Indexado",
+ "indexedTitle": "Indexado para busca BM25 / por palavra-chave",
+ "notIndexedTitle": "Fora do índice de palavras-chave — clique para indexar",
+ "index": "Indexar",
+ "indexing": "Indexando…"
+ },
+ "failure": {
+ "loading": "Carregando erro…",
+ "loadError": "Não foi possível carregar o detalhe do erro.",
+ "none": "Nenhum detalhe de erro registrado."
+ },
+ "filters": {
+ "searchPlaceholder": "Buscar nomes de arquivo…",
+ "statusAria": "Status",
+ "anyStatus": "Qualquer status",
+ "typeAria": "Tipo de arquivo",
+ "anyType": "Qualquer tipo",
+ "indexedAria": "Indexado",
+ "indexedAny": "Indexado: qualquer",
+ "indexed": "Indexado",
+ "notIndexed": "Não indexado",
+ "storageBackend": "Backend de armazenamento",
+ "embeddingModel": "Modelo de embedding",
+ "size": "Tamanho (bytes)",
+ "created": "Criado"
+ },
+ "upload": {
+ "uploading": "Enviando…",
+ "drag": "Arraste arquivos aqui, ou ",
+ "browse": "procurar",
+ "hint": "PDF, texto, Markdown · DOCX/PPTX com docling · até {{max}} MB cada"
+ }
+ },
+ "auth": {
+ "field": {
+ "username": "Usuário",
+ "password": "Senha"
+ },
+ "login": {
+ "subtitle": "Entre na sua conta.",
+ "submit": "Entrar",
+ "submitting": "Entrando…",
+ "useMaster": "Usar chave mestra",
+ "invalid": "Usuário ou senha inválidos."
+ },
+ "unlock": {
+ "subtitle": "Digite sua chave mestra para desbloquear o console.",
+ "masterKey": "Chave mestra",
+ "submit": "Desbloquear",
+ "submitting": "Verificando…",
+ "back": "Voltar para o login",
+ "rejected": "Essa chave foi rejeitada. Verifique e tente novamente."
+ },
+ "api": {
+ "checking": "Verificando API…",
+ "unreachable": "API inacessível",
+ "ready": "API pronta · {{model}}",
+ "modelFallback": "modelo"
+ },
+ "password": {
+ "current": "Senha atual",
+ "new": "Nova senha",
+ "confirm": "Confirmar nova senha",
+ "hint": "Pelo menos {{min}} caracteres.",
+ "tooShort": "Use pelo menos {{min}} caracteres.",
+ "mismatch": "As senhas não coincidem.",
+ "update": "Atualizar senha",
+ "setContinue": "Definir senha e continuar",
+ "changeTitle": "Alterar senha",
+ "updated": "Senha atualizada.",
+ "failed": "Não foi possível alterar a senha.",
+ "setTitle": "Definir uma nova senha",
+ "signedInAs": "Conectado como {{name}}. ",
+ "chooseNew": "Escolha uma nova senha para concluir o login."
+ }
+ },
+ "errors": {
+ "network": "Não foi possível conectar à API. O sistema está em execução?",
+ "sessionExpired": "Sessão expirada. Entre novamente.",
+ "accountDeactivated": "Sua conta foi desativada.",
+ "adminRequired": "Acesso de administrador necessário.",
+ "configNotLoaded": "Configuração ainda não carregada.",
+ "passwordChangeRequired": "É necessário alterar a senha."
+ },
+ "search": {
+ "title": "Busca",
+ "subtitle": "Busca híbrida BM25 + semântica em todas as suas coleções.",
+ "queryPlaceholder": "Busque em suas coleções…",
+ "submit": "Buscar",
+ "selectPrompt": "Selecione ao menos uma coleção para buscar.",
+ "topK": "Top-K",
+ "modeLabel": "Modo",
+ "mode": {
+ "hybrid": "Híbrida",
+ "semantic": "Semântica",
+ "bm25": "BM25",
+ "semantic_only": "Somente semântica"
+ },
+ "modeHint": {
+ "hybrid": "BM25 + semântica",
+ "semantic": "Similaridade vetorial",
+ "bm25": "Ranking por palavra-chave",
+ "semantic_only": "Índice BM25 indisponível — recorreu à busca semântica"
+ },
+ "alpha": "Alfa",
+ "alphaHint": "0 = somente BM25, 1 = somente semântica",
+ "hideFilters": "Ocultar filtros",
+ "filters": "Filtros",
+ "filename": "Nome do arquivo",
+ "filenamePlaceholder": "ex.: relatorio.pdf",
+ "commaSeparated": "Separadas por vírgula",
+ "tagsPlaceholder": "ex.: financeiro, 3tri",
+ "failed": "A busca falhou",
+ "empty": {
+ "title": "Busque em suas coleções",
+ "description": "Escolha uma ou mais coleções à esquerda, digite uma consulta e execute."
+ },
+ "noMatches": {
+ "title": "Nenhum resultado",
+ "description": "Nenhum chunk correspondeu a esta consulta nas coleções selecionadas. Tente ampliar a consulta ou afrouxar os filtros."
+ },
+ "timing": "embedding {{embed}}ms · busca {{search}}ms · total {{total}}ms",
+ "underDelivered": {
+ "withTopK": "Menos resultados que o solicitado (top_k {{topK}}). Um filtro seletivo ou um conjunto pequeno de candidatos deixou algumas vagas sem preencher.",
+ "noTopK": "Menos resultados que o solicitado. Um filtro seletivo ou um conjunto pequeno de candidatos deixou algumas vagas sem preencher."
+ },
+ "breakdown": {
+ "title": "Detalhamento por coleção",
+ "stats": "{{retrieved}} → {{returned}} · {{contributed}} no top-k"
+ },
+ "relevanceScore": "Pontuação de relevância",
+ "unknownFile": "Arquivo desconhecido",
+ "pages": "· pp.{{range}}",
+ "page": "· p.{{page}}",
+ "tree": {
+ "noWorkspaces": "Nenhum espaço de trabalho ainda.",
+ "collapse": "Recolher {{name}}",
+ "expand": "Expandir {{name}}",
+ "all": "Todas",
+ "noCollections": "Nenhuma coleção"
+ }
+ },
+ "tags": {
+ "subtitle": "Correlacione e filtre coleções e documentos neste espaço de trabalho.",
+ "new": "Nova tag",
+ "error": "Não foi possível carregar as tags",
+ "empty": {
+ "title": "Nenhuma tag ainda",
+ "description": "Crie uma tag para correlacionar e filtrar coleções e documentos."
+ },
+ "count": {
+ "col": "{{count}} col",
+ "doc": "{{count}} doc"
+ },
+ "workspaceBadge": "espaço de trabalho",
+ "mergeAria": "Mesclar {{name}}",
+ "itemsError": "Não foi possível carregar os itens",
+ "notAssigned": "Ainda não atribuída a nada.",
+ "none": "Nenhum",
+ "editTitle": "Editar tag",
+ "nameHint": "Convertida para minúsculas e sem espaços nas pontas ao salvar.",
+ "namePlaceholder": "ex.: financeiro",
+ "mergeTitle": "Mesclar tag",
+ "merge": "Mesclar",
+ "mergeInto": "Mesclar em",
+ "mergeIntro": {
+ "before": "Mova todas as atribuições de ",
+ "after": " para outra tag e depois exclua-a."
+ },
+ "noMergeTargets": "Nenhuma outra tag para mesclar.",
+ "selectTag": "Selecione uma tag…",
+ "filterByTag": "Filtrar por tag:",
+ "delete": {
+ "title": "Excluir tag",
+ "message": "Excluir “{{name}}”? Ela será removida de todas as coleções e documentos que marca. Isso não pode ser desfeito."
+ },
+ "toast": {
+ "created": "Tag “{{name}}” criada.",
+ "updated": "Tag atualizada.",
+ "merged": "Tags mescladas.",
+ "deleted": "Tag “{{name}}” excluída."
+ },
+ "picker": {
+ "add": "Tag",
+ "placeholder": "Filtrar ou criar…",
+ "create": "Criar “{{name}}”",
+ "empty": "Nenhuma tag ainda."
+ }
+ },
+ "graph": {
+ "title": "Grafo",
+ "subtitle": "Arquivos (os nós maiores) ligados pelas tags que compartilham. Nós de tag mais quentes são mais reutilizados. Arraste um nó para fixá-lo e destacá-lo, clique para soltá-lo; ajuste a força de repulsão ou redefina para recentralizar.",
+ "workspaceAria": "Espaço de trabalho",
+ "collectionAria": "Coleção",
+ "allCollections": "Todas as coleções",
+ "error": "Não foi possível carregar o grafo",
+ "empty": {
+ "title": "Nada para exibir no grafo ainda",
+ "description": "Marque alguns documentos neste escopo e eles aparecerão aqui, ligados pelas tags que compartilham."
+ },
+ "resetView": "Redefinir visualização",
+ "reset": "Redefinir",
+ "heat": {
+ "title": "Calor das tags",
+ "files_one": "{{count}} arquivo",
+ "files_other": "{{count}} arquivos"
+ },
+ "open": "Abrir",
+ "download": "Baixar",
+ "files": "Arquivos",
+ "node": {
+ "usedBy_one": "Usada por {{count}} arquivo.",
+ "usedBy_other": "Usada por {{count}} arquivos.",
+ "tags_one": "{{count}} tag.",
+ "tags_other": "{{count}} tags."
+ },
+ "zoomIn": "Aproximar",
+ "zoomOut": "Afastar",
+ "fitToView": "Ajustar à tela",
+ "repel": {
+ "title": "Força de repulsão — o quanto os nós se afastam entre si",
+ "aria": "Força de repulsão"
+ }
+ },
+ "dashboard": {
+ "title": "Painel",
+ "subtitle": "Saúde do sistema e atividade recente num relance.",
+ "health": {
+ "unreachable": "Não foi possível acessar o endpoint de saúde.",
+ "system": "Sistema",
+ "vectorStore": "Armazenamento de vetores",
+ "version": "Versão",
+ "uptime": "Tempo de atividade"
+ },
+ "viewAll": "Ver todos",
+ "goToWorkspaces": "Ir para Espaços de trabalho",
+ "recentActivity": "Atividade recente",
+ "noActivity": {
+ "title": "Nenhuma atividade recente",
+ "description": "A ingestão de documentos em suas coleções aparecerá aqui."
+ }
+ },
+ "indexing": {
+ "title": "Indexação",
+ "subtitle": "Cobertura do índice de palavras-chave por coleção. Um documento fica pesquisável assim que seus chunks são armazenados — o índice de palavras-chave é mantido automaticamente, então não há nada a reconstruir. Qualquer cobertura incompleta é uma ingestão que não terminou; repita as que falharam aqui.",
+ "error": "Não foi possível carregar o status de indexação",
+ "empty": {
+ "title": "Nada para indexar ainda",
+ "description": "Envie documentos para uma coleção e eles aparecerão aqui."
+ },
+ "unindexed_one": "{{count}} não indexado",
+ "unindexed_other": "{{count}} não indexados",
+ "coverage": "{{indexed}}/{{total}} indexados",
+ "pendingSuffix": " · {{count}} em ingestão",
+ "failedSuffix": " · {{count}} com falha",
+ "failedDocs_one": "{{count}} documento com falha",
+ "failedDocs_other": "{{count}} documentos com falha",
+ "retryFailed": "Repetir falhas",
+ "retryDialog": {
+ "title": "Repetir ingestões com falha",
+ "message": "Reingerir {{docs}} em “{{name}}”? Cada um é reenfileirado e tem seus embeddings gerados novamente a partir do arquivo armazenado — retomando de onde a última tentativa parou — consumindo cota do provedor de embeddings. Documentos ainda em ingestão são mantidos."
+ },
+ "toast": {
+ "requeued_one": "{{count}} documento reenfileirado.",
+ "requeued_other": "{{count}} documentos reenfileirados."
+ }
+ },
+ "queue": {
+ "retryFailed": "Repetir falhas",
+ "retryAllFailed": "Repetir todas as falhas",
+ "queueEmpty": "Fila vazia",
+ "paused": "· pausado pela cota do provedor de embeddings — repetindo em {{time}}",
+ "live": "Ao vivo",
+ "connecting": "Conectando…",
+ "error": "Não foi possível carregar a fila",
+ "openInCollectionTitle": "Abrir na coleção para editar ou excluir",
+ "open": "Abrir",
+ "unknownCollection": "Coleção desconhecida",
+ "chunks": "Chunks",
+ "chunksLatest": "(mais recentes)",
+ "chunkProgress": "{{current}} / {{total}} chunks",
+ "retryIn": "repetir em {{time}}",
+ "retrying": "repetindo…",
+ "parts": {
+ "ingesting": "{{count}} em ingestão",
+ "queued": "{{count}} na fila",
+ "waiting": "{{count}} aguardando limite de taxa"
+ },
+ "filters": {
+ "collectionPlaceholder": "Nome da coleção"
+ },
+ "empty": {
+ "filtered": {
+ "title": "Nenhum trabalho correspondente",
+ "description": "Nenhum trabalho de ingestão corresponde aos filtros atuais. Ajuste ou limpe-os para ver mais."
+ },
+ "none": {
+ "title": "Nenhuma ingestão ainda",
+ "description": "Envie um documento e seus chunks aparecerão aqui em tempo real conforme seus embeddings são gerados."
+ }
+ },
+ "retryDialog": {
+ "title": "Repetir documentos com falha",
+ "message": "Reexecutar a ingestão de todos os documentos com falha na fila? Cada um é reenfileirado de onde parou.",
+ "messageFiltered": "Reexecutar a ingestão de todos os documentos com falha que correspondem aos filtros atuais? Cada um é reenfileirado de onde parou."
+ },
+ "toast": {
+ "reenqueued_one": "{{count}} documento com falha reenfileirado.",
+ "reenqueued_other": "{{count}} documentos com falha reenfileirados.",
+ "noneMatched": "Nenhum documento com falha corresponde aos filtros atuais."
+ },
+ "phase": {
+ "parsing": "análise",
+ "embedding": "embedding",
+ "storing": "armazenamento",
+ "rate_limited": "limite atingido",
+ "done": "concluído",
+ "failed": "falhou"
+ }
+ },
+ "ui": {
+ "queryError": {
+ "title": "Não foi possível carregar isto"
+ },
+ "copyButton": {
+ "failed": "Falha ao copiar — selecione e copie manualmente."
+ },
+ "errorBoundary": {
+ "title": "Algo deu errado"
+ },
+ "pager": {
+ "range": "Mostrando {{from}}–{{to}} de {{total}}",
+ "previous": "Anterior",
+ "page": "Página {{page}} de {{pages}}",
+ "next": "Próxima"
+ }
+ },
+ "users": {
+ "title": "Usuários",
+ "subtitle": "Cada usuário entra com nome de usuário + senha (administradores têm o console completo; os demais são restritos por suas permissões) e pode ter uma chave de API para acesso programático/via MCP.",
+ "new": "Novo usuário",
+ "editTitle": "Editar usuário",
+ "admin": "Admin",
+ "error": "Não foi possível carregar os usuários",
+ "revoke": "Revogar",
+ "empty": {
+ "title": "Nenhum usuário ainda",
+ "description": "Crie um usuário, compartilhe a senha de uso único e conceda acesso a espaços de trabalho ou coleções."
+ },
+ "status": {
+ "active": "Ativo",
+ "inactive": "Inativo"
+ },
+ "actions": {
+ "permissions": "Permissões",
+ "apiKey": "Chave de API",
+ "edit": "Editar",
+ "delete": "Excluir"
+ },
+ "row": {
+ "key": "chave {{prefix}}…",
+ "noKey": "sem chave de API",
+ "created": "criado em {{date}}",
+ "rpm": "{{rpm}} rpm",
+ "activateAria": "Ativar usuário",
+ "deactivateAria": "Desativar usuário",
+ "manageAria": "Gerenciar {{username}}"
+ },
+ "toast": {
+ "updated": "Usuário atualizado.",
+ "deleted": "Usuário “{{username}}” excluído."
+ },
+ "reveal": {
+ "done": "Concluir",
+ "created": {
+ "title": "Usuário “{{username}}” criado",
+ "message": "Compartilhe esta senha de uso único com o usuário — será solicitado que ele a altere no primeiro acesso. Ela é exibida apenas uma vez."
+ },
+ "reset": {
+ "title": "Senha redefinida para “{{username}}”",
+ "message": "Compartilhe esta senha de uso único com o usuário — a senha antiga não funciona mais e ele definirá uma nova no próximo acesso. Exibida apenas uma vez."
+ }
+ },
+ "delete": {
+ "title": "Excluir usuário",
+ "message": "Excluir “{{username}}”? A chave de API e todas as permissões concedidas serão removidas permanentemente. Isso não pode ser desfeito."
+ },
+ "reset": {
+ "title": "Redefinir senha",
+ "message": "Redefinir a senha de “{{username}}”? A senha atual para de funcionar imediatamente e qualquer sessão ativa é encerrada."
+ },
+ "form": {
+ "usernameHint": "Usado para entrar. Letras, dígitos e . _ + @ - (3–64 caracteres).",
+ "usernameError": "Digite um nome de usuário válido (3–64 caracteres).",
+ "usernamePlaceholder": "ex.: joana",
+ "email": "E-mail",
+ "emailHint": "Opcional — deixe em branco se o usuário não tiver e-mail.",
+ "emailError": "Digite um endereço de e-mail válido.",
+ "emailPlaceholder": "ex.: joana@exemplo.com",
+ "nameHint": "Opcional — um nome de exibição.",
+ "rateLimit": "Limite de requisições MCP (requisições/min)",
+ "rateLimitHint": "Limita a chave deste usuário em /api/mcp. 0 = usar o padrão global.",
+ "activeLabel": "Ativo — um usuário inativo não pode entrar e sua chave de API para de funcionar",
+ "adminLabel": "Admin — acesso total ao console (gerenciar usuários, configurações e todos os espaços de trabalho)"
+ },
+ "key": {
+ "title": "Chave de API — {{name}}",
+ "revealMessage": "Copie esta chave agora — ela é exibida por completo apenas uma vez e não pode ser recuperada novamente.",
+ "used": "usada {{ago}}",
+ "neverUsed": "nunca usada",
+ "revokeAria": "Revogar chave",
+ "none": "Este usuário ainda não tem chave de API.",
+ "rotate": "Rotacionar chave",
+ "create": "Criar chave",
+ "rotateHint": "Rotacionar substitui a chave atual — a antiga para de funcionar imediatamente.",
+ "toast": {
+ "revoked": "Chave revogada."
+ }
+ },
+ "grants": {
+ "modalTitle": "Permissões — {{email}}",
+ "intro": {
+ "lead": "As permissões",
+ "emphasis": "restringem este usuário",
+ "rest": ". Sem nenhuma, ele vê e grava tudo. Um espaço de trabalho o limita a ele; adicione uma coleção (ou documento) para restringi-lo a apenas essa."
+ },
+ "error": "Não foi possível carregar as permissões",
+ "empty": {
+ "title": "Nenhuma permissão ainda",
+ "description": "Conceda a este usuário leitura ou escrita em um espaço de trabalho, coleção ou documento acima."
+ },
+ "resourceType": "Tipo de recurso",
+ "type": {
+ "workspace": "Espaço de trabalho",
+ "collection": "Coleção",
+ "document": "Documento",
+ "capability": "Capacidade"
+ },
+ "levelLabel": "Nível",
+ "readOption": "Leitura — buscar, listar, baixar",
+ "writeOption": "Escrita — ingerir, excluir (e ler)",
+ "read": "Leitura",
+ "write": "Escrita",
+ "capabilityLabel": {
+ "create_workspace": "Criar espaços de trabalho",
+ "manage_tags": "Gerenciar tags"
+ },
+ "capabilityHelp": {
+ "create_workspace": "Permite que este usuário crie novos espaços de trabalho — ele obtém acesso de escrita a todos os que criar.",
+ "manage_tags": "Permite que este usuário crie, edite, exclua e atribua tags em qualquer espaço de trabalho que possa ler."
+ },
+ "selectWorkspace": "Selecione um espaço de trabalho…",
+ "select": "Selecione…",
+ "pickWorkspaceFirst": "Escolha um espaço de trabalho primeiro",
+ "documentId": "ID do documento",
+ "documentIdHint": "O id do documento (ex.: doc_…), da sua coleção.",
+ "addPermission": "Adicionar permissão",
+ "deleted": "excluído",
+ "copyIdTitle": "Copiar id: {{id}}",
+ "copyIdAria": "Copiar id {{id}}",
+ "revokeAria": "Revogar permissão",
+ "toast": {
+ "granted": "Permissão concedida.",
+ "revoked": "Permissão revogada."
+ }
+ }
+ },
+ "settings": {
+ "title": "Configurações",
+ "subtitle": "Configuração de runtime, segurança do console e acesso MCP.",
+ "tabs": {
+ "general": "Geral",
+ "mcp": "Servidor MCP"
+ },
+ "config": {
+ "title": "Configuração",
+ "loadError": "Não foi possível carregar a configuração"
+ },
+ "runtime": {
+ "title": "Runtime",
+ "unreachable": "API inacessível",
+ "unreachableDetail": "Não foi possível acessar o endpoint de health.",
+ "service": "Serviço",
+ "version": "Versão",
+ "vectorStore": "Vector store",
+ "embeddingProvider": "Provedor de embedding",
+ "embeddingModel": "Modelo de embedding",
+ "uptime": "Uptime"
+ },
+ "security": {
+ "title": "Segurança",
+ "session": "Sessão",
+ "sessionNote": "Sua credencial é mantida apenas neste navegador. Saia para limpá-la.",
+ "signOut": "Sair"
+ },
+ "field": {
+ "provider": "Provedor",
+ "model": "Modelo",
+ "baseUrl": "URL base",
+ "apiKey": "Chave de API",
+ "apiKeyHint": "Somente escrita; nunca exibida após salvar.",
+ "keySetPlaceholder": "chave definida — deixe em branco para manter",
+ "aiStudioKey": "Chave do AI Studio",
+ "baseUrlRequiredOpenrouter": "obrigatório — ex.: https://openrouter.ai/api",
+ "baseUrlOllamaDefault": "em branco usa o padrão do Ollama",
+ "modelIdPlaceholder": "id do modelo",
+ "ollamaModelsHint": "Modelos do Ollama instalados",
+ "ollamaUnreachable": "Não foi possível acessar o Ollama",
+ "loadingModels": "Carregando modelos…",
+ "noModelsInstalled": "Nenhum modelo instalado"
+ },
+ "provider": {
+ "ollamaLocal": "Ollama (local)",
+ "sentenceTransformers": "Sentence-Transformers (em processo)",
+ "openaiCompat": "Compatível com OpenAI",
+ "gemini": "Google Gemini",
+ "geminiAiStudio": "Google Gemini (AI Studio)"
+ },
+ "storage": {
+ "blurb": "Onde os arquivos enviados são armazenados. O backend ativo é escolhido em config.yaml / .env. A retenção é definida por upload (1–30 dias, ou mantido permanentemente) na página de Documentos — não há configuração global de retenção.",
+ "title": "Armazenamento de objetos",
+ "activeBackend": "Backend ativo"
+ },
+ "parser": {
+ "title": "Análise de PDF",
+ "backend": "Backend",
+ "optionPymupdf": "PyMuPDF — rápido, um chunk por página",
+ "optionDocling": "docling — reconhece seções + tabelas/OCR",
+ "needsGpuSuffix": " (precisa de GPU)",
+ "gpuOlderThanAmpere": "Sua GPU ({{name}}, capacidade {{capability}}) é anterior à Ampere — ",
+ "noGpuDetected": "Nenhuma GPU detectada — ",
+ "doclingCpuWarn": "o docling roda na CPU e é muito lento (minutos por documento, e PDFs grandes podem atingir o limite de tempo de ingestão). O PyMuPDF é recomendado neste hardware.",
+ "save": "Salvar backend de PDF",
+ "detectingGpu": "Detectando GPU…",
+ "compatibleGpu": "GPU compatível detectada ({{name}}, capacidade {{capability}}).",
+ "gpuBelowAmpere": "A GPU {{name}} (capacidade {{capability}}) é anterior à Ampere.",
+ "noCompatibleGpu": "Nenhuma GPU compatível detectada.",
+ "recommendedLabel": "Recomendado:"
+ },
+ "embedding": {
+ "blurb": "O modelo de embedding transforma documentos em vetores. Alterar o provedor, o modelo ou as dimensões de saída muda o tamanho do vetor — as coleções existentes precisam ser reindexadas ou a busca vai quebrar. Alterar apenas uma chave de API é seguro.",
+ "title": "Modelo de embedding",
+ "modelHintGemini": "ex.: gemini-embedding-001",
+ "modelHint": "o id exato do modelo neste servidor",
+ "outputDim": "Dimensões de saída",
+ "outputDimHint": "opcional; em branco = 3072 completo",
+ "maxRpm": "Máx. de requisições / min",
+ "maxRpmHint": "limite os embeds para que a ingestão em massa fique dentro da cota do provedor (cada texto = uma requisição); em branco ou 0 = ilimitado",
+ "maxRpmPlaceholder": "0 (ilimitado)",
+ "reindexRequired": "Reindexação necessária após salvar",
+ "save": "Salvar configuração de embedding"
+ },
+ "reranker": {
+ "blurb": "O reranker reordena os chunks recuperados pela relevância real da consulta antes do corte final — o maior ganho de precisão. Ele é opcional e seguro : se o modelo ou um provedor remoto estiver indisponível, a busca volta para a ordenação normal.",
+ "title": "Reranker",
+ "enable": "Ativar reranking",
+ "providerCrossEncoder": "Cross-encoder (local, sem chave)",
+ "providerRerankApi": "API de rerank hospedada (Cohere / Jina / Voyage)",
+ "providerLlm": "LLM como reranker",
+ "llmProvider": "Provedor de LLM",
+ "modelHintRerankApi": "ex.: rerank-v3.5 (Cohere), jina-reranker-v2 (Jina)",
+ "modelHintGemini": "ex.: gemini-2.5-flash",
+ "modelHintLlm": "o id exato do modelo de chat",
+ "modelHintCrossEncoder": "um id de cross-encoder do HuggingFace; o padrão embutido funciona offline",
+ "baseUrlHintRerankApi": "obrigatório — o endpoint /rerank completo",
+ "topN": "Candidatos avaliados (top_n)",
+ "topNHint": "máximo de candidatos que o reranker avalia por coleção",
+ "save": "Salvar configuração do reranker"
+ },
+ "search": {
+ "blurb": "A expansão de adjacência puxa os chunks vizinhos a cada resultado e os mescla em um único trecho, para que uma seção que se estende por várias páginas volte inteira em vez de cortada no limite do top-K. É uma busca simples — sem chamadas extras a modelos — e segura : se não conseguir buscar, a busca retorna os resultados não expandidos.",
+ "title": "Recuperação",
+ "expand": "Expansão de adjacência (vizinhos)",
+ "expandHint": "chunks puxados de cada lado de um acerto; 0 = desativado, máx {{max}}",
+ "save": "Salvar configuração de busca"
+ },
+ "mcp": {
+ "blurb": "O endpoint do MCP (/api/mcp) limita cada chave de API a esta quantidade de requisições por minuto; a próxima requisição no mesmo minuto retorna 429. Cada chave tem seu próprio orçamento, e o novo limite vale a partir da próxima requisição MCP ",
+ "title": "Servidor MCP",
+ "rateLimit": "Limite de taxa (requisições / min por chave)",
+ "rateLimitHint": "por chave de API em /api/mcp; a próxima requisição no mesmo minuto retorna 429",
+ "save": "Salvar configuração de MCP"
+ },
+ "toast": {
+ "pdfSaved": "Backend de PDF salvo. Os serviços estão recarregando.",
+ "embeddingSaved": "Configuração de embedding salva. Os serviços estão recarregando.",
+ "rerankerSaved": "Configuração do reranker salva. Os serviços estão recarregando.",
+ "searchSaved": "Configuração de busca salva. Os serviços estão recarregando.",
+ "mcpSaved": "Configuração de MCP salva. Os serviços estão recarregando."
+ }
+ },
+ "mcp": {
+ "tagsNote": "As ferramentas de tags precisam da permissão manage_tags; atribuir a uma coleção/documento também exige escrita nele.",
+ "connection": {
+ "title": "Conexão",
+ "address": "Endereço do servidor",
+ "addressHintLocal": "Este endereço só funciona nesta máquina; o servidor não conseguiu determinar um IP de rede local para outros dispositivos usarem.",
+ "addressHint": "Detectado automaticamente a partir do servidor. Usado para montar o endpoint e os trechos abaixo.",
+ "endpoint": "Endpoint",
+ "transport": "Transporte",
+ "authHeader": "Cabeçalho de autenticação",
+ "authNote": "Envie uma chave de API em cada requisição como ou — a chave mestra, ou uma chave por usuário para acesso restrito (cada ferramenta respeita as permissões de leitura/escrita desse usuário). Substitua pela sua nos trechos abaixo; nada fica embutido aqui."
+ },
+ "clientSetup": {
+ "title": "Configuração do cliente",
+ "instructions": "Adicione isto à configuração de servidores do seu cliente MCP (substitua a chave de exemplo):",
+ "copyConfig": "Copiar config"
+ },
+ "skill": {
+ "title": "Skill do agente",
+ "download": "Baixar .zip",
+ "folderNote": "Uma pequena pasta de skill (SKILL.md + references/). Descompacte-a no diretório de skills do seu agente (ex.: .claude/skills/embedbase/) — o agente carrega o SKILL.md logo de início e abre um arquivo de referência apenas quando a tarefa precisa, para que uma sessão somente leitura nunca puxe as regras de upload.",
+ "bundled": "Incluídos:",
+ "unavailable": "A skill está indisponível — não foi possível carregar o catálogo de ferramentas."
+ },
+ "tools": {
+ "title": "Ferramentas",
+ "count_one": "{{count}} ferramenta · cada uma respeita suas permissões",
+ "count_other": "{{count}} ferramentas · cada uma respeita suas permissões",
+ "loadError": "Não foi possível carregar o catálogo de ferramentas"
+ },
+ "rest": {
+ "title": "Referência da API REST",
+ "blurb": "Uma referência OpenAPI independente apenas dos endpoints de integração — busca e acesso a espaços de trabalho/coleções/documentos — para a IA ler como padrão da API."
+ }
+ }
+}
diff --git a/ui/src/i18n/parity.ts b/ui/src/i18n/parity.ts
new file mode 100644
index 0000000..743481a
--- /dev/null
+++ b/ui/src/i18n/parity.ts
@@ -0,0 +1,16 @@
+/**
+ * Compile-time completeness guard: the two catalogs must have identical key sets.
+ * A key present in one locale but missing from the other fails `tsc` (leaf values
+ * are widened to `string`, so differing translations are fine). This module is
+ * never imported at runtime — it exists purely so the typecheck covers it.
+ */
+import en from './locales/en/translation.json'
+import ptBR from './locales/pt-BR/translation.json'
+
+/** Same nested shape as `T`, but every leaf string widened to `string`. */
+type Stringify = { [K in keyof T]: T[K] extends string ? string : Stringify }
+
+// Both assignments together enforce exact key parity: `_ptCoversEn` fails if
+// `pt-BR` is missing an `en` key; `_enCoversPt` fails for the reverse.
+export const _ptCoversEn: Stringify = ptBR
+export const _enCoversPt: Stringify = en
diff --git a/ui/src/lib/format.ts b/ui/src/lib/format.ts
index 8a2903d..0722e94 100644
--- a/ui/src/lib/format.ts
+++ b/ui/src/lib/format.ts
@@ -12,35 +12,47 @@ export function formatUptime(seconds: number): string {
return `${Math.floor(seconds)}s`
}
-const UNITS: [limit: number, secs: number, label: string][] = [
- [60, 1, 's'],
- [3_600, 60, 'm'],
- [86_400, 3_600, 'h'],
- [2_592_000, 86_400, 'd'],
+// Thresholds → the Intl relative-time unit to render at that magnitude.
+const REL_UNITS: [limit: number, secs: number, unit: Intl.RelativeTimeFormatUnit][] = [
+ [60, 1, 'second'],
+ [3_600, 60, 'minute'],
+ [86_400, 3_600, 'hour'],
+ [2_592_000, 86_400, 'day'],
]
-/** Render an ISO timestamp as a coarse relative age (`3m ago`, `2h ago`). */
+/** Render an ISO timestamp as a coarse, localized relative age (`3 min ago` / `há 3 min`). */
export function timeAgo(iso: string): string {
const then = Date.parse(iso)
if (Number.isNaN(then)) return '—'
const diff = Math.max(0, (Date.now() - then) / 1_000)
- for (const [limit, secs, label] of UNITS) {
- if (diff < limit) return `${Math.floor(diff / secs)}${label} ago`
+ for (const [limit, secs, unit] of REL_UNITS) {
+ if (diff < limit) return relFmt.format(-Math.floor(diff / secs), unit)
}
- return `${Math.floor(diff / 2_592_000)}mo ago`
+ return relFmt.format(-Math.floor(diff / 2_592_000), 'month')
}
-const DATE_FMT = new Intl.DateTimeFormat(undefined, {
+const DATE_FMT_OPTS: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric',
-})
+}
+// Both rebuilt on language change (see `i18n/index.ts`) so calendar dates and
+// relative ages track the active language; start on the runtime default until
+// i18n boots.
+let dateFmt = new Intl.DateTimeFormat(undefined, DATE_FMT_OPTS)
+let relFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'always', style: 'narrow' })
+
+/** Point the shared date + relative-time formatters at a new locale (e.g. `pt-BR`). */
+export function setDateLocale(locale: string): void {
+ dateFmt = new Intl.DateTimeFormat(locale, DATE_FMT_OPTS)
+ relFmt = new Intl.RelativeTimeFormat(locale, { numeric: 'always', style: 'narrow' })
+}
/** Render an ISO timestamp as an absolute calendar date (`Jun 12, 2026`). */
export function formatDate(iso: string): string {
const ms = Date.parse(iso)
if (Number.isNaN(ms)) return '—'
- return DATE_FMT.format(ms)
+ return dateFmt.format(ms)
}
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB']
diff --git a/ui/src/main.tsx b/ui/src/main.tsx
index d9e8cb0..4161dda 100644
--- a/ui/src/main.tsx
+++ b/ui/src/main.tsx
@@ -6,6 +6,7 @@ import '@fontsource-variable/public-sans'
import '@fontsource-variable/jetbrains-mono'
import App from './App'
import './index.css'
+import './i18n'
import { ErrorBoundary } from './components/layout/ErrorBoundary'
import { ToastProvider } from './components/ui/Toast'
import { AuthProvider } from './auth/AuthContext'
diff --git a/ui/src/pages/Collections.tsx b/ui/src/pages/Collections.tsx
index c5d24ed..f07a26d 100644
--- a/ui/src/pages/Collections.tsx
+++ b/ui/src/pages/Collections.tsx
@@ -1,6 +1,7 @@
import { useMemo, useState, type MouseEvent } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { ChevronRight, FileText, Layers, Pencil, Plus, Tags as TagsIcon, Trash2 } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import {
useAssignCollectionTag,
useCollections,
@@ -30,6 +31,7 @@ import {
CollectionFormModal,
type CollectionFormValues,
} from '../components/collections/CollectionFormModal'
+import { apiErrorMessage } from '../i18n/apiError'
import { formatDate } from '../lib/format'
import { useAuth } from '../auth/AuthContext'
@@ -52,6 +54,7 @@ function changedFields(col: Collection, values: CollectionFormValues): Collectio
/** Collections within a workspace: list, create, edit, and delete. */
export default function Collections() {
+ const { t } = useTranslation()
const { wsId = '' } = useParams()
const navigate = useNavigate()
const workspace = useWorkspace(wsId)
@@ -85,10 +88,10 @@ export default function Collections() {
if (dialog.kind === 'create') {
createMut.mutate(values, {
onSuccess: () => {
- toast.success(`Collection “${values.name}” created.`)
+ toast.success(t('collections.toast.created', { name: values.name }))
close()
},
- onError: (e) => toast.error(e.message),
+ onError: (e) => toast.error(apiErrorMessage(e, t)),
})
} else if (dialog.kind === 'edit') {
const body = changedFields(dialog.col, values)
@@ -100,10 +103,10 @@ export default function Collections() {
{ colId: dialog.col.id, body },
{
onSuccess: () => {
- toast.success('Collection updated.')
+ toast.success(t('collections.toast.updated'))
close()
},
- onError: (e) => toast.error(e.message),
+ onError: (e) => toast.error(apiErrorMessage(e, t)),
},
)
}
@@ -114,10 +117,10 @@ export default function Collections() {
const { col } = dialog
deleteMut.mutate(col.id, {
onSuccess: () => {
- toast.success(`Collection “${col.name}” deleted.`)
+ toast.success(t('collections.toast.deleted', { name: col.name }))
close()
},
- onError: (e) => toast.error(e.message),
+ onError: (e) => toast.error(apiErrorMessage(e, t)),
})
}
@@ -125,7 +128,7 @@ export default function Collections() {
- Workspaces
+ {t('nav.workspaces')}
{workspace.data?.name ?? '…'}
@@ -133,22 +136,20 @@ export default function Collections() {
-
Collections
-
- Searchable document sets within this workspace.
-
+
{t('collections.title')}
+
{t('collections.subtitle')}
{canTag && (
navigate(`/workspaces/${wsId}/tags`)}>
- Tags
+ {t('common.tags')}
)}
{canWrite && (
setDialog({ kind: 'create' })}>
- New collection
+ {t('collections.new')}
)}
@@ -161,7 +162,7 @@ export default function Collections() {
data={shown}
isLoading={isLoading}
isError={isError}
- message={error?.message}
+ message={error ? apiErrorMessage(error, t) : undefined}
canCreate={canWrite}
onRetry={() => void refetch()}
onCreate={() => setDialog({ kind: 'create' })}
@@ -179,10 +180,10 @@ export default function Collections() {
void
onDelete: (col: Collection) => void
}) {
+ const { t } = useTranslation()
if (isLoading) {
return (
@@ -227,19 +229,15 @@ function CollectionList({
)
}
if (isError) {
- return
+ return
}
if (!data || data.length === 0) {
return (
}
- title="No collections yet"
- description={
- canCreate
- ? 'Create a collection to start ingesting and searching documents.'
- : 'No collections here yet.'
- }
- action={canCreate ? New collection : undefined}
+ title={t('collections.empty.title')}
+ description={canCreate ? t('collections.empty.canCreate') : t('collections.empty.readonly')}
+ action={canCreate ? {t('collections.new')} : undefined}
/>
)
}
@@ -270,6 +268,7 @@ function CollectionCard({
onEdit: (col: Collection) => void
onDelete: (col: Collection) => void
}) {
+ const { t } = useTranslation()
const navigate = useNavigate()
const { isAdmin, canManageTags } = useAuth()
// (Un)assigning a collection tag needs the manage_tags privilege AND write on the collection —
@@ -281,7 +280,7 @@ function CollectionCard({
const unassignMut = useUnassignCollectionTag(wsId)
const createMut = useCreateTag(wsId)
const tagBusy = assignMut.isPending || unassignMut.isPending || createMut.isPending
- const onErr = (e: Error) => toast.error(e.message)
+ const onErr = (e: Error) => toast.error(apiErrorMessage(e, t))
const handleCreate = (name: string) =>
createMut.mutate(
@@ -320,20 +319,20 @@ function CollectionCard({
)}
- {col.description || 'No description'}
+ {col.description || t('common.noDescription')}
{((col.tags ?? []).length > 0 || canManageColTags) && (
e.stopPropagation()}>
- {(col.tags ?? []).map((t) => (
+ {(col.tags ?? []).map((tag) => (
unassignMut.mutate({ colId: col.id, tagId: t.id }, { onError: onErr })
+ ? () => unassignMut.mutate({ colId: col.id, tagId: tag.id }, { onError: onErr })
: undefined
}
/>
@@ -353,13 +352,15 @@ function CollectionCard({
)}
-
Created {formatDate(col.created_at)}
+
+ {t('common.created', { date: formatDate(col.created_at) })}
+
{col.can_write && (
onEdit(col))}
className="h-10 w-10 px-0"
>
@@ -368,7 +369,7 @@ function CollectionCard({
onDelete(col))}
className="h-10 w-10 px-0 hover:text-err"
>
diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx
index c278190..2860c71 100644
--- a/ui/src/pages/Dashboard.tsx
+++ b/ui/src/pages/Dashboard.tsx
@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom'
import { FileClock, FileText, FolderKanban, Layers } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import { useHealth, useRecentDocuments, useWorkspaces } from '../api/hooks'
import type { Workspace } from '../api/types'
import {
@@ -12,17 +13,19 @@ import {
Skeleton,
StatusBadge,
} from '../components/ui'
+import { apiErrorMessage } from '../i18n/apiError'
import { cn } from '../lib/cn'
import { formatUptime, timeAgo } from '../lib/format'
/** Operator landing page: system health, workspace overview, recent activity. */
export default function Dashboard() {
+ const { t } = useTranslation()
return (
@@ -49,13 +52,14 @@ function StatItem({ label, value, online }: { label: string; value: string; onli
/** `GET /healthz` summary card. */
function HealthCard() {
+ const { t } = useTranslation()
const { data, isLoading, isError, error, refetch } = useHealth()
if (isLoading) return
if (isError || !data) {
return (
void refetch()}
/>
)
@@ -64,7 +68,7 @@ function HealthCard() {
return (
-
System
+ {t('dashboard.health.system')}
{data.status}
@@ -72,17 +76,17 @@ function HealthCard() {
-
-
+
+
{data.embedding_provider} · {data.service}
@@ -93,23 +97,24 @@ function HealthCard() {
/** Workspace count plus a compact grid of clickable workspace cards. */
function WorkspaceOverview() {
+ const { t } = useTranslation()
const { data, isLoading, isError, error, refetch } = useWorkspaces()
return (
- Workspaces
+ {t('workspaces.title')}
{data && · {data.length} }
- View all
+ {t('dashboard.viewAll')}
void refetch()}
/>
@@ -130,6 +135,7 @@ function WorkspaceGrid({
message?: string
onRetry: () => void
}) {
+ const { t } = useTranslation()
if (isLoading) {
return (
@@ -139,16 +145,16 @@ function WorkspaceGrid({
)
}
- if (isError) return
+ if (isError) return
if (!data || data.length === 0) {
return (
}
- title="No workspaces yet"
- description="Create your first workspace to start organising collections."
+ title={t('workspaces.empty.title')}
+ description={t('workspaces.empty.canCreate')}
action={
- Go to Workspaces
+ {t('dashboard.goToWorkspaces')}
}
/>
@@ -165,6 +171,7 @@ function WorkspaceGrid({
/** A single workspace card that links through to its collections. */
function WorkspaceCard({ ws }: { ws: Workspace }) {
+ const { t } = useTranslation()
return (
@@ -185,7 +192,7 @@ function WorkspaceCard({ ws }: { ws: Workspace }) {
)}
- {ws.description || 'No description'}
+ {ws.description || t('common.noDescription')}
@@ -195,10 +202,11 @@ function WorkspaceCard({ ws }: { ws: Workspace }) {
/** Most-recently-updated documents aggregated across every collection. */
function RecentActivity() {
+ const { t } = useTranslation()
const { documents, isLoading } = useRecentDocuments()
return (
- Recent activity
+ {t('dashboard.recentActivity')}
)
@@ -211,6 +219,7 @@ function RecentActivityBody({
documents: ReturnType
['documents']
isLoading: boolean
}) {
+ const { t } = useTranslation()
if (isLoading && documents.length === 0) {
return (
@@ -227,8 +236,8 @@ function RecentActivityBody({
return (
}
- title="No recent activity"
- description="Document ingestion across your collections will show up here."
+ title={t('dashboard.noActivity.title')}
+ description={t('dashboard.noActivity.description')}
/>
)
}
diff --git a/ui/src/pages/Documents.tsx b/ui/src/pages/Documents.tsx
index 06e768b..a109231 100644
--- a/ui/src/pages/Documents.tsx
+++ b/ui/src/pages/Documents.tsx
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { AlertCircle, ChevronRight, Database, DatabaseZap, Download, ExternalLink, FileDown, FileText, Trash2 } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import {
useAssignDocumentTag,
useCollection,
@@ -36,6 +37,7 @@ import {
import { UploadZone } from '../components/documents/UploadZone'
import { DocumentFilters, type DocumentFilterValues } from '../components/documents/DocumentFilters'
import { Pager } from '../components/Pager'
+import { apiErrorMessage } from '../i18n/apiError'
import { formatBytes, timeAgo } from '../lib/format'
import { useAuth } from '../auth/AuthContext'
@@ -52,6 +54,7 @@ const PAGE_SIZE = 50
/** Documents within a collection: upload, live ingestion status, and delete. */
export default function Documents() {
+ const { t } = useTranslation()
const { wsId = '', colId = '' } = useParams()
const workspace = useWorkspace(wsId)
const collection = useCollection(wsId, colId)
@@ -113,7 +116,7 @@ export default function Documents() {
// page, so a selected tag stays deselectable even when it filters the list down to zero rows.
const tagOptions = useMemo(() => {
const present = collectTags(data?.items)
- const names = new Set(present.map((t) => t.name))
+ const names = new Set(present.map((tag) => tag.name))
const missing = tagFilter
.filter((n) => !names.has(n))
.map((n) => ({ id: n, name: n, color: null }))
@@ -129,11 +132,11 @@ export default function Documents() {
await uploadMut.mutateAsync({ file: f, retentionDays })
ok += 1
} catch (e) {
- toast.error(`${f.name}: ${(e as Error).message}`)
+ toast.error(t('documents.toast.uploadError', { name: f.name, error: apiErrorMessage(e, t) }))
}
}
setUploading(false)
- if (ok > 0) toast.success(`${ok} file${ok === 1 ? '' : 's'} queued for ingestion.`)
+ if (ok > 0) toast.success(t('documents.toast.queued', { count: ok }))
}
const handleFiles = (files: File[]) => {
@@ -141,7 +144,7 @@ export default function Documents() {
const valid: File[] = []
for (const f of files) {
if (f.size > maxBytes) {
- toast.error(`${f.name} is larger than ${MAX_FILE_SIZE_MB} MB and was skipped.`)
+ toast.error(t('documents.toast.tooLarge', { name: f.name, max: MAX_FILE_SIZE_MB }))
} else {
valid.push(f)
}
@@ -155,10 +158,10 @@ export default function Documents() {
const doc = deleteTarget
deleteMut.mutate(doc.document_id, {
onSuccess: () => {
- toast.success(`Deleted “${doc.filename}”.`)
+ toast.success(t('documents.toast.deleted', { name: doc.filename }))
setDeleteTarget(null)
},
- onError: (e) => toast.error(e.message),
+ onError: (e) => toast.error(apiErrorMessage(e, t)),
})
}
@@ -166,7 +169,7 @@ export default function Documents() {
- Workspaces
+ {t('nav.workspaces')}
@@ -177,11 +180,9 @@ export default function Documents() {
@@ -198,7 +199,7 @@ export default function Documents() {
onChange={(e) => setRetentionDays(e.target.checked ? DEFAULT_RETENTION_DAYS : null)}
className="h-4 w-4 accent-accent"
/>
- Temporary — automatically delete after
+ {t('documents.retention.label')}
setRetentionDays(clampRetention(Number(e.target.value)))}
- aria-label="Retention in days"
+ aria-label={t('documents.retention.aria')}
className="w-16 rounded-control border border-border bg-surface px-2 py-1 text-ink disabled:opacity-40"
/>
-
day{retentionDays === 1 ? '' : 's'}
+
{t('documents.retention.day', { count: retentionDays ?? DEFAULT_RETENTION_DAYS })}
)}
@@ -228,7 +229,7 @@ export default function Documents() {
filtered={hasFilters}
isLoading={isLoading}
isError={isError}
- message={error?.message}
+ message={error ? apiErrorMessage(error, t) : undefined}
onRetry={() => void refetch()}
onDelete={setDeleteTarget}
/>
@@ -244,10 +245,10 @@ export default function Documents() {
void
onDelete: (doc: DocumentSummary) => void
}) {
+ const { t } = useTranslation()
const progressById = useIngestionProgress(wsId, colId)
if (isLoading) {
return (
@@ -296,20 +298,20 @@ function DocumentList({
)
}
if (isError) {
- return
+ return
}
if (!data || data.length === 0) {
return filtered ? (
}
- title="No matching documents"
- description="No documents match the current filters. Adjust or clear them to see more."
+ title={t('documents.empty.filtered.title')}
+ description={t('documents.empty.filtered.description')}
/>
) : (
}
- title="No documents yet"
- description="Drop files into the area above to start ingesting them."
+ title={t('documents.empty.none.title')}
+ description={t('documents.empty.none.description')}
/>
)
}
@@ -337,9 +339,10 @@ function DocumentList({
* parsing/embedding/storing. doc.status can't drive this — the list only refetches on
* the terminal event, so it's stale at "pending" for the whole run. */
function IngestProgress({ progress }: { progress?: IngestionProgress }) {
+ const { t } = useTranslation()
const pct = progress?.pct ?? null
const determinate = pct != null
- const label = progress ? 'Ingesting' : 'Uploading'
+ const label = progress ? t('documents.progress.ingesting') : t('status.uploading')
return (
@@ -378,6 +381,7 @@ function DocumentRow({
progress?: IngestionProgress
onDelete: (doc: DocumentSummary) => void
}) {
+ const { t } = useTranslation()
const { isAdmin, canManageTags } = useAuth()
// (Un)assigning a document tag needs the manage_tags privilege AND write on the resource —
// mirror the backend (authorize_tag_management + authorize_document write) so we don't show a
@@ -393,7 +397,7 @@ function DocumentRow({
const unassignMut = useUnassignDocumentTag(wsId, colId)
const createMut = useCreateTag(wsId)
const tagBusy = assignMut.isPending || unassignMut.isPending || createMut.isPending
- const onErr = (e: Error) => toast.error(e.message)
+ const onErr = (e: Error) => toast.error(apiErrorMessage(e, t))
const handleCreate = (name: string) =>
createMut.mutate(
@@ -436,9 +440,10 @@ function DocumentRow({
{doc.file_type.toUpperCase()} · {formatBytes(doc.file_size)}
{doc.chunk_count != null &&
- ` · ${doc.chunk_count} chunk${doc.chunk_count === 1 ? '' : 's'}`}{' '}
- · updated {timeAgo(doc.updated_at)}
- {doc.has_original && ` · original: ${doc.original_filename ?? doc.filename}`}
+ ` · ${t('documents.row.chunk', { count: doc.chunk_count })}`}{' '}
+ · {t('documents.row.updated', { ago: timeAgo(doc.updated_at) })}
+ {doc.has_original &&
+ ` · ${t('documents.row.original', { name: doc.original_filename ?? doc.filename })}`}
@@ -449,7 +454,7 @@ function DocumentRow({
onClick={() => setShowError((v) => !v)}
className="text-xs font-medium text-err hover:underline"
>
- {showError ? 'Hide' : 'Why?'}
+ {showError ? t('documents.row.hide') : t('documents.row.why')}
)}
{failed && canWrite && (
@@ -458,13 +463,13 @@ function DocumentRow({
disabled={reprocessMut.isPending}
onClick={() =>
reprocessMut.mutate(doc.document_id, {
- onSuccess: () => toast.success(`Reprocessing “${doc.filename}”.`),
+ onSuccess: () => toast.success(t('documents.toast.reprocessing', { name: doc.filename })),
onError: onErr,
})
}
className="text-xs font-medium text-accent hover:underline disabled:opacity-60"
>
- {reprocessMut.isPending ? 'Retrying…' : 'Retry'}
+ {reprocessMut.isPending ? t('common.retrying') : t('common.retry')}
)}
{doc.status === 'pending' || doc.status === 'processing' ? (
@@ -478,7 +483,7 @@ function DocumentRow({
canWrite={canWrite}
onIndex={() =>
indexMut.mutate(doc.document_id, {
- onSuccess: () => toast.success(`Indexing “${doc.filename}”.`),
+ onSuccess: () => toast.success(t('documents.toast.indexing', { name: doc.filename })),
onError: onErr,
})
}
@@ -486,7 +491,7 @@ function DocumentRow({
void api.openDocument(doc.document_id).catch((e) => onErr(e as Error))}
className="h-10 w-10 px-0"
>
@@ -495,7 +500,7 @@ function DocumentRow({
void api.downloadDocument(doc.document_id, doc.filename).catch((e) => onErr(e as Error))
}
@@ -507,7 +512,7 @@ function DocumentRow({
void api
.downloadDocument(doc.document_id, doc.original_filename ?? doc.filename, {
@@ -524,7 +529,7 @@ function DocumentRow({
onDelete(doc)}
className="h-10 w-10 px-0 hover:text-err"
>
@@ -535,16 +540,16 @@ function DocumentRow({
{((doc.tags ?? []).length > 0 || canManageDocTags) && (
- {(doc.tags ?? []).map((t) => (
+ {(doc.tags ?? []).map((tag) => (
unassignMut.mutate(
- { docId: doc.document_id, tagId: t.id },
+ { docId: doc.document_id, tagId: tag.id },
{ onError: onErr },
)
: undefined
@@ -589,15 +594,16 @@ function IndexBadge({
canWrite: boolean
onIndex: () => void
}) {
+ const { t } = useTranslation()
if (doc.status !== 'done') return null
if (doc.indexed) {
return (
- Indexed
+ {t('documents.index.indexed')}
)
}
@@ -608,23 +614,24 @@ function IndexBadge({
type="button"
onClick={onIndex}
disabled={busy}
- title="Not in the keyword index — click to index"
+ title={t('documents.index.notIndexedTitle')}
className="inline-flex items-center gap-1 rounded-full border border-dashed border-warn/40 px-2 py-0.5 text-xs font-medium text-warn transition-colors hover:bg-warn/5 disabled:opacity-60"
>
- {busy ? 'Indexing…' : 'Index'}
+ {busy ? t('documents.index.indexing') : t('documents.index.index')}
)
}
/** Lazily fetch and show a failed document's ingestion error. */
function FailureReason({ wsId, colId, docId }: { wsId: string; colId: string; docId: string }) {
+ const { t } = useTranslation()
const { data, isLoading, isError } = useDocumentStatus(wsId, colId, docId, true)
const text = isLoading
- ? 'Loading error…'
+ ? t('documents.failure.loading')
: isError
- ? 'Could not load the error detail.'
- : (data?.error ?? 'No error detail recorded.')
+ ? t('documents.failure.loadError')
+ : (data?.error ?? t('documents.failure.none'))
return (
diff --git a/ui/src/pages/Graph.tsx b/ui/src/pages/Graph.tsx
index 979be6a..ba17fa0 100644
--- a/ui/src/pages/Graph.tsx
+++ b/ui/src/pages/Graph.tsx
@@ -1,13 +1,16 @@
import { useEffect, useMemo, useState } from 'react'
import { Download, ExternalLink, RotateCcw, Workflow, X } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import { useCollections, useGraph, useWorkspaces } from '../api/hooks'
import { api } from '../api/client'
import type { GraphNode, GraphResponse } from '../api/types'
import { GraphCanvas } from '../components/graph/GraphCanvas'
import { Badge, Button, Card, EmptyState, QueryError, Select, Skeleton, useToast } from '../components/ui'
+import { apiErrorMessage } from '../i18n/apiError'
/** Tag-correlation graph: pick a scope, see files linked through their tags. */
export default function Graph() {
+ const { t } = useTranslation()
const workspaces = useWorkspaces()
const [wsId, setWsId] = useState('')
const [colId, setColId] = useState('') // '' = whole workspace
@@ -34,11 +37,9 @@ export default function Graph() {
-
Graph
+
{t('graph.title')}
- Files (the larger nodes) linked through the tags they share. Hotter tag nodes are
- more-reused. Drag a node to pin and highlight it, click it to release; tune the repel
- force or reset to re-center.
+ {t('graph.subtitle')}
@@ -46,7 +47,7 @@ export default function Graph() {
value={wsId}
onChange={(e) => setWsId(e.target.value)}
disabled={!workspaces.data || workspaces.data.length === 0}
- aria-label="Workspace"
+ aria-label={t('graph.workspaceAria')}
className="w-44"
>
{workspaces.data?.map((ws) => (
@@ -58,10 +59,10 @@ export default function Graph() {
setColId(e.target.value)}
- aria-label="Collection"
+ aria-label={t('graph.collectionAria')}
className="w-44"
>
- All collections
+ {t('graph.allCollections')}
{collections.data?.map((col) => (
{col.name}
@@ -74,7 +75,7 @@ export default function Graph() {
void graph.refetch()}
data={graph.data}
selected={selected}
@@ -111,14 +112,15 @@ function GraphBody({
fitNonce: number
onReset: () => void
}) {
+ const { t } = useTranslation()
if (isLoading) return
- if (isError) return
+ if (isError) return
if (!data || data.nodes.length === 0) {
return (
}
- title="Nothing to graph yet"
- description="Tag some documents in this scope and they'll appear here, linked through their shared tags."
+ title={t('graph.empty.title')}
+ description={t('graph.empty.description')}
/>
)
}
@@ -131,10 +133,10 @@ function GraphBody({
size="sm"
onClick={onReset}
className="absolute right-3 top-3"
- aria-label="Reset view"
+ aria-label={t('graph.resetView')}
>
- Reset
+ {t('graph.reset')}
{selected && onSelect(null)} />}
@@ -143,17 +145,18 @@ function GraphBody({
/** Cold→hot gradient key for tag heat, anchored to the busiest tag. */
function HeatLegend({ maxHeat }: { maxHeat: number }) {
+ const { t } = useTranslation()
if (maxHeat <= 0) return null
return (
-
Tag heat
+
{t('graph.heat.title')}
1
- {maxHeat} files
+ {t('graph.heat.files', { count: maxHeat })}
)
@@ -169,6 +172,7 @@ function DetailPanel({
nodeId: string
onClose: () => void
}) {
+ const { t } = useTranslation()
const toast = useToast()
const node = useMemo(() => graph.nodes.find((n) => n.id === nodeId), [graph, nodeId])
const neighbors = useMemo(() => neighborLabels(graph, nodeId), [graph, nodeId])
@@ -180,7 +184,7 @@ function DetailPanel({
{node.kind}
{node.label}
-
+
@@ -191,28 +195,28 @@ function DetailPanel({
variant="secondary"
size="sm"
className="flex-1"
- onClick={() => void api.openDocument(node.id).catch((e) => toast.error((e as Error).message))}
+ onClick={() => void api.openDocument(node.id).catch((e) => toast.error(apiErrorMessage(e, t)))}
>
- Open
+ {t('graph.open')}
- void api.downloadDocument(node.id, node.label).catch((e) => toast.error((e as Error).message))
+ void api.downloadDocument(node.id, node.label).catch((e) => toast.error(apiErrorMessage(e, t)))
}
>
- Download
+ {t('graph.download')}
)}
{neighbors.length > 0 && (
- {node.kind === 'tag' ? 'Files' : 'Tags'}
+ {node.kind === 'tag' ? t('graph.files') : t('common.tags')}
{neighbors.map((label) => (
@@ -227,11 +231,11 @@ function DetailPanel({
/** The kind-specific stat line for a node. */
function NodeFacts({ node }: { node: GraphNode }) {
+ const { t } = useTranslation()
if (node.kind === 'tag') {
return (
- Used by {node.heat} file
- {node.heat === 1 ? '' : 's'}.
+ {t('graph.node.usedBy', { count: node.heat })}
)
}
@@ -239,7 +243,7 @@ function NodeFacts({ node }: { node: GraphNode }) {
return (
{type ? `${type.toUpperCase()} · ` : ''}
- {node.degree} tag{node.degree === 1 ? '' : 's'}.
+ {t('graph.node.tags', { count: node.degree })}
)
}
diff --git a/ui/src/pages/Indexing.tsx b/ui/src/pages/Indexing.tsx
index b6b935f..907cd9d 100644
--- a/ui/src/pages/Indexing.tsx
+++ b/ui/src/pages/Indexing.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { AlertTriangle, DatabaseZap, RefreshCw } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import { useAuth } from '../auth/AuthContext'
import { useIndexStatus, useRetryFailedJobs } from '../api/hooks'
import type { CollectionIndexStatus } from '../api/types'
@@ -12,9 +13,11 @@ import {
Skeleton,
useToast,
} from '../components/ui'
+import { apiErrorMessage } from '../i18n/apiError'
/** Keyword-index coverage per workspace → collection, with a retry for failed ingestions. */
export default function Indexing() {
+ const { t } = useTranslation()
const { data, isLoading, isError, error, refetch } = useIndexStatus()
if (isLoading) {
@@ -34,8 +37,8 @@ export default function Indexing() {
void refetch()}
/>
@@ -48,8 +51,8 @@ export default function Indexing() {
{workspaces.length === 0 ? (
}
- title="Nothing to index yet"
- description="Upload documents to a collection and they'll appear here."
+ title={t('indexing.empty.title')}
+ description={t('indexing.empty.description')}
/>
) : (
@@ -72,14 +75,12 @@ export default function Indexing() {
}
function Header() {
+ const { t } = useTranslation()
return (
- Indexing
+ {t('indexing.title')}
- Keyword-index coverage per collection. A document is searchable once its chunks are
- stored — the keyword index is maintained automatically, so there is nothing to rebuild.
- Anything short of full coverage is an ingestion that has not finished; retry the failed
- ones here.
+ {t('indexing.subtitle')}
)
@@ -94,6 +95,7 @@ function Header() {
* documents — which re-embeds them, hence the confirmation. In-flight ones are left to finish.
*/
function CollectionRow({ col }: { col: CollectionIndexStatus }) {
+ const { t } = useTranslation()
// A non-admin sees coverage for their permitted collections but can't retry (re-ingest).
const { isAdmin } = useAuth()
const toast = useToast()
@@ -101,18 +103,18 @@ function CollectionRow({ col }: { col: CollectionIndexStatus }) {
const [confirming, setConfirming] = useState(false)
const pct = col.total > 0 ? Math.round((col.indexed / col.total) * 100) : 100
const fullyIndexed = col.unindexed === 0
- const failedDocs = `${col.failed} failed document${col.failed === 1 ? '' : 's'}`
+ const failedDocs = t('indexing.failedDocs', { count: col.failed })
const retryFailed = () =>
retryMut.mutate(
{ collection_id: col.collection_id },
{
onSuccess: ({ retried }) => {
- toast.success(`Re-queued ${retried} document${retried === 1 ? '' : 's'}.`)
+ toast.success(t('indexing.toast.requeued', { count: retried }))
setConfirming(false)
},
// Leave the dialog open on failure so it can be retried from there.
- onError: (e) => toast.error(e.message),
+ onError: (e) => toast.error(apiErrorMessage(e, t)),
},
)
@@ -125,7 +127,7 @@ function CollectionRow({ col }: { col: CollectionIndexStatus }) {
{!fullyIndexed && (
- {col.unindexed} unindexed
+ {t('indexing.unindexed', { count: col.unindexed })}
)}
@@ -137,9 +139,9 @@ function CollectionRow({ col }: { col: CollectionIndexStatus }) {
/>
- {col.indexed}/{col.total} indexed
- {col.pending > 0 && ` · ${col.pending} ingesting`}
- {col.failed > 0 && ` · ${col.failed} failed`}
+ {t('indexing.coverage', { indexed: col.indexed, total: col.total })}
+ {col.pending > 0 && t('indexing.pendingSuffix', { count: col.pending })}
+ {col.failed > 0 && t('indexing.failedSuffix', { count: col.failed })}
@@ -153,16 +155,19 @@ function CollectionRow({ col }: { col: CollectionIndexStatus }) {
onClick={() => setConfirming(true)}
>
- Retry failed
+ {t('indexing.retryFailed')}
)}
setConfirming(false)}
diff --git a/ui/src/pages/IngestionQueue.tsx b/ui/src/pages/IngestionQueue.tsx
index 583c785..4c39130 100644
--- a/ui/src/pages/IngestionQueue.tsx
+++ b/ui/src/pages/IngestionQueue.tsx
@@ -13,6 +13,7 @@ import {
X,
XCircle,
} from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import {
Badge,
Button,
@@ -25,6 +26,7 @@ import {
useToast,
} from '../components/ui'
import type { BadgeStatus } from '../components/ui'
+import { apiErrorMessage } from '../i18n/apiError'
import { Pager } from '../components/Pager'
import {
FILE_TYPES,
@@ -51,6 +53,7 @@ const PAGE_SIZE = 50
* jobs start and settle, so page 1 stays current without polling.
*/
export default function IngestionQueue() {
+ const { t } = useTranslation()
// A non-admin views the queue scoped to their grants (the backend filters jobs, stats, and the
// live stream); retrying re-ingests documents, so it stays an admin action.
const { isAdmin } = useAuth()
@@ -76,12 +79,12 @@ export default function IngestionQueue() {
onSuccess: ({ retried }) => {
toast.success(
retried > 0
- ? `Re-enqueued ${retried} failed document${retried === 1 ? '' : 's'}.`
- : 'No failed documents matched the current filters.',
+ ? t('queue.toast.reenqueued', { count: retried })
+ : t('queue.toast.noneMatched'),
)
setConfirmRetry(false)
},
- onError: (e) => toast.error(e.message),
+ onError: (e) => toast.error(apiErrorMessage(e, t)),
})
}
const lastPage = Math.max(0, Math.ceil(total / PAGE_SIZE) - 1)
@@ -119,7 +122,7 @@ export default function IngestionQueue() {
filtered={hasFilters}
isLoading={isLoading}
isError={isError}
- message={error?.message}
+ message={error ? apiErrorMessage(error, t) : undefined}
onRetry={() => void refetch()}
/>
setConfirmRetry(false)}
@@ -172,27 +175,27 @@ function Header({
retrying: boolean
onRetryAll: () => void
}) {
+ const { t } = useTranslation()
const processing = counts.processing ?? 0
const pending = counts.pending ?? 0
const waiting = counts.rate_limited ?? 0
const parts: string[] = []
- if (processing > 0) parts.push(`${processing} ingesting`)
- if (pending > 0) parts.push(`${pending} queued`)
- if (waiting > 0) parts.push(`${waiting} waiting on rate limit`)
+ if (processing > 0) parts.push(t('queue.parts.ingesting', { count: processing }))
+ if (pending > 0) parts.push(t('queue.parts.queued', { count: pending }))
+ if (waiting > 0) parts.push(t('queue.parts.waiting', { count: waiting }))
return (
- Ingestion Queue
+ {t('nav.ingestionQueue')}
- {parts.length ? parts.join(' · ') : 'Queue empty'}
+ {parts.length ? parts.join(' · ') : t('queue.queueEmpty')}
{pausedSeconds > 0 && (
{' '}
- · paused on the embedding provider's quota — retrying in{' '}
- {formatBackoff(pausedSeconds)}
+ {t('queue.paused', { time: formatBackoff(pausedSeconds) })}
)}
@@ -203,12 +206,12 @@ function Header({
{canRetryAll && (
- {retrying ? 'Retrying…' : 'Retry all failed'}
+ {retrying ? t('common.retrying') : t('queue.retryAllFailed')}
)}
- {connected ? 'Live' : 'Connecting…'}
+ {connected ? t('queue.live') : t('queue.connecting')}
@@ -237,6 +240,7 @@ function JobFilters({
value: JobFilterValues
onChange: (next: JobFilterValues) => void
}) {
+ const { t } = useTranslation()
const [expanded, setExpanded] = useState(false)
const set = (key: K, v: JobFilterValues[K]) =>
onChange({ ...value, [key]: v })
@@ -249,34 +253,34 @@ function JobFilters({
set('filename', text(e.target.value))}
/>
set('status', text(e.target.value))}
>
- Any status
+ {t('documents.filters.anyStatus')}
{STATUSES.map((s) => (
- {s}
+ {t(`status.${s}`)}
))}
set('file_type', text(e.target.value))}
>
- Any type
- {FILE_TYPES.map((t) => (
-
- {t}
+ {t('documents.filters.anyType')}
+ {FILE_TYPES.map((ft) => (
+
+ {ft}
))}
@@ -288,7 +292,7 @@ function JobFilters({
}`}
>
- More
+ {t('common.more')}
{active && (
- Clear
+ {t('common.clear')}
)}
@@ -306,12 +310,12 @@ function JobFilters({
set('collection', text(e.target.value))}
/>
- Created
+ {t('documents.filters.created')}
void
}) {
+ const { t } = useTranslation()
if (isLoading) {
return (
@@ -366,20 +371,20 @@ function QueueBody({
)
}
if (isError) {
- return
+ return
}
if (jobs.length === 0) {
return filtered ? (
}
- title="No matching jobs"
- description="No ingestion jobs match the current filters. Adjust or clear them to see more."
+ title={t('queue.empty.filtered.title')}
+ description={t('queue.empty.filtered.description')}
/>
) : (
}
- title="No ingestions yet"
- description="Upload a document and its chunks will stream here in real time as they're embedded."
+ title={t('queue.empty.none.title')}
+ description={t('queue.empty.none.description')}
/>
)
}
@@ -418,15 +423,16 @@ function OpenInCollection({
collectionId: string
filename: string
}) {
+ const { t } = useTranslation()
if (!workspaceId) return null
return (
- Open
+ {t('queue.open')}
)
}
@@ -434,6 +440,7 @@ function OpenInCollection({
/** A static queue row for a job with no live stream (finished, failed, or not currently in the
* live buffer). The failure reason ships in the row payload, so no extra request is needed. */
function JobRow({ job }: { job: JobSummary }) {
+ const { t } = useTranslation()
const { isAdmin } = useAuth()
const [showError, setShowError] = useState(false)
const failed = job.status === 'failed'
@@ -447,9 +454,9 @@ function JobRow({ job }: { job: JobSummary }) {
{job.filename}
- {job.collection_name ?? 'Unknown collection'} · {job.file_type.toUpperCase()}
+ {job.collection_name ?? t('queue.unknownCollection')} · {job.file_type.toUpperCase()}
{job.chunk_count != null &&
- ` · ${job.chunk_count} chunk${job.chunk_count === 1 ? '' : 's'}`}{' '}
+ ` · ${t('documents.row.chunk', { count: job.chunk_count })}`}{' '}
· {timeAgo(job.updated_at)}
@@ -466,7 +473,7 @@ function JobRow({ job }: { job: JobSummary }) {
onClick={() => setShowError((v) => !v)}
className="text-xs font-medium text-err hover:underline"
>
- {showError ? 'Hide' : 'Why?'}
+ {showError ? t('documents.row.hide') : t('documents.row.why')}
)}
{failed && isAdmin && (
@@ -475,13 +482,13 @@ function JobRow({ job }: { job: JobSummary }) {
disabled={reprocessMut.isPending}
onClick={() =>
reprocessMut.mutate(job.document_id, {
- onSuccess: () => toast.success(`Reprocessing “${job.filename}”.`),
- onError: (e) => toast.error(e.message),
+ onSuccess: () => toast.success(t('documents.toast.reprocessing', { name: job.filename })),
+ onError: (e) => toast.error(apiErrorMessage(e, t)),
})
}
className="text-xs font-medium text-accent hover:underline disabled:opacity-60"
>
- {reprocessMut.isPending ? 'Retrying…' : 'Retry'}
+ {reprocessMut.isPending ? t('common.retrying') : t('common.retry')}
)}
@@ -508,6 +515,7 @@ const PHASE_STYLE: Record
= {
}
function QueueCard({ item }: { item: QueueItem }) {
+ const { t } = useTranslation()
const determinate = item.pct != null
const terminal = item.status === 'done' || item.status === 'failed'
return (
@@ -542,11 +550,11 @@ function QueueCard({ item }: { item: QueueItem }) {
{item.status === 'rate_limited' && item.retry_at ? (
) : (
- {item.phase}
+ {t(`queue.phase.${item.phase}`)}
)}
{item.total != null && (
- {item.current ?? 0} / {item.total} chunks
+ {t('queue.chunkProgress', { current: item.current ?? 0, total: item.total })}
)}
@@ -566,7 +574,7 @@ function QueueCard({ item }: { item: QueueItem }) {
{item.recentChunks.length > 0 && (
- Chunks {terminal ? '' : '(latest)'}
+ {t('queue.chunks')} {terminal ? '' : t('queue.chunksLatest')}