feat(webui): add i18n support and locale switcher

This commit is contained in:
Xubin Ren
2026-04-19 06:39:06 +00:00
parent be10ba1f0d
commit 4650b23d75
34 changed files with 6654 additions and 65 deletions
+86 -2
View File
@@ -4,7 +4,11 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<meta name="description" content="nanobot web UI — chat with your nanobot workspace." />
<meta
name="description"
content="nanobot web UI — chat with your nanobot workspace."
data-i18n-meta="description"
/>
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#161618" media="(prefers-color-scheme: dark)" />
<link rel="icon" type="image/png" sizes="32x32" href="/brand/nanobot_favicon_32.png" />
@@ -84,6 +88,86 @@
} catch {}
})();
</script>
<script>
(function () {
var localeKey = "nanobot.locale";
var copy = {
en: {
boot: "Loading nanobot…",
description: "nanobot web UI — chat with your nanobot workspace."
},
"zh-CN": {
boot: "正在加载 nanobot…",
description: "nanobot Web UI —— 与你的 nanobot 工作区对话。"
},
"zh-TW": {
boot: "正在載入 nanobot…",
description: "nanobot Web UI —— 與你的 nanobot 工作區對話。"
},
fr: {
boot: "Chargement de nanobot…",
description: "Interface web nanobot — discutez avec votre espace de travail nanobot."
},
ja: {
boot: "nanobot を読み込み中…",
description: "nanobot Web UI — nanobot ワークスペースと会話します。"
},
ko: {
boot: "nanobot 불러오는 중…",
description: "nanobot 웹 UI — nanobot 작업공간과 대화하세요."
},
es: {
boot: "Cargando nanobot…",
description: "Interfaz web de nanobot: conversa con tu espacio de trabajo de nanobot."
},
vi: {
boot: "Đang tải nanobot…",
description: "Giao diện web nanobot — trò chuyện với workspace nanobot của bạn."
},
id: {
boot: "Memuat nanobot…",
description: "UI web nanobot — ngobrol dengan workspace nanobot Anda."
}
};
function normalizeLocale(input) {
if (!input) return "en";
var raw = String(input).trim();
if (!raw) return "en";
if (copy[raw]) return raw;
var lower = raw.toLowerCase();
if (lower === "zh" || lower.indexOf("zh-cn") === 0 || lower.indexOf("zh-sg") === 0) {
return "zh-CN";
}
if (
lower.indexOf("zh-tw") === 0 ||
lower.indexOf("zh-hk") === 0 ||
lower.indexOf("zh-mo") === 0 ||
lower.indexOf("zh-hant") === 0
) {
return "zh-TW";
}
var base = lower.split("-")[0];
return copy[base] ? base : "en";
}
try {
var stored = localStorage.getItem(localeKey);
var detected =
stored ||
(navigator.languages && navigator.languages[0]) ||
navigator.language ||
"en";
var locale = normalizeLocale(detected);
var localized = copy[locale] || copy.en;
document.documentElement.lang = locale;
var description = document.querySelector('[data-i18n-meta=\"description\"]');
if (description) description.setAttribute("content", localized.description);
var boot = document.querySelector("[data-boot-copy]");
if (boot) boot.textContent = localized.boot;
} catch {}
})();
</script>
<title>nanobot</title>
</head>
<body class="bg-background text-foreground antialiased">
@@ -91,7 +175,7 @@
<div class="boot-splash">
<div class="boot-splash-inner">
<span class="boot-dot" aria-hidden="true"></span>
<span>Loading nanobot…</span>
<span data-boot-copy>Loading nanobot…</span>
</div>
</div>
</div>
+5309
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -22,9 +22,11 @@
"@radix-ui/react-tooltip": "^1.1.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^26.0.6",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.4",
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",
+20 -7
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { DeleteConfirm } from "@/components/DeleteConfirm";
import { Sidebar } from "@/components/Sidebar";
import { ThreadShell } from "@/components/thread/ThreadShell";
@@ -37,6 +38,7 @@ function readSidebarOpen(): boolean {
}
export default function App() {
const { t } = useTranslation();
const [state, setState] = useState<BootState>({ status: "loading" });
useEffect(() => {
@@ -107,7 +109,7 @@ export default function App() {
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-foreground/40" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-foreground/60" />
</span>
Connecting to nanobot
{t("app.loading.connecting")}
</div>
</div>
</div>
@@ -124,11 +126,10 @@ export default function App() {
aria-hidden
draggable={false}
/>
<p className="text-lg font-semibold">Couldn't reach nanobot</p>
<p className="text-lg font-semibold">{t("app.error.title")}</p>
<p className="text-sm text-muted-foreground">{state.message}</p>
<p className="text-xs text-muted-foreground">
Make sure the gateway is running (`nanobot web`) and that this page
is open on the same machine.
{t("app.error.gatewayHint")}
</p>
</div>
</div>
@@ -147,6 +148,7 @@ export default function App() {
}
function Shell() {
const { t, i18n } = useTranslation();
const { theme, toggle } = useTheme();
const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
const [activeKey, setActiveKey] = useState<string | null>(null);
@@ -241,8 +243,15 @@ function Shell() {
}, [pendingDelete, deleteChat, activeKey, sessions]);
const headerTitle = activeSession
? activeSession.preview || `Chat ${activeSession.chatId.slice(0, 6)}`
: "nanobot";
? activeSession.preview ||
t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) })
: t("app.brand");
useEffect(() => {
document.title = activeSession
? t("app.documentTitle.chat", { title: headerTitle })
: t("app.documentTitle.base");
}, [activeSession, headerTitle, i18n.resolvedLanguage, t]);
const sidebarProps = {
sessions,
@@ -284,7 +293,11 @@ function Shell() {
open={mobileSidebarOpen}
onOpenChange={(open) => setMobileSidebarOpen(open)}
>
<SheetContent side="left" className="w-[279px] p-0 sm:max-w-[279px] lg:hidden">
<SheetContent
side="left"
showCloseButton={false}
className="w-[279px] p-0 sm:max-w-[279px] lg:hidden"
>
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
</SheetContent>
</Sheet>
+14 -7
View File
@@ -1,4 +1,5 @@
import { MoreHorizontal, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
DropdownMenu,
@@ -19,10 +20,10 @@ interface ChatListProps {
loading?: boolean;
}
function titleFor(s: ChatSummary): string {
function titleFor(s: ChatSummary, fallbackTitle: string): string {
const p = s.preview?.trim();
if (p) return p.length > 48 ? `${p.slice(0, 45)}` : p;
return `Chat ${s.chatId.slice(0, 6)}`;
return fallbackTitle;
}
export function ChatList({
@@ -32,16 +33,19 @@ export function ChatList({
onRequestDelete,
loading,
}: ChatListProps) {
const { t } = useTranslation();
if (loading && sessions.length === 0) {
return (
<div className="px-3 py-6 text-[12px] text-muted-foreground">Loading</div>
<div className="px-3 py-6 text-[12px] text-muted-foreground">
{t("chat.loading")}
</div>
);
}
if (sessions.length === 0) {
return (
<div className="px-3 py-6 text-xs text-muted-foreground">
No sessions yet.
{t("chat.noSessions")}
</div>
);
}
@@ -51,7 +55,10 @@ export function ChatList({
<ul className="space-y-0.5 px-2 py-1">
{sessions.map((s) => {
const active = s.key === activeKey;
const title = titleFor(s);
const title = titleFor(
s,
t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }),
);
return (
<li key={s.key}>
<div
@@ -80,7 +87,7 @@ export function ChatList({
"focus-visible:opacity-100",
active && "opacity-100",
)}
aria-label={`Chat actions for ${title}`}
aria-label={t("chat.actions", { title })}
>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
@@ -95,7 +102,7 @@ export function ChatList({
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
{t("chat.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+7 -3
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { Check, Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import {
oneDark,
@@ -15,6 +16,7 @@ interface CodeBlockProps {
}
export function CodeBlock({ language, code, className }: CodeBlockProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const onCopy = () => {
@@ -33,19 +35,21 @@ export function CodeBlock({ language, code, className }: CodeBlockProps) {
return (
<div className={cn("overflow-hidden rounded-lg", className)}>
<div className="flex items-center justify-between bg-zinc-900 px-4 py-1.5 text-xs font-medium text-zinc-200">
<span className="lowercase">{language || "code"}</span>
<span className="lowercase">
{language || t("code.fallbackLanguage")}
</span>
<button
type="button"
onClick={onCopy}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-zinc-300 transition-colors hover:bg-zinc-800 hover:text-zinc-100"
aria-label="Copy code"
aria-label={t("code.copyAria")}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copied ? "Copied" : "Copy"}</span>
<span>{copied ? t("code.copied") : t("code.copy")}</span>
</button>
</div>
<SyntaxHighlighter
+5 -8
View File
@@ -1,34 +1,31 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
import type { ConnectionStatus } from "@/lib/types";
const COPY: Record<ConnectionStatus, { label: string; color: string }> = {
idle: { label: "Idle", color: "bg-card/40 text-muted-foreground" },
const COPY: Record<ConnectionStatus, { color: string }> = {
idle: { color: "bg-card/40 text-muted-foreground" },
connecting: {
label: "Connecting…",
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
},
open: {
label: "Connected",
color: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
},
reconnecting: {
label: "Reconnecting…",
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
},
closed: {
label: "Disconnected",
color: "bg-card/40 text-muted-foreground",
},
error: {
label: "Connection error",
color: "bg-destructive/10 text-destructive",
},
};
export function ConnectionBadge() {
const { t } = useTranslation();
const { client } = useClient();
const [status, setStatus] = useState<ConnectionStatus>(client.status);
@@ -53,7 +50,7 @@ export function ConnectionBadge() {
)}
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
</span>
{meta.label}
{t(`connection.${status}`)}
</span>
);
}
+10 -4
View File
@@ -8,6 +8,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useTranslation } from "react-i18next";
interface DeleteConfirmProps {
open: boolean;
@@ -22,22 +23,27 @@ export function DeleteConfirm({
onCancel,
onConfirm,
}: DeleteConfirmProps) {
const { t } = useTranslation();
return (
<AlertDialog open={open} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {title}?</AlertDialogTitle>
<AlertDialogTitle>
{t("deleteConfirm.title", { title })}
</AlertDialogTitle>
<AlertDialogDescription>
The session file will be removed from disk. This cannot be undone.
{t("deleteConfirm.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogCancel onClick={onCancel}>
{t("deleteConfirm.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
{t("deleteConfirm.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+67
View File
@@ -0,0 +1,67 @@
import { Globe } from "lucide-react";
import { useTranslation } from "react-i18next";
import { setAppLanguage } from "@/i18n";
import {
currentLocale,
} from "@/i18n";
import {
localeOption,
supportedLocales,
type SupportedLocale,
} from "@/i18n/config";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function LanguageSwitcher() {
const { t } = useTranslation();
const locale = currentLocale();
const selected = localeOption(locale);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
aria-label={t("sidebar.language.ariaLabel")}
className="h-7 gap-1.5 rounded-md px-2 text-[11px] text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
<Globe className="h-3.5 w-3.5" />
<span className="max-w-[7rem] truncate">{selected.nativeLabel}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t("sidebar.language.label")}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={locale}
onValueChange={(value) => {
void setAppLanguage(value as SupportedLocale);
}}
>
{supportedLocales.map((option) => (
<DropdownMenuRadioItem key={option.code} value={option.code}>
<span className="flex min-w-0 items-center gap-2">
<span>{option.nativeLabel}</span>
{option.nativeLabel !== option.label ? (
<span className="truncate text-xs text-muted-foreground">
{option.label}
</span>
) : null}
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
+9 -3
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { ChevronRight, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next";
import { MarkdownText } from "@/components/MarkdownText";
import { cn } from "@/lib/utils";
@@ -63,9 +64,10 @@ export function MessageBubble({ message }: MessageBubbleProps) {
/** Blinking cursor appended at the end of streaming text. */
function StreamCursor() {
const { t } = useTranslation();
return (
<span
aria-label="streaming"
aria-label={t("message.streaming")}
className={cn(
"ml-0.5 inline-block h-[1em] w-[3px] translate-y-[2px] align-middle",
"rounded-sm bg-foreground/70 animate-pulse",
@@ -76,9 +78,10 @@ function StreamCursor() {
/** Pre-token-arrival placeholder: three bouncing dots. */
function TypingDots() {
const { t } = useTranslation();
return (
<span
aria-label="Assistant is typing"
aria-label={t("message.assistantTyping")}
className="inline-flex items-center gap-1 py-1"
>
<Dot delay="0ms" />
@@ -111,6 +114,7 @@ interface TraceGroupProps {
* group down to a one-line summary so it never dominates the thread.
*/
function TraceGroup({ message, animClass }: TraceGroupProps) {
const { t } = useTranslation();
const lines = message.traces ?? [message.content];
const count = lines.length;
const [open, setOpen] = useState(true);
@@ -127,7 +131,9 @@ function TraceGroup({ message, animClass }: TraceGroupProps) {
>
<Wrench className="h-3.5 w-3.5" aria-hidden />
<span className="font-medium">
{count === 1 ? "Using a tool" : `Used ${count} tools`}
{count === 1
? t("message.toolSingle")
: t("message.toolMany", { count })}
</span>
<ChevronRight
aria-hidden
+10 -6
View File
@@ -1,7 +1,9 @@
import { Moon, PanelLeftClose, Plus, RefreshCcw, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import type { ChatSummary } from "@/lib/types";
@@ -20,13 +22,14 @@ interface SidebarProps {
}
export function Sidebar(props: SidebarProps) {
const { t } = useTranslation();
return (
<aside className="flex h-full w-full flex-col border-r border-sidebar-border/70 bg-sidebar text-sidebar-foreground">
<div className="flex items-center justify-between px-2 py-2">
<Button
variant="ghost"
size="icon"
aria-label="Collapse sidebar"
aria-label={t("sidebar.collapse")}
onClick={props.onCollapse}
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
@@ -35,7 +38,7 @@ export function Sidebar(props: SidebarProps) {
<Button
variant="ghost"
size="icon"
aria-label="Toggle theme"
aria-label={t("sidebar.toggleTheme")}
onClick={props.onToggleTheme}
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
@@ -53,18 +56,18 @@ export function Sidebar(props: SidebarProps) {
variant="outline"
>
<Plus className="h-3.5 w-3.5" />
New chat
{t("sidebar.newChat")}
</Button>
</div>
<Separator className="bg-sidebar-border/70" />
<div className="flex items-center justify-between px-2.5 py-2 text-[11px] font-medium text-muted-foreground">
<span>Recent</span>
<span>{t("sidebar.recent")}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-md text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
onClick={props.onRefresh}
aria-label="Refresh sessions"
aria-label={t("sidebar.refreshSessions")}
>
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
@@ -79,8 +82,9 @@ export function Sidebar(props: SidebarProps) {
/>
</div>
<Separator className="bg-sidebar-border/70" />
<div className="flex items-center justify-between px-2.5 py-2 text-xs">
<div className="flex items-center justify-between gap-2 px-2.5 py-2 text-xs">
<ConnectionBadge />
<LanguageSwitcher />
</div>
</aside>
);
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -15,13 +16,16 @@ interface ThreadComposerProps {
export function ThreadComposer({
onSend,
disabled,
placeholder = "Type your message…",
placeholder,
modelLabel = null,
variant = "thread",
}: ThreadComposerProps) {
const { t } = useTranslation();
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isHero = variant === "hero";
const resolvedPlaceholder =
placeholder ?? t("thread.composer.placeholderThread");
useEffect(() => {
if (disabled) return;
@@ -83,9 +87,9 @@ export function ThreadComposer({
onInput={onInput}
onKeyDown={onKeyDown}
rows={1}
placeholder={placeholder}
placeholder={resolvedPlaceholder}
disabled={disabled}
aria-label="Message input"
aria-label={t("thread.composer.inputAria")}
className={cn(
"w-full resize-none bg-transparent",
isHero
@@ -120,7 +124,7 @@ export function ThreadComposer({
</span>
) : null}
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
Enter to send · Shift+Enter for newline
{t("thread.composer.sendHint")}
</span>
</div>
<span className="sm:hidden" aria-hidden />
@@ -128,7 +132,7 @@ export function ThreadComposer({
type="submit"
size="icon"
disabled={disabled || !value.trim()}
aria-label="Send message"
aria-label={t("thread.composer.send")}
className={cn(
"rounded-full border border-border/70 bg-secondary/85 text-secondary-foreground shadow-none transition-transform hover:bg-accent",
isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5",
+3 -1
View File
@@ -1,4 +1,5 @@
import { PanelLeftOpen } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -16,13 +17,14 @@ export function ThreadHeader({
onGoHome,
hideSidebarToggleOnDesktop = false,
}: ThreadHeaderProps) {
const { t } = useTranslation();
return (
<div className="relative z-10 flex items-center justify-between gap-3 px-3 py-2">
<div className="relative flex min-w-0 items-center gap-2">
<Button
variant="ghost"
size="icon"
aria-label="Toggle sidebar"
aria-label={t("thread.header.toggleSidebar")}
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
+14 -4
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { ThreadHeader } from "@/components/thread/ThreadHeader";
@@ -33,6 +34,7 @@ export function ThreadShell({
onNewChat,
hideSidebarToggleOnDesktop = false,
}: ThreadShellProps) {
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null;
const { messages: historical, loading } = useSessionHistory(historyKey);
@@ -105,7 +107,7 @@ export function ThreadShell({
const emptyState = loading ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading conversation
{t("thread.loadingConversation")}
</div>
) : (
<div className="flex w-full max-w-[40rem] flex-col gap-2 text-left animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
@@ -120,7 +122,7 @@ export function ThreadShell({
<span className="text-foreground/82">nanobot</span>
</div>
<p className="max-w-[28rem] text-[13px] leading-6 text-muted-foreground">
Ask questions, continue local work, or start a new thread.
{t("thread.empty.description")}
</p>
</div>
);
@@ -142,7 +144,11 @@ export function ThreadShell({
<ThreadComposer
onSend={send}
disabled={!chatId}
placeholder={showHeroComposer ? "What's on your mind?" : "Type your message…"}
placeholder={
showHeroComposer
? t("thread.composer.placeholderHero")
: t("thread.composer.placeholderThread")
}
modelLabel={toModelBadgeLabel(modelName)}
variant={showHeroComposer ? "hero" : "thread"}
/>
@@ -150,7 +156,11 @@ export function ThreadShell({
<ThreadComposer
onSend={handleWelcomeSend}
disabled={booting}
placeholder={booting ? "Opening a new chat…" : "What's on your mind?"}
placeholder={
booting
? t("thread.composer.placeholderOpening")
: t("thread.composer.placeholderHero")
}
modelLabel={toModelBadgeLabel(modelName)}
variant="hero"
/>
@@ -1,5 +1,6 @@
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { ArrowDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ThreadMessages } from "@/components/thread/ThreadMessages";
import { Button } from "@/components/ui/button";
@@ -21,6 +22,7 @@ export function ThreadViewport({
composer,
emptyState,
}: ThreadViewportProps) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const [atBottom, setAtBottom] = useState(true);
const hasMessages = messages.length > 0;
@@ -104,7 +106,7 @@ export function ThreadViewport({
"bg-background/90 backdrop-blur",
"animate-in fade-in-0 zoom-in-95",
)}
aria-label="Scroll to bottom"
aria-label={t("thread.scrollToBottom")}
>
<ArrowDown className="h-4 w-4" />
</Button>
+10 -6
View File
@@ -58,12 +58,14 @@ const sheetVariants = cva(
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
VariantProps<typeof sheetVariants> {
showCloseButton?: boolean;
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
>(({ side = "right", className, children, showCloseButton = true, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
@@ -77,10 +79,12 @@ const SheetContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
{showCloseButton ? (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
) : null}
</DialogPrimitive.Content>
</SheetPortal>
));
+5 -1
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useClient } from "@/providers/ClientProvider";
import i18n from "@/i18n";
import {
ApiError,
deleteSession as apiDeleteSession,
@@ -183,5 +184,8 @@ export function sessionTitle(
session: ChatSummary,
firstUserMessage?: string,
): string {
return deriveTitle(firstUserMessage || session.preview, "New chat");
return deriveTitle(
firstUserMessage || session.preview,
i18n.t("chat.newChat"),
);
}
+93
View File
@@ -0,0 +1,93 @@
export const LOCALE_STORAGE_KEY = "nanobot.locale";
export const supportedLocales = [
{ code: "en", label: "English", nativeLabel: "English" },
{ code: "zh-CN", label: "Chinese (Simplified)", nativeLabel: "简体中文" },
{ code: "zh-TW", label: "Chinese (Traditional)", nativeLabel: "繁體中文" },
{ code: "fr", label: "French", nativeLabel: "Français" },
{ code: "ja", label: "Japanese", nativeLabel: "日本語" },
{ code: "ko", label: "Korean", nativeLabel: "한국어" },
{ code: "es", label: "Spanish", nativeLabel: "Español" },
{ code: "vi", label: "Vietnamese", nativeLabel: "Tiếng Việt" },
{ code: "id", label: "Indonesian", nativeLabel: "Bahasa Indonesia" },
] as const;
export type SupportedLocale = (typeof supportedLocales)[number]["code"];
export const defaultLocale: SupportedLocale = "en";
export const fallbackLocale: SupportedLocale = "en";
export function normalizeLocale(
input: string | null | undefined,
): SupportedLocale {
if (!input) return defaultLocale;
const trimmed = input.trim();
if (!trimmed) return defaultLocale;
const exact = supportedLocales.find((locale) => locale.code === trimmed);
if (exact) return exact.code;
const lower = trimmed.toLowerCase();
if (lower === "zh" || lower.startsWith("zh-cn") || lower.startsWith("zh-sg")) {
return "zh-CN";
}
if (
lower.startsWith("zh-tw") ||
lower.startsWith("zh-hk") ||
lower.startsWith("zh-mo") ||
lower.startsWith("zh-hant")
) {
return "zh-TW";
}
const base = lower.split("-")[0];
const baseMatch = supportedLocales.find(
(locale) => locale.code.toLowerCase() === base,
);
return baseMatch?.code ?? defaultLocale;
}
export function readStoredLocale(): SupportedLocale | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY);
return raw ? normalizeLocale(raw) : null;
} catch {
return null;
}
}
export function detectNavigatorLocale(): SupportedLocale {
if (typeof navigator === "undefined") return defaultLocale;
const candidates = [
...(navigator.languages ?? []),
navigator.language,
].filter(Boolean);
for (const locale of candidates) {
const normalized = normalizeLocale(locale);
if (normalized) return normalized;
}
return defaultLocale;
}
export function resolveInitialLocale(): SupportedLocale {
return readStoredLocale() ?? detectNavigatorLocale();
}
export function persistLocale(locale: SupportedLocale): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
} catch {
// ignore storage errors
}
}
export function applyDocumentLocale(locale: SupportedLocale): void {
if (typeof document === "undefined") return;
document.documentElement.lang = locale;
}
export function localeOption(locale: SupportedLocale) {
return supportedLocales.find((entry) => entry.code === locale) ?? supportedLocales[0];
}
+72
View File
@@ -0,0 +1,72 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import {
applyDocumentLocale,
defaultLocale,
fallbackLocale,
LOCALE_STORAGE_KEY,
normalizeLocale,
persistLocale,
resolveInitialLocale,
type SupportedLocale,
} from "./config";
import enCommon from "./locales/en/common.json";
import zhCNCommon from "./locales/zh-CN/common.json";
import zhTWCommon from "./locales/zh-TW/common.json";
import frCommon from "./locales/fr/common.json";
import jaCommon from "./locales/ja/common.json";
import koCommon from "./locales/ko/common.json";
import esCommon from "./locales/es/common.json";
import viCommon from "./locales/vi/common.json";
import idCommon from "./locales/id/common.json";
export const resources = {
en: { common: enCommon },
"zh-CN": { common: zhCNCommon },
"zh-TW": { common: zhTWCommon },
fr: { common: frCommon },
ja: { common: jaCommon },
ko: { common: koCommon },
es: { common: esCommon },
vi: { common: viCommon },
id: { common: idCommon },
} as const;
export function currentLocale(): SupportedLocale {
return normalizeLocale(i18n.resolvedLanguage ?? i18n.language ?? defaultLocale);
}
export async function setAppLanguage(locale: SupportedLocale): Promise<void> {
await i18n.changeLanguage(locale);
}
if (!i18n.isInitialized) {
void i18n
.use(initReactI18next)
.init({
resources,
lng: resolveInitialLocale(),
fallbackLng: fallbackLocale,
defaultNS: "common",
ns: ["common"],
interpolation: {
escapeValue: false,
},
returnNull: false,
supportedLngs: Object.keys(resources),
});
}
const syncLocaleSideEffects = (language: string) => {
const locale = normalizeLocale(language);
applyDocumentLocale(locale);
persistLocale(locale);
};
syncLocaleSideEffects(currentLocale());
i18n.on("languageChanged", syncLocaleSideEffects);
export { LOCALE_STORAGE_KEY };
export default i18n;
+83
View File
@@ -0,0 +1,83 @@
{
"app": {
"brand": "nanobot",
"loading": {
"connecting": "Connecting to nanobot…",
"boot": "Loading nanobot…"
},
"error": {
"title": "Couldn't reach nanobot",
"gatewayHint": "Make sure the gateway is running (`nanobot web`) and that this page is open on the same machine."
},
"documentTitle": {
"base": "nanobot",
"chat": "{{title}} · nanobot"
},
"meta": {
"description": "nanobot web UI — chat with your nanobot workspace."
}
},
"sidebar": {
"collapse": "Collapse sidebar",
"toggleTheme": "Toggle theme",
"newChat": "New chat",
"recent": "Recent",
"refreshSessions": "Refresh sessions",
"language": {
"label": "Language",
"ariaLabel": "Change language"
}
},
"chat": {
"fallbackTitle": "Chat {{id}}",
"loading": "Loading…",
"noSessions": "No sessions yet.",
"actions": "Chat actions for {{title}}",
"delete": "Delete",
"newChat": "New chat"
},
"deleteConfirm": {
"title": "Delete “{{title}}”?",
"description": "The session file will be removed from disk. This cannot be undone.",
"cancel": "Cancel",
"confirm": "Delete"
},
"connection": {
"idle": "Idle",
"connecting": "Connecting…",
"open": "Connected",
"reconnecting": "Reconnecting…",
"closed": "Disconnected",
"error": "Connection error"
},
"thread": {
"loadingConversation": "Loading conversation…",
"empty": {
"description": "Ask questions, continue local work, or start a new thread."
},
"header": {
"toggleSidebar": "Toggle sidebar"
},
"composer": {
"placeholderThread": "Type your message…",
"placeholderHero": "What's on your mind?",
"placeholderOpening": "Opening a new chat…",
"inputAria": "Message input",
"sendHint": "Enter to send · Shift+Enter for newline",
"send": "Send message"
},
"scrollToBottom": "Scroll to bottom"
},
"message": {
"streaming": "streaming",
"assistantTyping": "Assistant is typing",
"toolSingle": "Using a tool",
"toolMany": "Used {{count}} tools"
},
"code": {
"fallbackLanguage": "code",
"copyAria": "Copy code",
"copy": "Copy",
"copied": "Copied"
}
}
+83
View File
@@ -0,0 +1,83 @@
{
"app": {
"brand": "nanobot",
"loading": {
"connecting": "Conectando con nanobot…",
"boot": "Cargando nanobot…"
},
"error": {
"title": "No se pudo conectar con nanobot",
"gatewayHint": "Asegúrate de que la gateway esté en ejecución (`nanobot web`) y de que esta página esté abierta en la misma máquina."
},
"documentTitle": {
"base": "nanobot",
"chat": "{{title}} · nanobot"
},
"meta": {
"description": "Interfaz web de nanobot: conversa con tu espacio de trabajo de nanobot."
}
},
"sidebar": {
"collapse": "Contraer barra lateral",
"toggleTheme": "Cambiar tema",
"newChat": "Nuevo chat",
"recent": "Recientes",
"refreshSessions": "Actualizar sesiones",
"language": {
"label": "Idioma",
"ariaLabel": "Cambiar idioma"
}
},
"chat": {
"fallbackTitle": "Chat {{id}}",
"loading": "Cargando…",
"noSessions": "Todavía no hay sesiones.",
"actions": "Acciones del chat {{title}}",
"delete": "Eliminar",
"newChat": "Nuevo chat"
},
"deleteConfirm": {
"title": "¿Eliminar “{{title}}”?",
"description": "El archivo de sesión se eliminará del disco. Esta acción no se puede deshacer.",
"cancel": "Cancelar",
"confirm": "Eliminar"
},
"connection": {
"idle": "Inactivo",
"connecting": "Conectando…",
"open": "Conectado",
"reconnecting": "Reconectando…",
"closed": "Desconectado",
"error": "Error de conexión"
},
"thread": {
"loadingConversation": "Cargando conversación…",
"empty": {
"description": "Haz preguntas, continúa tu trabajo local o inicia un nuevo hilo."
},
"header": {
"toggleSidebar": "Mostrar u ocultar la barra lateral"
},
"composer": {
"placeholderThread": "Escribe tu mensaje…",
"placeholderHero": "¿Qué tienes en mente?",
"placeholderOpening": "Abriendo un nuevo chat…",
"inputAria": "Entrada de mensaje",
"sendHint": "Enter para enviar · Shift+Enter para nueva línea",
"send": "Enviar mensaje"
},
"scrollToBottom": "Desplazarse al final"
},
"message": {
"streaming": "transmitiendo",
"assistantTyping": "El asistente está escribiendo",
"toolSingle": "Usando una herramienta",
"toolMany": "Se usaron {{count}} herramientas"
},
"code": {
"fallbackLanguage": "código",
"copyAria": "Copiar código",
"copy": "Copiar",
"copied": "Copiado"
}
}
+83
View File
@@ -0,0 +1,83 @@
{
"app": {
"brand": "nanobot",
"loading": {
"connecting": "Connexion à nanobot…",
"boot": "Chargement de nanobot…"
},
"error": {
"title": "Impossible de joindre nanobot",
"gatewayHint": "Assurez-vous que la gateway est en cours dexécution (`nanobot web`) et que cette page est ouverte sur la même machine."
},
"documentTitle": {
"base": "nanobot",
"chat": "{{title}} · nanobot"
},
"meta": {
"description": "Interface web nanobot — discutez avec votre espace de travail nanobot."
}
},
"sidebar": {
"collapse": "Réduire la barre latérale",
"toggleTheme": "Changer de thème",
"newChat": "Nouvelle discussion",
"recent": "Récentes",
"refreshSessions": "Actualiser les sessions",
"language": {
"label": "Langue",
"ariaLabel": "Changer de langue"
}
},
"chat": {
"fallbackTitle": "Discussion {{id}}",
"loading": "Chargement…",
"noSessions": "Aucune session pour le moment.",
"actions": "Actions de la discussion {{title}}",
"delete": "Supprimer",
"newChat": "Nouvelle discussion"
},
"deleteConfirm": {
"title": "Supprimer « {{title}} » ?",
"description": "Le fichier de session sera supprimé du disque. Cette action est irréversible.",
"cancel": "Annuler",
"confirm": "Supprimer"
},
"connection": {
"idle": "Inactif",
"connecting": "Connexion…",
"open": "Connecté",
"reconnecting": "Reconnexion…",
"closed": "Déconnecté",
"error": "Erreur de connexion"
},
"thread": {
"loadingConversation": "Chargement de la conversation…",
"empty": {
"description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil."
},
"header": {
"toggleSidebar": "Afficher ou masquer la barre latérale"
},
"composer": {
"placeholderThread": "Saisissez votre message…",
"placeholderHero": "Quavez-vous en tête ?",
"placeholderOpening": "Ouverture dune nouvelle discussion…",
"inputAria": "Champ de message",
"sendHint": "Entrée pour envoyer · Maj+Entrée pour un retour à la ligne",
"send": "Envoyer le message"
},
"scrollToBottom": "Faire défiler vers le bas"
},
"message": {
"streaming": "en cours de génération",
"assistantTyping": "Lassistant est en train d’écrire",
"toolSingle": "Utilisation dun outil",
"toolMany": "{{count}} outils utilisés"
},
"code": {
"fallbackLanguage": "code",
"copyAria": "Copier le code",
"copy": "Copier",
"copied": "Copié"
}
}
+83
View File
@@ -0,0 +1,83 @@
{
"app": {
"brand": "nanobot",
"loading": {
"connecting": "Menghubungkan ke nanobot…",
"boot": "Memuat nanobot…"
},
"error": {
"title": "Tidak dapat menjangkau nanobot",
"gatewayHint": "Pastikan gateway sedang berjalan (`nanobot web`) dan halaman ini dibuka pada mesin yang sama."
},
"documentTitle": {
"base": "nanobot",
"chat": "{{title}} · nanobot"
},
"meta": {
"description": "UI web nanobot — ngobrol dengan workspace nanobot Anda."
}
},
"sidebar": {
"collapse": "Ciutkan sidebar",
"toggleTheme": "Ganti tema",
"newChat": "Obrolan baru",
"recent": "Terbaru",
"refreshSessions": "Segarkan sesi",
"language": {
"label": "Bahasa",
"ariaLabel": "Ganti bahasa"
}
},
"chat": {
"fallbackTitle": "Obrolan {{id}}",
"loading": "Memuat…",
"noSessions": "Belum ada sesi.",
"actions": "Aksi obrolan untuk {{title}}",
"delete": "Hapus",
"newChat": "Obrolan baru"
},
"deleteConfirm": {
"title": "Hapus “{{title}}”?",
"description": "File sesi akan dihapus dari disk. Tindakan ini tidak dapat dibatalkan.",
"cancel": "Batal",
"confirm": "Hapus"
},
"connection": {
"idle": "Idle",
"connecting": "Menghubungkan…",
"open": "Terhubung",
"reconnecting": "Menyambung ulang…",
"closed": "Terputus",
"error": "Kesalahan koneksi"
},
"thread": {
"loadingConversation": "Memuat percakapan…",
"empty": {
"description": "Ajukan pertanyaan, lanjutkan pekerjaan lokal, atau mulai thread baru."
},
"header": {
"toggleSidebar": "Tampilkan atau sembunyikan sidebar"
},
"composer": {
"placeholderThread": "Ketik pesan Anda…",
"placeholderHero": "Apa yang sedang Anda pikirkan?",
"placeholderOpening": "Membuka obrolan baru…",
"inputAria": "Input pesan",
"sendHint": "Enter untuk kirim · Shift+Enter untuk baris baru",
"send": "Kirim pesan"
},
"scrollToBottom": "Gulir ke bawah"
},
"message": {
"streaming": "sedang mengalir",
"assistantTyping": "Asisten sedang mengetik",
"toolSingle": "Menggunakan sebuah alat",
"toolMany": "Menggunakan {{count}} alat"
},
"code": {
"fallbackLanguage": "kode",
"copyAria": "Salin kode",
"copy": "Salin",
"copied": "Tersalin"
}
}
+83
View File
@@ -0,0 +1,83 @@
{
"app": {
"brand": "nanobot",
"loading": {
"connecting": "nanobot に接続中…",
"boot": "nanobot を読み込み中…"
},
"error": {
"title": "nanobot に接続できませんでした",
"gatewayHint": "gateway`nanobot web`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
},
"documentTitle": {
"base": "nanobot",
"chat": "{{title}} · nanobot"
},
"meta": {
"description": "nanobot Web UI — nanobot ワークスペースと会話します。"
}
},
"sidebar": {
"collapse": "サイドバーを閉じる",
"toggleTheme": "テーマを切り替える",
"newChat": "新しいチャット",
"recent": "最近のチャット",
"refreshSessions": "セッションを更新",
"language": {
"label": "言語",
"ariaLabel": "言語を変更"
}
},
"chat": {
"fallbackTitle": "チャット {{id}}",
"loading": "読み込み中…",
"noSessions": "まだセッションがありません。",
"actions": "「{{title}}」のチャット操作",
"delete": "削除",
"newChat": "新しいチャット"
},
"deleteConfirm": {
"title": "「{{title}}」を削除しますか?",
"description": "セッションファイルはディスクから削除されます。この操作は元に戻せません。",
"cancel": "キャンセル",
"confirm": "削除"
},
"connection": {
"idle": "待機中",
"connecting": "接続中…",
"open": "接続済み",
"reconnecting": "再接続中…",
"closed": "切断済み",
"error": "接続エラー"
},
"thread": {
"loadingConversation": "会話を読み込み中…",
"empty": {
"description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。"
},
"header": {
"toggleSidebar": "サイドバーを切り替える"
},
"composer": {
"placeholderThread": "メッセージを入力…",
"placeholderHero": "何を考えていますか?",
"placeholderOpening": "新しいチャットを開いています…",
"inputAria": "メッセージ入力欄",
"sendHint": "Enter で送信 · Shift+Enter で改行",
"send": "メッセージを送信"
},
"scrollToBottom": "一番下へスクロール"
},
"message": {
"streaming": "生成中",
"assistantTyping": "アシスタントが入力中",
"toolSingle": "ツールを使用中",
"toolMany": "{{count}} 個のツールを使用"
},
"code": {
"fallbackLanguage": "コード",
"copyAria": "コードをコピー",
"copy": "コピー",
"copied": "コピーしました"
}
}
+83
View File
@@ -0,0 +1,83 @@
{
"app": {
"brand": "nanobot",
"loading": {
"connecting": "nanobot에 연결 중…",
"boot": "nanobot 불러오는 중…"
},
"error": {
"title": "nanobot에 연결할 수 없습니다",
"gatewayHint": "gateway(`nanobot web`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
},
"documentTitle": {
"base": "nanobot",
"chat": "{{title}} · nanobot"
},
"meta": {
"description": "nanobot 웹 UI — nanobot 작업공간과 대화하세요."
}
},
"sidebar": {
"collapse": "사이드바 접기",
"toggleTheme": "테마 전환",
"newChat": "새 채팅",
"recent": "최근 대화",
"refreshSessions": "세션 새로고침",
"language": {
"label": "언어",
"ariaLabel": "언어 변경"
}
},
"chat": {
"fallbackTitle": "채팅 {{id}}",
"loading": "불러오는 중…",
"noSessions": "아직 세션이 없습니다.",
"actions": "{{title}} 채팅 작업",
"delete": "삭제",
"newChat": "새 채팅"
},
"deleteConfirm": {
"title": "“{{title}}”을(를) 삭제할까요?",
"description": "세션 파일이 디스크에서 제거됩니다. 이 작업은 되돌릴 수 없습니다.",
"cancel": "취소",
"confirm": "삭제"
},
"connection": {
"idle": "대기 중",
"connecting": "연결 중…",
"open": "연결됨",
"reconnecting": "재연결 중…",
"closed": "연결 끊김",
"error": "연결 오류"
},
"thread": {
"loadingConversation": "대화 불러오는 중…",
"empty": {
"description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다."
},
"header": {
"toggleSidebar": "사이드바 전환"
},
"composer": {
"placeholderThread": "메시지를 입력하세요…",
"placeholderHero": "무슨 생각을 하고 있나요?",
"placeholderOpening": "새 채팅을 여는 중…",
"inputAria": "메시지 입력",
"sendHint": "Enter로 전송 · Shift+Enter로 줄바꿈",
"send": "메시지 보내기"
},
"scrollToBottom": "맨 아래로 스크롤"
},
"message": {
"streaming": "생성 중",
"assistantTyping": "도우미가 입력 중",
"toolSingle": "도구 사용 중",
"toolMany": "도구 {{count}}개 사용됨"
},
"code": {
"fallbackLanguage": "코드",
"copyAria": "코드 복사",
"copy": "복사",
"copied": "복사됨"
}
}
+83
View File
@@ -0,0 +1,83 @@
{
"app": {
"brand": "nanobot",
"loading": {
"connecting": "Đang kết nối tới nanobot…",
"boot": "Đang tải nanobot…"
},
"error": {
"title": "Không thể kết nối tới nanobot",
"gatewayHint": "Hãy chắc chắn gateway đang chạy (`nanobot web`) và trang này được mở trên cùng máy."
},
"documentTitle": {
"base": "nanobot",
"chat": "{{title}} · nanobot"
},
"meta": {
"description": "Giao diện web nanobot — trò chuyện với workspace nanobot của bạn."
}
},
"sidebar": {
"collapse": "Thu gọn thanh bên",
"toggleTheme": "Chuyển giao diện",
"newChat": "Cuộc trò chuyện mới",
"recent": "Gần đây",
"refreshSessions": "Làm mới phiên",
"language": {
"label": "Ngôn ngữ",
"ariaLabel": "Đổi ngôn ngữ"
}
},
"chat": {
"fallbackTitle": "Trò chuyện {{id}}",
"loading": "Đang tải…",
"noSessions": "Chưa có phiên nào.",
"actions": "Tác vụ cho cuộc trò chuyện {{title}}",
"delete": "Xóa",
"newChat": "Cuộc trò chuyện mới"
},
"deleteConfirm": {
"title": "Xóa “{{title}}”?",
"description": "Tệp phiên sẽ bị xóa khỏi đĩa. Không thể hoàn tác thao tác này.",
"cancel": "Hủy",
"confirm": "Xóa"
},
"connection": {
"idle": "Rảnh",
"connecting": "Đang kết nối…",
"open": "Đã kết nối",
"reconnecting": "Đang kết nối lại…",
"closed": "Đã ngắt kết nối",
"error": "Lỗi kết nối"
},
"thread": {
"loadingConversation": "Đang tải cuộc trò chuyện…",
"empty": {
"description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới."
},
"header": {
"toggleSidebar": "Bật/tắt thanh bên"
},
"composer": {
"placeholderThread": "Nhập tin nhắn…",
"placeholderHero": "Bạn đang nghĩ gì?",
"placeholderOpening": "Đang mở cuộc trò chuyện mới…",
"inputAria": "Ô nhập tin nhắn",
"sendHint": "Enter để gửi · Shift+Enter để xuống dòng",
"send": "Gửi tin nhắn"
},
"scrollToBottom": "Cuộn xuống cuối"
},
"message": {
"streaming": "đang truyền",
"assistantTyping": "Trợ lý đang nhập",
"toolSingle": "Đang dùng một công cụ",
"toolMany": "Đã dùng {{count}} công cụ"
},
"code": {
"fallbackLanguage": "mã",
"copyAria": "Sao chép mã",
"copy": "Sao chép",
"copied": "Đã sao chép"
}
}
+83
View File
@@ -0,0 +1,83 @@
{
"app": {
"brand": "nanobot",
"loading": {
"connecting": "正在连接 nanobot…",
"boot": "正在加载 nanobot…"
},
"error": {
"title": "无法连接到 nanobot",
"gatewayHint": "请确认 gateway 已启动(`nanobot web`),并且当前页面与 gateway 运行在同一台机器上。"
},
"documentTitle": {
"base": "nanobot",
"chat": "{{title}} · nanobot"
},
"meta": {
"description": "nanobot Web UI —— 与你的 nanobot 工作区对话。"
}
},
"sidebar": {
"collapse": "收起侧边栏",
"toggleTheme": "切换主题",
"newChat": "新建对话",
"recent": "最近对话",
"refreshSessions": "刷新会话",
"language": {
"label": "语言",
"ariaLabel": "切换语言"
}
},
"chat": {
"fallbackTitle": "对话 {{id}}",
"loading": "加载中…",
"noSessions": "还没有会话。",
"actions": "“{{title}}” 的会话操作",
"delete": "删除",
"newChat": "新建对话"
},
"deleteConfirm": {
"title": "删除“{{title}}”?",
"description": "这个会话文件会从磁盘中删除,且无法撤销。",
"cancel": "取消",
"confirm": "删除"
},
"connection": {
"idle": "空闲",
"connecting": "连接中…",
"open": "已连接",
"reconnecting": "重连中…",
"closed": "已断开",
"error": "连接出错"
},
"thread": {
"loadingConversation": "正在加载对话…",
"empty": {
"description": "可以提问、继续本地工作,或者开启一个新线程。"
},
"header": {
"toggleSidebar": "切换侧边栏"
},
"composer": {
"placeholderThread": "输入消息…",
"placeholderHero": "你在想什么?",
"placeholderOpening": "正在打开新对话…",
"inputAria": "消息输入框",
"sendHint": "Enter 发送 · Shift+Enter 换行",
"send": "发送消息"
},
"scrollToBottom": "滚动到底部"
},
"message": {
"streaming": "流式输出中",
"assistantTyping": "助手正在输入",
"toolSingle": "正在使用工具",
"toolMany": "已使用 {{count}} 个工具"
},
"code": {
"fallbackLanguage": "代码",
"copyAria": "复制代码",
"copy": "复制",
"copied": "已复制"
}
}
+83
View File
@@ -0,0 +1,83 @@
{
"app": {
"brand": "nanobot",
"loading": {
"connecting": "正在連線到 nanobot…",
"boot": "正在載入 nanobot…"
},
"error": {
"title": "無法連線到 nanobot",
"gatewayHint": "請確認 gateway 已啟動(`nanobot web`),並且目前頁面與 gateway 在同一台機器上開啟。"
},
"documentTitle": {
"base": "nanobot",
"chat": "{{title}} · nanobot"
},
"meta": {
"description": "nanobot Web UI —— 與你的 nanobot 工作區對話。"
}
},
"sidebar": {
"collapse": "收合側邊欄",
"toggleTheme": "切換主題",
"newChat": "新增對話",
"recent": "最近對話",
"refreshSessions": "重新整理會話",
"language": {
"label": "語言",
"ariaLabel": "切換語言"
}
},
"chat": {
"fallbackTitle": "對話 {{id}}",
"loading": "載入中…",
"noSessions": "目前還沒有會話。",
"actions": "「{{title}}」的會話操作",
"delete": "刪除",
"newChat": "新增對話"
},
"deleteConfirm": {
"title": "刪除「{{title}}」?",
"description": "這個會話檔案會從磁碟中移除,而且無法復原。",
"cancel": "取消",
"confirm": "刪除"
},
"connection": {
"idle": "閒置",
"connecting": "連線中…",
"open": "已連線",
"reconnecting": "重新連線中…",
"closed": "已中斷",
"error": "連線錯誤"
},
"thread": {
"loadingConversation": "正在載入對話…",
"empty": {
"description": "你可以提問、延續本地工作,或是開始新的執行緒。"
},
"header": {
"toggleSidebar": "切換側邊欄"
},
"composer": {
"placeholderThread": "輸入訊息…",
"placeholderHero": "你在想什麼?",
"placeholderOpening": "正在開啟新對話…",
"inputAria": "訊息輸入框",
"sendHint": "Enter 送出 · Shift+Enter 換行",
"send": "送出訊息"
},
"scrollToBottom": "捲動到底部"
},
"message": {
"streaming": "串流輸出中",
"assistantTyping": "助理正在輸入",
"toolSingle": "正在使用工具",
"toolMany": "已使用 {{count}} 個工具"
},
"code": {
"fallbackLanguage": "程式碼",
"copyAria": "複製程式碼",
"copy": "複製",
"copied": "已複製"
}
}
+39 -6
View File
@@ -1,3 +1,5 @@
import i18n, { currentLocale } from "@/i18n";
/** Truncate the first user message into a chat title. */
export function deriveTitle(preview: string | undefined, fallback: string): string {
if (!preview) return fallback;
@@ -23,22 +25,53 @@ const RELATIVE_THRESHOLDS: [number, Intl.RelativeTimeFormatUnit][] = [
[Number.POSITIVE_INFINITY, "year"],
];
const RTF = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
const relativeTimeFormatters = new Map<string, Intl.RelativeTimeFormat>();
const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>();
export function relativeTime(value: string | number | null | undefined): string {
function activeLocale(locale?: string): string {
return locale || i18n.resolvedLanguage || i18n.language || currentLocale();
}
function relativeTimeFormatter(locale: string): Intl.RelativeTimeFormat {
const existing = relativeTimeFormatters.get(locale);
if (existing) return existing;
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
relativeTimeFormatters.set(locale, formatter);
return formatter;
}
function dateTimeFormatter(locale: string): Intl.DateTimeFormat {
const existing = dateTimeFormatters.get(locale);
if (existing) return existing;
const formatter = new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "short",
});
dateTimeFormatters.set(locale, formatter);
return formatter;
}
export function relativeTime(
value: string | number | null | undefined,
locale?: string,
): string {
const date = parseDate(value);
if (!date) return "";
let delta = (date.getTime() - Date.now()) / 1000;
const formatter = relativeTimeFormatter(activeLocale(locale));
for (const [step, unit] of RELATIVE_THRESHOLDS) {
if (Math.abs(delta) < step) {
return RTF.format(Math.round(delta), unit);
return formatter.format(Math.round(delta), unit);
}
delta /= step;
}
return RTF.format(Math.round(delta), "year");
return formatter.format(Math.round(delta), "year");
}
export function fmtDateTime(value: string | number | null | undefined): string {
export function fmtDateTime(
value: string | number | null | undefined,
locale?: string,
): string {
const date = parseDate(value);
return date ? date.toLocaleString() : "";
return date ? dateTimeFormatter(activeLocale(locale)).format(date) : "";
}
+1
View File
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import App from "./App";
import "./globals.css";
import "./i18n";
const root = document.getElementById("root");
if (!root) throw new Error("root element missing");
+1 -1
View File
@@ -144,5 +144,5 @@ describe("App layout", () => {
);
expect(screen.queryByText('Delete “First chat”?')).not.toBeInTheDocument();
expect(document.body.style.pointerEvents).not.toBe("none");
});
}, 15_000);
});
+64
View File
@@ -0,0 +1,64 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setAppLanguage } from "@/i18n";
import { fmtDateTime, relativeTime } from "@/lib/format";
describe("localized format helpers", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-18T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("formats relative time using the active locale", async () => {
const value = "2026-04-18T11:59:00Z";
await setAppLanguage("en");
const english = relativeTime(value);
await setAppLanguage("zh-CN");
const chinese = relativeTime(value);
expect(english).toBe(
new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(
-1,
"minute",
),
);
expect(chinese).toBe(
new Intl.RelativeTimeFormat("zh-CN", { numeric: "auto" }).format(
-1,
"minute",
),
);
expect(english).not.toBe(chinese);
});
it("formats date-time using the active locale", async () => {
const value = "2026-04-18T08:30:00Z";
const date = new Date(value);
await setAppLanguage("en");
const english = fmtDateTime(value);
await setAppLanguage("fr");
const french = fmtDateTime(value);
expect(english).toBe(
new Intl.DateTimeFormat("en", {
dateStyle: "medium",
timeStyle: "short",
}).format(date),
);
expect(french).toBe(
new Intl.DateTimeFormat("fr", {
dateStyle: "medium",
timeStyle: "short",
}).format(date),
);
expect(english).not.toBe(french);
});
});
+44
View File
@@ -0,0 +1,44 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
describe("webui i18n", () => {
it("switches UI copy and document locale through the language switcher", async () => {
const user = userEvent.setup();
render(
<>
<LanguageSwitcher />
<ThreadComposer onSend={vi.fn()} />
</>,
);
expect(
screen.getByPlaceholderText("Type your message…"),
).toBeInTheDocument();
expect(document.documentElement.lang).toBe("en");
await user.click(screen.getByRole("button", { name: "Change language" }));
await user.click(screen.getByRole("menuitemradio", { name: /简体中文/i }));
await waitFor(() => {
expect(document.documentElement.lang).toBe("zh-CN");
});
expect(localStorage.getItem("nanobot.locale")).toBe("zh-CN");
expect(screen.getByPlaceholderText("输入消息…")).toBeInTheDocument();
});
it("updates the composer aria label when the language changes", async () => {
render(<ThreadComposer onSend={vi.fn()} />);
await act(async () => {
const { setAppLanguage } = await import("@/i18n");
await setAppLanguage("ja");
});
expect(screen.getByLabelText("メッセージ入力欄")).toBeInTheDocument();
});
});
+10
View File
@@ -1,4 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { beforeEach } from "vitest";
import i18n from "@/i18n";
// happy-dom doesn't ship with ``crypto.randomUUID``; shim a tiny v4-ish helper.
if (!("randomUUID" in globalThis.crypto)) {
@@ -12,3 +15,10 @@ if (!("randomUUID" in globalThis.crypto)) {
configurable: true,
});
}
beforeEach(async () => {
await i18n.changeLanguage("en");
document.documentElement.lang = "en";
document.title = "nanobot";
localStorage.setItem("nanobot.locale", "en");
});