feat(webui): add i18n support and locale switcher
This commit is contained in:
+86
-2
@@ -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>
|
||||
|
||||
Generated
+5309
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 d’exé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": "Qu’avez-vous en tête ?",
|
||||
"placeholderOpening": "Ouverture d’une 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": "L’assistant est en train d’écrire",
|
||||
"toolSingle": "Utilisation d’un outil",
|
||||
"toolMany": "{{count}} outils utilisés"
|
||||
},
|
||||
"code": {
|
||||
"fallbackLanguage": "code",
|
||||
"copyAria": "Copier le code",
|
||||
"copy": "Copier",
|
||||
"copied": "Copié"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "コピーしました"
|
||||
}
|
||||
}
|
||||
@@ -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": "복사됨"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "已复制"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) : "";
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user