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
19 changed files with 1715 additions and 977 deletions

View File

@@ -20,13 +20,7 @@ import { Markdown } from "@/components/Markdown";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
const ROLE_STYLES: Record<string, { bg: string; text: string; label: string }> = {
user: { bg: "bg-primary/10", text: "text-primary", label: "User" },
assistant: { bg: "bg-success/10", text: "text-success", label: "Assistant" },
system: { bg: "bg-muted", text: "text-muted-foreground", label: "System" },
tool: { bg: "bg-warning/10", text: "text-warning", label: "Tool" },
};
import { useI18n } from "@/i18n";
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> = {
cli: { icon: Terminal, color: "text-primary" },
@@ -50,7 +44,7 @@ function SnippetHighlight({ snippet }: { snippet: string }) {
parts.push(snippet.slice(last, match.index));
}
parts.push(
<mark key={i++} className="bg-warning/30 text-warning px-0.5">
<mark key={i++} className="bg-warning/30 text-warning rounded-sm px-0.5">
{match[1]}
</mark>
);
@@ -68,6 +62,7 @@ function SnippetHighlight({ snippet }: { snippet: string }) {
function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name: string; arguments: string } } }) {
const [open, setOpen] = useState(false);
const { t } = useI18n();
let args = toolCall.function.arguments;
try {
@@ -77,12 +72,12 @@ function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name:
}
return (
<div className="mt-2 border border-warning/20 bg-warning/5">
<div className="mt-2 rounded-md border border-warning/20 bg-warning/5">
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-warning cursor-pointer hover:bg-warning/10 transition-colors"
onClick={() => setOpen(!open)}
aria-label={`${open ? "Collapse" : "Expand"} tool call ${toolCall.function.name}`}
aria-label={`${open ? t.common.collapse : t.common.expand} tool call ${toolCall.function.name}`}
>
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
<span className="font-mono-ui font-medium">{toolCall.function.name}</span>
@@ -98,8 +93,17 @@ function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name:
}
function MessageBubble({ msg, highlight }: { msg: SessionMessage; highlight?: string }) {
const { t } = useI18n();
const ROLE_STYLES: Record<string, { bg: string; text: string; label: string }> = {
user: { bg: "bg-primary/10", text: "text-primary", label: t.sessions.roles.user },
assistant: { bg: "bg-success/10", text: "text-success", label: t.sessions.roles.assistant },
system: { bg: "bg-muted", text: "text-muted-foreground", label: t.sessions.roles.system },
tool: { bg: "bg-warning/10", text: "text-warning", label: t.sessions.roles.tool },
};
const style = ROLE_STYLES[msg.role] ?? ROLE_STYLES.system;
const label = msg.tool_name ? `Tool: ${msg.tool_name}` : style.label;
const label = msg.tool_name ? `${t.sessions.roles.tool}: ${msg.tool_name}` : style.label;
// Check if any search term appears as a prefix of any word in content
const isHit = (() => {
@@ -119,7 +123,7 @@ function MessageBubble({ msg, highlight }: { msg: SessionMessage; highlight?: st
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-semibold ${style.text}`}>{label}</span>
{isHit && (
<Badge variant="warning" className="text-[9px] py-0 px-1.5">match</Badge>
<Badge variant="warning" className="text-[9px] py-0 px-1.5">{t.common.match}</Badge>
)}
{msg.timestamp && (
<span className="text-[10px] text-muted-foreground">{timeAgo(msg.timestamp)}</span>
@@ -184,6 +188,7 @@ function SessionRow({
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { t } = useI18n();
useEffect(() => {
if (isExpanded && messages === null && !loading) {
@@ -217,23 +222,23 @@ function SessionRow({
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-sm truncate pr-2 ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}>
{hasTitle ? session.title : (session.preview ? session.preview.slice(0, 60) : "Untitled session")}
{hasTitle ? session.title : (session.preview ? session.preview.slice(0, 60) : t.sessions.untitledSession)}
</span>
{session.is_active && (
<Badge variant="success" className="text-[10px] shrink-0">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
Live
{t.common.live}
</Badge>
)}
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="truncate max-w-[120px] sm:max-w-[180px]">{(session.model ?? "unknown").split("/").pop()}</span>
<span className="truncate max-w-[120px] sm:max-w-[180px]">{(session.model ?? t.common.unknown).split("/").pop()}</span>
<span className="text-border">&#183;</span>
<span>{session.message_count} msgs</span>
<span>{session.message_count} {t.common.msgs}</span>
{session.tool_call_count > 0 && (
<>
<span className="text-border">&#183;</span>
<span>{session.tool_call_count} tools</span>
<span>{session.tool_call_count} {t.common.tools}</span>
</>
)}
<span className="text-border">&#183;</span>
@@ -253,7 +258,7 @@ function SessionRow({
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
aria-label="Delete session"
aria-label={t.sessions.deleteSession}
onClick={(e) => {
e.stopPropagation();
onDelete();
@@ -275,7 +280,7 @@ function SessionRow({
<p className="text-sm text-destructive py-4 text-center">{error}</p>
)}
{messages && messages.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">No messages</p>
<p className="text-sm text-muted-foreground py-4 text-center">{t.sessions.noMessages}</p>
)}
{messages && messages.length > 0 && (
<MessageList messages={messages} highlight={searchQuery} />
@@ -297,6 +302,7 @@ export default function SessionsPage() {
const [searchResults, setSearchResults] = useState<SessionSearchResult[] | null>(null);
const [searching, setSearching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const { t } = useI18n();
const loadSessions = useCallback((p: number) => {
setLoading(true);
@@ -377,7 +383,7 @@ export default function SessionsPage() {
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
<div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-muted-foreground" />
<h1 className="text-base font-semibold">Sessions</h1>
<h1 className="text-base font-semibold">{t.sessions.title}</h1>
<Badge variant="secondary" className="text-xs">
{total}
</Badge>
@@ -389,7 +395,7 @@ export default function SessionsPage() {
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
)}
<Input
placeholder="Search message content..."
placeholder={t.sessions.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 pr-7 h-8 text-xs"
@@ -410,10 +416,10 @@ export default function SessionsPage() {
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Clock className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">
{search ? "No sessions match your search" : "No sessions yet"}
{search ? t.sessions.noMatch : t.sessions.noSessions}
</p>
{!search && (
<p className="text-xs mt-1 text-muted-foreground/60">Start a conversation to see it here</p>
<p className="text-xs mt-1 text-muted-foreground/60">{t.sessions.startConversation}</p>
)}
</div>
) : (
@@ -438,7 +444,7 @@ export default function SessionsPage() {
{!searchResults && total > PAGE_SIZE && (
<div className="flex items-center justify-between pt-2">
<span className="text-xs text-muted-foreground">
{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)} of {total}
{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)} {t.common.of} {total}
</span>
<div className="flex items-center gap-1">
<Button
@@ -447,12 +453,12 @@ export default function SessionsPage() {
className="h-7 w-7 p-0"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
aria-label="Previous page"
aria-label={t.sessions.previousPage}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground px-2">
Page {page + 1} of {Math.ceil(total / PAGE_SIZE)}
{t.common.page} {page + 1} {t.common.of} {Math.ceil(total / PAGE_SIZE)}
</span>
<Button
variant="outline"
@@ -460,7 +466,7 @@ export default function SessionsPage() {
className="h-7 w-7 p-0"
disabled={(page + 1) * PAGE_SIZE >= total}
onClick={() => setPage((p) => p + 1)}
aria-label="Next page"
aria-label={t.sessions.nextPage}
>
<ChevronRight className="h-4 w-4" />
</Button>