Skip to content

Commit 3235c4f

Browse files
committed
feat(web): add French / Spanish / Russian UI languages
Expands the interface beyond zh/en to the remaining UN official languages (minus Arabic for now — RTL is a separate effort): - SUPPORTED_LANGUAGES gains fr/es/ru; the LanguageToggle becomes a dropdown menu (radio group of every language by its autonym) instead of a 2-way toggle, and initial-language detection matches the browser locale's base subtag against the supported set. - Full fr/es/ru translations for all eight namespaces (common, home, kbList, kb, kbSettings, settings, chat, artifacts), preserving every {{placeholder}} and <0/> tag; Russian includes the one/few/many/other plural forms. Each locale has complete key coverage vs en (no zh fallback at runtime). Verified: build passes (i18n:check en/zh parity + no-hardcoded-CJK, tsc, vite); the menu lists 中文/English/Français/Español/Русский and switching to Français translates the whole UI (headless Chromium). Note: fr/es/ru strings are machine translations and should get a native-speaker review pass before release.
1 parent ed06fba commit 3235c4f

28 files changed

Lines changed: 1181 additions & 19 deletions

frontend/src/lib/i18n.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import i18n from "i18next"
22
import { initReactI18next } from "react-i18next"
33
import resourcesToBackend from "i18next-resources-to-backend"
44

5-
export const SUPPORTED_LANGUAGES = ["zh", "en"] as const
5+
export const SUPPORTED_LANGUAGES = ["zh", "en", "fr", "es", "ru"] as const
66
export type Language = (typeof SUPPORTED_LANGUAGES)[number]
77

88
/** All namespaces. Only `common` is preloaded (see `ns` below); every other

frontend/src/lib/language.tsx

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { createContext, useContext, useState, type ReactNode } from "react"
22
import { Languages } from "lucide-react"
33
import { useTranslation } from "react-i18next"
4-
import i18n, { type Language } from "@/lib/i18n"
4+
import i18n, { type Language, SUPPORTED_LANGUAGES } from "@/lib/i18n"
5+
import {
6+
DropdownMenu,
7+
DropdownMenuContent,
8+
DropdownMenuRadioGroup,
9+
DropdownMenuRadioItem,
10+
DropdownMenuTrigger,
11+
} from "@/components/ui/dropdown-menu"
512

613
const KEY = "openkb_lang"
714

@@ -14,11 +21,14 @@ const LanguageCtx = createContext<{
1421
* "zh" → zh, else en — `en` is the only non-Chinese bucket for the 2-locale
1522
* scope); else the zh fallback. Detection only picks the INITIAL value. */
1623
function detectInitial(): Language {
24+
const supported = SUPPORTED_LANGUAGES as readonly string[]
1725
const stored = localStorage.getItem(KEY)
18-
if (stored === "zh" || stored === "en") return stored
26+
if (stored && supported.includes(stored)) return stored as Language
27+
// Match the browser/OS language by its base subtag ("fr-CA" → "fr").
1928
const nav = (navigator.languages?.[0] ?? navigator.language ?? "").toLowerCase()
20-
if (nav.startsWith("zh")) return "zh"
21-
return nav ? "en" : "zh"
29+
const code = nav.split("-")[0]
30+
if (supported.includes(code)) return code as Language
31+
return "en"
2232
}
2333

