mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-02 08:47:26 +08:00
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:
@@ -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">·</span>
|
||||
<span>{session.message_count} msgs</span>
|
||||
<span>{session.message_count} {t.common.msgs}</span>
|
||||
{session.tool_call_count > 0 && (
|
||||
<>
|
||||
<span className="text-border">·</span>
|
||||
<span>{session.tool_call_count} tools</span>
|
||||
<span>{session.tool_call_count} {t.common.tools}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-border">·</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>
|
||||
|
||||
Reference in New Issue
Block a user