feat: add internationalization (i18n) to web dashboard — English + Chinese (#9453)

Add a lightweight i18n system to the web dashboard with English (default) and
Chinese language support. A language switcher with flag icons is placed in the
header bar, allowing users to toggle between languages. The choice persists
to localStorage.

Implementation:
- src/i18n/ — types, translation files (en.ts, zh.ts), React context + hook
- LanguageSwitcher component shows the *other* language's flag as the toggle
- I18nProvider wraps the app in main.tsx
- All 8 pages + OAuth components updated to use t() translation calls
- Zero new dependencies — pure React context + localStorage
This commit is contained in:
Teknium 2026-04-13 23:19:13 -07:00 committed by GitHub
parent 19199cd38d
commit a2ea237db2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1715 additions and 977 deletions

View file

@ -24,6 +24,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useI18n } from "@/i18n";
/* ------------------------------------------------------------------ */
/* Provider grouping */
@ -72,11 +73,11 @@ interface ProviderGroup {
hasAnySet: boolean;
}
const CATEGORY_META: Record<string, { label: string; icon: typeof KeyRound }> = {
provider: { label: "LLM Providers", icon: Zap },
tool: { label: "Tool API Keys", icon: KeyRound },
messaging: { label: "Messaging Platforms", icon: MessageSquare },
setting: { label: "Agent Settings", icon: Settings },
const CATEGORY_META_ICONS: Record<string, typeof KeyRound> = {
provider: Zap,
tool: KeyRound,
messaging: MessageSquare,
setting: Settings,
};
/* ------------------------------------------------------------------ */
@ -108,6 +109,7 @@ function EnvVarRow({
onCancelEdit: (key: string) => void;
compact?: boolean;
}) {
const { t } = useI18n();
const isEditing = edits[varKey] !== undefined;
const isRevealed = !!revealed[varKey];
const displayValue = isRevealed ? revealed[varKey] : (info.redacted_value ?? "---");
@ -124,13 +126,13 @@ function EnvVarRow({
{info.url && (
<a href={info.url} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
Get key <ExternalLink className="h-2.5 w-2.5" />
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
<Button size="sm" variant="outline" className="h-6 text-[0.6rem] px-2"
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
<Pencil className="h-2.5 w-2.5" />
Set
{t.common.set}
</Button>
</div>
</div>
@ -149,13 +151,13 @@ function EnvVarRow({
{info.url && (
<a href={info.url} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
Get key <ExternalLink className="h-2.5 w-2.5" />
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
<Button size="sm" variant="outline" className="h-7 text-[0.6rem]"
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
<Pencil className="h-3 w-3" />
Set
{t.common.set}
</Button>
</div>
</div>
@ -169,13 +171,13 @@ function EnvVarRow({
<div className="flex items-center gap-2">
<Label className="font-mono-ui text-[0.7rem]">{varKey}</Label>
<Badge variant={info.is_set ? "success" : "outline"}>
{info.is_set ? "Set" : "Not set"}
{info.is_set ? t.common.set : t.env.notSet}
</Badge>
</div>
{info.url && (
<a href={info.url} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
Get key <ExternalLink className="h-2.5 w-2.5" />
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</div>
@ -200,7 +202,7 @@ function EnvVarRow({
{info.is_set && (
<Button size="sm" variant="ghost" onClick={() => onReveal(varKey)}
title={isRevealed ? "Hide value" : "Show real value"}
title={isRevealed ? t.env.hideValue : t.env.showValue}
aria-label={isRevealed ? `Hide ${varKey}` : `Reveal ${varKey}`}>
{isRevealed
? <EyeOff className="h-4 w-4" />
@ -211,7 +213,7 @@ function EnvVarRow({
<Button size="sm" variant="outline"
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
<Pencil className="h-3 w-3" />
{info.is_set ? "Replace" : "Set"}
{info.is_set ? t.common.replace : t.common.set}
</Button>
{info.is_set && (
@ -219,7 +221,7 @@ function EnvVarRow({
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onClear(varKey)} disabled={saving === varKey}>
<Trash2 className="h-3 w-3" />
{saving === varKey ? "..." : "Clear"}
{saving === varKey ? "..." : t.common.clear}
</Button>
)}
</div>
@ -229,15 +231,15 @@ function EnvVarRow({
<div className="flex items-center gap-2">
<Input autoFocus type="text" value={edits[varKey]}
onChange={(e) => setEdits((prev) => ({ ...prev, [varKey]: e.target.value }))}
placeholder={info.is_set ? `Replace current value (${info.redacted_value ?? "---"})` : "Enter value..."}
placeholder={info.is_set ? t.env.replaceCurrentValue.replace("{preview}", info.redacted_value ?? "---") : t.env.enterValue}
className="flex-1 font-mono-ui text-xs" />
<Button size="sm" onClick={() => onSave(varKey)}
disabled={saving === varKey || !edits[varKey]}>
<Save className="h-3 w-3" />
{saving === varKey ? "..." : "Save"}
{saving === varKey ? "..." : t.common.save}
</Button>
<Button size="sm" variant="ghost" onClick={() => onCancelEdit(varKey)}>
<X className="h-3 w-3" /> Cancel
<X className="h-3 w-3" /> {t.common.cancel}
</Button>
</div>
)}
@ -271,6 +273,7 @@ function ProviderGroupCard({
onCancelEdit: (key: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const { t } = useI18n();
// Separate API keys from base URLs and other settings
const apiKeys = group.entries.filter(([k]) => k.endsWith("_API_KEY") || k.endsWith("_TOKEN"));
@ -292,10 +295,10 @@ function ProviderGroupCard({
>
<div className="flex items-center gap-3 min-w-0">
{expanded ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
<span className="font-semibold text-sm tracking-wide">{group.name}</span>
<span className="font-semibold text-sm tracking-wide">{group.name === "Other" ? t.common.other : group.name}</span>
{hasAnyConfigured && (
<Badge variant="success" className="text-[0.6rem]">
{configuredCount} set
{configuredCount} {t.common.set.toLowerCase()}
</Badge>
)}
</div>
@ -304,11 +307,11 @@ function ProviderGroupCard({
<a href={keyUrl} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
onClick={(e) => e.stopPropagation()}>
Get key <ExternalLink className="h-2.5 w-2.5" />
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
<span className="text-[0.65rem] text-muted-foreground/60">
{group.entries.length} key{group.entries.length !== 1 ? "s" : ""}
{t.env.keysCount.replace("{count}", String(group.entries.length)).replace("{s}", group.entries.length !== 1 ? "s" : "")}
</span>
</div>
</button>
@ -357,6 +360,7 @@ export default function EnvPage() {
const [saving, setSaving] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(true); // Show all providers by default
const { toast, showToast } = useToast();
const { t } = useI18n();
useEffect(() => {
api.getEnvVars().then(setVars).catch(() => {});
@ -378,9 +382,9 @@ export default function EnvPage() {
);
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
showToast(`${key} saved`, "success");
showToast(`${key} ${t.common.save.toLowerCase()}d`, "success");
} catch (e) {
showToast(`Failed to save ${key}: ${e}`, "error");
showToast(`${t.config.failedToSave} ${key}: ${e}`, "error");
} finally {
setSaving(null);
}
@ -397,9 +401,9 @@ export default function EnvPage() {
);
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
showToast(`${key} removed`, "success");
showToast(`${key} ${t.common.removed}`, "success");
} catch (e) {
showToast(`Failed to remove ${key}: ${e}`, "error");
showToast(`${t.common.failedToRemove} ${key}: ${e}`, "error");
} finally {
setSaving(null);
}
@ -414,7 +418,7 @@ export default function EnvPage() {
const resp = await api.revealEnvVar(key);
setRevealed((prev) => ({ ...prev, [key]: resp.value }));
} catch {
showToast(`Failed to reveal ${key}`, "error");
showToast(`${t.common.failedToReveal} ${key}`, "error");
}
};
@ -447,7 +451,12 @@ export default function EnvPage() {
}))
.sort((a, b) => a.priority - b.priority);
// Non-provider categories
// Non-provider categories — use translated labels
const CATEGORY_META_LABELS: Record<string, string> = {
tool: t.app.nav.keys,
messaging: t.common.messaging,
setting: t.app.nav.config,
};
const otherCategories = ["tool", "messaging", "setting"];
const nonProvider = otherCategories.map((cat) => {
const entries = Object.entries(vars).filter(
@ -456,7 +465,8 @@ export default function EnvPage() {
const setEntries = entries.filter(([, info]) => info.is_set);
const unsetEntries = entries.filter(([, info]) => !info.is_set);
return {
...CATEGORY_META[cat],
label: CATEGORY_META_LABELS[cat] ?? cat,
icon: CATEGORY_META_ICONS[cat] ?? KeyRound,
category: cat,
setEntries,
unsetEntries,
@ -465,7 +475,7 @@ export default function EnvPage() {
});
return { providerGroups: groups, nonProviderGrouped: nonProvider };
}, [vars, showAdvanced]);
}, [vars, showAdvanced, t]);
if (!vars) {
return (
@ -485,18 +495,18 @@ export default function EnvPage() {
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<p className="text-sm text-muted-foreground">
Manage API keys and secrets stored in <code>~/.hermes/.env</code>
{t.env.description} <code>~/.hermes/.env</code>
</p>
<p className="text-[0.7rem] text-muted-foreground/70">
Changes are saved to disk immediately. Active sessions pick up new keys automatically.
{t.env.changesNote}
</p>
</div>
<Button variant="ghost" size="sm" onClick={() => setShowAdvanced(!showAdvanced)}>
{showAdvanced ? "Hide Advanced" : "Show Advanced"}
{showAdvanced ? t.env.hideAdvanced : t.env.showAdvanced}
</Button>
</div>
{/* ═══════════════ OAuth Logins (sits above API keys — distinct auth mode) ══ */}
{/* ═══════════════ OAuth Logins ══ */}
<OAuthProvidersCard
onError={(msg) => showToast(msg, "error")}
onSuccess={(msg) => showToast(msg, "success")}
@ -507,10 +517,10 @@ export default function EnvPage() {
<CardHeader className="sticky top-14 z-10 bg-card border-b border-border">
<div className="flex items-center gap-2">
<Zap className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">LLM Providers</CardTitle>
<CardTitle className="text-base">{t.env.llmProviders}</CardTitle>
</div>
<CardDescription>
{configuredProviders} of {totalProviders} providers configured
{t.env.providersConfigured.replace("{configured}", String(configuredProviders)).replace("{total}", String(totalProviders))}
</CardDescription>
</CardHeader>
@ -538,7 +548,7 @@ export default function EnvPage() {
<CardTitle className="text-base">{label}</CardTitle>
</div>
<CardDescription>
{setEntries.length} of {totalEntries} configured
{setEntries.length} {t.common.of} {totalEntries} {t.common.configured}
</CardDescription>
</CardHeader>
@ -595,6 +605,7 @@ function CollapsibleUnset({
onCancelEdit: (key: string) => void;
}) {
const [collapsed, setCollapsed] = useState(true);
const { t } = useI18n();
return (
<>
@ -606,7 +617,7 @@ function CollapsibleUnset({
{collapsed
? <ChevronRight className="h-3 w-3" />
: <ChevronDown className="h-3 w-3" />}
<span>{unsetEntries.length} not configured</span>
<span>{t.env.notConfigured.replace("{count}", String(unsetEntries.length))}</span>
</button>
{!collapsed && unsetEntries.map(([key, info]) => (