2434
export function LanguageProvider({ children }: { children: ReactNode }) {
@@ -43,21 +53,36 @@ export function useLanguage() {
4353
return ctx
4454
}
4555

46-
/** Language toggle icon button (toggles between zh and en). Same `h-8 w-8`
47-
* shape as ThemeToggle; slots into App.tsx's top-right chrome pill next to it. */
56+
/** Language menu button. A dropdown of every supported language (shown by its
57+
* own autonym); same `h-8 w-8` shape as ThemeToggle, slots into App.tsx's
58+
* top-right chrome pill next to it. */
4859
export function LanguageToggle({ className }: { className?: string }) {
4960
const { language, setLanguage } = useLanguage()
5061
const { t } = useTranslation("common")
51-
const next: Language = language === "zh" ? "en" : "zh"
52-
const label = language === "zh" ? t("language.zh") : t("language.en")
62+
const label = t(`language.${language}`)
5363
return (
54-
<button
55-
onClick={() => setLanguage(next)}
56-
title={t("language.toggleTitle", { label })}
57-
aria-label={t("language.toggleAria", { label })}
58-
className={`grid h-8 w-8 place-items-center rounded-lg ${className ?? ""}`}
59-
>
60-
<Languages className="w-4 h-4" />
61-
</button>
64+
<DropdownMenu>
65+
<DropdownMenuTrigger asChild>
66+
<button
67+
title={t("language.toggleTitle", { label })}
68+
aria-label={t("language.toggleAria", { label })}
69+
className={`grid h-8 w-8 place-items-center rounded-lg ${className ?? ""}`}
70+
>
71+
<Languages className="w-4 h-4" />
72+
</button>
73+
</DropdownMenuTrigger>
74+
<DropdownMenuContent align="end">
75+
<DropdownMenuRadioGroup
76+
value={language}
77+
onValueChange={(v) => setLanguage(v as Language)}
78+
>
79+
{SUPPORTED_LANGUAGES.map((lng) => (
80+
<DropdownMenuRadioItem key={lng} value={lng}>
81+
{t(`language.${lng}`)}
82+
</DropdownMenuRadioItem>
83+
))}
84+
</DropdownMenuRadioGroup>
85+
</DropdownMenuContent>
86+
</DropdownMenu>
6287
)
6388
}

frontend/src/locales/en/common.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@
3737
"language": {
3838
"zh": "中文",
3939
"en": "English",
40-
"toggleTitle": "Language: {{label}} (click to switch)",
40+
"fr": "Français",
41+
"es": "Español",
42+
"ru": "Русский",
43+
"toggleTitle": "Language: {{label}}",
4144
"toggleAria": "Language: {{label}}"
4245
},
4346
"auth": {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"card": {
3+
"done": "Generado",
4+
"open": "Abrir"
5+
},
6+
"deck": {
7+
"subtitle": "Deck HTML · {{name}}"
8+
},
9+
"file": {
10+
"subtitle": "Archivo HTML · {{name}}"
11+
},
12+
"graph": {
13+
"label": "Grafo de conocimiento",
14+
"empty": "Aún no hay grafo de conceptos que visualizar",
15+
"subtitle_one": "{{count}} concepto · {{edges}} enlaces",
16+
"subtitle_other": "{{count}} conceptos · {{edges}} enlaces",
17+
"subtitleWithTypes_one": "{{count}} concepto · {{edges}} enlaces · {{types}} tipos",
18+
"subtitleWithTypes_other": "{{count}} conceptos · {{edges}} enlaces · {{types}} tipos"
19+
},
20+
"skill": {
21+
"downloadError": "No se pudo descargar el paquete de la habilidad: {{error}}",
22+
"body": "La habilidad se ha generado como un directorio instalable. Descarga el <0>.zip</0>, descomprímelo en la carpeta de habilidades de tu agente (Claude Code / Codex / Gemini CLI) y estará lista para usar.",
23+
"download": "Descargar paquete de habilidad (.zip)"
24+
},
25+
"unknown": "Artefacto desconocido",
26+
"previewUnsupported": "Este tipo de artefacto no se puede previsualizar",
27+
"loadError": "No se pudo cargar el artefacto: {{error}}",
28+
"openError": "Error al abrir: {{error}}",
29+
"downloadError": "Error al descargar: {{error}}",
30+
"panel": {
31+
"resize": "Arrastra para cambiar el ancho",
32+
"openTab": "Abrir en una pestaña nueva",
33+
"download": "Descargar"
34+
}
35+
}

frontend/src/locales/es/chat.json

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
{
2+
"phase": {
3+
"generating": "Generando (llamada al LLM, puede tardar unos minutos)…",
4+
"done": "Listo",
5+
"preparing": "Preparando…",
6+
"buildingGraph": "Construyendo el grafo de conceptos…"
7+
},
8+
"artifact": {
9+
"failed": "Error en la generación"
10+
},
11+
"sources": {
12+
"heading": "Fuentes",
13+
"pageTip": "Haz clic para ver esta página del wiki",
14+
"internalTip": "Contenido interno de un documento largo (PageIndex); no hay página independiente que abrir"
15+
},
16+
"reading": {
17+
"page": "Leyendo <0>{{label}}</0>",
18+
"doc": "Leyendo el documento largo <0>{{label}}</0>",
19+
"thinking": "Pensando…"
20+
},
21+
"step": {
22+
"read": "Leído <0>{{path}}</0>"
23+
},
24+
"requestError": "La solicitud falló: {{error}}",
25+
"savedTo": "Guardado en {{path}}",
26+
"usage": {
27+
"deck": "Uso: /deck <nombre> <intención>, p. ej. «retrieval-intro presentar la recuperación sin vectores a ingenieros»",
28+
"skill": "Uso: /skill <nombre> <intención>, p. ej. «pageindex-expert experto en preguntas y respuestas sobre recuperación de documentos largos»"
29+
},
30+
"genErrorToast": "Error en la generación: {{error}}",
31+
"requestErrorToast": "La solicitud falló: {{error}}",
32+
"errors": {
33+
"noKb": "No se pudo determinar a qué base de conocimiento pertenece esta sesión; entra desde la página de inicio",
34+
"loadSession": "No se pudo cargar la sesión: {{error}}",
35+
"openSource": "No se pudo abrir la fuente {{path}}: {{error}}"
36+
},
37+
"newSession": "Nueva sesión",
38+
"inputPlaceholder": "Haz una pregunta de seguimiento, o escribe / para ver comandos…",
39+
"commands": {
40+
"paletteHeading": "Comandos · generadores",
41+
"deck": {
42+
"label": "Generar diapositivas",
43+
"desc": "Compilar el conocimiento en un deck HTML de un solo archivo"
44+
},
45+
"skill": {
46+
"label": "Destilar una habilidad",
47+
"desc": "Generar una Agent Skill instalable"
48+
},
49+
"visualize": {
50+
"label": "Grafo de conocimiento",
51+
"desc": "Generar un grafo de conceptos interactivo"
52+
}
53+
},
54+
"input": {
55+
"cmdPlaceholder": "{{label}}: describe tu intención…",
56+
"defaultPlaceholder": "Pregunta a tu base de conocimiento, o escribe / para ver comandos…",
57+
"selectKb": "Selecciona una base de conocimiento",
58+
"noKbs": "Aún no hay bases de conocimiento",
59+
"current": "Actual",
60+
"stop": "Detener",
61+
"hint": "Escribe <0>/</0> para ver comandos"
62+
}
63+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
{
2+
"loading": "Cargando…",
3+
"pageLoadError": "No se pudo cargar la página: {{error}}",
4+
"noChanges": "No hay cambios que guardar",
5+
"saveError": "Error al guardar: {{error}}",
6+
"configLoadError": "No se pudo cargar la configuración: {{error}}",
7+
"actions": {
8+
"newKb": "Nueva base de conocimiento",
9+
"close": "Cerrar",
10+
"refresh": "Actualizar"
11+
},
12+
"fields": {
13+
"model": "Modelo",
14+
"wikiLanguage": "Idioma de salida del wiki",
15+
"threshold": "Umbral de PageIndex (páginas)"
16+
},
17+
"errors": {
18+
"requestFailed": "La solicitud falló",
19+
"uploadFailed": "Error al subir: {{detail}}"
20+
},
21+
"nav": {
22+
"home": "Inicio",
23+
"kbs": "Bases de conocimiento",
24+
"settings": "Ajustes"
25+
},
26+
"titlebar": {
27+
"chat": "Chat",
28+
"local": "Local"
29+
},
30+
"theme": {
31+
"light": "Claro",
32+
"dark": "Oscuro",
33+
"system": "Sistema",
34+
"toggleTitle": "Tema: {{label}} (clic para cambiar)",
35+
"toggleAria": "Tema: {{label}}"
36+
},
37+
"language": {
38+
"zh": "中文",
39+
"en": "English",
40+
"fr": "Français",
41+
"es": "Español",
42+
"ru": "Русский",
43+
"toggleTitle": "Idioma: {{label}}",
44+
"toggleAria": "Idioma: {{label}}"
45+
},
46+
"auth": {
47+
"title": "Se requiere un token de acceso",
48+
"description": "Este servidor tiene un token de acceso configurado (OPENKB_API_TOKEN). Introduce el token para continuar; se almacena solo en este navegador.",
49+
"tokenLabel": "Token de acceso",
50+
"baseLabel": "Dirección de la API (opcional; déjalo en blanco para usar el origen actual)",
51+
"connect": "Conectar"
52+
},
53+
"connector": {
54+
"tooltip": "La sincronización en la nube está en desarrollo · vótala en GitHub",
55+
"vote": "¿La quieres? Vota"
56+
},
57+
"onboarding": {
58+
"welcomeTitle": "Te damos la bienvenida a OpenKB",
59+
"welcomeBody": "Convierte tus documentos en una base de conocimiento interconectada y siempre actualizada. Dos pasos rápidos y listo.",
60+
"getStarted": "Empezar",
61+
"skip": "Omitir por ahora",
62+
"configTitle": "Conecta tu modelo",
63+
"configBody": "OpenKB usa un LLM para compilar y responder. Pega una clave de API — se almacena localmente y nunca sale de tu equipo.",
64+
"modelLabel": "Modelo LLM",
65+
"langLabel": "Idioma del wiki",
66+
"apiKeyLabel": "Clave de API del LLM",
67+
"apiKeyPlaceholder": "Pega tu clave de API",
68+
"baseUrlLabel": "URL base personalizada (opcional)",
69+
"baseUrlPlaceholder": "p. ej. http://localhost:11434 (Ollama, LM Studio)",
70+
"continue": "Continuar",
71+
"back": "Atrás",
72+
"kbTitle": "Crea tu primera base de conocimiento",
73+
"kbBody": "Una base de conocimiento contiene tus documentos y el wiki compilado. Podrás añadir más después.",
74+
"kbNameLabel": "Nombre de la base de conocimiento",
75+
"createFinish": "Crear y finalizar",
76+
"saving": "Guardando…",
77+
"createdToast": "{{name}} creada"
78+
}
79+
}

frontend/src/locales/es/home.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"greeting": "¿Qué te gustaría preguntar?",
3+
"ready": "Conocimiento compilado · <0>{{docs}}</0> docs · <1>{{kbs}}</1> bases",
4+
"recentSessions": "Sesiones recientes",
5+
"untitledSession": "Sesión sin título",
6+
"tagline": "Con la tecnología de OpenKB · tu conocimiento siempre en Markdown puro, y siempre tuyo"
7+
}

frontend/src/locales/es/kb.json

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
{
2+
"settingsButton": "Ajustes de la base de conocimiento",
3+
"loadError": "Error al cargar: {{error}}",
4+
"wikiNote": "wiki/ es una carpeta de Markdown puro en este equipo — tus datos siempre son tuyos",
5+
"reader": {
6+
"empty": "Esta base de conocimiento aún no tiene páginas compiladas",
7+
"selectPage": "Selecciona una página a la izquierda para ver su contenido"
8+
},
9+
"upload": {
10+
"added": "{{count}} añadido(s)",
11+
"skipped": "{{count}} omitido(s)",
12+
"failed": "{{count}} con error",
13+
"errorToast": "Error al subir: {{summary}}",
14+
"reasonSuffix": " ({{reason}})",
15+
"partialToast": "Éxito parcial: {{summary}}",
16+
"successToast": "Subida completada: {{summary}}",
17+
"existsToast": "Los documentos ya existen, no se añadió nada: {{summary}}",
18+
"incompleteToast": "Subida interrumpida: no se recibió señal de finalización; actualiza para comprobar los resultados",
19+
"note": "Los documentos subidos van a la carpeta local raw/ y se compilan automáticamente en la base; los archivos permanecen en este equipo",
20+
"inProgress": "Subiendo y compilando…",
21+
"dropzone": "Suelta archivos aquí, o haz clic para elegir",
22+
"dropzoneHint": "PDF / Word / Markdown / texto y más; se admite selección múltiple",
23+
"progressHeading": "Progreso de la subida · {{count}}",
24+
"fileStatus": {
25+
"pending": "En espera",
26+
"processing": "Compilando…",
27+
"added": "Añadido",
28+
"skipped": "Omitido",
29+
"failed": "Con error"
30+
}
31+
},
32+
"docs": {
33+
"heading": "Documentos subidos · {{count}}",
34+
"empty": "Aún no hay documentos · se compilan automáticamente en la base tras subirlos",
35+
"pages_one": "{{count}} página",
36+
"pages_other": "{{count}} páginas",
37+
"delete": {
38+
"action": "Eliminar documento",
39+
"prompt": "¿Eliminar?",
40+
"confirm": "Eliminar",
41+
"cancel": "Cancelar",
42+
"success": "{{name}} eliminado",
43+
"error": "Error al eliminar: {{error}}",
44+
"partial": "Se eliminaron los archivos locales, pero falló la limpieza del índice remoto: {{name}}",
45+
"reasonSuffix": " ({{reason}})",
46+
"multiple": "Error al eliminar: {{message}} (candidatos: {{names}})"
47+
}
48+
},
49+
"remote": {
50+
"heading": "Fuentes de datos remotas",
51+
"note": "Los conectores en la nube están en desarrollo y aún no disponibles · ¿quieres uno? Haz clic en una tarjeta para votar en GitHub"
52+
},
53+
"overview": {
54+
"index": {
55+
"label": "Índice",
56+
"caption": "raíz del wiki"
57+
},
58+
"concepts": {
59+
"label": "Conceptos",
60+
"caption": "interconectados"
61+
},
62+
"entities": {
63+
"label": "Entidades",
64+
"caption": "personas · orgs"
65+
},
66+
"summaries": {
67+
"label": "Resúmenes",
68+
"caption": "por documento"
69+
},
70+
"reports": {
71+
"label": "Informes",
72+
"caption": "lint · recompilar"
73+
},
74+
"documents": {
75+
"label": "Documentos",
76+
"caption": "docs compilados"
77+
}
78+
},
79+
"pageList": {
80+
"empty": "Aún no hay páginas de este tipo",
81+
"prev": "Anterior",
82+
"next": "Siguiente"
83+
}
84+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"notCompiled": "Aún sin compilar",
3+
"updatedAt": "Actualizada {{date}}",
4+
"title": "Bases de conocimiento",
5+
"subtitle": "Cada base de conocimiento es un wiki compilado de forma continua que puede vincular varias fuentes de datos",
6+
"docsLabel": "Docs",
7+
"rawReady": "raw/ listo",
8+
"rawMissing": "Sin raw/",
9+
"create": {
10+
"description": "Ponle un nombre a tu nueva base de conocimiento. El modelo y las credenciales heredan los valores globales por defecto y podrás ajustarlos después en sus ajustes.",
11+
"nameLabel": "Nombre",
12+
"namePlaceholder": "p. ej. my-kb",
13+
"cancel": "Cancelar",
14+
"submit": "Crear",
15+
"submitting": "Creando…",
16+
"previewLabel": "Se creará en {{path}}",
17+
"advancedToggle": "Ruta personalizada",
18+
"customPathLabel": "Ruta personalizada",
19+
"customPathPlaceholder": "/ruta/absoluta/a/la/base",
20+
"customPathHint": "Déjalo vacío para usar la raíz por defecto"
21+
}
22+
}

0 commit comments

Comments
 (0)