;
}) => (
{children}
@@ -61,6 +64,7 @@ describe("CodeBlock", () => {
});
expect(screen.getByTestId("highlighted-code")).toBeInTheDocument();
+ expect(screen.getByTestId("highlighted-code")).toHaveAttribute("data-language", "text");
expect(screen.getByText("const value = 1;")).toBeInTheDocument();
});
diff --git a/webui/src/tests/markdown-text-renderer.test.tsx b/webui/src/tests/markdown-text-renderer.test.tsx
index 2eb9488f5..b0282b756 100644
--- a/webui/src/tests/markdown-text-renderer.test.tsx
+++ b/webui/src/tests/markdown-text-renderer.test.tsx
@@ -24,6 +24,7 @@ describe("MarkdownTextRenderer", () => {
);
expect(screen.getByText("code without language")).toBeInTheDocument();
+ expect(screen.getByText("text")).toBeInTheDocument();
expect(container.querySelectorAll("pre")).toHaveLength(1);
});
From 31722120b7b980eb00388f82903b19a7c8cd04eb Mon Sep 17 00:00:00 2001
From: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
Date: Sun, 31 May 2026 17:00:42 +0800
Subject: [PATCH 07/71] feat(webui): polish native host experience
---
webui/src/App.tsx | 63 +++++++++++------
webui/src/components/Sidebar.tsx | 3 +-
.../src/components/settings/SettingsView.tsx | 9 ++-
webui/src/components/thread/ThreadHeader.tsx | 32 +++++----
webui/src/components/thread/ThreadShell.tsx | 3 +
.../src/components/thread/ThreadViewport.tsx | 2 +-
.../thread/activity/FileEditRow.tsx | 28 +++++---
webui/src/globals.css | 69 +++++++++++++++++++
webui/src/lib/nanobot-client.ts | 19 +++--
webui/src/lib/runtime.ts | 21 +++---
.../src/tests/agent-activity-cluster.test.tsx | 33 +++++++++
webui/src/tests/app-layout.test.tsx | 4 +-
webui/src/tests/nanobot-client.test.ts | 56 +++++++++++++++
webui/src/tests/settings-view.test.tsx | 34 +++++++++
14 files changed, 316 insertions(+), 60 deletions(-)
diff --git a/webui/src/App.tsx b/webui/src/App.tsx
index 1dec4a6f9..6119a79b1 100644
--- a/webui/src/App.tsx
+++ b/webui/src/App.tsx
@@ -218,7 +218,7 @@ function HostChrome({
)}
) : (
-
+
)}
);
@@ -252,7 +252,19 @@ export default function App() {
refreshed.token,
refreshed.ws_url,
);
+ const refreshedSurface = refreshed.runtime_surface
+ ? toRuntimeSurface(refreshed.runtime_surface)
+ : runtimeSurface;
+ const refreshedHost = createRuntimeHost(
+ refreshedSurface,
+ refreshed.runtime_capabilities,
+ );
const tokenExpiresAt = bootstrapTokenExpiresAt(refreshed.expires_in);
+ if (refreshedHost.socketFactory) {
+ client.updateUrl(refreshedUrl, refreshedHost.socketFactory);
+ } else {
+ client.updateUrl(refreshedUrl);
+ }
setState((current) =>
current.status === "ready" && current.client === client
? {
@@ -260,10 +272,7 @@ export default function App() {
token: refreshed.token,
tokenExpiresAt,
modelName: refreshed.model_name ?? current.modelName,
- runtimeSurface:
- refreshed.runtime_surface
- ? toRuntimeSurface(refreshed.runtime_surface)
- : current.runtimeSurface,
+ runtimeSurface: refreshedSurface,
}
: current,
);
@@ -307,8 +316,16 @@ export default function App() {
try {
const boot = await fetchBootstrap("", bootstrapSecretRef.current);
const url = deriveWsUrl(boot.ws_path, boot.token, boot.ws_url);
+ const runtimeSurface = boot.runtime_surface
+ ? toRuntimeSurface(boot.runtime_surface)
+ : state.runtimeSurface;
+ const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in);
- client.updateUrl(url);
+ if (runtimeHost.socketFactory) {
+ client.updateUrl(url, runtimeHost.socketFactory);
+ } else {
+ client.updateUrl(url);
+ }
setState((current) =>
current.status === "ready" && current.client === client
? {
@@ -316,9 +333,7 @@ export default function App() {
token: boot.token,
tokenExpiresAt,
modelName: boot.model_name ?? current.modelName,
- runtimeSurface: boot.runtime_surface
- ? toRuntimeSurface(boot.runtime_surface)
- : current.runtimeSurface,
+ runtimeSurface,
}
: current,
);
@@ -1058,12 +1073,19 @@ function Shell({
const showHostChrome = isNativeHostSetupSurface;
const showMainSidebar = view !== "settings";
+ useEffect(() => {
+ document.documentElement.classList.toggle("native-host", showHostChrome);
+ return () => {
+ document.documentElement.classList.remove("native-host");
+ };
+ }, [showHostChrome]);
+
return (
{showHostChrome ? (
@@ -1071,7 +1093,6 @@ function Shell({
onToggleSidebar={showMainSidebar ? toggleSidebar : undefined}
theme={theme}
onToggleTheme={toggle}
- showThemeButton={view !== "chat"}
/>
) : null}
-
+
diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx
index 0fc92ae18..227737ae9 100644
--- a/webui/src/components/settings/SettingsView.tsx
+++ b/webui/src/components/settings/SettingsView.tsx
@@ -4277,7 +4277,7 @@ function ProviderPicker({
const disabled = providers.length === 0;
return (
-
+
preset.name === value) ?? presets[0] ?? null;
return (
-
+
{
+ window.setTimeout(onCreateConfiguration, 0);
+ }}
className={cn(
"flex cursor-default items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] font-medium",
"text-foreground focus:bg-muted/85 focus:text-foreground",
diff --git a/webui/src/components/thread/ThreadHeader.tsx b/webui/src/components/thread/ThreadHeader.tsx
index d7f0f6fee..8fe414a98 100644
--- a/webui/src/components/thread/ThreadHeader.tsx
+++ b/webui/src/components/thread/ThreadHeader.tsx
@@ -10,6 +10,7 @@ interface ThreadHeaderProps {
theme: "light" | "dark";
onToggleTheme: () => void;
hideSidebarToggleForHostChrome?: boolean;
+ hideThemeButton?: boolean;
minimal?: boolean;
}
@@ -19,6 +20,7 @@ export function ThreadHeader({
theme,
onToggleTheme,
hideSidebarToggleForHostChrome = false,
+ hideThemeButton = false,
minimal = false,
}: ThreadHeaderProps) {
const { t } = useTranslation();
@@ -37,12 +39,14 @@ export function ThreadHeader({
>
-
+ {!hideThemeButton ? (
+
+ ) : null}
);
}
@@ -67,12 +71,14 @@ export function ThreadHeader({
-
+ {!hideThemeButton ? (
+
+ ) : null}
@@ -97,7 +103,7 @@ function ThemeButton({
aria-label={label}
onClick={onToggleTheme}
className={cn(
- "h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground",
+ "host-no-drag h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground",
className,
)}
>
diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx
index 689a93d3f..315d4cef4 100644
--- a/webui/src/components/thread/ThreadShell.tsx
+++ b/webui/src/components/thread/ThreadShell.tsx
@@ -62,6 +62,7 @@ interface ThreadShellProps {
theme?: "light" | "dark";
onToggleTheme?: () => void;
hideSidebarToggleForHostChrome?: boolean;
+ hideThemeButton?: boolean;
hideHeader?: boolean;
workspaceScope?: WorkspaceScopePayload | null;
workspaceDefaultScope?: WorkspaceScopePayload | null;
@@ -142,6 +143,7 @@ export function ThreadShell({
theme = "light",
onToggleTheme = () => {},
hideSidebarToggleForHostChrome = false,
+ hideThemeButton = false,
hideHeader = false,
workspaceScope = null,
workspaceDefaultScope = null,
@@ -567,6 +569,7 @@ export function ThreadShell({
theme={theme}
onToggleTheme={onToggleTheme}
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
+ hideThemeButton={hideThemeButton}
minimal={!session && !loading}
/>
) : null}
diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx
index 85444e16b..9c81b33b7 100644
--- a/webui/src/components/thread/ThreadViewport.tsx
+++ b/webui/src/components/thread/ThreadViewport.tsx
@@ -237,7 +237,7 @@ export function ThreadViewport({
)}
- detail={failed ? (
-
+ detail={null}
+ aside={hasCountedDiff ? : null}
+ >
+ {failed ? (
+
{failureDetail}
) : null}
- aside={hasCountedDiff ? : null}
- />
+
);
}
@@ -96,14 +99,23 @@ export function hasVisibleDiffStats(edit: Pick 0 || edit.deleted > 0;
}
-function formatFileEditError(error?: string): string {
+function cleanFileEditError(error?: string): string {
const firstLine = (error || "").replace(/\s+/g, " ").trim();
if (!firstLine) return "";
- const cleaned = firstLine
+ return firstLine
.replace(/^Error applying patch:\s*/i, "")
.replace(/^Error writing file:\s*/i, "")
.replace(/^Error editing file:\s*/i, "")
.replace(/^Error:\s*/i, "");
+}
+
+function formatFileEditError(error?: string): string {
+ const cleaned = cleanFileEditError(error);
+ if (!cleaned) return "";
+
+ if (/\bpermission denied\b/i.test(cleaned) || /\boperation not permitted\b/i.test(cleaned)) {
+ return "No permission to change this location.";
+ }
return cleaned
.replace(/^old_text not found in (.+)$/i, "Target text was not found in $1.")
diff --git a/webui/src/globals.css b/webui/src/globals.css
index 27d4b3e38..0afb9c334 100644
--- a/webui/src/globals.css
+++ b/webui/src/globals.css
@@ -89,6 +89,75 @@
-webkit-app-region: no-drag;
}
+ html.native-host,
+ html.native-host body,
+ html.native-host #root {
+ background: transparent;
+ }
+
+ html.native-host body {
+ overflow: hidden;
+ }
+
+ .host-window-shell,
+ .host-sidebar-glass {
+ --host-glass-spot: hsl(var(--background) / 0.42);
+ --host-glass-start: hsl(var(--sidebar) / 0.5);
+ --host-glass-end: hsl(var(--sidebar) / 0.3);
+ background:
+ radial-gradient(
+ circle at 18% 0%,
+ var(--host-glass-spot),
+ transparent 34rem
+ ),
+ linear-gradient(
+ 180deg,
+ var(--host-glass-start),
+ var(--host-glass-end)
+ );
+ background-attachment: fixed, fixed;
+ -webkit-backdrop-filter: saturate(185%) blur(34px);
+ backdrop-filter: saturate(185%) blur(34px);
+ }
+
+ .host-sidebar-glass {
+ box-shadow:
+ inset -1px 0 0 hsl(var(--border) / 0.36),
+ inset 1px 0 0 hsl(var(--background) / 0.34),
+ 18px 0 44px -42px rgb(0 0 0 / 0.42);
+ }
+
+ .dark .host-window-shell,
+ .dark .host-sidebar-glass {
+ --host-glass-spot: hsl(var(--foreground) / 0.06);
+ --host-glass-start: hsl(var(--sidebar) / 0.48);
+ --host-glass-end: hsl(var(--sidebar) / 0.3);
+ }
+
+ .dark .host-sidebar-glass {
+ box-shadow:
+ inset -1px 0 0 hsl(var(--border) / 0.42),
+ inset 1px 0 0 hsl(var(--foreground) / 0.05),
+ 18px 0 46px -42px rgb(0 0 0 / 0.72);
+ }
+
+ @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
+ .host-sidebar-glass {
+ background: hsl(var(--sidebar) / 0.92);
+ }
+ }
+
+ html.native-host * {
+ scrollbar-width: none;
+ scrollbar-gutter: auto !important;
+ }
+
+ html.native-host *::-webkit-scrollbar {
+ display: none;
+ width: 0;
+ height: 0;
+ }
+
.shadow-inner-right {
box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02);
}
diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts
index 2db278b30..a739afb2e 100644
--- a/webui/src/lib/nanobot-client.ts
+++ b/webui/src/lib/nanobot-client.ts
@@ -9,11 +9,20 @@ import type {
GoalStateWsPayload,
WorkspaceScopePayload,
} from "./types";
+import { createHostWebSocket } from "./runtime";
/** WebSocket readyState constants, referenced by value to stay portable
* across runtimes that don't expose a global ``WebSocket`` (tests, SSR). */
const WS_OPEN = 1;
const WS_CLOSING = 2;
+const HOST_SOCKET_URL_PREFIX = "nanobot-host://";
+
+function createDefaultSocket(url: string): WebSocket {
+ if (url.startsWith(HOST_SOCKET_URL_PREFIX)) {
+ return createHostWebSocket(url);
+ }
+ return new WebSocket(url);
+}
/** Inbound WebSocket ``console.log`` / parse-failure ``console.warn``.
*
@@ -129,7 +138,7 @@ export class NanobotClient {
private reconnectTimer: ReturnType | null = null;
private readonly shouldReconnect: boolean;
private readonly maxBackoffMs: number;
- private readonly socketFactory: (url: string) => WebSocket;
+ private socketFactory: (url: string) => WebSocket;
private currentUrl: string;
private status_: ConnectionStatus = "idle";
private readyChatId: string | null = null;
@@ -140,8 +149,7 @@ export class NanobotClient {
constructor(private options: NanobotClientOptions) {
this.shouldReconnect = options.reconnect ?? true;
this.maxBackoffMs = options.maxBackoffMs ?? 15_000;
- this.socketFactory =
- options.socketFactory ?? ((url) => new WebSocket(url));
+ this.socketFactory = options.socketFactory ?? createDefaultSocket;
this.currentUrl = options.url;
}
@@ -154,8 +162,11 @@ export class NanobotClient {
}
/** Swap the URL (e.g. after fetching a fresh token) then reconnect. */
- updateUrl(url: string): void {
+ updateUrl(url: string, socketFactory?: (url: string) => WebSocket): void {
this.currentUrl = url;
+ if (socketFactory) {
+ this.socketFactory = socketFactory;
+ }
}
onStatus(handler: StatusHandler): Unsubscribe {
diff --git a/webui/src/lib/runtime.ts b/webui/src/lib/runtime.ts
index a9c406eb3..e11fb70ad 100644
--- a/webui/src/lib/runtime.ts
+++ b/webui/src/lib/runtime.ts
@@ -51,6 +51,11 @@ type HostSocketBridge = Required>;
+const HOST_WS_CONNECTING = 0;
+const HOST_WS_OPEN = 1;
+const HOST_WS_CLOSING = 2;
+const HOST_WS_CLOSED = 3;
+
declare global {
interface Window {
nanobotHost?: NanobotHostApi;
@@ -122,7 +127,7 @@ class HostWebSocket {
onerror: ((this: WebSocket, ev: Event) => unknown) | null = null;
onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null = null;
onopen: ((this: WebSocket, ev: Event) => unknown) | null = null;
- readyState: number = WebSocket.CONNECTING;
+ readyState: number = HOST_WS_CONNECTING;
readonly url: string;
private id: string | null = null;
@@ -140,7 +145,7 @@ class HostWebSocket {
this.id = id;
},
() => {
- this.readyState = WebSocket.CLOSED;
+ this.readyState = HOST_WS_CLOSED;
this.onerror?.call(this as unknown as WebSocket, new Event("error"));
this.onclose?.call(this as unknown as WebSocket, closeEvent());
this.unsubscribe();
@@ -149,14 +154,14 @@ class HostWebSocket {
}
close(): void {
- if (this.readyState === WebSocket.CLOSING || this.readyState === WebSocket.CLOSED) {
+ if (this.readyState === HOST_WS_CLOSING || this.readyState === HOST_WS_CLOSED) {
return;
}
- this.readyState = WebSocket.CLOSING;
+ this.readyState = HOST_WS_CLOSING;
if (this.id) {
void this.api.closeSocket(this.id);
} else {
- this.readyState = WebSocket.CLOSED;
+ this.readyState = HOST_WS_CLOSED;
this.unsubscribe();
}
}
@@ -165,7 +170,7 @@ class HostWebSocket {
if (typeof data !== "string") {
throw new Error("Host WebSocket bridge only supports text frames");
}
- if (this.readyState === WebSocket.OPEN && this.id) {
+ if (this.readyState === HOST_WS_OPEN && this.id) {
void this.api.sendSocket(this.id, data);
return;
}
@@ -175,7 +180,7 @@ class HostWebSocket {
private handleEvent(event: HostSocketEvent): void {
if (!this.id || event.id !== this.id) return;
if (event.type === "open") {
- this.readyState = WebSocket.OPEN;
+ this.readyState = HOST_WS_OPEN;
this.onopen?.call(this as unknown as WebSocket, new Event("open"));
while (this.queued.length > 0 && this.id) {
const data = this.queued.shift();
@@ -194,7 +199,7 @@ class HostWebSocket {
this.onerror?.call(this as unknown as WebSocket, new Event("error"));
return;
}
- this.readyState = WebSocket.CLOSED;
+ this.readyState = HOST_WS_CLOSED;
this.onclose?.call(
this as unknown as WebSocket,
closeEvent(event.code, event.reason),
diff --git a/webui/src/tests/agent-activity-cluster.test.tsx b/webui/src/tests/agent-activity-cluster.test.tsx
index 8de2e260e..5ebd0969f 100644
--- a/webui/src/tests/agent-activity-cluster.test.tsx
+++ b/webui/src/tests/agent-activity-cluster.test.tsx
@@ -869,6 +869,39 @@ describe("AgentActivityCluster", () => {
expect(screen.getByText("Target text was not found in angry-birds.html.")).toBeInTheDocument();
});
+ it("keeps permission errors readable for failed file edits", () => {
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /failed composition\.html/i }));
+
+ expect(screen.getByText("No permission to change this location.")).toBeInTheDocument();
+ expect(screen.queryByText(/\[Errno 13\]/)).not.toBeInTheDocument();
+ });
+
it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
const restoreMotion = installReducedMotion();
try {
diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx
index 4e378fc44..2e5f9ed75 100644
--- a/webui/src/tests/app-layout.test.tsx
+++ b/webui/src/tests/app-layout.test.tsx
@@ -891,9 +891,9 @@ describe("App layout", () => {
expect(screen.queryByText("AI")).not.toBeInTheDocument();
expect(screen.getByText("Current configuration")).toBeInTheDocument();
expect(screen.queryByText("Presets")).not.toBeInTheDocument();
- fireEvent.pointerDown(screen.getAllByRole("button", { name: /openai\/gpt-4o/ })[0]);
+ fireEvent.pointerDown(screen.getByRole("button", { name: "Current configuration" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Add configuration" }));
- const modelDialog = screen.getByRole("dialog", { name: "New model configuration" });
+ const modelDialog = await screen.findByRole("dialog", { name: "New model configuration" });
expect(within(modelDialog).getByText("Save a provider and model as a one-click option.")).toBeInTheDocument();
fireEvent.change(within(modelDialog).getByPlaceholderText("Fast writing"), {
target: { value: "Fast writing" },
diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts
index 6a3df3de9..fdfad82c7 100644
--- a/webui/src/tests/nanobot-client.test.ts
+++ b/webui/src/tests/nanobot-client.test.ts
@@ -65,6 +65,7 @@ beforeEach(() => {
});
afterEach(() => {
+ Reflect.deleteProperty(window, "nanobotHost");
vi.useRealTimers();
});
@@ -89,6 +90,61 @@ describe("NanobotClient", () => {
});
});
+ it("can swap the socket factory when the runtime URL changes", () => {
+ const browserFactory = vi.fn(
+ (url: string) => new FakeSocket(`browser:${url}`) as unknown as WebSocket,
+ );
+ const hostFactory = vi.fn(
+ (url: string) => new FakeSocket(`host:${url}`) as unknown as WebSocket,
+ );
+ const client = new NanobotClient({
+ url: "ws://test",
+ reconnect: false,
+ socketFactory: browserFactory,
+ });
+
+ client.connect();
+ expect(lastSocket().url).toBe("browser:ws://test");
+ client.close();
+ client.updateUrl("nanobot-host://engine/", hostFactory);
+ client.connect();
+
+ expect(hostFactory).toHaveBeenCalledWith("nanobot-host://engine/");
+ expect(lastSocket().url).toBe("host:nanobot-host://engine/");
+ });
+
+ it("uses the host socket bridge for native host URLs", async () => {
+ let socketEventHandler:
+ | ((event: { id: string; type: "open" | "close" | "error"; message?: string }) => void)
+ | null = null;
+ const openSocket = vi.fn(async () => "host-socket-1");
+ Object.defineProperty(window, "nanobotHost", {
+ configurable: true,
+ value: {
+ openSocket,
+ sendSocket: vi.fn(async () => undefined),
+ closeSocket: vi.fn(async () => undefined),
+ onSocketEvent: vi.fn((handler) => {
+ socketEventHandler = handler;
+ return vi.fn();
+ }),
+ },
+ });
+ const client = new NanobotClient({
+ url: "nanobot-host://engine/",
+ reconnect: false,
+ });
+ const status = vi.fn();
+ client.onStatus(status);
+
+ client.connect();
+ await Promise.resolve();
+ socketEventHandler?.({ id: "host-socket-1", type: "open" });
+
+ expect(openSocket).toHaveBeenCalledWith("nanobot-host://engine/");
+ expect(status).toHaveBeenLastCalledWith("open");
+ });
+
it("buffers chat events while no chat handler is registered and replays on subscribe", () => {
const client = new NanobotClient({
url: "ws://test",
diff --git a/webui/src/tests/settings-view.test.tsx b/webui/src/tests/settings-view.test.tsx
index c149235aa..cff71844a 100644
--- a/webui/src/tests/settings-view.test.tsx
+++ b/webui/src/tests/settings-view.test.tsx
@@ -245,6 +245,40 @@ describe("SettingsView Apps catalog", () => {
expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument();
});
+ it("can close the new configuration dialog without trapping the settings page", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url === "/api/settings") return jsonResponse(settingsPayload());
+ if (url === "/api/settings/cli-apps") {
+ return jsonResponse({ apps: [], installed_count: 0 });
+ }
+ if (url === "/api/settings/mcp-presets") {
+ return jsonResponse({ presets: [], installed_count: 0 });
+ }
+ return { ok: false, status: 404, json: async () => ({}) } as Response;
+ }),
+ );
+
+ renderSettingsView({ initialSection: "models" });
+
+ const configurationButton = await screen.findByRole("button", { name: "Current configuration" });
+ fireEvent.pointerDown(configurationButton!);
+ fireEvent.click(await screen.findByText("Add configuration"));
+
+ expect(await screen.findByRole("heading", { name: "New model configuration" })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+
+ await waitFor(() =>
+ expect(screen.queryByRole("heading", { name: "New model configuration" })).not.toBeInTheDocument(),
+ );
+ expect(document.body.style.pointerEvents).not.toBe("none");
+
+ fireEvent.pointerDown(configurationButton!);
+ expect(await screen.findByText("Add configuration")).toBeInTheDocument();
+ });
+
it("loads provider models and lets users choose one without typing the id manually", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
From 34386fe676b23eda4c6ee5d9e8171fd906c96e5c Mon Sep 17 00:00:00 2001
From: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
Date: Sun, 31 May 2026 20:52:56 +0800
Subject: [PATCH 08/71] fix(webui): stabilize streaming output and settings
i18n
---
.../src/components/thread/ThreadMessages.tsx | 11 -
webui/src/hooks/useNanobotStream.ts | 80 ++---
webui/src/i18n/locales/es/common.json | 260 ++++++++--------
webui/src/i18n/locales/fr/common.json | 288 +++++++++---------
webui/src/i18n/locales/id/common.json | 254 +++++++--------
webui/src/i18n/locales/ja/common.json | 214 ++++++-------
webui/src/i18n/locales/ko/common.json | 246 +++++++--------
webui/src/i18n/locales/vi/common.json | 268 ++++++++--------
webui/src/i18n/locales/zh-CN/common.json | 146 ++++-----
webui/src/i18n/locales/zh-TW/common.json | 262 ++++++++--------
webui/src/tests/i18n.test.tsx | 68 +++++
webui/src/tests/thread-messages.test.tsx | 3 +-
webui/src/tests/useNanobotStream.test.tsx | 112 ++++++-
13 files changed, 1164 insertions(+), 1048 deletions(-)
diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx
index 9fd01e9c6..1f5b2de9c 100644
--- a/webui/src/components/thread/ThreadMessages.tsx
+++ b/webui/src/components/thread/ThreadMessages.tsx
@@ -137,10 +137,6 @@ function currentActivityClusterIndices(units: DisplayUnit[]): Set {
if (!markedCurrentActivity) {
indices.add(i);
markedCurrentActivity = true;
- continue;
- }
- if (activityHasLiveFileEdit(unit)) {
- indices.add(i);
}
continue;
}
@@ -150,13 +146,6 @@ function currentActivityClusterIndices(units: DisplayUnit[]): Set {
return indices;
}
-function activityHasLiveFileEdit(unit: Extract): boolean {
- return unit.messages.some((message) => (
- message.kind === "trace"
- && message.fileEdits?.some((edit) => edit.status === "editing" || edit.pending || !edit.path)
- ));
-}
-
function unitKey(unit: DisplayUnit, index: number): string {
if (unit.type === "activity") {
const anchor = unit.messages[0]?.id;
diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts
index 48c00ca98..9bdc43b76 100644
--- a/webui/src/hooks/useNanobotStream.ts
+++ b/webui/src/hooks/useNanobotStream.ts
@@ -58,11 +58,10 @@ function findStreamingAssistantIndex(
/**
* Append a reasoning chunk to the last open reasoning stream in ``prev``.
*
- * Lookup rule: prefer the most recent assistant turn in the active UI tail.
- * Most providers emit reasoning before answer text, but some only expose
- * ``reasoning_content`` after the answer stream completes. In that post-hoc
- * case the reasoning still belongs to the same assistant turn and must render
- * above the answer, not as a new row below it.
+ * Lookup rule: reasoning can only extend the current reasoning placeholder.
+ * Once ordinary answer text has appeared, the next reasoning chunk starts a
+ * fresh Thought block so streamed output stays in arrival order:
+ * Thought -> answer -> Thought -> answer.
*/
function attachReasoningChunk(
prev: UIMessage[],
@@ -83,10 +82,10 @@ function attachReasoningChunk(
if (candidate.role !== "assistant") continue;
const activitySegmentId = candidate.activitySegmentId ?? segments?.ensure();
const hasAnswer = candidate.content.length > 0;
+ if (hasAnswer) break;
if (
candidate.reasoningStreaming
|| candidate.reasoning !== undefined
- || hasAnswer
|| candidate.isStreaming
) {
const merged: UIMessage = {
@@ -97,15 +96,6 @@ function attachReasoningChunk(
};
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
}
- if (!hasAnswer && candidate.isStreaming) {
- const merged: UIMessage = {
- ...candidate,
- reasoning: chunk,
- reasoningStreaming: true,
- ...(activitySegmentId ? { activitySegmentId } : {}),
- };
- return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
- }
break;
}
const activitySegmentId = segments?.ensure();
@@ -293,38 +283,6 @@ function stripCoveredFileEditToolHints(message: UIMessage, edits: UIFileEdit[]):
};
}
-function demoteInterruptedAssistantToActivity(
- prev: UIMessage[],
- segmentId: string,
-): UIMessage[] {
- for (let i = prev.length - 1; i >= 0; i -= 1) {
- const message = prev[i];
- if (message.role === "user") break;
- if (
- message.role !== "assistant"
- || message.kind === "trace"
- || !message.isStreaming
- || !message.content.trim()
- || message.media?.length
- ) {
- continue;
- }
- const reasoning = [message.reasoning, message.content]
- .filter((part): part is string => typeof part === "string" && part.trim().length > 0)
- .join("\n\n");
- const demoted: UIMessage = {
- ...message,
- content: "",
- reasoning,
- reasoningStreaming: false,
- isStreaming: false,
- activitySegmentId: message.activitySegmentId ?? segmentId,
- };
- return replaceMessageAt(prev, i, demoted);
- }
- return prev;
-}
-
function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null;
const inferredStatus =
@@ -511,6 +469,7 @@ export function useNanobotStream(
if (closedStreamId) closedAssistantStreamIdsRef.current.add(closedStreamId);
buffer.current = null;
activeAssistantRef.current = null;
+ return !!closedStreamId;
}, []);
const resolveActiveAssistantIndex = useCallback((prev: UIMessage[]): number | null => {
@@ -589,15 +548,18 @@ export function useNanobotStream(
text += events[i].text;
i += 1;
}
- next = kind === "delta"
- ? appendAnswerChunk(next, text)
- : attachReasoningChunk(next, text, {
- ensure: ensureActivitySegmentId,
- });
+ if (kind === "delta") {
+ next = appendAnswerChunk(next, text);
+ } else {
+ if (closeActiveAssistantStream()) clearActivitySegment();
+ next = attachReasoningChunk(next, text, {
+ ensure: ensureActivitySegmentId,
+ });
+ }
}
return next;
},
- [appendAnswerChunk, ensureActivitySegmentId],
+ [appendAnswerChunk, clearActivitySegment, closeActiveAssistantStream, ensureActivitySegmentId],
);
const flushPendingStreamEvents = useCallback((options?: {
@@ -735,7 +697,13 @@ export function useNanobotStream(
return;
}
- flushPendingStreamEvents();
+ const shouldCloseAnswerBeforeEvent =
+ ev.event === "file_edit"
+ || (
+ ev.event === "message"
+ && (ev.kind === "tool_hint" || ev.kind === "progress")
+ );
+ flushPendingStreamEvents({ closeAnswerSegment: shouldCloseAnswerBeforeEvent });
if (ev.event === "reasoning_end") {
if (suppressStreamUntilTurnEndRef.current) return;
@@ -812,7 +780,7 @@ export function useNanobotStream(
const structuredEvents = normalizeToolProgressEvents(ev.tool_events);
setMessages((prev) => {
const segmentId = ensureActivitySegmentId();
- const base = demoteInterruptedAssistantToActivity(prev, segmentId);
+ const base = prev;
const visibleStructuredEvents = filterCoveredFileEditToolEvents(base, structuredEvents);
const structuredLines = toolTraceLinesFromEvents(visibleStructuredEvents);
const lines = structuredLines.length > 0
@@ -914,7 +882,7 @@ export function useNanobotStream(
}
setMessages((prev) => {
let segmentId = eventSegmentId;
- const base = segmentId ? demoteInterruptedAssistantToActivity(prev, segmentId) : prev;
+ const base = prev;
const targetIndex = findFileEditTraceIndex(base, segmentId, normalized);
if (targetIndex !== null) {
const target = base[targetIndex];
diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json
index 4f742daa2..6b234aae1 100644
--- a/webui/src/i18n/locales/es/common.json
+++ b/webui/src/i18n/locales/es/common.json
@@ -59,43 +59,43 @@
"settings": {
"backToChat": "Volver al chat",
"sidebar": {
- "title": "Configuración",
- "ariaLabel": "Secciones de configuración"
+ "title": "Ajustes",
+ "ariaLabel": "Secciones de ajustes"
},
"nav": {
"general": "General",
"byok": "BYOK",
- "overview": "Overview",
- "appearance": "Appearance",
- "models": "Models",
- "providers": "Providers",
- "image": "Image",
- "browser": "Web",
+ "overview": "Resumen",
+ "appearance": "Apariencia",
+ "models": "Modelos",
+ "providers": "Proveedores",
+ "image": "Imagen",
+ "browser": "Internet",
"runtime": "Sistema",
- "advanced": "Security",
+ "advanced": "Seguridad",
"cliApps": "Apps CLI",
"mcp": "MCP",
- "apps": "Apps"
+ "apps": "Aplicaciones"
},
"sections": {
"interface": "Interfaz",
- "ai": "IA",
+ "ai": "AI",
"system": "Sistema",
- "status": "Status",
- "localPreferences": "Local preferences",
- "presets": "Presets",
+ "status": "Estado",
+ "localPreferences": "Preferencias locales",
+ "presets": "Preajustes",
"imageGeneration": "Generación de imágenes",
"imageDefaults": "Valores predeterminados",
- "webSearch": "Web search",
- "webBehavior": "Behavior",
- "identity": "Identity",
- "webuiSafety": "Web safety",
+ "webSearch": "Búsqueda web",
+ "webBehavior": "Comportamiento",
+ "identity": "Identidad",
+ "webuiSafety": "Seguridad de WebUI",
"capabilities": "Capacidades",
- "cliApps": "Apps CLI",
+ "cliApps": "Aplicaciones CLI",
"mcp": "Servicios MCP",
- "apps": "Apps",
- "nativeHost": "App",
- "hostSafety": "App safety"
+ "apps": "Aplicaciones",
+ "nativeHost": "Host nativo",
+ "hostSafety": "Seguridad de la app"
},
"rows": {
"theme": "Tema",
@@ -104,118 +104,118 @@
"model": "Modelo",
"restart": "Reiniciar nanobot",
"configPath": "Ruta de configuración",
- "activePreset": "Active preset",
- "gateway": "Gateway",
- "restartState": "Restart state",
+ "activePreset": "Preajuste activo",
+ "gateway": "Pasarela",
+ "restartState": "Estado de reinicio",
"pendingChanges": "Cambios pendientes",
- "selectedPreset": "Selected preset",
- "presetModel": "Preset model",
- "density": "Density",
- "activityMode": "Activity detail",
- "codeWrap": "Code wrapping",
- "maxResults": "Max results",
- "timeout": "Timeout",
- "jinaReader": "Jina reader",
+ "selectedPreset": "Preajuste seleccionado",
+ "presetModel": "Modelo del preajuste",
+ "density": "Densidad",
+ "activityMode": "Detalle de actividad",
+ "codeWrap": "Ajuste de código",
+ "maxResults": "Resultados máximos",
+ "timeout": "Tiempo de espera",
+ "jinaReader": "Lector Jina",
"imageGeneration": "Generación de imágenes",
"imageProvider": "Proveedor de imágenes",
"imageProviderStatus": "Estado del proveedor",
"imageProviderBase": "Base del proveedor",
"imageModel": "Modelo de imagen",
- "defaultAspectRatio": "Relación predeterminada",
+ "defaultAspectRatio": "Proporción predeterminada",
"defaultImageSize": "Tamaño predeterminado",
- "maxImagesPerTurn": "Máximo de imágenes por turno",
+ "maxImagesPerTurn": "Máx. imágenes por turno",
"imageSaveDir": "Directorio de guardado",
- "botName": "Bot name",
- "botIcon": "Bot icon",
- "timezone": "Timezone",
+ "botName": "Nombre del bot",
+ "botIcon": "Icono del bot",
+ "timezone": "Zona horaria",
"workspacePath": "Workspace predeterminado",
- "localServiceAccess": "Local services",
- "webuiDefaultAccess": "Default access",
+ "localServiceAccess": "Servicios locales",
+ "webuiDefaultAccess": "Acceso predeterminado",
"currentModel": "Configuración actual",
- "brandLogos": "Logotipos de marca",
- "cliAppsCatalog": "Catálogo de apps CLI",
- "cliAppsFilter": "Filtro de apps CLI",
+ "brandLogos": "Logos de marca",
+ "cliAppsCatalog": "Catálogo",
+ "cliAppsFilter": "Filtro",
"engine": "Motor",
"logs": "Registros",
"diagnostics": "Diagnóstico",
- "contextWindow": "Context window"
+ "contextWindow": "Ventana de contexto"
},
"help": {
"theme": "Cambia entre apariencia clara y oscura.",
- "language": "Elige el idioma usado por la WebUI.",
+ "language": "Elige el idioma usado por WebUI.",
"provider": "Selecciona el proveedor para nuevas solicitudes de modelo.",
- "model": "Define el nombre del modelo predeterminado que usa nanobot.",
- "configPath": "El archivo de configuración que usa actualmente el gateway.",
- "selectedPreset": "Named presets are read-only here; edit them in config.json.",
- "presetModel": "Switch to Default to edit model and provider from the WebUI.",
- "density": "Stored only in this browser.",
- "activityMode": "Choose how much agent activity chrome to show by default.",
- "codeWrap": "Keep long code lines readable on smaller screens.",
- "maxResults": "Results returned by each web_search call.",
- "timeout": "Seconds before a search provider request times out.",
- "jinaReader": "Use Jina Reader for web_fetch when available.",
- "imageGeneration": "Expone generate_image en los chats cuando hay un proveedor de imágenes configurado disponible.",
- "imageProvider": "Elige el proveedor del registro que usará generate_image.",
- "imageProviderStatus": "La generación de imágenes reutiliza las credenciales de Proveedores.",
+ "model": "Define el nombre de modelo predeterminado de nanobot.",
+ "configPath": "Archivo de configuración que usa actualmente el gateway.",
+ "selectedPreset": "Los preajustes con nombre son de solo lectura aquí; edítalos en config.json.",
+ "presetModel": "Cambia a Default para editar modelo y proveedor desde WebUI.",
+ "density": "Solo se guarda en este navegador.",
+ "activityMode": "Elige cuánto detalle de actividad del agente se muestra por defecto.",
+ "codeWrap": "Mantiene legibles las líneas largas de código en pantallas pequeñas.",
+ "maxResults": "Resultados devueltos por cada llamada web_search.",
+ "timeout": "Segundos antes de que una solicitud de búsqueda expire.",
+ "jinaReader": "Usa Jina Reader para web_fetch cuando esté disponible.",
+ "imageGeneration": "Expone generate_image en chats cuando hay un proveedor de imagen configurado.",
+ "imageProvider": "Elige el proveedor registrado usado por generate_image.",
+ "imageProviderStatus": "La generación de imágenes reutiliza credenciales de Proveedores.",
"imageModel": "Nombre del modelo enviado al proveedor de imágenes seleccionado.",
- "defaultAspectRatio": "Se usa cuando el prompt no elige una relación de aspecto.",
- "defaultImageSize": "Sugerencia de tamaño enviada a los proveedores que la admiten.",
+ "defaultAspectRatio": "Se usa cuando el prompt no elige una proporción.",
+ "defaultImageSize": "Pista de tamaño enviada a proveedores compatibles.",
"maxImagesPerTurn": "Límite superior para una solicitud generate_image.",
"botName": "Se muestra donde nanobot usa un nombre visible.",
- "botIcon": "Emoji o texto corto mostrado junto al nombre del bot.",
- "timezone": "Se usa para programaciones y respuestas sensibles al tiempo.",
- "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
- "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
- "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
- "currentModel": "Elige la configuración de modelo que nanobot usará para las próximas respuestas.",
- "selectedModelProvider": "Lo define el modelo seleccionado.",
- "selectedModelValue": "Lo define el modelo seleccionado.",
- "brandLogos": "Los logotipos se cargan desde los dominios de las marcas con una reserva de icono local.",
- "cliAppsCatalog": "Explora CLIs de apps que nanobot puede ejecutar localmente.",
+ "botIcon": "Emoji o texto corto junto al nombre del bot.",
+ "timezone": "Se usa para horarios y respuestas con conciencia temporal.",
+ "localServiceAccess": "Permite que comandos shell con Full Access alcancen servicios localhost.",
+ "webuiDefaultAccess": "Usado por chats web sin permiso específico de proyecto.",
+ "securityManagedControls": "Las capturas web siempre protegen servicios locales, privados y metadata. La seguridad de canales core se gestiona en config.json.",
+ "currentModel": "Se usa para nuevas respuestas.",
+ "selectedModelProvider": "Definido por el modelo seleccionado.",
+ "selectedModelValue": "Definido por el modelo seleccionado.",
+ "brandLogos": "Muestra logos de proveedores de terceros y CLI en Ajustes.",
+ "cliAppsCatalog": "Instala solo adaptadores CLI de apps que nanobot puede ejecutar localmente; las apps nativas no se modifican.",
"cliAppsFilter": "Busca por app, categoría o capacidad.",
- "logs": "Abre la carpeta de registros del motor de escritorio.",
+ "logs": "Abre la carpeta de registros del motor nativo.",
"diagnostics": "Exporta un pequeño informe de runtime para soporte.",
- "localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
- "webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
- "contextWindow": "Choose the default context budget for this model configuration."
+ "localServiceAccessNative": "Permite que comandos shell con Full Access alcancen servicios en este Mac.",
+ "webuiDefaultAccessNative": "Usado por chats nativos sin permiso específico de proyecto.",
+ "contextWindow": "Elige el presupuesto de contexto predeterminado para esta configuración de modelo."
},
"values": {
"light": "Claro",
"dark": "Oscuro",
"notAvailable": "No disponible",
- "enabled": "Enabled",
- "disabled": "Disabled",
+ "enabled": "Activado",
+ "disabled": "Desactivado",
"restartPending": "Reinicio pendiente",
"ready": "Listo",
- "comfortable": "Comfortable",
- "compact": "Compact",
- "auto": "Auto",
- "expanded": "Expanded",
- "on": "On",
- "off": "Off",
- "defaultPermission": "Default Permission",
- "fullAccess": "Full Access",
- "configured": "Configured",
- "notConfigured": "Not configured",
+ "comfortable": "Cómodo",
+ "compact": "Compacto",
+ "auto": "Automático",
+ "expanded": "Expandido",
+ "on": "Activado",
+ "off": "Desactivado",
+ "defaultPermission": "Permiso predeterminado",
+ "fullAccess": "Acceso completo",
+ "configured": "Configurado",
+ "notConfigured": "Sin configurar",
"pending": "Pendiente",
"restartingEngine": "Reiniciando"
},
"status": {
- "loading": "Cargando configuración...",
- "loadError": "No se pudo cargar la configuración",
- "unsaved": "Hay cambios sin guardar.",
+ "loading": "Cargando ajustes...",
+ "loadError": "No se pudieron cargar los ajustes",
+ "unsaved": "Cambios sin guardar.",
"upToDate": "Actualizado.",
"savedRestart": "Guardado. Reinicia nanobot para aplicar.",
- "restartAfterSaving": "Guarda los cambios y reinicia cuando estés listo.",
- "savedRestartApply": "Guardado. Reinicia cuando estés listo.",
- "imageProviderRestart": "Cambios del proveedor de imágenes guardados. Reinicia cuando estés listo.",
- "hostRestartAfterSaving": "Guarda los cambios y nanobot reiniciará su motor.",
- "hostRestartPending": "Guardado. Reiniciando el motor cuando esté listo.",
- "hostApiUnavailable": "Host actions are only available inside the native app.",
- "logsOpened": "Opened logs folder.",
- "logsOpenFailed": "Could not open logs folder.",
- "diagnosticsExported": "Diagnostics exported to {{path}}.",
- "diagnosticsExportFailed": "Could not export diagnostics."
+ "restartAfterSaving": "Guarda los cambios y reinicia cuando puedas.",
+ "savedRestartApply": "Guardado. Reinicia cuando puedas.",
+ "imageProviderRestart": "Cambios del proveedor de imagen guardados. Reinicia cuando puedas.",
+ "hostRestartAfterSaving": "Al guardar, nanobot reiniciará su motor.",
+ "hostRestartPending": "Guardado. El motor se reiniciará cuando esté listo.",
+ "hostApiUnavailable": "Las acciones del host solo están disponibles en la app nativa.",
+ "logsOpened": "Carpeta de registros abierta.",
+ "logsOpenFailed": "No se pudo abrir la carpeta de registros.",
+ "diagnosticsExported": "Diagnóstico exportado a {{path}}.",
+ "diagnosticsExportFailed": "No se pudo exportar el diagnóstico."
},
"actions": {
"save": "Guardar",
@@ -224,8 +224,8 @@
"cancel": "Cancelar",
"open": "Abrir",
"export": "Exportar",
- "opening": "Opening...",
- "exporting": "Exporting..."
+ "opening": "Abriendo...",
+ "exporting": "Exportando..."
},
"byok": {
"description": "Usa tus propias claves de proveedor. Nanobot lee estos valores desde la configuración actual, y solo los proveedores configurados se pueden elegir en General.",
@@ -250,7 +250,7 @@
"tabs": {
"ariaLabel": "Tipo de credencial BYOK",
"llm": "LLM",
- "webSearch": "Web Search"
+ "webSearch": "Búsqueda web"
},
"webSearch": {
"provider": "Proveedor de búsqueda",
@@ -270,44 +270,44 @@
}
},
"overview": {
- "model": "Current model",
- "providers": "Providers",
- "configuredCount": "{{count}} configured",
- "totalProviders": "{{count}} available",
- "webSearch": "Web search",
+ "model": "Modelo actual",
+ "providers": "Proveedores",
+ "configuredCount": "{{count}} configurados",
+ "totalProviders": "{{count}} disponibles",
+ "webSearch": "Búsqueda web",
"imageGeneration": "Generación de imágenes",
- "workspace": "Workspace"
+ "workspace": "Espacio de trabajo"
},
"providers": {
- "searchPlaceholder": "Search providers",
- "noMatches": "No providers match this search.",
+ "searchPlaceholder": "Buscar proveedores",
+ "noMatches": "Ningún proveedor coincide con esta búsqueda.",
"saveProvider": "Guardar proveedor"
},
"image": {
"selectProvider": "Seleccionar proveedor",
- "selectAspect": "Seleccionar relación",
+ "selectAspect": "Seleccionar proporción",
"selectSize": "Seleccionar tamaño",
"configureProvider": "Configurar proveedor",
"missingCredential": "Configura este proveedor antes de activar la generación de imágenes."
},
"models": {
"selectModel": "Seleccionar modelo",
- "addConfiguration": "Añadir configuración",
+ "addConfiguration": "Agregar configuración",
"newConfiguration": "Nueva configuración de modelo",
- "newConfigurationHelp": "Guarda un proveedor y un modelo como una opción de un clic.",
+ "newConfigurationHelp": "Guarda un proveedor y modelo como opción de un clic.",
"configurationName": "Nombre de configuración",
- "configurationNameHelp": "Cambia el nombre de esta configuración de modelo guardada.",
+ "configurationNameHelp": "Renombra esta configuración de modelo guardada.",
"configurationNamePlaceholder": "Escritura rápida",
"searchModels": "Buscar o escribir ID de modelo",
"useCustomModel": "Usar",
"loadingModels": "Cargando modelos...",
- "searchCatalog": "Busca en el catálogo del proveedor para elegir un modelo.",
+ "searchCatalog": "Busca el catálogo del proveedor para elegir un modelo.",
"modelsAvailable": "disponibles",
"noModelResults": "No hay modelos coincidentes.",
"loadFailed": "Lista de modelos no disponible.",
- "unsupportedModelList": "Escribe manualmente un ID de modelo.",
+ "unsupportedModelList": "Escribe un ID de modelo manualmente.",
"providerNotConfigured": "Configura este proveedor antes de cargar modelos.",
- "autoProviderCustomOnly": "El modo de proveedor automático usa ID de modelo personalizados."
+ "autoProviderCustomOnly": "El modo de proveedor automático usa IDs de modelo personalizados."
},
"timezone": {
"select": "Seleccionar zona horaria",
@@ -316,7 +316,7 @@
},
"cliApps": {
"allCategories": "Todas las categorías",
- "availableCount": "{{count}} apps",
+ "availableCount": "{{count}} aplicaciones",
"installedCount": "{{count}} instaladas",
"summary": "{{installed}} de {{total}} CLIs instaladas",
"filterAll": "Todas",
@@ -402,7 +402,7 @@
"thirdPartyBrands": "Los nombres, logotipos y marcas de productos pertenecen a sus respectivos propietarios. Su uso es solo identificativo y no implica respaldo."
},
"apps": {
- "description": "Añade CLI de apps y servicios MCP que nanobot puede usar desde el chat.",
+ "description": "Agrega CLI de apps y servicios MCP que nanobot puede usar desde el chat.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "Todo",
@@ -416,17 +416,17 @@
"empty": "Ninguna app coincide con este filtro."
},
"oauth": {
- "authentication": "OAuth authentication",
- "signIn": "Sign in",
- "signingIn": "Signing in...",
- "signInAgain": "Sign in again",
- "signOut": "Sign out",
- "signedInAs": "Signed in as {{account}}",
- "signInHelp": "Sign in from this device; no API key is stored in config.",
- "signInRequired": "Sign in required",
- "signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
- "signedIn": "Signed in",
- "notSignedIn": "Not signed in"
+ "authentication": "Autenticación OAuth",
+ "signIn": "Iniciar sesión",
+ "signingIn": "Iniciando sesión...",
+ "signInAgain": "Iniciar sesión de nuevo",
+ "signOut": "Cerrar sesión",
+ "signedInAs": "Sesión iniciada como {{account}}",
+ "signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
+ "signInRequired": "Inicio de sesión requerido",
+ "signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
+ "signedIn": "Sesión iniciada",
+ "notSignedIn": "Sin sesión"
}
},
"chat": {
diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json
index ce211b254..b124c48e2 100644
--- a/webui/src/i18n/locales/fr/common.json
+++ b/webui/src/i18n/locales/fr/common.json
@@ -57,45 +57,45 @@
"apps": "Apps"
},
"settings": {
- "backToChat": "Retour à la discussion",
+ "backToChat": "Retour au chat",
"sidebar": {
- "title": "Paramètres",
- "ariaLabel": "Sections des paramètres"
+ "title": "Réglages",
+ "ariaLabel": "Sections des réglages"
},
"nav": {
"general": "Général",
"byok": "BYOK",
- "overview": "Overview",
- "appearance": "Appearance",
- "models": "Models",
- "providers": "Providers",
- "image": "Image",
- "browser": "Web",
+ "overview": "Aperçu",
+ "appearance": "Apparence",
+ "models": "Modèles",
+ "providers": "Fournisseurs",
+ "image": "Images",
+ "browser": "Internet",
"runtime": "Système",
- "advanced": "Security",
+ "advanced": "Sécurité",
"cliApps": "Apps CLI",
"mcp": "MCP",
- "apps": "Apps"
+ "apps": "Applications"
},
"sections": {
- "interface": "Interface",
- "ai": "IA",
+ "interface": "Interface utilisateur",
+ "ai": "AI",
"system": "Système",
- "status": "Status",
- "localPreferences": "Local preferences",
- "presets": "Presets",
- "imageGeneration": "Génération d'images",
+ "status": "État",
+ "localPreferences": "Préférences locales",
+ "presets": "Préréglages",
+ "imageGeneration": "Génération d’images",
"imageDefaults": "Valeurs par défaut",
- "webSearch": "Web search",
- "webBehavior": "Behavior",
- "identity": "Identity",
- "webuiSafety": "Web safety",
+ "webSearch": "Recherche web",
+ "webBehavior": "Comportement",
+ "identity": "Identité",
+ "webuiSafety": "Sécurité WebUI",
"capabilities": "Capacités",
- "cliApps": "Apps CLI",
+ "cliApps": "Applications CLI",
"mcp": "Services MCP",
- "apps": "Apps",
- "nativeHost": "App",
- "hostSafety": "App safety"
+ "apps": "Applications",
+ "nativeHost": "Hôte natif",
+ "hostSafety": "Sécurité de l’app"
},
"rows": {
"theme": "Thème",
@@ -103,119 +103,119 @@
"provider": "Fournisseur",
"model": "Modèle",
"restart": "Redémarrer nanobot",
- "configPath": "Chemin de configuration",
- "activePreset": "Active preset",
- "gateway": "Gateway",
- "restartState": "Restart state",
+ "configPath": "Chemin de config",
+ "activePreset": "Préréglage actif",
+ "gateway": "Passerelle",
+ "restartState": "État du redémarrage",
"pendingChanges": "Modifications en attente",
- "selectedPreset": "Selected preset",
- "presetModel": "Preset model",
- "density": "Density",
- "activityMode": "Activity detail",
- "codeWrap": "Code wrapping",
- "maxResults": "Max results",
- "timeout": "Timeout",
- "jinaReader": "Jina reader",
- "imageGeneration": "Génération d'images",
- "imageProvider": "Fournisseur d'images",
+ "selectedPreset": "Préréglage sélectionné",
+ "presetModel": "Modèle du préréglage",
+ "density": "Densité",
+ "activityMode": "Détail d’activité",
+ "codeWrap": "Retour à la ligne du code",
+ "maxResults": "Résultats max.",
+ "timeout": "Délai d’attente",
+ "jinaReader": "Lecteur Jina",
+ "imageGeneration": "Génération d’images",
+ "imageProvider": "Fournisseur d’images",
"imageProviderStatus": "État du fournisseur",
"imageProviderBase": "Base du fournisseur",
- "imageModel": "Modèle d'image",
- "defaultAspectRatio": "Format par défaut",
+ "imageModel": "Modèle d’image",
+ "defaultAspectRatio": "Ratio par défaut",
"defaultImageSize": "Taille par défaut",
- "maxImagesPerTurn": "Nombre max. d'images par tour",
- "imageSaveDir": "Répertoire de sauvegarde",
- "botName": "Bot name",
- "botIcon": "Bot icon",
- "timezone": "Timezone",
+ "maxImagesPerTurn": "Images max. par tour",
+ "imageSaveDir": "Dossier d’enregistrement",
+ "botName": "Nom du bot",
+ "botIcon": "Icône du bot",
+ "timezone": "Fuseau horaire",
"workspacePath": "Espace de travail par défaut",
- "localServiceAccess": "Local services",
- "webuiDefaultAccess": "Default access",
+ "localServiceAccess": "Services locaux",
+ "webuiDefaultAccess": "Accès par défaut",
"currentModel": "Configuration actuelle",
"brandLogos": "Logos de marque",
- "cliAppsCatalog": "Catalogue d'apps CLI",
- "cliAppsFilter": "Filtre des apps CLI",
+ "cliAppsCatalog": "Catalogue",
+ "cliAppsFilter": "Filtre",
"engine": "Moteur",
"logs": "Journaux",
- "diagnostics": "Diagnostics",
- "contextWindow": "Context window"
+ "diagnostics": "Diagnostic",
+ "contextWindow": "Fenêtre de contexte"
},
"help": {
- "theme": "Basculer entre les apparences claire et sombre.",
- "language": "Choisissez la langue utilisée par le WebUI.",
- "provider": "Sélectionnez le fournisseur des nouvelles requêtes de modèle.",
- "model": "Définissez le nom du modèle par défaut utilisé par nanobot.",
+ "theme": "Basculer entre l’apparence claire et sombre.",
+ "language": "Choisissez la langue utilisée par WebUI.",
+ "provider": "Sélectionnez le fournisseur à utiliser pour les nouvelles requêtes de modèle.",
+ "model": "Définissez le nom du modèle utilisé par défaut par nanobot.",
"configPath": "Le fichier de configuration actuellement utilisé par la passerelle.",
- "selectedPreset": "Named presets are read-only here; edit them in config.json.",
- "presetModel": "Switch to Default to edit model and provider from the WebUI.",
- "density": "Stored only in this browser.",
- "activityMode": "Choose how much agent activity chrome to show by default.",
- "codeWrap": "Keep long code lines readable on smaller screens.",
- "maxResults": "Results returned by each web_search call.",
- "timeout": "Seconds before a search provider request times out.",
- "jinaReader": "Use Jina Reader for web_fetch when available.",
- "imageGeneration": "Expose generate_image dans les chats lorsqu’un fournisseur d’images configuré est disponible.",
- "imageProvider": "Choisissez le fournisseur du registre utilisé par generate_image.",
- "imageProviderStatus": "La génération d’images réutilise les identifiants de Fournisseurs.",
+ "selectedPreset": "Les préréglages nommés sont en lecture seule ici ; modifiez-les dans config.json.",
+ "presetModel": "Passez à Default pour modifier le modèle et le fournisseur depuis WebUI.",
+ "density": "Enregistré seulement dans ce navigateur.",
+ "activityMode": "Choisissez le niveau de détail d’activité agent affiché par défaut.",
+ "codeWrap": "Garde les longues lignes de code lisibles sur les petits écrans.",
+ "maxResults": "Résultats renvoyés par chaque appel web_search.",
+ "timeout": "Nombre de secondes avant l’expiration d’une requête de recherche.",
+ "jinaReader": "Utilise Jina Reader pour web_fetch lorsque disponible.",
+ "imageGeneration": "Expose generate_image dans les chats lorsqu’un fournisseur d’image configuré est disponible.",
+ "imageProvider": "Choisissez le fournisseur inscrit utilisé par generate_image.",
+ "imageProviderStatus": "La génération d’images réutilise les identifiants des fournisseurs.",
"imageModel": "Nom du modèle envoyé au fournisseur d’images sélectionné.",
- "defaultAspectRatio": "Utilisé lorsque le prompt ne choisit pas de format.",
- "defaultImageSize": "Indication de taille envoyée aux fournisseurs qui la prennent en charge.",
+ "defaultAspectRatio": "Utilisé lorsque le prompt ne choisit pas de ratio.",
+ "defaultImageSize": "Indication de taille envoyée aux fournisseurs compatibles.",
"maxImagesPerTurn": "Limite supérieure pour une requête generate_image.",
- "botName": "Affiché partout où nanobot utilise un nom visible.",
+ "botName": "Affiché là où nanobot utilise un nom visible.",
"botIcon": "Emoji ou texte court affiché avec le nom du bot.",
- "timezone": "Utilisé pour les planifications et les réponses sensibles à l’heure.",
- "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
- "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
- "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
- "currentModel": "Choisissez la configuration de modèle que nanobot utilisera pour les prochaines réponses.",
+ "timezone": "Utilisé pour les horaires et les réponses tenant compte du temps.",
+ "localServiceAccess": "Autorise les commandes shell Full Access à atteindre les services localhost.",
+ "webuiDefaultAccess": "Utilisé par les chats web sans permission propre au projet.",
+ "securityManagedControls": "Les récupérations web protègent toujours les services locaux, privés et de métadonnées. La sécurité des canaux principaux reste gérée dans config.json.",
+ "currentModel": "Utilisée pour les nouvelles réponses.",
"selectedModelProvider": "Défini par le modèle sélectionné.",
"selectedModelValue": "Défini par le modèle sélectionné.",
- "brandLogos": "Les logos sont chargés depuis les domaines des marques avec une icône locale en secours.",
- "cliAppsCatalog": "Parcourez les CLIs d'apps que nanobot peut exécuter localement.",
+ "brandLogos": "Affiche les logos de fournisseurs tiers et CLI dans les Réglages.",
+ "cliAppsCatalog": "Installe uniquement les adaptateurs CLI d’apps que nanobot peut exécuter localement ; les apps natives restent inchangées.",
"cliAppsFilter": "Recherchez par app, catégorie ou capacité.",
- "logs": "Ouvrir le dossier des journaux du moteur natif.",
- "diagnostics": "Exporter un petit rapport runtime pour le support.",
- "localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
- "webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
- "contextWindow": "Choose the default context budget for this model configuration."
+ "logs": "Ouvre le dossier des journaux du moteur natif.",
+ "diagnostics": "Exporte un petit rapport d’exécution pour le support.",
+ "localServiceAccessNative": "Autorise les commandes shell Full Access à atteindre les services sur ce Mac.",
+ "webuiDefaultAccessNative": "Utilisé par les chats natifs sans permission propre au projet.",
+ "contextWindow": "Choisissez le budget de contexte par défaut pour cette configuration de modèle."
},
"values": {
"light": "Clair",
"dark": "Sombre",
"notAvailable": "Indisponible",
- "enabled": "Enabled",
- "disabled": "Disabled",
+ "enabled": "Activé",
+ "disabled": "Désactivé",
"restartPending": "Redémarrage en attente",
"ready": "Prêt",
- "comfortable": "Comfortable",
- "compact": "Compact",
- "auto": "Auto",
- "expanded": "Expanded",
- "on": "On",
- "off": "Off",
- "defaultPermission": "Default Permission",
- "fullAccess": "Full Access",
- "configured": "Configured",
- "notConfigured": "Not configured",
+ "comfortable": "Confortable",
+ "compact": "Compacte",
+ "auto": "Automatique",
+ "expanded": "Développé",
+ "on": "Activé",
+ "off": "Désactivé",
+ "defaultPermission": "Autorisation par défaut",
+ "fullAccess": "Accès complet",
+ "configured": "Configuré",
+ "notConfigured": "Non configuré",
"pending": "En attente",
"restartingEngine": "Redémarrage"
},
"status": {
- "loading": "Chargement des paramètres...",
- "loadError": "Impossible de charger les paramètres",
+ "loading": "Chargement des réglages...",
+ "loadError": "Impossible de charger les réglages",
"unsaved": "Modifications non enregistrées.",
"upToDate": "À jour.",
"savedRestart": "Enregistré. Redémarrez nanobot pour appliquer.",
- "restartAfterSaving": "Enregistrez les modifications, puis redémarrez lorsque vous êtes prêt.",
- "savedRestartApply": "Enregistré. Redémarrez lorsque vous êtes prêt.",
- "imageProviderRestart": "Modifications du fournisseur d’images enregistrées. Redémarrez lorsque vous êtes prêt.",
- "hostRestartAfterSaving": "Enregistrez les changements et nanobot redémarrera son moteur.",
- "hostRestartPending": "Enregistré. Redémarrage du moteur quand il sera prêt.",
- "hostApiUnavailable": "Host actions are only available inside the native app.",
- "logsOpened": "Opened logs folder.",
- "logsOpenFailed": "Could not open logs folder.",
- "diagnosticsExported": "Diagnostics exported to {{path}}.",
- "diagnosticsExportFailed": "Could not export diagnostics."
+ "restartAfterSaving": "Enregistrez les changements, puis redémarrez quand vous êtes prêt.",
+ "savedRestartApply": "Enregistré. Redémarrez quand vous êtes prêt.",
+ "imageProviderRestart": "Changements du fournisseur d’image enregistrés. Redémarrez quand vous êtes prêt.",
+ "hostRestartAfterSaving": "En enregistrant, nanobot redémarrera son moteur.",
+ "hostRestartPending": "Enregistré. Le moteur redémarrera lorsqu’il sera prêt.",
+ "hostApiUnavailable": "Les actions de l’hôte ne sont disponibles que dans l’app native.",
+ "logsOpened": "Dossier des journaux ouvert.",
+ "logsOpenFailed": "Impossible d’ouvrir le dossier des journaux.",
+ "diagnosticsExported": "Diagnostic exporté vers {{path}}.",
+ "diagnosticsExportFailed": "Impossible d’exporter le diagnostic."
},
"actions": {
"save": "Enregistrer",
@@ -224,8 +224,8 @@
"cancel": "Annuler",
"open": "Ouvrir",
"export": "Exporter",
- "opening": "Opening...",
- "exporting": "Exporting..."
+ "opening": "Ouverture...",
+ "exporting": "Exportation..."
},
"byok": {
"description": "Utilisez vos propres clés de fournisseur. Nanobot lit ces valeurs depuis la configuration actuelle, et seuls les fournisseurs configurés peuvent être sélectionnés dans Général.",
@@ -250,7 +250,7 @@
"tabs": {
"ariaLabel": "Type d'identifiants BYOK",
"llm": "LLM",
- "webSearch": "Web Search"
+ "webSearch": "Recherche web"
},
"webSearch": {
"provider": "Fournisseur de recherche",
@@ -270,53 +270,53 @@
}
},
"overview": {
- "model": "Current model",
- "providers": "Providers",
- "configuredCount": "{{count}} configured",
- "totalProviders": "{{count}} available",
- "webSearch": "Web search",
- "imageGeneration": "Génération d'images",
- "workspace": "Workspace"
+ "model": "Modèle actuel",
+ "providers": "Fournisseurs",
+ "configuredCount": "{{count}} configurés",
+ "totalProviders": "{{count}} disponibles",
+ "webSearch": "Recherche web",
+ "imageGeneration": "Génération d’images",
+ "workspace": "Espace de travail"
},
"providers": {
- "searchPlaceholder": "Search providers",
- "noMatches": "No providers match this search.",
+ "searchPlaceholder": "Rechercher des fournisseurs",
+ "noMatches": "Aucun fournisseur ne correspond.",
"saveProvider": "Enregistrer le fournisseur"
},
"image": {
- "selectProvider": "Sélectionner un fournisseur",
- "selectAspect": "Sélectionner un format",
- "selectSize": "Sélectionner une taille",
+ "selectProvider": "Choisir un fournisseur",
+ "selectAspect": "Choisir un ratio",
+ "selectSize": "Choisir une taille",
"configureProvider": "Configurer le fournisseur",
- "missingCredential": "Configurez ce fournisseur avant d’activer la génération d’images."
+ "missingCredential": "Configura este proveedor antes de activar la generación de imágenes."
},
"models": {
- "selectModel": "Sélectionner un modèle",
+ "selectModel": "Choisir un modèle",
"addConfiguration": "Ajouter une configuration",
"newConfiguration": "Nouvelle configuration de modèle",
- "newConfigurationHelp": "Enregistrez un fournisseur et un modèle comme option en un clic.",
+ "newConfigurationHelp": "Enregistre un fournisseur et un modèle comme option en un clic.",
"configurationName": "Nom de la configuration",
- "configurationNameHelp": "Renommez cette configuration de modèle enregistrée.",
+ "configurationNameHelp": "Renomme cette configuration de modèle enregistrée.",
"configurationNamePlaceholder": "Rédaction rapide",
- "searchModels": "Rechercher ou saisir l’ID du modèle",
+ "searchModels": "Rechercher ou saisir un ID de modèle",
"useCustomModel": "Utiliser",
"loadingModels": "Chargement des modèles...",
- "searchCatalog": "Recherchez dans le catalogue du fournisseur pour choisir un modèle.",
+ "searchCatalog": "Rechercher dans le catalogue du fournisseur pour choisir un modèle.",
"modelsAvailable": "disponibles",
"noModelResults": "Aucun modèle correspondant.",
"loadFailed": "Liste des modèles indisponible.",
"unsupportedModelList": "Saisissez manuellement un ID de modèle.",
"providerNotConfigured": "Configurez ce fournisseur avant de charger les modèles.",
- "autoProviderCustomOnly": "Le mode fournisseur automatique utilise des ID de modèle personnalisés."
+ "autoProviderCustomOnly": "Le mode fournisseur automatique utilise des IDs de modèle personnalisés."
},
"timezone": {
- "select": "Sélectionner un fuseau horaire",
+ "select": "Choisir un fuseau horaire",
"search": "Rechercher un fuseau horaire",
"empty": "Aucun fuseau horaire correspondant."
},
"cliApps": {
"allCategories": "Toutes les catégories",
- "availableCount": "{{count}} apps",
+ "availableCount": "{{count}} applications",
"installedCount": "{{count}} installées",
"summary": "{{installed}} CLIs installées sur {{total}}",
"filterAll": "Tout",
@@ -402,7 +402,7 @@
"thirdPartyBrands": "Les noms, logos et marques de produits appartiennent à leurs propriétaires respectifs. Leur utilisation sert uniquement à l'identification et n'implique aucune approbation."
},
"apps": {
- "description": "Ajoutez des CLI d’apps et des services MCP que nanobot peut utiliser dans le chat.",
+ "description": "Ajoutez des CLI d’apps et services MCP utilisables par nanobot depuis le chat.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "Tout",
@@ -411,22 +411,22 @@
"enabledSummary": "{{count}} activés",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Rechercher des apps",
- "featured": "En vedette",
+ "featured": "À la une",
"loading": "Chargement des apps...",
- "empty": "Aucune app ne correspond à ce filtre."
+ "empty": "Aucune app ne correspond."
},
"oauth": {
- "authentication": "OAuth authentication",
- "signIn": "Sign in",
- "signingIn": "Signing in...",
- "signInAgain": "Sign in again",
- "signOut": "Sign out",
- "signedInAs": "Signed in as {{account}}",
- "signInHelp": "Sign in from this device; no API key is stored in config.",
- "signInRequired": "Sign in required",
- "signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
- "signedIn": "Signed in",
- "notSignedIn": "Not signed in"
+ "authentication": "Authentification OAuth",
+ "signIn": "Se connecter",
+ "signingIn": "Connexion...",
+ "signInAgain": "Se reconnecter",
+ "signOut": "Se déconnecter",
+ "signedInAs": "Connecté en tant que {{account}}",
+ "signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
+ "signInRequired": "Connexion requise",
+ "signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
+ "signedIn": "Connecté",
+ "notSignedIn": "Non connecté"
}
},
"chat": {
diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json
index 132f99436..e17795046 100644
--- a/webui/src/i18n/locales/id/common.json
+++ b/webui/src/i18n/locales/id/common.json
@@ -57,7 +57,7 @@
"apps": "Aplikasi"
},
"settings": {
- "backToChat": "Kembali ke obrolan",
+ "backToChat": "Kembali ke chat",
"sidebar": {
"title": "Pengaturan",
"ariaLabel": "Bagian pengaturan"
@@ -65,14 +65,14 @@
"nav": {
"general": "Umum",
"byok": "BYOK",
- "overview": "Overview",
- "appearance": "Appearance",
- "models": "Models",
- "providers": "Providers",
- "image": "Image",
- "browser": "Web",
+ "overview": "Ikhtisar",
+ "appearance": "Tampilan",
+ "models": "Model",
+ "providers": "Penyedia",
+ "image": "Gambar",
+ "browser": "Internet",
"runtime": "Sistem",
- "advanced": "Security",
+ "advanced": "Keamanan",
"cliApps": "Aplikasi CLI",
"mcp": "MCP",
"apps": "Aplikasi"
@@ -82,20 +82,20 @@
"ai": "AI",
"system": "Sistem",
"status": "Status",
- "localPreferences": "Local preferences",
- "presets": "Presets",
+ "localPreferences": "Preferensi lokal",
+ "presets": "Preset",
"imageGeneration": "Pembuatan gambar",
"imageDefaults": "Default",
- "webSearch": "Web search",
- "webBehavior": "Behavior",
- "identity": "Identity",
- "webuiSafety": "Web safety",
- "capabilities": "Kapabilitas",
- "cliApps": "App CLI",
+ "webSearch": "Pencarian web",
+ "webBehavior": "Perilaku",
+ "identity": "Identitas",
+ "webuiSafety": "Keamanan WebUI",
+ "capabilities": "Kemampuan",
+ "cliApps": "Aplikasi CLI",
"mcp": "Layanan MCP",
"apps": "Aplikasi",
- "nativeHost": "Native host",
- "hostSafety": "App safety"
+ "nativeHost": "Host native",
+ "hostSafety": "Keamanan aplikasi"
},
"rows": {
"theme": "Tema",
@@ -104,18 +104,18 @@
"model": "Model",
"restart": "Mulai ulang nanobot",
"configPath": "Path konfigurasi",
- "activePreset": "Active preset",
- "gateway": "Gateway",
- "restartState": "Restart state",
+ "activePreset": "Preset aktif",
+ "gateway": "Gerbang",
+ "restartState": "Status mulai ulang",
"pendingChanges": "Perubahan tertunda",
- "selectedPreset": "Selected preset",
- "presetModel": "Preset model",
- "density": "Density",
- "activityMode": "Activity detail",
- "codeWrap": "Code wrapping",
- "maxResults": "Max results",
- "timeout": "Timeout",
- "jinaReader": "Jina reader",
+ "selectedPreset": "Preset terpilih",
+ "presetModel": "Model preset",
+ "density": "Kerapatan",
+ "activityMode": "Detail aktivitas",
+ "codeWrap": "Bungkus kode",
+ "maxResults": "Hasil maksimum",
+ "timeout": "Batas waktu",
+ "jinaReader": "Pembaca Jina",
"imageGeneration": "Pembuatan gambar",
"imageProvider": "Penyedia gambar",
"imageProviderStatus": "Status penyedia",
@@ -124,98 +124,98 @@
"defaultAspectRatio": "Rasio default",
"defaultImageSize": "Ukuran default",
"maxImagesPerTurn": "Maks. gambar per giliran",
- "imageSaveDir": "Direktori penyimpanan",
- "botName": "Bot name",
- "botIcon": "Bot icon",
- "timezone": "Timezone",
+ "imageSaveDir": "Direktori simpan",
+ "botName": "Nama bot",
+ "botIcon": "Ikon bot",
+ "timezone": "Zona waktu",
"workspacePath": "Workspace default",
- "localServiceAccess": "Local services",
- "webuiDefaultAccess": "Default access",
+ "localServiceAccess": "Layanan lokal",
+ "webuiDefaultAccess": "Akses default",
"currentModel": "Konfigurasi saat ini",
"brandLogos": "Logo merek",
- "cliAppsCatalog": "Katalog aplikasi CLI",
- "cliAppsFilter": "Filter aplikasi CLI",
- "engine": "Engine",
+ "cliAppsCatalog": "Katalog",
+ "cliAppsFilter": "Saring",
+ "engine": "Mesin",
"logs": "Log",
"diagnostics": "Diagnostik",
- "contextWindow": "Context window"
+ "contextWindow": "Jendela konteks"
},
"help": {
"theme": "Beralih antara tampilan terang dan gelap.",
"language": "Pilih bahasa yang digunakan WebUI.",
- "provider": "Pilih penyedia untuk permintaan model baru.",
- "model": "Atur nama model default yang digunakan nanobot.",
- "configPath": "File konfigurasi gateway yang sedang digunakan.",
- "selectedPreset": "Named presets are read-only here; edit them in config.json.",
- "presetModel": "Switch to Default to edit model and provider from the WebUI.",
- "density": "Stored only in this browser.",
- "activityMode": "Choose how much agent activity chrome to show by default.",
- "codeWrap": "Keep long code lines readable on smaller screens.",
- "maxResults": "Results returned by each web_search call.",
- "timeout": "Seconds before a search provider request times out.",
- "jinaReader": "Use Jina Reader for web_fetch when available.",
- "imageGeneration": "Tampilkan generate_image di chat saat penyedia gambar yang dikonfigurasi tersedia.",
- "imageProvider": "Pilih penyedia registry yang digunakan oleh generate_image.",
- "imageProviderStatus": "Pembuatan gambar menggunakan ulang kredensial penyedia dari Providers.",
- "imageModel": "Nama model yang dikirim ke penyedia gambar yang dipilih.",
- "defaultAspectRatio": "Digunakan saat prompt tidak memilih rasio aspek.",
+ "provider": "Selecciona el proveedor para nuevas solicitudes de modelo.",
+ "model": "Define el nombre de modelo predeterminado de nanobot.",
+ "configPath": "Archivo de configuración que usa actualmente el gateway.",
+ "selectedPreset": "Los preajustes con nombre son de solo lectura aquí; edítalos en config.json.",
+ "presetModel": "Beralih ke Default untuk mengedit model dan penyedia dari WebUI.",
+ "density": "Hanya disimpan di browser ini.",
+ "activityMode": "Pilih seberapa banyak detail aktivitas agen yang ditampilkan secara default.",
+ "codeWrap": "Menjaga baris kode panjang tetap terbaca di layar kecil.",
+ "maxResults": "Resultados devueltos por cada llamada web_search.",
+ "timeout": "Segundos antes de que una solicitud de búsqueda expire.",
+ "jinaReader": "Usa Jina Reader para web_fetch cuando esté disponible.",
+ "imageGeneration": "Expone generate_image en chats cuando hay un proveedor de imagen configurado.",
+ "imageProvider": "Elige el proveedor registrado usado por generate_image.",
+ "imageProviderStatus": "La generación de imágenes reutiliza credenciales de Proveedores.",
+ "imageModel": "Nombre del modelo enviado al proveedor de imágenes seleccionado.",
+ "defaultAspectRatio": "Se usa cuando el prompt no elige una proporción.",
"defaultImageSize": "Petunjuk ukuran yang dikirim ke penyedia yang mendukungnya.",
"maxImagesPerTurn": "Batas atas untuk satu permintaan generate_image.",
- "botName": "Ditampilkan di tempat nanobot memakai nama tampilan.",
- "botIcon": "Emoji atau teks pendek yang tampil bersama nama bot.",
- "timezone": "Dipakai untuk jadwal dan balasan yang peka waktu.",
- "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
- "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
- "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
- "currentModel": "Pilih konfigurasi model yang digunakan nanobot untuk balasan berikutnya.",
- "selectedModelProvider": "Ditentukan oleh model yang dipilih.",
- "selectedModelValue": "Ditentukan oleh model yang dipilih.",
- "brandLogos": "Logo dimuat dari domain merek dengan ikon lokal sebagai cadangan.",
- "cliAppsCatalog": "Jelajahi CLI aplikasi yang dapat dijalankan nanobot secara lokal.",
- "cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan.",
- "logs": "Buka folder log native engine.",
- "diagnostics": "Ekspor laporan runtime kecil untuk dukungan.",
- "localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
- "webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
- "contextWindow": "Choose the default context budget for this model configuration."
+ "botName": "Se muestra donde nanobot usa un nombre visible.",
+ "botIcon": "Emoji o texto corto junto al nombre del bot.",
+ "timezone": "Se usa para horarios y respuestas con conciencia temporal.",
+ "localServiceAccess": "Izinkan perintah shell Full Access menjangkau layanan localhost.",
+ "webuiDefaultAccess": "Digunakan oleh chat web tanpa izin khusus proyek.",
+ "securityManagedControls": "Las capturas web siempre protegen servicios locales, privados y metadata. La seguridad de canales core se gestiona en config.json.",
+ "currentModel": "Digunakan untuk balasan baru.",
+ "selectedModelProvider": "Definido por el modelo seleccionado.",
+ "selectedModelValue": "Definido por el modelo seleccionado.",
+ "brandLogos": "Tampilkan logo penyedia pihak ketiga dan CLI di Pengaturan.",
+ "cliAppsCatalog": "Instala solo adaptadores CLI de apps que nanobot puede ejecutar localmente; las apps nativas no se modifican.",
+ "cliAppsFilter": "Busca por app, categoría o capacidad.",
+ "logs": "Abre la carpeta de registros del motor nativo.",
+ "diagnostics": "Exporta un pequeño informe de runtime para soporte.",
+ "localServiceAccessNative": "Permite que comandos shell con Full Access alcancen servicios en este Mac.",
+ "webuiDefaultAccessNative": "Usado por chats nativos sin permiso específico de proyecto.",
+ "contextWindow": "Pilih anggaran konteks default untuk konfigurasi model ini."
},
"values": {
"light": "Terang",
"dark": "Gelap",
"notAvailable": "Tidak tersedia",
- "enabled": "Enabled",
- "disabled": "Disabled",
- "restartPending": "Menunggu restart",
+ "enabled": "Aktif",
+ "disabled": "Nonaktif",
+ "restartPending": "Menunggu mulai ulang",
"ready": "Siap",
- "comfortable": "Comfortable",
- "compact": "Compact",
- "auto": "Auto",
- "expanded": "Expanded",
- "on": "On",
- "off": "Off",
- "defaultPermission": "Default Permission",
- "fullAccess": "Full Access",
- "configured": "Configured",
- "notConfigured": "Not configured",
+ "comfortable": "Nyaman",
+ "compact": "Ringkas",
+ "auto": "Otomatis",
+ "expanded": "Diperluas",
+ "on": "Aktif",
+ "off": "Nonaktif",
+ "defaultPermission": "Izin default",
+ "fullAccess": "Akses penuh",
+ "configured": "Terkonfigurasi",
+ "notConfigured": "Belum dikonfigurasi",
"pending": "Tertunda",
"restartingEngine": "Memulai ulang"
},
"status": {
"loading": "Memuat pengaturan...",
"loadError": "Tidak dapat memuat pengaturan",
- "unsaved": "Ada perubahan yang belum disimpan.",
+ "unsaved": "Perubahan belum disimpan.",
"upToDate": "Sudah terbaru.",
- "savedRestart": "Tersimpan. Mulai ulang nanobot untuk menerapkan.",
- "restartAfterSaving": "Simpan perubahan, lalu restart saat siap.",
- "savedRestartApply": "Tersimpan. Restart saat siap.",
- "imageProviderRestart": "Perubahan penyedia gambar tersimpan. Restart saat siap.",
- "hostRestartAfterSaving": "Simpan perubahan dan nanobot akan memulai ulang engine.",
- "hostRestartPending": "Tersimpan. Engine akan dimulai ulang saat siap.",
- "hostApiUnavailable": "Host actions are only available inside the native app.",
- "logsOpened": "Opened logs folder.",
- "logsOpenFailed": "Could not open logs folder.",
- "diagnosticsExported": "Diagnostics exported to {{path}}.",
- "diagnosticsExportFailed": "Could not export diagnostics."
+ "savedRestart": "Guardado. Reinicia nanobot para aplicar.",
+ "restartAfterSaving": "Guarda los cambios y reinicia cuando puedas.",
+ "savedRestartApply": "Guardado. Reinicia cuando puedas.",
+ "imageProviderRestart": "Cambios del proveedor de imagen guardados. Reinicia cuando puedas.",
+ "hostRestartAfterSaving": "Al guardar, nanobot reiniciará su motor.",
+ "hostRestartPending": "Guardado. El motor se reiniciará cuando esté listo.",
+ "hostApiUnavailable": "Las acciones del host solo están disponibles en la app nativa.",
+ "logsOpened": "Carpeta de registros abierta.",
+ "logsOpenFailed": "No se pudo abrir la carpeta de registros.",
+ "diagnosticsExported": "Diagnóstico exportado a {{path}}.",
+ "diagnosticsExportFailed": "No se pudo exportar el diagnóstico."
},
"actions": {
"save": "Simpan",
@@ -224,8 +224,8 @@
"cancel": "Batal",
"open": "Buka",
"export": "Ekspor",
- "opening": "Opening...",
- "exporting": "Exporting..."
+ "opening": "Membuka...",
+ "exporting": "Mengekspor..."
},
"byok": {
"description": "Gunakan kunci provider Anda sendiri. Nanobot membaca nilai ini dari config saat ini, dan hanya provider yang sudah dikonfigurasi yang bisa dipilih di Umum.",
@@ -250,10 +250,10 @@
"tabs": {
"ariaLabel": "Jenis kredensial BYOK",
"llm": "LLM",
- "webSearch": "Web Search"
+ "webSearch": "Pencarian web"
},
"webSearch": {
- "provider": "Search provider",
+ "provider": "Penyedia pencarian",
"providerHelp": "Pilih backend yang digunakan alat web search.",
"selectProvider": "Pilih provider",
"credentials": "Kredensial",
@@ -270,17 +270,17 @@
}
},
"overview": {
- "model": "Current model",
- "providers": "Providers",
- "configuredCount": "{{count}} configured",
- "totalProviders": "{{count}} available",
- "webSearch": "Web search",
+ "model": "Model saat ini",
+ "providers": "Penyedia",
+ "configuredCount": "{{count}} dikonfigurasi",
+ "totalProviders": "{{count}} tersedia",
+ "webSearch": "Pencarian web",
"imageGeneration": "Pembuatan gambar",
- "workspace": "Workspace"
+ "workspace": "Ruang kerja"
},
"providers": {
- "searchPlaceholder": "Search providers",
- "noMatches": "No providers match this search.",
+ "searchPlaceholder": "Cari penyedia",
+ "noMatches": "Tidak ada penyedia yang cocok.",
"saveProvider": "Simpan penyedia"
},
"image": {
@@ -288,7 +288,7 @@
"selectAspect": "Pilih rasio",
"selectSize": "Pilih ukuran",
"configureProvider": "Konfigurasi penyedia",
- "missingCredential": "Konfigurasikan penyedia ini sebelum mengaktifkan pembuatan gambar."
+ "missingCredential": "Configura este proveedor antes de activar la generación de imágenes."
},
"models": {
"selectModel": "Pilih model",
@@ -296,8 +296,8 @@
"newConfiguration": "Konfigurasi model baru",
"newConfigurationHelp": "Simpan penyedia dan model sebagai opsi sekali klik.",
"configurationName": "Nama konfigurasi",
- "configurationNameHelp": "Ganti nama konfigurasi model yang tersimpan ini.",
- "configurationNamePlaceholder": "Penulisan cepat",
+ "configurationNameHelp": "Ganti nama konfigurasi model tersimpan ini.",
+ "configurationNamePlaceholder": "Menulis cepat",
"searchModels": "Cari atau ketik ID model",
"useCustomModel": "Gunakan",
"loadingModels": "Memuat model...",
@@ -307,7 +307,7 @@
"loadFailed": "Daftar model tidak tersedia.",
"unsupportedModelList": "Ketik ID model secara manual.",
"providerNotConfigured": "Konfigurasikan penyedia ini sebelum memuat model.",
- "autoProviderCustomOnly": "Mode penyedia otomatis menggunakan ID model khusus."
+ "autoProviderCustomOnly": "Mode penyedia otomatis memakai ID model kustom."
},
"timezone": {
"select": "Pilih zona waktu",
@@ -402,31 +402,31 @@
"thirdPartyBrands": "Nama produk, logo, dan merek adalah milik pemiliknya masing-masing. Penggunaan hanya untuk identifikasi dan tidak menyiratkan dukungan."
},
"apps": {
- "description": "Tambahkan CLI app dan layanan MCP yang dapat digunakan nanobot dari chat.",
+ "description": "Tambahkan CLI aplikasi dan layanan MCP yang dapat digunakan nanobot dari chat.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "Semua",
- "filterCli": "App CLI",
+ "filterCli": "Aplikasi CLI",
"filterMcp": "Layanan MCP",
"enabledSummary": "{{count}} aktif",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Cari aplikasi",
"featured": "Unggulan",
"loading": "Memuat aplikasi...",
- "empty": "Tidak ada aplikasi yang cocok dengan filter ini."
+ "empty": "Tidak ada aplikasi yang cocok."
},
"oauth": {
- "authentication": "OAuth authentication",
- "signIn": "Sign in",
- "signingIn": "Signing in...",
- "signInAgain": "Sign in again",
- "signOut": "Sign out",
- "signedInAs": "Signed in as {{account}}",
- "signInHelp": "Sign in from this device; no API key is stored in config.",
- "signInRequired": "Sign in required",
- "signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
- "signedIn": "Signed in",
- "notSignedIn": "Not signed in"
+ "authentication": "Autentikasi OAuth",
+ "signIn": "Masuk",
+ "signingIn": "Masuk...",
+ "signInAgain": "Masuk lagi",
+ "signOut": "Keluar",
+ "signedInAs": "Masuk sebagai {{account}}",
+ "signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
+ "signInRequired": "Perlu masuk",
+ "signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
+ "signedIn": "Sudah masuk",
+ "notSignedIn": "Belum masuk"
}
},
"chat": {
diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json
index d029ca7c9..9c07ece83 100644
--- a/webui/src/i18n/locales/ja/common.json
+++ b/webui/src/i18n/locales/ja/common.json
@@ -65,14 +65,14 @@
"nav": {
"general": "一般",
"byok": "BYOK",
- "overview": "Overview",
- "appearance": "Appearance",
- "models": "Models",
- "providers": "Providers",
- "image": "Image",
- "browser": "Web",
+ "overview": "概要",
+ "appearance": "外観",
+ "models": "モデル",
+ "providers": "プロバイダー",
+ "image": "画像",
+ "browser": "ウェブ",
"runtime": "システム",
- "advanced": "Security",
+ "advanced": "セキュリティ",
"cliApps": "CLI アプリ",
"mcp": "MCP",
"apps": "アプリ"
@@ -81,21 +81,21 @@
"interface": "インターフェース",
"ai": "AI",
"system": "システム",
- "status": "Status",
- "localPreferences": "Local preferences",
- "presets": "Presets",
+ "status": "状態",
+ "localPreferences": "ローカル設定",
+ "presets": "プリセット",
"imageGeneration": "画像生成",
"imageDefaults": "既定値",
- "webSearch": "Web search",
- "webBehavior": "Behavior",
- "identity": "Identity",
- "webuiSafety": "Web safety",
+ "webSearch": "ウェブ検索",
+ "webBehavior": "動作",
+ "identity": "ID",
+ "webuiSafety": "WebUI の安全性",
"capabilities": "機能",
"cliApps": "CLI アプリ",
"mcp": "MCP サービス",
"apps": "アプリ",
- "nativeHost": "App",
- "hostSafety": "App safety"
+ "nativeHost": "ネイティブホスト",
+ "hostSafety": "アプリの安全性"
},
"rows": {
"theme": "テーマ",
@@ -104,41 +104,41 @@
"model": "モデル",
"restart": "nanobot を再起動",
"configPath": "設定パス",
- "activePreset": "Active preset",
- "gateway": "Gateway",
- "restartState": "Restart state",
+ "activePreset": "アクティブなプリセット",
+ "gateway": "ゲートウェイ",
+ "restartState": "再起動状態",
"pendingChanges": "保留中の変更",
- "selectedPreset": "Selected preset",
- "presetModel": "Preset model",
- "density": "Density",
- "activityMode": "Activity detail",
- "codeWrap": "Code wrapping",
- "maxResults": "Max results",
- "timeout": "Timeout",
- "jinaReader": "Jina reader",
+ "selectedPreset": "選択中のプリセット",
+ "presetModel": "プリセットモデル",
+ "density": "表示密度",
+ "activityMode": "アクティビティ詳細",
+ "codeWrap": "コードの折り返し",
+ "maxResults": "最大結果数",
+ "timeout": "タイムアウト",
+ "jinaReader": "Jina リーダー",
"imageGeneration": "画像生成",
"imageProvider": "画像プロバイダー",
- "imageProviderStatus": "プロバイダーの状態",
- "imageProviderBase": "プロバイダーのベース URL",
+ "imageProviderStatus": "プロバイダー状態",
+ "imageProviderBase": "プロバイダー URL",
"imageModel": "画像モデル",
"defaultAspectRatio": "既定の比率",
"defaultImageSize": "既定のサイズ",
- "maxImagesPerTurn": "1 ターンあたりの最大画像数",
+ "maxImagesPerTurn": "1 ターンの最大画像数",
"imageSaveDir": "保存先ディレクトリ",
- "botName": "Bot name",
- "botIcon": "Bot icon",
- "timezone": "Timezone",
- "workspacePath": "デフォルトワークスペース",
- "localServiceAccess": "Local services",
- "webuiDefaultAccess": "Default access",
+ "botName": "Bot 名",
+ "botIcon": "Bot アイコン",
+ "timezone": "タイムゾーン",
+ "workspacePath": "既定のワークスペース",
+ "localServiceAccess": "ローカルサービス",
+ "webuiDefaultAccess": "既定の権限",
"currentModel": "現在の設定",
"brandLogos": "ブランドロゴ",
- "cliAppsCatalog": "CLI アプリカタログ",
- "cliAppsFilter": "CLI アプリフィルター",
+ "cliAppsCatalog": "カタログ",
+ "cliAppsFilter": "フィルター",
"engine": "エンジン",
"logs": "ログ",
"diagnostics": "診断",
- "contextWindow": "Context window"
+ "contextWindow": "コンテキストウィンドウ"
},
"help": {
"theme": "ライト表示とダーク表示を切り替えます。",
@@ -146,57 +146,57 @@
"provider": "新しいモデルリクエストに使うプロバイダーを選択します。",
"model": "nanobot が既定で使用するモデル名を設定します。",
"configPath": "現在ゲートウェイが使用している設定ファイルです。",
- "selectedPreset": "Named presets are read-only here; edit them in config.json.",
- "presetModel": "Switch to Default to edit model and provider from the WebUI.",
- "density": "Stored only in this browser.",
- "activityMode": "Choose how much agent activity chrome to show by default.",
- "codeWrap": "Keep long code lines readable on smaller screens.",
- "maxResults": "Results returned by each web_search call.",
- "timeout": "Seconds before a search provider request times out.",
- "jinaReader": "Use Jina Reader for web_fetch when available.",
- "imageGeneration": "設定済みの画像プロバイダーが利用できる場合、チャットで generate_image を有効にします。",
+ "selectedPreset": "名前付きプリセットはここでは読み取り専用です。編集するには config.json を変更してください。",
+ "presetModel": "Default に切り替えると、WebUI からモデルとプロバイダーを編集できます。",
+ "density": "このブラウザーにのみ保存されます。",
+ "activityMode": "既定で表示する agent アクティビティの詳細量を選択します。",
+ "codeWrap": "小さな画面でも長いコード行を読みやすくします。",
+ "maxResults": "各 web_search 呼び出しで返す結果数です。",
+ "timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。",
+ "jinaReader": "利用可能な場合、web_fetch に Jina Reader を使います。",
+ "imageGeneration": "画像プロバイダーが設定済みのとき、チャットで generate_image を有効にします。",
"imageProvider": "generate_image で使用する登録済みプロバイダーを選択します。",
"imageProviderStatus": "画像生成は「プロバイダー」の認証情報を再利用します。",
"imageModel": "選択した画像プロバイダーへ送信するモデル名です。",
- "defaultAspectRatio": "プロンプトでアスペクト比が指定されていない場合に使用します。",
+ "defaultAspectRatio": "プロンプトで比率が指定されていない場合に使用します。",
"defaultImageSize": "対応しているプロバイダーへ送信するサイズ指定です。",
"maxImagesPerTurn": "1 回の generate_image リクエストで生成できる画像数の上限です。",
"botName": "nanobot が表示名を使う場所に表示されます。",
"botIcon": "Bot 名の横に表示する短い emoji またはテキストです。",
"timezone": "スケジュールと時刻を考慮する返信に使用します。",
- "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
- "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
- "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
- "currentModel": "今後の返信で nanobot が使用するモデル設定を選択します。",
+ "localServiceAccess": "Full Access の shell コマンドが localhost サービスにアクセスできるようにします。",
+ "webuiDefaultAccess": "プロジェクト固有の権限がない Web チャットで使用します。",
+ "securityManagedControls": "Web 取得は常にローカル、プライベート、メタデータサービスを保護します。コアチャネルの安全性は config.json で管理されます。",
+ "currentModel": "新しい返信に使用します。",
"selectedModelProvider": "選択したモデルによって設定されます。",
"selectedModelValue": "選択したモデルによって設定されます。",
- "brandLogos": "ロゴはブランドのドメインから読み込まれ、ローカルアイコンにフォールバックします。",
- "cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI を探します。",
+ "brandLogos": "設定で第三者プロバイダーと CLI のロゴを表示します。",
+ "cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI アダプターだけをインストールします。ネイティブアプリは変更しません。",
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。",
- "logs": "Appエンジンのログフォルダを開きます。",
+ "logs": "ネイティブエンジンのログフォルダーを開きます。",
"diagnostics": "サポート用の小さなランタイムレポートを書き出します。",
- "localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
- "webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
- "contextWindow": "Choose the default context budget for this model configuration."
+ "localServiceAccessNative": "Full Access の shell コマンドがこの Mac 上のサービスにアクセスできるようにします。",
+ "webuiDefaultAccessNative": "プロジェクト固有の権限がないネイティブチャットで使用します。",
+ "contextWindow": "このモデル設定で使う既定のコンテキスト予算を選択します。"
},
"values": {
"light": "ライト",
"dark": "ダーク",
"notAvailable": "利用不可",
- "enabled": "Enabled",
- "disabled": "Disabled",
+ "enabled": "有効",
+ "disabled": "無効",
"restartPending": "再起動待ち",
"ready": "準備完了",
- "comfortable": "Comfortable",
- "compact": "Compact",
- "auto": "Auto",
- "expanded": "Expanded",
- "on": "On",
- "off": "Off",
- "defaultPermission": "Default Permission",
- "fullAccess": "Full Access",
- "configured": "Configured",
- "notConfigured": "Not configured",
+ "comfortable": "標準",
+ "compact": "コンパクト",
+ "auto": "自動",
+ "expanded": "展開",
+ "on": "オン",
+ "off": "オフ",
+ "defaultPermission": "既定の権限",
+ "fullAccess": "完全アクセス",
+ "configured": "設定済み",
+ "notConfigured": "未設定",
"pending": "保留中",
"restartingEngine": "再起動中"
},
@@ -211,11 +211,11 @@
"imageProviderRestart": "画像プロバイダーの変更を保存しました。準備ができたら再起動してください。",
"hostRestartAfterSaving": "保存すると nanobot がエンジンを再起動します。",
"hostRestartPending": "保存しました。準備ができたらエンジンを再起動します。",
- "hostApiUnavailable": "Host actions are only available inside the native app.",
- "logsOpened": "Opened logs folder.",
- "logsOpenFailed": "Could not open logs folder.",
- "diagnosticsExported": "Diagnostics exported to {{path}}.",
- "diagnosticsExportFailed": "Could not export diagnostics."
+ "hostApiUnavailable": "ホスト操作はネイティブアプリ内でのみ利用できます。",
+ "logsOpened": "ログフォルダーを開きました。",
+ "logsOpenFailed": "ログフォルダーを開けませんでした。",
+ "diagnosticsExported": "診断を {{path}} に書き出しました。",
+ "diagnosticsExportFailed": "診断を書き出せませんでした。"
},
"actions": {
"save": "保存",
@@ -224,8 +224,8 @@
"cancel": "キャンセル",
"open": "開く",
"export": "書き出す",
- "opening": "Opening...",
- "exporting": "Exporting..."
+ "opening": "開いています...",
+ "exporting": "書き出しています..."
},
"byok": {
"description": "自分の provider キーを使います。Nanobot は現在の config から値を読み込み、設定済みの provider だけを一般設定で選択できます。",
@@ -250,7 +250,7 @@
"tabs": {
"ariaLabel": "BYOK 認証情報タイプ",
"llm": "LLM",
- "webSearch": "Web Search"
+ "webSearch": "ウェブ検索"
},
"webSearch": {
"provider": "検索 provider",
@@ -270,17 +270,17 @@
}
},
"overview": {
- "model": "Current model",
- "providers": "Providers",
- "configuredCount": "{{count}} configured",
- "totalProviders": "{{count}} available",
- "webSearch": "Web search",
+ "model": "現在のモデル",
+ "providers": "プロバイダー",
+ "configuredCount": "{{count}} 個設定済み",
+ "totalProviders": "{{count}} 個利用可能",
+ "webSearch": "Web 検索",
"imageGeneration": "画像生成",
- "workspace": "Workspace"
+ "workspace": "ワークスペース"
},
"providers": {
- "searchPlaceholder": "Search providers",
- "noMatches": "No providers match this search.",
+ "searchPlaceholder": "プロバイダーを検索",
+ "noMatches": "一致するプロバイダーはありません。",
"saveProvider": "プロバイダーを保存"
},
"image": {
@@ -297,12 +297,12 @@
"newConfigurationHelp": "プロバイダーとモデルをワンクリックの選択肢として保存します。",
"configurationName": "設定名",
"configurationNameHelp": "保存済みのモデル設定の名前を変更します。",
- "configurationNamePlaceholder": "高速ライティング",
- "searchModels": "検索またはモデル ID を入力",
+ "configurationNamePlaceholder": "高速執筆",
+ "searchModels": "モデル ID を検索または入力",
"useCustomModel": "使用",
- "loadingModels": "モデルを読み込み中...",
- "searchCatalog": "プロバイダーのカタログを検索してモデルを選択します。",
- "modelsAvailable": "件利用可能",
+ "loadingModels": "モデルを読み込んでいます...",
+ "searchCatalog": "プロバイダーのカタログからモデルを選択します。",
+ "modelsAvailable": "利用可能",
"noModelResults": "一致するモデルはありません。",
"loadFailed": "モデル一覧を利用できません。",
"unsupportedModelList": "モデル ID を手動で入力してください。",
@@ -402,31 +402,31 @@
"thirdPartyBrands": "製品名、ロゴ、ブランドはそれぞれの所有者に帰属します。使用は識別のみを目的とし、承認を意味するものではありません。"
},
"apps": {
- "description": "チャットから nanobot が使えるアプリ CLI と MCP サービスを追加します。",
+ "description": "nanobot がチャットで使用できる App CLI と MCP サービスを追加します。",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "すべて",
"filterCli": "CLI アプリ",
"filterMcp": "MCP サービス",
"enabledSummary": "{{count}} 件有効",
- "caption": "{{cli}} CLI · {{mcp}} MCP",
+ "caption": "CLI {{cli}} 件 · MCP {{mcp}} 件",
"searchPlaceholder": "アプリを検索",
- "featured": "おすすめ",
+ "featured": "注目",
"loading": "アプリを読み込み中...",
- "empty": "このフィルターに一致するアプリはありません。"
+ "empty": "一致するアプリはありません。"
},
"oauth": {
- "authentication": "OAuth authentication",
- "signIn": "Sign in",
- "signingIn": "Signing in...",
- "signInAgain": "Sign in again",
- "signOut": "Sign out",
- "signedInAs": "Signed in as {{account}}",
- "signInHelp": "Sign in from this device; no API key is stored in config.",
- "signInRequired": "Sign in required",
- "signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
- "signedIn": "Signed in",
- "notSignedIn": "Not signed in"
+ "authentication": "OAuth 認証",
+ "signIn": "サインイン",
+ "signingIn": "サインイン中...",
+ "signInAgain": "再度サインイン",
+ "signOut": "サインアウト",
+ "signedInAs": "{{account}} としてサインイン済み",
+ "signInHelp": "このデバイスからサインインします。API key は config に保存されません。",
+ "signInRequired": "サインインが必要です",
+ "signInBeforeSaving": "この OAuth プロバイダーをアクティブなモデルプロバイダーとして保存する前にサインインしてください。",
+ "signedIn": "サインイン済み",
+ "notSignedIn": "未サインイン"
}
},
"chat": {
diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json
index 5b1569a29..cddf22634 100644
--- a/webui/src/i18n/locales/ko/common.json
+++ b/webui/src/i18n/locales/ko/common.json
@@ -65,14 +65,14 @@
"nav": {
"general": "일반",
"byok": "BYOK",
- "overview": "Overview",
- "appearance": "Appearance",
- "models": "Models",
- "providers": "Providers",
- "image": "Image",
- "browser": "Web",
+ "overview": "개요",
+ "appearance": "외관",
+ "models": "모델",
+ "providers": "제공자",
+ "image": "이미지",
+ "browser": "웹",
"runtime": "시스템",
- "advanced": "Security",
+ "advanced": "보안",
"cliApps": "CLI 앱",
"mcp": "MCP",
"apps": "앱"
@@ -81,21 +81,21 @@
"interface": "인터페이스",
"ai": "AI",
"system": "시스템",
- "status": "Status",
- "localPreferences": "Local preferences",
- "presets": "Presets",
+ "status": "상태",
+ "localPreferences": "로컬 환경설정",
+ "presets": "프리셋",
"imageGeneration": "이미지 생성",
"imageDefaults": "기본값",
- "webSearch": "Web search",
- "webBehavior": "Behavior",
- "identity": "Identity",
- "webuiSafety": "Web safety",
+ "webSearch": "웹 검색",
+ "webBehavior": "동작",
+ "identity": "ID",
+ "webuiSafety": "WebUI 보안",
"capabilities": "기능",
"cliApps": "CLI 앱",
"mcp": "MCP 서비스",
"apps": "앱",
- "nativeHost": "App",
- "hostSafety": "App safety"
+ "nativeHost": "네이티브 호스트",
+ "hostSafety": "앱 보안"
},
"rows": {
"theme": "테마",
@@ -104,118 +104,118 @@
"model": "모델",
"restart": "nanobot 재시작",
"configPath": "설정 경로",
- "activePreset": "Active preset",
- "gateway": "Gateway",
- "restartState": "Restart state",
- "pendingChanges": "대기 중인 변경 사항",
- "selectedPreset": "Selected preset",
- "presetModel": "Preset model",
- "density": "Density",
- "activityMode": "Activity detail",
- "codeWrap": "Code wrapping",
- "maxResults": "Max results",
- "timeout": "Timeout",
- "jinaReader": "Jina reader",
+ "activePreset": "활성 프리셋",
+ "gateway": "게이트웨이",
+ "restartState": "재시작 상태",
+ "pendingChanges": "대기 중인 변경",
+ "selectedPreset": "선택한 프리셋",
+ "presetModel": "프리셋 모델",
+ "density": "밀도",
+ "activityMode": "활동 상세",
+ "codeWrap": "코드 줄바꿈",
+ "maxResults": "최대 결과 수",
+ "timeout": "타임아웃",
+ "jinaReader": "Jina 리더",
"imageGeneration": "이미지 생성",
"imageProvider": "이미지 제공자",
"imageProviderStatus": "제공자 상태",
- "imageProviderBase": "제공자 기준 주소",
+ "imageProviderBase": "제공자 주소",
"imageModel": "이미지 모델",
"defaultAspectRatio": "기본 비율",
"defaultImageSize": "기본 크기",
"maxImagesPerTurn": "턴당 최대 이미지 수",
"imageSaveDir": "저장 디렉터리",
- "botName": "Bot name",
- "botIcon": "Bot icon",
- "timezone": "Timezone",
+ "botName": "Bot 이름",
+ "botIcon": "Bot 아이콘",
+ "timezone": "시간대",
"workspacePath": "기본 작업공간",
- "localServiceAccess": "Local services",
- "webuiDefaultAccess": "Default access",
+ "localServiceAccess": "로컬 서비스",
+ "webuiDefaultAccess": "기본 권한",
"currentModel": "현재 구성",
"brandLogos": "브랜드 로고",
- "cliAppsCatalog": "CLI 앱 카탈로그",
- "cliAppsFilter": "CLI 앱 필터",
+ "cliAppsCatalog": "카탈로그",
+ "cliAppsFilter": "필터",
"engine": "엔진",
"logs": "로그",
"diagnostics": "진단",
- "contextWindow": "Context window"
+ "contextWindow": "컨텍스트 창"
},
"help": {
"theme": "밝은 모드와 어두운 모드를 전환합니다.",
"language": "WebUI에서 사용할 언어를 선택합니다.",
- "provider": "새 모델 요청에 사용할 제공자를 선택합니다.",
+ "provider": "새 모델 요청을 처리할 제공자를 선택합니다.",
"model": "nanobot이 기본으로 사용할 모델 이름을 설정합니다.",
"configPath": "현재 게이트웨이가 사용하는 설정 파일입니다.",
- "selectedPreset": "Named presets are read-only here; edit them in config.json.",
- "presetModel": "Switch to Default to edit model and provider from the WebUI.",
- "density": "Stored only in this browser.",
- "activityMode": "Choose how much agent activity chrome to show by default.",
- "codeWrap": "Keep long code lines readable on smaller screens.",
- "maxResults": "Results returned by each web_search call.",
- "timeout": "Seconds before a search provider request times out.",
- "jinaReader": "Use Jina Reader for web_fetch when available.",
- "imageGeneration": "구성된 이미지 제공자를 사용할 수 있을 때 채팅에서 generate_image를 노출합니다.",
- "imageProvider": "generate_image가 사용할 레지스트리 제공자를 선택합니다.",
- "imageProviderStatus": "이미지 생성은 Providers의 제공자 자격 증명을 재사용합니다.",
+ "selectedPreset": "이름 있는 프리셋은 여기서 읽기 전용입니다. config.json에서 편집하세요.",
+ "presetModel": "Default로 전환하면 WebUI에서 모델과 제공자를 편집할 수 있습니다.",
+ "density": "이 브라우저에만 저장됩니다.",
+ "activityMode": "기본으로 표시할 agent 활동 세부 수준을 선택합니다.",
+ "codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.",
+ "maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.",
+ "timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.",
+ "jinaReader": "가능할 때 web_fetch에 Jina Reader를 사용합니다.",
+ "imageGeneration": "구성된 이미지 제공자가 있을 때 채팅에서 generate_image를 노출합니다.",
+ "imageProvider": "generate_image에 사용할 등록 제공자를 선택합니다.",
+ "imageProviderStatus": "이미지 생성은 제공자 자격 증명을 재사용합니다.",
"imageModel": "선택한 이미지 제공자에 보낼 모델 이름입니다.",
- "defaultAspectRatio": "프롬프트에서 가로세로 비율을 선택하지 않았을 때 사용됩니다.",
- "defaultImageSize": "지원하는 제공자에게 보내는 크기 힌트입니다.",
- "maxImagesPerTurn": "한 번의 generate_image 요청에 대한 상한입니다.",
- "botName": "nanobot이 표시 이름을 쓰는 곳에 표시됩니다.",
+ "defaultAspectRatio": "프롬프트가 비율을 선택하지 않을 때 사용됩니다.",
+ "defaultImageSize": "지원하는 제공자에 보낼 크기 힌트입니다.",
+ "maxImagesPerTurn": "한 번의 generate_image 요청에서 생성할 수 있는 이미지 상한입니다.",
+ "botName": "nanobot이 표시 이름을 사용하는 곳에 표시됩니다.",
"botIcon": "Bot 이름 옆에 표시할 짧은 emoji 또는 텍스트입니다.",
- "timezone": "예약과 시간 인식 답변에 사용됩니다.",
- "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
- "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
- "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
- "currentModel": "nanobot이 새 답변에 사용할 모델 구성을 선택합니다.",
- "selectedModelProvider": "선택한 모델에서 설정됩니다.",
- "selectedModelValue": "선택한 모델에서 설정됩니다.",
- "brandLogos": "로고는 브랜드 도메인에서 불러오며, 실패하면 로컬 아이콘을 사용합니다.",
- "cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI를 살펴봅니다.",
+ "timezone": "일정과 시간 인식 답변에 사용됩니다.",
+ "localServiceAccess": "Full Access shell 명령이 localhost 서비스에 접근할 수 있게 합니다.",
+ "webuiDefaultAccess": "프로젝트별 권한이 없는 Web 채팅에 사용됩니다.",
+ "securityManagedControls": "웹 가져오기는 항상 로컬, 사설, 메타데이터 서비스를 보호합니다. 핵심 채널 보안은 config.json에서 관리됩니다.",
+ "currentModel": "새 응답에 사용됩니다.",
+ "selectedModelProvider": "선택한 모델에 의해 설정됩니다.",
+ "selectedModelValue": "선택한 모델에 의해 설정됩니다.",
+ "brandLogos": "설정에서 타사 제공자와 CLI 로고를 표시합니다.",
+ "cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI 어댑터만 설치합니다. 네이티브 앱은 변경하지 않습니다.",
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.",
- "logs": "App 엔진 로그 폴더를 엽니다.",
+ "logs": "네이티브 엔진 로그 폴더를 엽니다.",
"diagnostics": "지원용 작은 런타임 보고서를 내보냅니다.",
- "localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
- "webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
- "contextWindow": "Choose the default context budget for this model configuration."
+ "localServiceAccessNative": "Full Access shell 명령이 이 Mac의 서비스에 접근할 수 있게 합니다.",
+ "webuiDefaultAccessNative": "프로젝트별 권한이 없는 네이티브 채팅에 사용됩니다.",
+ "contextWindow": "이 모델 구성의 기본 컨텍스트 예산을 선택합니다."
},
"values": {
"light": "라이트",
"dark": "다크",
- "notAvailable": "사용할 수 없음",
- "enabled": "Enabled",
- "disabled": "Disabled",
- "restartPending": "재시작 대기 중",
+ "notAvailable": "사용 불가",
+ "enabled": "활성화됨",
+ "disabled": "비활성화됨",
+ "restartPending": "재시작 대기",
"ready": "준비됨",
- "comfortable": "Comfortable",
- "compact": "Compact",
- "auto": "Auto",
- "expanded": "Expanded",
- "on": "On",
- "off": "Off",
- "defaultPermission": "Default Permission",
- "fullAccess": "Full Access",
- "configured": "Configured",
- "notConfigured": "Not configured",
+ "comfortable": "편안함",
+ "compact": "컴팩트",
+ "auto": "자동",
+ "expanded": "펼침",
+ "on": "켜짐",
+ "off": "꺼짐",
+ "defaultPermission": "기본 권한",
+ "fullAccess": "전체 접근",
+ "configured": "구성됨",
+ "notConfigured": "미구성",
"pending": "대기 중",
- "restartingEngine": "다시 시작 중"
+ "restartingEngine": "재시작 중"
},
"status": {
"loading": "설정을 불러오는 중...",
"loadError": "설정을 불러올 수 없습니다",
"unsaved": "저장되지 않은 변경 사항이 있습니다.",
"upToDate": "최신 상태입니다.",
- "savedRestart": "저장되었습니다. 적용하려면 nanobot을 재시작하세요.",
+ "savedRestart": "저장했습니다. 적용하려면 nanobot을 재시작하세요.",
"restartAfterSaving": "변경 사항을 저장한 뒤 준비되면 재시작하세요.",
- "savedRestartApply": "저장되었습니다. 준비되면 재시작하세요.",
- "imageProviderRestart": "이미지 제공자 변경 사항이 저장되었습니다. 준비되면 재시작하세요.",
- "hostRestartAfterSaving": "저장하면 nanobot이 엔진을 다시 시작합니다.",
- "hostRestartPending": "저장되었습니다. 준비되면 엔진을 다시 시작합니다.",
- "hostApiUnavailable": "Host actions are only available inside the native app.",
- "logsOpened": "Opened logs folder.",
- "logsOpenFailed": "Could not open logs folder.",
- "diagnosticsExported": "Diagnostics exported to {{path}}.",
- "diagnosticsExportFailed": "Could not export diagnostics."
+ "savedRestartApply": "저장했습니다. 준비되면 재시작하세요.",
+ "imageProviderRestart": "이미지 제공자 변경 사항을 저장했습니다. 준비되면 재시작하세요.",
+ "hostRestartAfterSaving": "저장하면 nanobot이 엔진을 재시작합니다.",
+ "hostRestartPending": "저장했습니다. 준비되면 엔진을 재시작합니다.",
+ "hostApiUnavailable": "호스트 작업은 네이티브 앱 안에서만 사용할 수 있습니다.",
+ "logsOpened": "로그 폴더를 열었습니다.",
+ "logsOpenFailed": "로그 폴더를 열 수 없습니다.",
+ "diagnosticsExported": "진단을 {{path}}에 내보냈습니다.",
+ "diagnosticsExportFailed": "진단을 내보낼 수 없습니다."
},
"actions": {
"save": "저장",
@@ -224,8 +224,8 @@
"cancel": "취소",
"open": "열기",
"export": "내보내기",
- "opening": "Opening...",
- "exporting": "Exporting..."
+ "opening": "여는 중...",
+ "exporting": "내보내는 중..."
},
"byok": {
"description": "직접 provider 키를 가져옵니다. Nanobot은 현재 config에서 값을 읽고, 설정된 provider만 일반 설정에서 선택할 수 있습니다.",
@@ -250,7 +250,7 @@
"tabs": {
"ariaLabel": "BYOK 자격 증명 유형",
"llm": "LLM",
- "webSearch": "Web Search"
+ "webSearch": "웹 검색"
},
"webSearch": {
"provider": "검색 provider",
@@ -270,17 +270,17 @@
}
},
"overview": {
- "model": "Current model",
- "providers": "Providers",
- "configuredCount": "{{count}} configured",
- "totalProviders": "{{count}} available",
- "webSearch": "Web search",
+ "model": "현재 모델",
+ "providers": "제공자",
+ "configuredCount": "{{count}}개 구성됨",
+ "totalProviders": "{{count}}개 사용 가능",
+ "webSearch": "웹 검색",
"imageGeneration": "이미지 생성",
- "workspace": "Workspace"
+ "workspace": "작업공간"
},
"providers": {
- "searchPlaceholder": "Search providers",
- "noMatches": "No providers match this search.",
+ "searchPlaceholder": "제공자 검색",
+ "noMatches": "일치하는 제공자가 없습니다.",
"saveProvider": "제공자 저장"
},
"image": {
@@ -294,15 +294,15 @@
"selectModel": "모델 선택",
"addConfiguration": "구성 추가",
"newConfiguration": "새 모델 구성",
- "newConfigurationHelp": "제공자와 모델을 한 번에 선택할 수 있는 옵션으로 저장합니다.",
+ "newConfigurationHelp": "제공자와 모델을 원클릭 옵션으로 저장합니다.",
"configurationName": "구성 이름",
"configurationNameHelp": "저장된 모델 구성의 이름을 변경합니다.",
- "configurationNamePlaceholder": "빠른 글쓰기",
- "searchModels": "검색하거나 모델 ID 입력",
+ "configurationNamePlaceholder": "빠른 작성",
+ "searchModels": "모델 ID 검색 또는 입력",
"useCustomModel": "사용",
- "loadingModels": "모델을 불러오는 중...",
- "searchCatalog": "제공자 카탈로그를 검색해 모델을 선택하세요.",
- "modelsAvailable": "개 사용 가능",
+ "loadingModels": "모델 불러오는 중...",
+ "searchCatalog": "제공자 카탈로그에서 모델을 선택합니다.",
+ "modelsAvailable": "사용 가능",
"noModelResults": "일치하는 모델이 없습니다.",
"loadFailed": "모델 목록을 사용할 수 없습니다.",
"unsupportedModelList": "모델 ID를 직접 입력하세요.",
@@ -402,31 +402,31 @@
"thirdPartyBrands": "제품 이름, 로고 및 브랜드는 각 소유자의 자산입니다. 사용은 식별 목적일 뿐 보증이나 제휴를 의미하지 않습니다."
},
"apps": {
- "description": "채팅에서 nanobot이 사용할 수 있는 앱 CLI와 MCP 서비스를 추가합니다.",
+ "description": "nanobot이 채팅에서 사용할 수 있는 App CLI와 MCP 서비스를 추가합니다.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "전체",
"filterCli": "CLI 앱",
"filterMcp": "MCP 서비스",
"enabledSummary": "{{count}}개 활성화됨",
- "caption": "{{cli}} CLI · {{mcp}} MCP",
+ "caption": "CLI {{cli}}개 · MCP {{mcp}}개",
"searchPlaceholder": "앱 검색",
"featured": "추천",
- "loading": "앱 불러오는 중...",
- "empty": "이 필터와 일치하는 앱이 없습니다."
+ "loading": "앱을 불러오는 중...",
+ "empty": "일치하는 앱이 없습니다."
},
"oauth": {
- "authentication": "OAuth authentication",
- "signIn": "Sign in",
- "signingIn": "Signing in...",
- "signInAgain": "Sign in again",
- "signOut": "Sign out",
- "signedInAs": "Signed in as {{account}}",
- "signInHelp": "Sign in from this device; no API key is stored in config.",
- "signInRequired": "Sign in required",
- "signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
- "signedIn": "Signed in",
- "notSignedIn": "Not signed in"
+ "authentication": "OAuth 인증",
+ "signIn": "로그인",
+ "signingIn": "로그인 중...",
+ "signInAgain": "다시 로그인",
+ "signOut": "로그아웃",
+ "signedInAs": "{{account}}로 로그인됨",
+ "signInHelp": "이 기기에서 로그인합니다. API key는 config에 저장되지 않습니다.",
+ "signInRequired": "로그인이 필요합니다",
+ "signInBeforeSaving": "이 OAuth 제공자를 활성 모델 제공자로 저장하기 전에 로그인하세요.",
+ "signedIn": "로그인됨",
+ "notSignedIn": "로그인 안 됨"
}
},
"chat": {
diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json
index f4892278f..a8ef89e30 100644
--- a/webui/src/i18n/locales/vi/common.json
+++ b/webui/src/i18n/locales/vi/common.json
@@ -57,22 +57,22 @@
"apps": "Ứng dụng"
},
"settings": {
- "backToChat": "Quay lại trò chuyện",
+ "backToChat": "Quay lại chat",
"sidebar": {
"title": "Cài đặt",
- "ariaLabel": "Các mục cài đặt"
+ "ariaLabel": "Mục cài đặt"
},
"nav": {
"general": "Chung",
"byok": "BYOK",
- "overview": "Overview",
- "appearance": "Appearance",
- "models": "Models",
- "providers": "Providers",
- "image": "Image",
- "browser": "Web",
+ "overview": "Tổng quan",
+ "appearance": "Giao diện",
+ "models": "Mô hình",
+ "providers": "Nhà cung cấp",
+ "image": "Hình ảnh",
+ "browser": "Trang web",
"runtime": "Hệ thống",
- "advanced": "Security",
+ "advanced": "Bảo mật",
"cliApps": "Ứng dụng CLI",
"mcp": "MCP",
"apps": "Ứng dụng"
@@ -81,122 +81,122 @@
"interface": "Giao diện",
"ai": "AI",
"system": "Hệ thống",
- "status": "Status",
- "localPreferences": "Local preferences",
- "presets": "Presets",
- "imageGeneration": "Tạo ảnh",
+ "status": "Trạng thái",
+ "localPreferences": "Tùy chọn cục bộ",
+ "presets": "Preset",
+ "imageGeneration": "Tạo hình ảnh",
"imageDefaults": "Mặc định",
- "webSearch": "Web search",
- "webBehavior": "Behavior",
- "identity": "Identity",
- "webuiSafety": "Web safety",
+ "webSearch": "Tìm kiếm web",
+ "webBehavior": "Hành vi",
+ "identity": "Danh tính",
+ "webuiSafety": "An toàn WebUI",
"capabilities": "Khả năng",
"cliApps": "Ứng dụng CLI",
"mcp": "Dịch vụ MCP",
"apps": "Ứng dụng",
- "nativeHost": "Native host",
- "hostSafety": "App safety"
+ "nativeHost": "Host gốc",
+ "hostSafety": "An toàn ứng dụng"
},
"rows": {
- "theme": "Giao diện",
+ "theme": "Chủ đề",
"language": "Ngôn ngữ",
"provider": "Nhà cung cấp",
"model": "Mô hình",
"restart": "Khởi động lại nanobot",
"configPath": "Đường dẫn cấu hình",
- "activePreset": "Active preset",
- "gateway": "Gateway",
- "restartState": "Restart state",
- "pendingChanges": "Thay đổi đang chờ",
- "selectedPreset": "Selected preset",
- "presetModel": "Preset model",
- "density": "Density",
- "activityMode": "Activity detail",
- "codeWrap": "Code wrapping",
- "maxResults": "Max results",
- "timeout": "Timeout",
- "jinaReader": "Jina reader",
- "imageGeneration": "Tạo ảnh",
- "imageProvider": "Nhà cung cấp ảnh",
+ "activePreset": "Preset đang dùng",
+ "gateway": "Cổng",
+ "restartState": "Trạng thái khởi động lại",
+ "pendingChanges": "Thay đổi chờ áp dụng",
+ "selectedPreset": "Preset đã chọn",
+ "presetModel": "Mô hình preset",
+ "density": "Mật độ",
+ "activityMode": "Chi tiết hoạt động",
+ "codeWrap": "Xuống dòng mã",
+ "maxResults": "Kết quả tối đa",
+ "timeout": "Thời gian chờ",
+ "jinaReader": "Trình đọc Jina",
+ "imageGeneration": "Tạo hình ảnh",
+ "imageProvider": "Nhà cung cấp hình ảnh",
"imageProviderStatus": "Trạng thái nhà cung cấp",
- "imageProviderBase": "Cơ sở nhà cung cấp",
- "imageModel": "Mô hình ảnh",
+ "imageProviderBase": "Địa chỉ nhà cung cấp",
+ "imageModel": "Mô hình hình ảnh",
"defaultAspectRatio": "Tỷ lệ mặc định",
"defaultImageSize": "Kích thước mặc định",
- "maxImagesPerTurn": "Số ảnh tối đa mỗi lượt",
+ "maxImagesPerTurn": "Ảnh tối đa mỗi lượt",
"imageSaveDir": "Thư mục lưu",
- "botName": "Bot name",
- "botIcon": "Bot icon",
- "timezone": "Timezone",
+ "botName": "Tên bot",
+ "botIcon": "Biểu tượng bot",
+ "timezone": "Múi giờ",
"workspacePath": "Workspace mặc định",
- "localServiceAccess": "Local services",
- "webuiDefaultAccess": "Default access",
+ "localServiceAccess": "Dịch vụ cục bộ",
+ "webuiDefaultAccess": "Quyền mặc định",
"currentModel": "Cấu hình hiện tại",
"brandLogos": "Logo thương hiệu",
- "cliAppsCatalog": "Danh mục ứng dụng CLI",
- "cliAppsFilter": "Bộ lọc ứng dụng CLI",
- "engine": "Engine",
+ "cliAppsCatalog": "Danh mục",
+ "cliAppsFilter": "Bộ lọc",
+ "engine": "Bộ máy",
"logs": "Nhật ký",
"diagnostics": "Chẩn đoán",
- "contextWindow": "Context window"
+ "contextWindow": "Cửa sổ ngữ cảnh"
},
"help": {
"theme": "Chuyển giữa giao diện sáng và tối.",
"language": "Chọn ngôn ngữ dùng trong WebUI.",
- "provider": "Chọn nhà cung cấp cho các yêu cầu mô hình mới.",
- "model": "Đặt tên mô hình mặc định mà nanobot sử dụng.",
- "configPath": "Tệp cấu hình gateway hiện đang dùng.",
- "selectedPreset": "Named presets are read-only here; edit them in config.json.",
- "presetModel": "Switch to Default to edit model and provider from the WebUI.",
- "density": "Stored only in this browser.",
- "activityMode": "Choose how much agent activity chrome to show by default.",
- "codeWrap": "Keep long code lines readable on smaller screens.",
- "maxResults": "Results returned by each web_search call.",
- "timeout": "Seconds before a search provider request times out.",
- "jinaReader": "Use Jina Reader for web_fetch when available.",
- "imageGeneration": "Hiển thị generate_image trong chat khi có nhà cung cấp ảnh đã cấu hình.",
- "imageProvider": "Chọn nhà cung cấp registry được generate_image sử dụng.",
- "imageProviderStatus": "Tạo ảnh dùng lại thông tin xác thực nhà cung cấp từ Providers.",
- "imageModel": "Tên mô hình gửi tới nhà cung cấp ảnh đã chọn.",
- "defaultAspectRatio": "Được dùng khi prompt không chọn tỷ lệ khung hình.",
- "defaultImageSize": "Gợi ý kích thước gửi tới các nhà cung cấp hỗ trợ.",
+ "provider": "Selecciona el proveedor para nuevas solicitudes de modelo.",
+ "model": "Define el nombre de modelo predeterminado de nanobot.",
+ "configPath": "Archivo de configuración que usa actualmente el gateway.",
+ "selectedPreset": "Los preajustes con nombre son de solo lectura aquí; edítalos en config.json.",
+ "presetModel": "Chuyển sang Default để chỉnh sửa mô hình và nhà cung cấp từ WebUI.",
+ "density": "Chỉ lưu trong trình duyệt này.",
+ "activityMode": "Chọn mức chi tiết hoạt động agent hiển thị mặc định.",
+ "codeWrap": "Giữ các dòng mã dài dễ đọc trên màn hình nhỏ.",
+ "maxResults": "Resultados devueltos por cada llamada web_search.",
+ "timeout": "Segundos antes de que una solicitud de búsqueda expire.",
+ "jinaReader": "Usa Jina Reader para web_fetch cuando esté disponible.",
+ "imageGeneration": "Expone generate_image en chats cuando hay un proveedor de imagen configurado.",
+ "imageProvider": "Elige el proveedor registrado usado por generate_image.",
+ "imageProviderStatus": "La generación de imágenes reutiliza credenciales de Proveedores.",
+ "imageModel": "Nombre del modelo enviado al proveedor de imágenes seleccionado.",
+ "defaultAspectRatio": "Se usa cuando el prompt no elige una proporción.",
+ "defaultImageSize": "Gợi ý kích thước gửi tới nhà cung cấp hỗ trợ.",
"maxImagesPerTurn": "Giới hạn trên cho một yêu cầu generate_image.",
- "botName": "Hiển thị ở nơi nanobot dùng tên hiển thị.",
- "botIcon": "Emoji hoặc văn bản ngắn hiển thị cùng tên bot.",
- "timezone": "Dùng cho lịch hẹn và câu trả lời có yếu tố thời gian.",
- "localServiceAccess": "Allow Full Access shell commands to reach localhost services.",
- "webuiDefaultAccess": "Used by web chats without a project-specific permission.",
- "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
- "currentModel": "Chọn cấu hình mô hình nanobot dùng cho các câu trả lời mới.",
- "selectedModelProvider": "Được đặt bởi mô hình đã chọn.",
- "selectedModelValue": "Được đặt bởi mô hình đã chọn.",
- "brandLogos": "Logo được tải từ tên miền thương hiệu, có biểu tượng cục bộ làm dự phòng.",
- "cliAppsCatalog": "Duyệt các CLI ứng dụng mà nanobot có thể chạy cục bộ.",
- "cliAppsFilter": "Tìm theo ứng dụng, danh mục hoặc khả năng.",
- "logs": "Mở thư mục nhật ký native engine.",
- "diagnostics": "Xuất báo cáo runtime nhỏ để hỗ trợ.",
- "localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
- "webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
- "contextWindow": "Choose the default context budget for this model configuration."
+ "botName": "Se muestra donde nanobot usa un nombre visible.",
+ "botIcon": "Emoji o texto corto junto al nombre del bot.",
+ "timezone": "Se usa para horarios y respuestas con conciencia temporal.",
+ "localServiceAccess": "Cho phép lệnh shell Full Access truy cập dịch vụ localhost.",
+ "webuiDefaultAccess": "Dùng cho chat web không có quyền riêng theo dự án.",
+ "securityManagedControls": "Las capturas web siempre protegen servicios locales, privados y metadata. La seguridad de canales core se gestiona en config.json.",
+ "currentModel": "Dùng cho các phản hồi mới.",
+ "selectedModelProvider": "Definido por el modelo seleccionado.",
+ "selectedModelValue": "Definido por el modelo seleccionado.",
+ "brandLogos": "Hiển thị logo nhà cung cấp bên thứ ba và CLI trong Cài đặt.",
+ "cliAppsCatalog": "Instala solo adaptadores CLI de apps que nanobot puede ejecutar localmente; las apps nativas no se modifican.",
+ "cliAppsFilter": "Busca por app, categoría o capacidad.",
+ "logs": "Abre la carpeta de registros del motor nativo.",
+ "diagnostics": "Exporta un pequeño informe de runtime para soporte.",
+ "localServiceAccessNative": "Permite que comandos shell con Full Access alcancen servicios en este Mac.",
+ "webuiDefaultAccessNative": "Usado por chats nativos sin permiso específico de proyecto.",
+ "contextWindow": "Chọn ngân sách ngữ cảnh mặc định cho cấu hình mô hình này."
},
"values": {
"light": "Sáng",
"dark": "Tối",
"notAvailable": "Không khả dụng",
- "enabled": "Enabled",
- "disabled": "Disabled",
- "restartPending": "Đang chờ khởi động lại",
+ "enabled": "Đã bật",
+ "disabled": "Đã tắt",
+ "restartPending": "Chờ khởi động lại",
"ready": "Sẵn sàng",
- "comfortable": "Comfortable",
- "compact": "Compact",
- "auto": "Auto",
- "expanded": "Expanded",
- "on": "On",
- "off": "Off",
- "defaultPermission": "Default Permission",
- "fullAccess": "Full Access",
- "configured": "Configured",
- "notConfigured": "Not configured",
+ "comfortable": "Thoải mái",
+ "compact": "Gọn",
+ "auto": "Tự động",
+ "expanded": "Mở rộng",
+ "on": "Bật",
+ "off": "Tắt",
+ "defaultPermission": "Quyền mặc định",
+ "fullAccess": "Toàn quyền",
+ "configured": "Đã cấu hình",
+ "notConfigured": "Chưa cấu hình",
"pending": "Đang chờ",
"restartingEngine": "Đang khởi động lại"
},
@@ -205,17 +205,17 @@
"loadError": "Không thể tải cài đặt",
"unsaved": "Có thay đổi chưa lưu.",
"upToDate": "Đã cập nhật.",
- "savedRestart": "Đã lưu. Khởi động lại nanobot để áp dụng.",
- "restartAfterSaving": "Lưu thay đổi, rồi khởi động lại khi sẵn sàng.",
- "savedRestartApply": "Đã lưu. Khởi động lại khi sẵn sàng.",
- "imageProviderRestart": "Đã lưu thay đổi nhà cung cấp ảnh. Khởi động lại khi sẵn sàng.",
- "hostRestartAfterSaving": "Lưu thay đổi và nanobot sẽ khởi động lại engine.",
- "hostRestartPending": "Đã lưu. Sẽ khởi động lại engine khi sẵn sàng.",
- "hostApiUnavailable": "Host actions are only available inside the native app.",
- "logsOpened": "Opened logs folder.",
- "logsOpenFailed": "Could not open logs folder.",
- "diagnosticsExported": "Diagnostics exported to {{path}}.",
- "diagnosticsExportFailed": "Could not export diagnostics."
+ "savedRestart": "Guardado. Reinicia nanobot para aplicar.",
+ "restartAfterSaving": "Guarda los cambios y reinicia cuando puedas.",
+ "savedRestartApply": "Guardado. Reinicia cuando puedas.",
+ "imageProviderRestart": "Cambios del proveedor de imagen guardados. Reinicia cuando puedas.",
+ "hostRestartAfterSaving": "Al guardar, nanobot reiniciará su motor.",
+ "hostRestartPending": "Guardado. El motor se reiniciará cuando esté listo.",
+ "hostApiUnavailable": "Las acciones del host solo están disponibles en la app nativa.",
+ "logsOpened": "Carpeta de registros abierta.",
+ "logsOpenFailed": "No se pudo abrir la carpeta de registros.",
+ "diagnosticsExported": "Diagnóstico exportado a {{path}}.",
+ "diagnosticsExportFailed": "No se pudo exportar el diagnóstico."
},
"actions": {
"save": "Lưu",
@@ -224,8 +224,8 @@
"cancel": "Hủy",
"open": "Mở",
"export": "Xuất",
- "opening": "Opening...",
- "exporting": "Exporting..."
+ "opening": "Đang mở...",
+ "exporting": "Đang xuất..."
},
"byok": {
"description": "Dùng key provider của riêng bạn. Nanobot đọc các giá trị này từ config hiện tại, và chỉ provider đã cấu hình mới có thể chọn trong Chung.",
@@ -250,10 +250,10 @@
"tabs": {
"ariaLabel": "Loại thông tin xác thực BYOK",
"llm": "LLM",
- "webSearch": "Web Search"
+ "webSearch": "Tìm kiếm web"
},
"webSearch": {
- "provider": "Search provider",
+ "provider": "Nhà cung cấp tìm kiếm",
"providerHelp": "Chọn backend mà công cụ web search sẽ dùng.",
"selectProvider": "Chọn provider",
"credentials": "Thông tin xác thực",
@@ -270,17 +270,17 @@
}
},
"overview": {
- "model": "Current model",
- "providers": "Providers",
- "configuredCount": "{{count}} configured",
- "totalProviders": "{{count}} available",
- "webSearch": "Web search",
- "imageGeneration": "Tạo ảnh",
- "workspace": "Workspace"
+ "model": "Mô hình hiện tại",
+ "providers": "Nhà cung cấp",
+ "configuredCount": "{{count}} đã cấu hình",
+ "totalProviders": "{{count}} khả dụng",
+ "webSearch": "Tìm kiếm web",
+ "imageGeneration": "Tạo hình ảnh",
+ "workspace": "Không gian làm việc"
},
"providers": {
- "searchPlaceholder": "Search providers",
- "noMatches": "No providers match this search.",
+ "searchPlaceholder": "Tìm nhà cung cấp",
+ "noMatches": "Không có nhà cung cấp phù hợp.",
"saveProvider": "Lưu nhà cung cấp"
},
"image": {
@@ -288,17 +288,17 @@
"selectAspect": "Chọn tỷ lệ",
"selectSize": "Chọn kích thước",
"configureProvider": "Cấu hình nhà cung cấp",
- "missingCredential": "Cấu hình nhà cung cấp này trước khi bật tạo ảnh."
+ "missingCredential": "Configura este proveedor antes de activar la generación de imágenes."
},
"models": {
"selectModel": "Chọn mô hình",
"addConfiguration": "Thêm cấu hình",
"newConfiguration": "Cấu hình mô hình mới",
- "newConfigurationHelp": "Lưu nhà cung cấp và mô hình thành một lựa chọn một lần nhấp.",
+ "newConfigurationHelp": "Lưu nhà cung cấp và mô hình thành một lựa chọn nhanh.",
"configurationName": "Tên cấu hình",
"configurationNameHelp": "Đổi tên cấu hình mô hình đã lưu này.",
"configurationNamePlaceholder": "Viết nhanh",
- "searchModels": "Tìm hoặc nhập ID mô hình",
+ "searchModels": "Tìm kiếm hoặc nhập ID mô hình",
"useCustomModel": "Dùng",
"loadingModels": "Đang tải mô hình...",
"searchCatalog": "Tìm trong danh mục nhà cung cấp để chọn mô hình.",
@@ -306,7 +306,7 @@
"noModelResults": "Không có mô hình phù hợp.",
"loadFailed": "Không tải được danh sách mô hình.",
"unsupportedModelList": "Nhập ID mô hình thủ công.",
- "providerNotConfigured": "Cấu hình nhà cung cấp này trước khi tải mô hình.",
+ "providerNotConfigured": "Hãy cấu hình nhà cung cấp này trước khi tải mô hình.",
"autoProviderCustomOnly": "Chế độ nhà cung cấp tự động dùng ID mô hình tùy chỉnh."
},
"timezone": {
@@ -402,7 +402,7 @@
"thirdPartyBrands": "Tên sản phẩm, logo và thương hiệu thuộc về chủ sở hữu tương ứng. Việc sử dụng chỉ nhằm nhận diện và không ngụ ý được xác nhận."
},
"apps": {
- "description": "Thêm CLI ứng dụng và dịch vụ MCP để nanobot dùng trong trò chuyện.",
+ "description": "Thêm CLI ứng dụng và dịch vụ MCP mà nanobot có thể dùng trong chat.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "Tất cả",
@@ -413,20 +413,20 @@
"searchPlaceholder": "Tìm ứng dụng",
"featured": "Nổi bật",
"loading": "Đang tải ứng dụng...",
- "empty": "Không có ứng dụng nào khớp với bộ lọc này."
+ "empty": "Không có ứng dụng phù hợp."
},
"oauth": {
- "authentication": "OAuth authentication",
- "signIn": "Sign in",
- "signingIn": "Signing in...",
- "signInAgain": "Sign in again",
- "signOut": "Sign out",
- "signedInAs": "Signed in as {{account}}",
- "signInHelp": "Sign in from this device; no API key is stored in config.",
- "signInRequired": "Sign in required",
- "signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
- "signedIn": "Signed in",
- "notSignedIn": "Not signed in"
+ "authentication": "Xác thực OAuth",
+ "signIn": "Đăng nhập",
+ "signingIn": "Đang đăng nhập...",
+ "signInAgain": "Đăng nhập lại",
+ "signOut": "Đăng xuất",
+ "signedInAs": "Đã đăng nhập bằng {{account}}",
+ "signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
+ "signInRequired": "Cần đăng nhập",
+ "signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
+ "signedIn": "Đã đăng nhập",
+ "notSignedIn": "Chưa đăng nhập"
}
},
"chat": {
diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json
index b16ea423f..b8efdd686 100644
--- a/webui/src/i18n/locales/zh-CN/common.json
+++ b/webui/src/i18n/locales/zh-CN/common.json
@@ -57,7 +57,7 @@
"apps": "应用"
},
"settings": {
- "backToChat": "返回对话",
+ "backToChat": "返回聊天",
"sidebar": {
"title": "设置",
"ariaLabel": "设置分区"
@@ -91,30 +91,30 @@
"cliApps": "CLI 应用",
"mcp": "MCP 服务",
"identity": "身份",
- "webuiSafety": "网页端安全",
+ "webuiSafety": "WebUI 安全",
"capabilities": "能力",
"apps": "应用",
- "nativeHost": "App",
- "hostSafety": "App 安全"
+ "nativeHost": "原生宿主",
+ "hostSafety": "应用安全"
},
"models": {
"selectModel": "选择模型",
"addConfiguration": "添加配置",
"newConfiguration": "新建模型配置",
- "newConfigurationHelp": "把服务商和模型保存为一个可直接切换的选项。",
+ "newConfigurationHelp": "把提供商和模型保存成一键选项。",
"configurationName": "配置名称",
"configurationNameHelp": "重命名这个已保存的模型配置。",
"configurationNamePlaceholder": "快速写作",
"searchModels": "搜索或输入模型 ID",
"useCustomModel": "使用",
"loadingModels": "正在加载模型...",
- "searchCatalog": "搜索服务商目录来选择模型。",
- "modelsAvailable": "个可用",
+ "searchCatalog": "搜索提供商目录来选择模型。",
+ "modelsAvailable": "可用",
"noModelResults": "没有匹配的模型。",
- "loadFailed": "模型列表暂不可用。",
+ "loadFailed": "模型列表不可用。",
"unsupportedModelList": "手动输入模型 ID。",
- "providerNotConfigured": "先配置这个服务商再加载模型。",
- "autoProviderCustomOnly": "自动服务商模式使用自定义模型 ID。"
+ "providerNotConfigured": "加载模型前请先配置此提供商。",
+ "autoProviderCustomOnly": "自动提供商模式使用自定义模型 ID。"
},
"rows": {
"theme": "主题",
@@ -126,25 +126,25 @@
"activePreset": "当前预设",
"gateway": "网关",
"restartState": "重启状态",
- "pendingChanges": "待处理更改",
+ "pendingChanges": "待应用更改",
"currentModel": "当前配置",
"selectedPreset": "选中的预设",
"presetModel": "预设模型",
"density": "密度",
- "activityMode": "活动细节",
+ "activityMode": "活动详情",
"codeWrap": "代码换行",
"brandLogos": "品牌 Logo",
"maxResults": "最大结果数",
"timeout": "超时",
- "jinaReader": "Jina Reader",
+ "jinaReader": "Jina 阅读器",
"imageGeneration": "图片生成",
- "imageProvider": "图片服务商",
- "imageProviderStatus": "服务商状态",
- "imageProviderBase": "服务商地址",
+ "imageProvider": "图片提供商",
+ "imageProviderStatus": "提供商状态",
+ "imageProviderBase": "提供商地址",
"imageModel": "图片模型",
"defaultAspectRatio": "默认比例",
"defaultImageSize": "默认尺寸",
- "maxImagesPerTurn": "每轮最多图片数",
+ "maxImagesPerTurn": "每轮最大图片数",
"imageSaveDir": "保存目录",
"botName": "Bot 名称",
"botIcon": "Bot 图标",
@@ -162,41 +162,41 @@
"help": {
"theme": "在浅色和深色外观之间切换。",
"language": "选择 WebUI 使用的语言。",
- "provider": "选择新模型请求使用的服务商。",
+ "provider": "选择处理新模型请求的提供商。",
"model": "设置 nanobot 默认使用的模型名称。",
"configPath": "当前网关正在使用的配置文件。",
- "currentModel": "选择 nanobot 接下来回复时使用的模型配置。",
- "selectedModelProvider": "由当前模型决定。",
- "selectedModelValue": "由当前模型决定。",
- "selectedPreset": "命名预设在这里只读;需要编辑时请改 config.json。",
- "presetModel": "切回 Default 后可在 WebUI 编辑模型和服务商。",
- "density": "仅保存在当前浏览器。",
+ "currentModel": "用于新的回复。",
+ "selectedModelProvider": "由选中的模型决定。",
+ "selectedModelValue": "由选中的模型决定。",
+ "selectedPreset": "命名预设在这里只读;请在 config.json 中编辑。",
+ "presetModel": "切回 Default 后可在 WebUI 中编辑模型和提供商。",
+ "density": "只保存在此浏览器中。",
"activityMode": "选择默认显示多少 agent 活动细节。",
- "codeWrap": "让较小屏幕上的长代码行更易读。",
- "brandLogos": "在设置页显示第三方服务商和 CLI 的 Logo。",
- "maxResults": "每次 web_search 返回的结果数量。",
- "timeout": "搜索服务商请求超时秒数。",
+ "codeWrap": "让长代码行在小屏幕上也易读。",
+ "brandLogos": "在设置中显示第三方提供商和 CLI 图标。",
+ "maxResults": "每次 web_search 调用返回的结果数。",
+ "timeout": "搜索提供商请求超时前的秒数。",
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
- "imageGeneration": "当已配置图片服务商时,在对话中开放 generate_image。",
- "imageProvider": "选择 generate_image 使用的服务商。",
- "imageProviderStatus": "图片生成复用服务商页里的凭证配置。",
- "imageModel": "发送给所选图片服务商的模型名称。",
- "defaultAspectRatio": "当提示词没有选择比例时使用。",
- "defaultImageSize": "发送给支持该能力的服务商的尺寸提示。",
- "maxImagesPerTurn": "单次 generate_image 请求允许的图片上限。",
- "botName": "显示在 nanobot 使用名称的地方。",
- "botIcon": "显示在 bot 名称旁的短 emoji 或文本。",
- "timezone": "用于计划任务和需要时间感知的回复。",
- "cliAppsCatalog": "只安装 nanobot 在本机调用应用时需要的 CLI 适配层,不触碰应用本体。",
- "cliAppsFilter": "按应用、分类或能力搜索。",
- "localServiceAccess": "允许完全访问模式下的 shell 命令访问 localhost 服务。",
- "webuiDefaultAccess": "用于没有单独选择权限的网页端对话。",
+ "imageGeneration": "配置图片提供商后,在聊天中开放 generate_image。",
+ "imageProvider": "选择 generate_image 使用的注册提供商。",
+ "imageProviderStatus": "图片生成会复用「提供商」里的凭据。",
+ "imageModel": "发送给所选图片提供商的模型名称。",
+ "defaultAspectRatio": "当提示词没有指定比例时使用。",
+ "defaultImageSize": "发送给支持此选项的提供商的尺寸提示。",
+ "maxImagesPerTurn": "单次 generate_image 请求可生成的图片上限。",
+ "botName": "显示在 nanobot 使用展示名称的地方。",
+ "botIcon": "显示在 Bot 名称旁的短 emoji 或文字。",
+ "timezone": "用于日程和需要时间感知的回复。",
+ "cliAppsCatalog": "只安装 nanobot 可本地运行的应用 CLI 适配器;不会改动原生应用。",
+ "cliAppsFilter": "按应用、类别或能力搜索。",
+ "localServiceAccess": "允许完全访问权限下的 shell 命令访问 localhost 服务。",
+ "webuiDefaultAccess": "用于没有单独项目权限的 Web 聊天。",
"securityManagedControls": "网页抓取始终保护本机、内网和元数据服务。核心渠道安全仍由 config.json 管理。",
- "logs": "打开App引擎日志文件夹。",
- "diagnostics": "导出一份用于支持排查的运行时报告。",
- "localServiceAccessNative": "允许完全访问模式下的 shell 命令访问这台 Mac 上的服务。",
- "webuiDefaultAccessNative": "用于没有单独选择权限的原生 App 对话。",
- "contextWindow": "选择这个模型配置默认使用的上下文预算。"
+ "logs": "打开原生引擎日志文件夹。",
+ "diagnostics": "导出一份用于支持排查的小型运行报告。",
+ "localServiceAccessNative": "允许完全访问权限下的 shell 命令访问这台 Mac 上的服务。",
+ "webuiDefaultAccessNative": "用于没有单独项目权限的原生聊天。",
+ "contextWindow": "选择此模型配置的默认上下文预算。"
},
"timezone": {
"select": "选择时区",
@@ -251,9 +251,9 @@
"serverUrl": "URL",
"transport": "传输方式",
"command": "命令",
- "args": "参数 JSON",
+ "args": "Args JSON",
"headers": "Headers JSON",
- "env": "环境变量 JSON",
+ "env": "Env JSON",
"timeout": "工具超时",
"advancedOptions": "高级选项",
"hideAdvanced": "收起高级",
@@ -299,13 +299,13 @@
"compact": "紧凑",
"auto": "自动",
"expanded": "展开",
- "on": "开",
- "off": "关",
+ "on": "开启",
+ "off": "关闭",
"defaultPermission": "默认权限",
- "fullAccess": "完全访问",
+ "fullAccess": "完全访问权限",
"configured": "已配置",
"notConfigured": "未配置",
- "pending": "待应用",
+ "pending": "等待中",
"restartingEngine": "正在重启"
},
"status": {
@@ -314,12 +314,12 @@
"unsaved": "有未保存的更改。",
"upToDate": "已是最新。",
"savedRestart": "已保存。重启 nanobot 后生效。",
- "restartAfterSaving": "保存后,可在合适时重启。",
- "savedRestartApply": "已保存,可稍后重启。",
- "imageProviderRestart": "图片服务商改动已保存,可稍后重启。",
- "hostRestartAfterSaving": "保存后,nanobot 会自动重启引擎。",
- "hostRestartPending": "已保存,将在合适时重启引擎。",
- "hostApiUnavailable": "宿主操作只能在原生 App 内使用。",
+ "restartAfterSaving": "保存更改后,在合适时重启。",
+ "savedRestartApply": "已保存。准备好后重启。",
+ "imageProviderRestart": "图片提供商更改已保存。准备好后重启。",
+ "hostRestartAfterSaving": "保存后 nanobot 会重启引擎。",
+ "hostRestartPending": "已保存。准备好后会重启引擎。",
+ "hostApiUnavailable": "宿主操作仅在原生应用中可用。",
"logsOpened": "已打开日志文件夹。",
"logsOpenFailed": "无法打开日志文件夹。",
"diagnosticsExported": "诊断已导出到 {{path}}。",
@@ -327,13 +327,13 @@
},
"actions": {
"save": "保存",
- "saving": "保存中",
+ "saving": "正在保存",
"edit": "编辑",
"cancel": "取消",
"open": "打开",
"export": "导出",
- "opening": "打开中...",
- "exporting": "导出中..."
+ "opening": "正在打开...",
+ "exporting": "正在导出..."
},
"byok": {
"description": "自带服务商密钥。Nanobot 会从当前 config 读取这些值,只有已配置的服务商才能在通用设置里选择。",
@@ -387,19 +387,19 @@
"workspace": "工作区"
},
"providers": {
- "searchPlaceholder": "搜索服务商",
- "noMatches": "没有匹配的服务商。",
- "saveProvider": "保存服务商"
+ "searchPlaceholder": "搜索提供商",
+ "noMatches": "没有匹配的提供商。",
+ "saveProvider": "保存提供商"
},
"legal": {
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
},
"image": {
- "selectProvider": "选择服务商",
+ "selectProvider": "选择提供商",
"selectAspect": "选择比例",
"selectSize": "选择尺寸",
- "configureProvider": "配置服务商",
- "missingCredential": "启用图片生成前,请先配置这个服务商。"
+ "configureProvider": "配置提供商",
+ "missingCredential": "启用图片生成前请先配置此提供商。"
},
"apps": {
"description": "添加 nanobot 可在聊天中使用的 App CLI 和 MCP 服务。",
@@ -409,22 +409,22 @@
"filterCli": "CLI 应用",
"filterMcp": "MCP 服务",
"enabledSummary": "已启用 {{count}} 个",
- "caption": "{{cli}} CLI · {{mcp}} MCP",
+ "caption": "{{cli}} 个 CLI · {{mcp}} 个 MCP",
"searchPlaceholder": "搜索应用",
"featured": "精选",
"loading": "正在加载应用...",
- "empty": "没有符合筛选条件的应用。"
+ "empty": "没有匹配的应用。"
},
"oauth": {
"authentication": "OAuth 认证",
"signIn": "登录",
- "signingIn": "正在登录…",
+ "signingIn": "登录中...",
"signInAgain": "重新登录",
"signOut": "退出登录",
"signedInAs": "已登录为 {{account}}",
- "signInHelp": "在这台设备上登录;不会把 API key 写入配置。",
+ "signInHelp": "从这台设备登录;不会在配置中保存 API key。",
"signInRequired": "需要登录",
- "signInBeforeSaving": "先登录这个 OAuth 提供商,然后再保存为当前模型提供商。",
+ "signInBeforeSaving": "将此 OAuth 提供商设为当前模型提供商前,请先登录。",
"signedIn": "已登录",
"notSignedIn": "未登录"
}
diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json
index e567363a6..810a938e1 100644
--- a/webui/src/i18n/locales/zh-TW/common.json
+++ b/webui/src/i18n/locales/zh-TW/common.json
@@ -57,7 +57,7 @@
"apps": "應用"
},
"settings": {
- "backToChat": "返回對話",
+ "backToChat": "返回聊天",
"sidebar": {
"title": "設定",
"ariaLabel": "設定分區"
@@ -65,14 +65,14 @@
"nav": {
"general": "一般",
"byok": "BYOK",
- "overview": "Overview",
- "appearance": "Appearance",
- "models": "Models",
- "providers": "Providers",
- "image": "Image",
- "browser": "Web",
+ "overview": "概覽",
+ "appearance": "外觀",
+ "models": "模型",
+ "providers": "提供商",
+ "image": "圖片",
+ "browser": "網頁",
"runtime": "系統",
- "advanced": "Security",
+ "advanced": "安全",
"cliApps": "CLI 應用",
"mcp": "MCP",
"apps": "應用"
@@ -81,60 +81,60 @@
"interface": "介面",
"ai": "AI",
"system": "系統",
- "status": "Status",
- "localPreferences": "Local preferences",
- "presets": "Presets",
+ "status": "狀態",
+ "localPreferences": "本機偏好",
+ "presets": "預設",
"imageGeneration": "圖片生成",
"imageDefaults": "預設值",
- "webSearch": "Web search",
- "webBehavior": "Behavior",
- "identity": "Identity",
- "webuiSafety": "網頁端安全",
- "capabilities": "功能",
+ "webSearch": "網頁搜尋",
+ "webBehavior": "行為",
+ "identity": "身分",
+ "webuiSafety": "WebUI 安全",
+ "capabilities": "能力",
"cliApps": "CLI 應用",
"mcp": "MCP 服務",
"apps": "應用",
- "nativeHost": "App",
+ "nativeHost": "原生宿主",
"hostSafety": "App 安全"
},
"rows": {
"theme": "主題",
"language": "語言",
- "provider": "提供者",
+ "provider": "供應商",
"model": "模型",
"restart": "重新啟動 nanobot",
- "configPath": "設定檔路徑",
- "activePreset": "Active preset",
- "gateway": "Gateway",
- "restartState": "Restart state",
- "pendingChanges": "待處理變更",
- "selectedPreset": "Selected preset",
- "presetModel": "Preset model",
- "density": "Density",
- "activityMode": "Activity detail",
- "codeWrap": "Code wrapping",
- "maxResults": "Max results",
- "timeout": "Timeout",
- "jinaReader": "Jina reader",
+ "configPath": "配置路徑",
+ "activePreset": "目前預設",
+ "gateway": "閘道",
+ "restartState": "重啟狀態",
+ "pendingChanges": "待套用更改",
+ "selectedPreset": "已選預設",
+ "presetModel": "預設模型",
+ "density": "密度",
+ "activityMode": "活動細節",
+ "codeWrap": "程式碼換行",
+ "maxResults": "最大結果數",
+ "timeout": "逾時",
+ "jinaReader": "Jina 閱讀器",
"imageGeneration": "圖片生成",
- "imageProvider": "圖片服務商",
- "imageProviderStatus": "服務商狀態",
- "imageProviderBase": "服務商位址",
+ "imageProvider": "圖片供應商",
+ "imageProviderStatus": "供應商狀態",
+ "imageProviderBase": "供應商位址",
"imageModel": "圖片模型",
"defaultAspectRatio": "預設比例",
"defaultImageSize": "預設尺寸",
- "maxImagesPerTurn": "每輪最多圖片數",
+ "maxImagesPerTurn": "每輪最大圖片數",
"imageSaveDir": "儲存目錄",
- "botName": "Bot name",
- "botIcon": "Bot icon",
- "timezone": "Timezone",
+ "botName": "Bot 名稱",
+ "botIcon": "Bot 圖示",
+ "timezone": "時區",
"workspacePath": "預設工作區",
- "localServiceAccess": "Local services",
- "webuiDefaultAccess": "Default access",
+ "localServiceAccess": "本機服務",
+ "webuiDefaultAccess": "預設權限",
"currentModel": "目前設定",
- "brandLogos": "品牌標誌",
- "cliAppsCatalog": "CLI 應用目錄",
- "cliAppsFilter": "CLI 應用篩選",
+ "brandLogos": "品牌 Logo",
+ "cliAppsCatalog": "目錄",
+ "cliAppsFilter": "篩選",
"engine": "引擎",
"logs": "日誌",
"diagnostics": "診斷",
@@ -143,75 +143,75 @@
"help": {
"theme": "在淺色與深色外觀之間切換。",
"language": "選擇 WebUI 使用的語言。",
- "provider": "選擇新模型請求使用的服務提供者。",
+ "provider": "選擇處理新模型請求的供應商。",
"model": "設定 nanobot 預設使用的模型名稱。",
- "configPath": "目前閘道正在使用的設定檔。",
- "selectedPreset": "Named presets are read-only here; edit them in config.json.",
- "presetModel": "Switch to Default to edit model and provider from the WebUI.",
- "density": "Stored only in this browser.",
- "activityMode": "Choose how much agent activity chrome to show by default.",
- "codeWrap": "Keep long code lines readable on smaller screens.",
- "maxResults": "Results returned by each web_search call.",
- "timeout": "Seconds before a search provider request times out.",
- "jinaReader": "Use Jina Reader for web_fetch when available.",
- "imageGeneration": "當已設定圖片服務商時,在對話中開放 generate_image。",
- "imageProvider": "選擇 generate_image 使用的登錄服務商。",
- "imageProviderStatus": "圖片生成會重用「服務商」中的憑證。",
- "imageModel": "傳送給所選圖片服務商的模型名稱。",
- "defaultAspectRatio": "當提示詞未指定長寬比時使用。",
- "defaultImageSize": "傳送給支援此功能的服務商的尺寸提示。",
- "maxImagesPerTurn": "單次 generate_image 請求的上限。",
- "botName": "顯示在 nanobot 使用名稱的地方。",
- "botIcon": "顯示在 bot 名稱旁的短 emoji 或文字。",
- "timezone": "用於排程與需要時間感知的回覆。",
- "localServiceAccess": "允許完全存取模式下的 shell 命令存取 localhost 服務。",
- "webuiDefaultAccess": "用於沒有單獨選擇權限的網頁端對話。",
- "securityManagedControls": "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
- "currentModel": "選擇 nanobot 接下來回覆時使用的模型設定。",
- "selectedModelProvider": "由目前模型決定。",
- "selectedModelValue": "由目前模型決定。",
- "brandLogos": "標誌會從品牌網域載入,並提供本地圖示作為備援。",
- "cliAppsCatalog": "瀏覽 nanobot 可在本機執行的應用 CLI。",
- "cliAppsFilter": "按應用、分類或能力搜尋。",
- "logs": "開啟App引擎日誌資料夾。",
- "diagnostics": "匯出一份供支援排查用的執行階段報告。",
- "localServiceAccessNative": "允許完全存取模式下的 shell 命令存取這台 Mac 上的服務。",
- "webuiDefaultAccessNative": "用於沒有單獨選擇權限的原生 App 對話。",
- "contextWindow": "選擇這個模型設定預設使用的上下文預算。"
+ "configPath": "目前閘道使用中的配置檔。",
+ "selectedPreset": "命名預設在此為唯讀;請在 config.json 中編輯。",
+ "presetModel": "切回 Default 後可在 WebUI 中編輯模型與供應商。",
+ "density": "只儲存在此瀏覽器中。",
+ "activityMode": "選擇預設顯示多少 agent 活動細節。",
+ "codeWrap": "讓長程式碼行在小螢幕上也易讀。",
+ "maxResults": "每次 web_search 呼叫返回的結果數。",
+ "timeout": "搜尋供應商請求逾時前的秒數。",
+ "jinaReader": "可用時為 web_fetch 使用 Jina Reader。",
+ "imageGeneration": "配置圖片供應商後,在聊天中開放 generate_image。",
+ "imageProvider": "選擇 generate_image 使用的註冊供應商。",
+ "imageProviderStatus": "圖片生成會重用「供應商」中的憑證。",
+ "imageModel": "傳送給所選圖片供應商的模型名稱。",
+ "defaultAspectRatio": "提示詞未指定比例時使用。",
+ "defaultImageSize": "傳送給支援此選項的供應商的尺寸提示。",
+ "maxImagesPerTurn": "單次 generate_image 請求可生成的圖片上限。",
+ "botName": "顯示在 nanobot 使用顯示名稱的位置。",
+ "botIcon": "顯示在 Bot 名稱旁的短 emoji 或文字。",
+ "timezone": "用於排程和需要時間感知的回覆。",
+ "localServiceAccess": "允許完全訪問權限下的 shell 命令訪問 localhost 服務。",
+ "webuiDefaultAccess": "用於沒有單獨專案權限的 Web 聊天。",
+ "securityManagedControls": "網頁抓取始終保護本機、內網和 metadata 服務。核心渠道安全仍由 config.json 管理。",
+ "currentModel": "用於新的回覆。",
+ "selectedModelProvider": "由選取的模型決定。",
+ "selectedModelValue": "由選取的模型決定。",
+ "brandLogos": "在設定中顯示第三方提供商和 CLI 圖示。",
+ "cliAppsCatalog": "只安裝 nanobot 可在本機執行的應用 CLI 轉接器;不會改動原生應用。",
+ "cliAppsFilter": "按應用、類別或能力搜尋。",
+ "logs": "開啟原生引擎日誌資料夾。",
+ "diagnostics": "匯出一份用於支援排查的小型執行報告。",
+ "localServiceAccessNative": "允許完全訪問權限下的 shell 命令訪問這台 Mac 上的服務。",
+ "webuiDefaultAccessNative": "用於沒有單獨專案權限的原生聊天。",
+ "contextWindow": "選擇此模型配置的預設上下文預算。"
},
"values": {
"light": "淺色",
"dark": "深色",
"notAvailable": "不可用",
- "enabled": "Enabled",
- "disabled": "Disabled",
- "restartPending": "等待重新啟動",
+ "enabled": "已啟用",
+ "disabled": "已停用",
+ "restartPending": "等待重啟",
"ready": "就緒",
- "comfortable": "Comfortable",
- "compact": "Compact",
- "auto": "Auto",
- "expanded": "Expanded",
- "on": "On",
- "off": "Off",
- "defaultPermission": "Default Permission",
- "fullAccess": "Full Access",
- "configured": "Configured",
- "notConfigured": "Not configured",
- "pending": "待套用",
- "restartingEngine": "正在重新啟動"
+ "comfortable": "舒適",
+ "compact": "緊湊",
+ "auto": "自動",
+ "expanded": "展開",
+ "on": "開啟",
+ "off": "關閉",
+ "defaultPermission": "預設權限",
+ "fullAccess": "完全訪問權限",
+ "configured": "已配置",
+ "notConfigured": "未配置",
+ "pending": "等待中",
+ "restartingEngine": "正在重啟"
},
"status": {
"loading": "正在載入設定...",
"loadError": "無法載入設定",
- "unsaved": "有未儲存的變更。",
+ "unsaved": "有未儲存的更改。",
"upToDate": "已是最新。",
"savedRestart": "已儲存。重新啟動 nanobot 後生效。",
- "restartAfterSaving": "儲存變更後,可在準備好時重新啟動。",
- "savedRestartApply": "已儲存,可在準備好時重新啟動。",
- "imageProviderRestart": "圖片服務商變更已儲存,可在準備好時重新啟動。",
- "hostRestartAfterSaving": "儲存後,nanobot 會自動重新啟動引擎。",
- "hostRestartPending": "已儲存,將在適當時重新啟動引擎。",
- "hostApiUnavailable": "宿主操作只能在原生 App 內使用。",
+ "restartAfterSaving": "儲存更改後,在合適時重啟。",
+ "savedRestartApply": "已儲存。準備好後重啟。",
+ "imageProviderRestart": "圖片供應商更改已儲存。準備好後重啟。",
+ "hostRestartAfterSaving": "儲存後 nanobot 會重啟引擎。",
+ "hostRestartPending": "已儲存。準備好後會重啟引擎。",
+ "hostApiUnavailable": "宿主操作僅在原生應用中可用。",
"logsOpened": "已開啟日誌資料夾。",
"logsOpenFailed": "無法開啟日誌資料夾。",
"diagnosticsExported": "診斷已匯出到 {{path}}。",
@@ -219,13 +219,13 @@
},
"actions": {
"save": "儲存",
- "saving": "儲存中",
+ "saving": "正在儲存",
"edit": "編輯",
"cancel": "取消",
"open": "開啟",
"export": "匯出",
- "opening": "開啟中...",
- "exporting": "匯出中..."
+ "opening": "正在開啟...",
+ "exporting": "正在匯出..."
},
"byok": {
"description": "自帶 provider key。Nanobot 會從目前 config 讀取這些值,只有已設定的 provider 才能在一般設定中選擇。",
@@ -250,7 +250,7 @@
"tabs": {
"ariaLabel": "BYOK 憑證類型",
"llm": "LLM",
- "webSearch": "Web Search"
+ "webSearch": "網頁搜尋"
},
"webSearch": {
"provider": "搜尋 provider",
@@ -270,44 +270,44 @@
}
},
"overview": {
- "model": "Current model",
- "providers": "Providers",
- "configuredCount": "{{count}} configured",
- "totalProviders": "{{count}} available",
- "webSearch": "Web search",
+ "model": "目前模型",
+ "providers": "供應商",
+ "configuredCount": "已配置 {{count}} 個",
+ "totalProviders": "共 {{count}} 個可用",
+ "webSearch": "網頁搜尋",
"imageGeneration": "圖片生成",
- "workspace": "Workspace"
+ "workspace": "工作區"
},
"providers": {
- "searchPlaceholder": "Search providers",
- "noMatches": "No providers match this search.",
- "saveProvider": "儲存服務商"
+ "searchPlaceholder": "搜尋供應商",
+ "noMatches": "沒有符合的供應商。",
+ "saveProvider": "儲存供應商"
},
"image": {
- "selectProvider": "選擇服務商",
+ "selectProvider": "選擇供應商",
"selectAspect": "選擇比例",
"selectSize": "選擇尺寸",
- "configureProvider": "設定服務商",
- "missingCredential": "啟用圖片生成前,請先設定此服務商。"
+ "configureProvider": "配置供應商",
+ "missingCredential": "啟用圖片生成前請先配置此供應商。"
},
"models": {
"selectModel": "選擇模型",
- "addConfiguration": "新增設定",
- "newConfiguration": "新增模型設定",
- "newConfigurationHelp": "把服務商和模型儲存為一個可直接切換的選項。",
- "configurationName": "設定名稱",
+ "addConfiguration": "新增配置",
+ "newConfiguration": "新增模型配置",
+ "newConfigurationHelp": "將供應商與模型儲存為一鍵選項。",
+ "configurationName": "配置名稱",
"configurationNameHelp": "重新命名這個已儲存的模型配置。",
"configurationNamePlaceholder": "快速寫作",
"searchModels": "搜尋或輸入模型 ID",
"useCustomModel": "使用",
"loadingModels": "正在載入模型...",
- "searchCatalog": "搜尋服務商目錄來選擇模型。",
- "modelsAvailable": "個可用",
+ "searchCatalog": "搜尋供應商目錄來選擇模型。",
+ "modelsAvailable": "可用",
"noModelResults": "沒有符合的模型。",
- "loadFailed": "模型列表暫不可用。",
+ "loadFailed": "模型列表不可用。",
"unsupportedModelList": "手動輸入模型 ID。",
- "providerNotConfigured": "先設定這個服務商再載入模型。",
- "autoProviderCustomOnly": "自動服務商模式使用自訂模型 ID。"
+ "providerNotConfigured": "載入模型前請先配置此供應商。",
+ "autoProviderCustomOnly": "自動供應商模式使用自訂模型 ID。"
},
"timezone": {
"select": "選擇時區",
@@ -402,29 +402,29 @@
"thirdPartyBrands": "產品名稱、標誌與品牌均屬於其各自擁有者。使用僅為識別用途,並不代表背書。"
},
"apps": {
- "description": "新增 nanobot 可在聊天中使用的 App CLI 與 MCP 服務。",
+ "description": "新增 nanobot 可在聊天中使用的 App CLI 和 MCP 服務。",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "全部",
"filterCli": "CLI 應用",
"filterMcp": "MCP 服務",
"enabledSummary": "已啟用 {{count}} 個",
- "caption": "{{cli}} CLI · {{mcp}} MCP",
+ "caption": "{{cli}} 個 CLI · {{mcp}} 個 MCP",
"searchPlaceholder": "搜尋應用",
"featured": "精選",
"loading": "正在載入應用...",
- "empty": "沒有符合篩選條件的應用。"
+ "empty": "沒有符合的應用。"
},
"oauth": {
- "authentication": "OAuth 驗證",
+ "authentication": "OAuth 認證",
"signIn": "登入",
- "signingIn": "正在登入…",
+ "signingIn": "登入中...",
"signInAgain": "重新登入",
"signOut": "登出",
"signedInAs": "已登入為 {{account}}",
- "signInHelp": "在這台裝置上登入;不會把 API key 寫入設定。",
+ "signInHelp": "從這台裝置登入;不會在配置中儲存 API key。",
"signInRequired": "需要登入",
- "signInBeforeSaving": "請先登入這個 OAuth 提供商,再儲存為目前模型提供商。",
+ "signInBeforeSaving": "將此 OAuth 供應商設為目前模型供應商前,請先登入。",
"signedIn": "已登入",
"notSignedIn": "未登入"
}
diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx
index 202b9d29e..99f1f0a08 100644
--- a/webui/src/tests/i18n.test.tsx
+++ b/webui/src/tests/i18n.test.tsx
@@ -34,6 +34,60 @@ const SETTINGS_NAV_KEYS = [
"runtime",
"advanced",
];
+const LOCALIZED_SETTINGS_COPY_KEYS = [
+ "settings.backToChat",
+ "settings.sidebar.title",
+ "settings.sidebar.ariaLabel",
+ "settings.nav.overview",
+ "settings.nav.appearance",
+ "settings.nav.models",
+ "settings.nav.providers",
+ "settings.nav.apps",
+ "settings.nav.runtime",
+ "settings.nav.advanced",
+ "settings.sections.interface",
+ "settings.sections.localPreferences",
+ "settings.sections.webSearch",
+ "settings.sections.webBehavior",
+ "settings.sections.webuiSafety",
+ "settings.sections.capabilities",
+ "settings.sections.apps",
+ "settings.rows.theme",
+ "settings.rows.language",
+ "settings.rows.density",
+ "settings.rows.activityMode",
+ "settings.rows.codeWrap",
+ "settings.rows.brandLogos",
+ "settings.rows.currentModel",
+ "settings.rows.localServiceAccess",
+ "settings.rows.webuiDefaultAccess",
+ "settings.rows.contextWindow",
+ "settings.help.theme",
+ "settings.help.language",
+ "settings.help.density",
+ "settings.help.activityMode",
+ "settings.help.codeWrap",
+ "settings.help.brandLogos",
+ "settings.help.currentModel",
+ "settings.help.localServiceAccess",
+ "settings.help.webuiDefaultAccess",
+ "settings.values.light",
+ "settings.values.dark",
+ "settings.values.comfortable",
+ "settings.values.compact",
+ "settings.values.expanded",
+ "settings.values.enabled",
+ "settings.values.disabled",
+ "settings.values.defaultPermission",
+ "settings.values.fullAccess",
+ "settings.values.configured",
+ "settings.values.notConfigured",
+ "settings.status.loading",
+ "settings.status.unsaved",
+ "settings.status.upToDate",
+ "settings.actions.save",
+ "settings.actions.saving",
+];
function isRecord(value: unknown): value is Record {
return !!value && typeof value === "object" && !Array.isArray(value);
}
@@ -190,6 +244,20 @@ describe("webui i18n", () => {
}
});
+ it("does not leak English settings chrome into localized locales", () => {
+ const english = flattenResource(resources.en.common);
+
+ for (const [locale, resource] of Object.entries(resources)) {
+ if (locale === "en") continue;
+ const current = flattenResource(resource.common);
+ const leaked = LOCALIZED_SETTINGS_COPY_KEYS.filter(
+ (key) => current.get(key) === english.get(key),
+ );
+
+ expect({ locale, leaked }).toEqual({ locale, leaked: [] });
+ }
+ });
+
it("keeps Simplified Chinese settings overview copy localized", () => {
const settings = resources["zh-CN"].common.settings;
diff --git a/webui/src/tests/thread-messages.test.tsx b/webui/src/tests/thread-messages.test.tsx
index 9319a0951..feed81496 100644
--- a/webui/src/tests/thread-messages.test.tsx
+++ b/webui/src/tests/thread-messages.test.tsx
@@ -194,7 +194,8 @@ describe("ThreadMessages", () => {
render( );
- expect(screen.getByLabelText(/editing foo\.txt/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/edited foo\.txt/i)).toBeInTheDocument();
+ expect(screen.queryByLabelText(/editing foo\.txt/i)).not.toBeInTheDocument();
});
it("folds final answer reasoning into the preceding activity timeline", () => {
diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx
index e880bf90f..08c32b17e 100644
--- a/webui/src/tests/useNanobotStream.test.tsx
+++ b/webui/src/tests/useNanobotStream.test.tsx
@@ -658,7 +658,7 @@ describe("useNanobotStream", () => {
}]);
});
- it("keeps interrupted pre-tool text inside activity before the final answer", async () => {
+ it("keeps interrupted pre-tool text as assistant output before activity", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-stream-segments", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
@@ -692,9 +692,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
- content: "",
- reasoning: "I created the files.",
- isStreaming: false,
+ content: "I created the files.",
});
expect(result.current.messages[1]).toMatchObject({
role: "tool",
@@ -739,9 +737,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
- content: "",
- reasoning: "I will inspect the project first.",
- isStreaming: false,
+ content: "I will inspect the project first.",
});
expect(result.current.messages[1]).toMatchObject({
role: "tool",
@@ -755,6 +751,51 @@ describe("useNanobotStream", () => {
});
});
+ it("splits live assistant output around tool hints without moving it into reasoning", async () => {
+ const fake = fakeClient();
+ const { result } = renderHook(() => useNanobotStream("chat-live-segments", EMPTY_MESSAGES), {
+ wrapper: wrap(fake.client),
+ });
+
+ act(() => {
+ fake.emit("chat-live-segments", {
+ event: "delta",
+ chat_id: "chat-live-segments",
+ text: "Lint passed; now rendering the video.",
+ });
+ fake.emit("chat-live-segments", {
+ event: "message",
+ chat_id: "chat-live-segments",
+ text: 'exec({"cmd":"hyperframes render"})',
+ kind: "tool_hint",
+ });
+ fake.emit("chat-live-segments", {
+ event: "delta",
+ chat_id: "chat-live-segments",
+ text: "Rendered successfully.",
+ });
+ });
+
+ await flushStreamFrame();
+
+ expect(result.current.messages).toHaveLength(3);
+ expect(result.current.messages[0]).toMatchObject({
+ role: "assistant",
+ content: "Lint passed; now rendering the video.",
+ });
+ expect(result.current.messages[0].reasoning).toBeUndefined();
+ expect(result.current.messages[1]).toMatchObject({
+ role: "tool",
+ kind: "trace",
+ traces: ['exec({"cmd":"hyperframes render"})'],
+ });
+ expect(result.current.messages[2]).toMatchObject({
+ role: "assistant",
+ content: "Rendered successfully.",
+ });
+ expect(result.current.messages[2].reasoning).toBeUndefined();
+ });
+
it("opens a new activity segment for reasoning after file edit activity", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-segments", EMPTY_MESSAGES), {
@@ -967,7 +1008,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages[0].reasoningStreaming).toBe(false);
});
- it("attaches post-hoc reasoning to the same assistant turn above the answer", () => {
+ it("starts a new Thought block when reasoning arrives after visible output", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-r5", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
@@ -988,12 +1029,61 @@ describe("useNanobotStream", () => {
fake.emit("chat-r5", { event: "reasoning_end", chat_id: "chat-r5" });
});
- expect(result.current.messages).toHaveLength(1);
+ expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[0].content).toBe("hi~");
- expect(result.current.messages[0].reasoning).toBe(
+ expect(result.current.messages[0].reasoning).toBeUndefined();
+ expect(result.current.messages[1].content).toBe("");
+ expect(result.current.messages[1].reasoning).toBe(
"This reasoning arrived after the answer stream.",
);
- expect(result.current.messages[0].reasoningStreaming).toBe(false);
+ expect(result.current.messages[1].reasoningStreaming).toBe(false);
+ });
+
+ it("keeps alternating reasoning and answer deltas in separate ordered blocks", async () => {
+ const fake = fakeClient();
+ const { result } = renderHook(() => useNanobotStream("chat-r5b", EMPTY_MESSAGES), {
+ wrapper: wrap(fake.client),
+ });
+
+ act(() => {
+ fake.emit("chat-r5b", {
+ event: "reasoning_delta",
+ chat_id: "chat-r5b",
+ text: "Plan first.",
+ });
+ fake.emit("chat-r5b", {
+ event: "delta",
+ chat_id: "chat-r5b",
+ text: "Visible progress.",
+ });
+ fake.emit("chat-r5b", {
+ event: "reasoning_delta",
+ chat_id: "chat-r5b",
+ text: "Think again.",
+ });
+ fake.emit("chat-r5b", {
+ event: "delta",
+ chat_id: "chat-r5b",
+ text: "Final visible text.",
+ });
+ });
+
+ await flushStreamFrame();
+
+ expect(result.current.messages).toHaveLength(2);
+ expect(result.current.messages[0]).toMatchObject({
+ role: "assistant",
+ reasoning: "Plan first.",
+ content: "Visible progress.",
+ });
+ expect(result.current.messages[1]).toMatchObject({
+ role: "assistant",
+ reasoning: "Think again.",
+ content: "Final visible text.",
+ });
+ expect(result.current.messages[1].activitySegmentId).not.toBe(
+ result.current.messages[0].activitySegmentId,
+ );
});
it("does not attach a new turn's reasoning across the latest user boundary", async () => {
From 33a13b701bcae7e321e32ee84b2be64c69176098 Mon Sep 17 00:00:00 2001
From: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
Date: Sun, 31 May 2026 21:43:12 +0800
Subject: [PATCH 09/71] feat(webui): render source links with favicons
---
webui/src/components/MarkdownTextRenderer.tsx | 132 ++++++++++++++++++
.../src/tests/markdown-text-renderer.test.tsx | 21 +++
2 files changed, 153 insertions(+)
diff --git a/webui/src/components/MarkdownTextRenderer.tsx b/webui/src/components/MarkdownTextRenderer.tsx
index 6f3f19bdf..2a4c410c7 100644
--- a/webui/src/components/MarkdownTextRenderer.tsx
+++ b/webui/src/components/MarkdownTextRenderer.tsx
@@ -30,6 +30,13 @@ type MarkdownAstNode = {
};
};
+type CitationLink = {
+ href: string;
+ origin: string;
+ title: string;
+ initials: string;
+};
+
const SAFE_INLINE_HTML_TAGS = new Set(["mark", "sub", "sup"]);
function extensionOf(value: string): string {
@@ -179,6 +186,115 @@ function nodeText(value: ReactNode): string {
.join("");
}
+function citationParts(value: ReactNode): { text: string; href?: string } {
+ let text = "";
+ let href: string | undefined;
+ for (const child of Children.toArray(value)) {
+ if (typeof child === "string" || typeof child === "number") {
+ text += String(child);
+ continue;
+ }
+ if (!isValidElement(child)) {
+ continue;
+ }
+ const props = child.props as { href?: unknown; children?: ReactNode };
+ if (!href && typeof props.href === "string" && /^https?:\/\//i.test(props.href)) {
+ href = props.href;
+ }
+ const nested = citationParts(props.children);
+ text += nested.text;
+ href ||= nested.href;
+ }
+ return { text, href };
+}
+
+function cleanCitationText(value: string): string {
+ return value
+ .replace(/\s+/g, " ")
+ .replace(/^[\s"'“”‘’]+|[\s"'“”‘’]+$/g, "")
+ .trim();
+}
+
+function citationInitials(value: string): string {
+ const clean = value
+ .replace(/^https?:\/\//i, "")
+ .replace(/^www\./i, "")
+ .replace(/\.[a-z]{2,}$/i, "");
+ const parts = clean.split(/[\s.-]+/).filter(Boolean);
+ return (parts.length > 1 ? parts.slice(0, 2).map((part) => part[0]).join("") : clean.slice(0, 2))
+ .toUpperCase();
+}
+
+function sourceLinkFromChildren(children: ReactNode): CitationLink | null {
+ const { text: rawText, href } = citationParts(children);
+ if (!href) return null;
+
+ let url: URL;
+ try {
+ url = new URL(href);
+ } catch {
+ return null;
+ }
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
+
+ const strippedUrl = rawText
+ .replace(/\s+/g, " ")
+ .replace(href, "")
+ .replace(url.toString(), "")
+ .replace(/https?:\/\/\S+/i, "")
+ .trim();
+ if (!strippedUrl || strippedUrl.length < 4) return null;
+
+ const sourceMatch = /^(.*?)\s*(?:[—–]| - |:)\s*(.+)$/.exec(strippedUrl);
+ const sourceLabel = sourceMatch?.[1] ? cleanCitationText(sourceMatch[1]) : undefined;
+ const title = cleanCitationText(sourceMatch?.[2] ?? strippedUrl);
+ if (!title || /^https?:\/\//i.test(title)) return null;
+
+ return {
+ href,
+ origin: url.origin,
+ title,
+ initials: citationInitials(sourceLabel || url.hostname),
+ };
+}
+
+function CitationRow({ citation }: { citation: CitationLink }) {
+ return (
+
+
+ {citation.initials}
+ {
+ event.currentTarget.style.display = "none";
+ }}
+ />
+
+
+ {citation.title}
+
+
+ );
+}
+
function isRenderedCodeBlock(value: ReactNode): boolean {
if (!isValidElement(value)) return false;
const props = value.props as { code?: unknown };
@@ -296,6 +412,22 @@ export default function MarkdownTextRenderer({
);
},
+ li({ children: markdownChildren, className: itemClassName, node: _node }) {
+ void _node;
+ const citation = sourceLinkFromChildren(markdownChildren);
+ if (citation) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+ {markdownChildren}
+
+ );
+ },
input({ type, checked }) {
if (type !== "checkbox") return null;
return (
diff --git a/webui/src/tests/markdown-text-renderer.test.tsx b/webui/src/tests/markdown-text-renderer.test.tsx
index b0282b756..3c76f5976 100644
--- a/webui/src/tests/markdown-text-renderer.test.tsx
+++ b/webui/src/tests/markdown-text-renderer.test.tsx
@@ -68,6 +68,27 @@ describe("MarkdownTextRenderer", () => {
expect(screen.queryByRole("img", { name: "index.html" })).not.toBeInTheDocument();
});
+ it("renders source-style link lists as citation rows", () => {
+ render(
+
+ {
+ "Sources:\n\n- Polymarket — “When will GPT-5.6 be released?”\n https://polymarket.com/event/when-will-gpt-5pt6-be-released\n- Polymarket — “GPT-5.6 released by...?”\n https://polymarket.com/event/gpt-5pt6-released-by"
+ }
+ ,
+ );
+
+ expect(
+ screen.getByRole("link", { name: "Open source: When will GPT-5.6 be released?" }),
+ ).toHaveAttribute(
+ "href",
+ "https://polymarket.com/event/when-will-gpt-5pt6-be-released",
+ );
+ expect(
+ screen.getByRole("link", { name: "Open source: GPT-5.6 released by...?" }),
+ ).toHaveAttribute("href", "https://polymarket.com/event/gpt-5pt6-released-by");
+ expect(screen.queryByText("Polymarket · polymarket.com")).not.toBeInTheDocument();
+ });
+
it("renders media attachments without an extra preview/code wrapper", () => {
render( );
From cba9ff1f576f633758588bbbf62f16e9e0521970 Mon Sep 17 00:00:00 2001
From: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
Date: Sun, 31 May 2026 23:02:43 +0800
Subject: [PATCH 10/71] fix(webui): simplify rendered source links
---
webui/src/components/MarkdownTextRenderer.tsx | 56 ++++++++++---------
.../src/tests/markdown-text-renderer.test.tsx | 26 ++++++++-
2 files changed, 53 insertions(+), 29 deletions(-)
diff --git a/webui/src/components/MarkdownTextRenderer.tsx b/webui/src/components/MarkdownTextRenderer.tsx
index 2a4c410c7..d3a664b92 100644
--- a/webui/src/components/MarkdownTextRenderer.tsx
+++ b/webui/src/components/MarkdownTextRenderer.tsx
@@ -30,9 +30,10 @@ type MarkdownAstNode = {
};
};
-type CitationLink = {
+type InlineLinkPreview = {
href: string;
origin: string;
+ prefix?: string;
title: string;
initials: string;
};
@@ -186,7 +187,7 @@ function nodeText(value: ReactNode): string {
.join("");
}
-function citationParts(value: ReactNode): { text: string; href?: string } {
+function linkPreviewParts(value: ReactNode): { text: string; href?: string } {
let text = "";
let href: string | undefined;
for (const child of Children.toArray(value)) {
@@ -201,21 +202,21 @@ function citationParts(value: ReactNode): { text: string; href?: string } {
if (!href && typeof props.href === "string" && /^https?:\/\//i.test(props.href)) {
href = props.href;
}
- const nested = citationParts(props.children);
+ const nested = linkPreviewParts(props.children);
text += nested.text;
href ||= nested.href;
}
return { text, href };
}
-function cleanCitationText(value: string): string {
+function cleanLinkPreviewText(value: string): string {
return value
.replace(/\s+/g, " ")
.replace(/^[\s"'“”‘’]+|[\s"'“”‘’]+$/g, "")
.trim();
}
-function citationInitials(value: string): string {
+function linkPreviewInitials(value: string): string {
const clean = value
.replace(/^https?:\/\//i, "")
.replace(/^www\./i, "")
@@ -225,8 +226,8 @@ function citationInitials(value: string): string {
.toUpperCase();
}
-function sourceLinkFromChildren(children: ReactNode): CitationLink | null {
- const { text: rawText, href } = citationParts(children);
+function inlineLinkPreviewFromChildren(children: ReactNode): InlineLinkPreview | null {
+ const { text: rawText, href } = linkPreviewParts(children);
if (!href) return null;
let url: URL;
@@ -246,50 +247,54 @@ function sourceLinkFromChildren(children: ReactNode): CitationLink | null {
if (!strippedUrl || strippedUrl.length < 4) return null;
const sourceMatch = /^(.*?)\s*(?:[—–]| - |:)\s*(.+)$/.exec(strippedUrl);
- const sourceLabel = sourceMatch?.[1] ? cleanCitationText(sourceMatch[1]) : undefined;
- const title = cleanCitationText(sourceMatch?.[2] ?? strippedUrl);
+ const prefix = sourceMatch?.[1] ? cleanLinkPreviewText(sourceMatch[1]) : undefined;
+ const title = cleanLinkPreviewText(sourceMatch?.[2] ?? strippedUrl);
if (!title || /^https?:\/\//i.test(title)) return null;
return {
href,
origin: url.origin,
+ prefix,
title,
- initials: citationInitials(sourceLabel || url.hostname),
+ initials: linkPreviewInitials(prefix || url.hostname),
};
}
-function CitationRow({ citation }: { citation: CitationLink }) {
+function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
+ const label = link.prefix
+ ? `${link.prefix} — ${link.title}`
+ : link.title;
return (
- {citation.initials}
+ {link.initials}
{
event.currentTarget.style.display = "none";
}}
/>
-
- {citation.title}
+
+ {label}
);
@@ -412,13 +417,12 @@ export default function MarkdownTextRenderer({
);
},
- li({ children: markdownChildren, className: itemClassName, node: _node }) {
- void _node;
- const citation = sourceLinkFromChildren(markdownChildren);
- if (citation) {
+ li({ children: markdownChildren, className: itemClassName }) {
+ const link = inlineLinkPreviewFromChildren(markdownChildren);
+ if (link) {
return (
-
+
);
}
diff --git a/webui/src/tests/markdown-text-renderer.test.tsx b/webui/src/tests/markdown-text-renderer.test.tsx
index 3c76f5976..4fc6ed1dc 100644
--- a/webui/src/tests/markdown-text-renderer.test.tsx
+++ b/webui/src/tests/markdown-text-renderer.test.tsx
@@ -68,7 +68,7 @@ describe("MarkdownTextRenderer", () => {
expect(screen.queryByRole("img", { name: "index.html" })).not.toBeInTheDocument();
});
- it("renders source-style link lists as citation rows", () => {
+ it("renders title plus url list items as compact link rows", () => {
render(
{
@@ -78,17 +78,37 @@ describe("MarkdownTextRenderer", () => {
);
expect(
- screen.getByRole("link", { name: "Open source: When will GPT-5.6 be released?" }),
+ screen.getByRole("link", {
+ name: "Open link: Polymarket — When will GPT-5.6 be released?",
+ }),
).toHaveAttribute(
"href",
"https://polymarket.com/event/when-will-gpt-5pt6-be-released",
);
expect(
- screen.getByRole("link", { name: "Open source: GPT-5.6 released by...?" }),
+ screen.getByRole("link", {
+ name: "Open link: Polymarket — GPT-5.6 released by...?",
+ }),
).toHaveAttribute("href", "https://polymarket.com/event/gpt-5pt6-released-by");
expect(screen.queryByText("Polymarket · polymarket.com")).not.toBeInTheDocument();
});
+ it("does not require a source heading for compact link rows", () => {
+ render(
+
+ {
+ "Useful links:\n\n- Polymarket — “When will GPT-5.6 be released?”\n https://polymarket.com/event/when-will-gpt-5pt6-be-released"
+ }
+ ,
+ );
+
+ expect(
+ screen.getByRole("link", {
+ name: "Open link: Polymarket — When will GPT-5.6 be released?",
+ }),
+ ).toHaveAttribute("href", "https://polymarket.com/event/when-will-gpt-5pt6-be-released");
+ });
+
it("renders media attachments without an extra preview/code wrapper", () => {
render( );
From be2e0172d1a9360418d49253d82b7af55d7d38f6 Mon Sep 17 00:00:00 2001
From: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
Date: Mon, 1 Jun 2026 00:56:39 +0800
Subject: [PATCH 11/71] fix(agent): extend sustained goal iteration budget
---
nanobot/agent/loop.py | 61 ++++-
nanobot/session/goal_state.py | 23 +-
nanobot/session/turn_continuation.py | 240 ++++++++++++++++++
nanobot/session/webui_turns.py | 23 +-
tests/agent/test_loop_runner_integration.py | 25 +-
tests/agent/test_loop_save_turn.py | 224 ++++++++++++++++
tests/session/test_turn_continuation.py | 127 +++++++++
tests/utils/test_webui_turn_helpers.py | 13 +
.../src/components/thread/ThreadComposer.tsx | 11 +-
webui/src/globals.css | 56 ++++
webui/src/tests/thread-composer.test.tsx | 1 +
11 files changed, 783 insertions(+), 21 deletions(-)
create mode 100644 nanobot/session/turn_continuation.py
create mode 100644 tests/session/test_turn_continuation.py
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 159423800..9d3f5771f 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -45,6 +45,7 @@ from nanobot.session.goal_state import (
sustained_goal_active,
)
from nanobot.session.manager import Session, SessionManager
+from nanobot.session import turn_continuation
from nanobot.session.webui_turns import (
WebuiTurnCoordinator,
build_bus_progress_callback,
@@ -112,6 +113,7 @@ class TurnContext:
save_skip: int = 0
outbound: OutboundMessage | None = None
+ suppress_response: bool = False
on_progress: Callable[..., Awaitable[None]] | None = None
on_stream: Callable[[str], Awaitable[None]] | None = None
@@ -121,6 +123,7 @@ class TurnContext:
pending_queue: asyncio.Queue | None = None
pending_summary: str | None = None
turn_wall_started_at: float = field(default_factory=time.time)
+ visible_run_started_at: float | None = None
turn_latency_ms: int | None = None
trace: list[StateTraceEntry] = field(default_factory=list)
@@ -565,6 +568,8 @@ class AgentLoop:
Returns True if the message was persisted.
"""
+ if not turn_continuation.should_persist_user_message(msg.metadata):
+ return False
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
has_text = isinstance(msg.content, str) and msg.content.strip()
if has_text or media_paths:
@@ -771,6 +776,7 @@ class AgentLoop:
+ "\n\nPlease continue working toward the objective using your tools, "
"or call complete_goal if the work is truly finished."
) if _goal_lines else SUSTAINED_GOAL_CONTINUE_PROMPT
+ session_metadata = session.metadata if session is not None else None
try:
result = await self.runner.run(AgentRunSpec(
initial_messages=initial_messages,
@@ -796,7 +802,8 @@ class AgentLoop:
llm_timeout_s=runner_wall_llm_timeout_s(
self.sessions,
session.key if session is not None else session_key,
- metadata=(session.metadata if session is not None else None),
+ metadata=session_metadata,
+ message_metadata=metadata,
),
goal_active_predicate=lambda: sustained_goal_active(session.metadata) if session is not None else False,
goal_continue_message=_goal_continue,
@@ -808,9 +815,15 @@ class AgentLoop:
self._last_usage = result.usage
if result.stop_reason == "max_iterations":
logger.warning("Max iterations ({}) reached", self.max_iterations)
+ should_stream = turn_continuation.should_stream_budget_response(
+ stop_reason=result.stop_reason,
+ pending_queue_available=pending_queue is not None and session is not None,
+ session_metadata=session_metadata,
+ message_metadata=metadata,
+ )
# Push final content through stream so streaming channels (e.g. Feishu)
# update the card instead of leaving it empty.
- if on_stream and on_stream_end:
+ if on_stream and on_stream_end and should_stream:
await on_stream(result.final_content or "")
await on_stream_end(resuming=False)
elif result.stop_reason == "error":
@@ -953,7 +966,8 @@ class AgentLoop:
channel=msg.channel, chat_id=msg.chat_id,
content="", metadata=msg.metadata or {},
))
- if msg.channel == "websocket":
+ continuing = turn_continuation.internal_continuation_pending(msg.metadata)
+ if msg.channel == "websocket" and not continuing:
turn_lat = self._pending_turn_latency_ms.pop(session_key, None)
await self._webui_turns.handle_turn_end(
msg,
@@ -1017,9 +1031,10 @@ class AgentLoop:
"Re-published {} leftover message(s) to bus for session {}",
leftover, session_key,
)
- await self._webui_turns.publish_run_status(msg, "idle")
- self._pending_turn_latency_ms.pop(session_key, None)
- self._webui_turns.discard(session_key)
+ if not turn_continuation.internal_continuation_pending(msg.metadata):
+ await self._webui_turns.publish_run_status(msg, "idle")
+ self._pending_turn_latency_ms.pop(session_key, None)
+ self._webui_turns.discard(session_key)
finally:
if pending is None:
await self._webui_turns.publish_run_status(msg, "idle")
@@ -1167,12 +1182,17 @@ class AgentLoop:
)
key = session_key or msg.session_key
+ t0 = time.time()
ctx = TurnContext(
msg=msg,
session=None,
session_key=key,
state=TurnState.RESTORE,
turn_id=f"{key}:{time.time_ns()}",
+ turn_wall_started_at=t0,
+ visible_run_started_at=turn_continuation.internal_continuation_run_started_at(
+ msg.metadata,
+ ),
on_progress=on_progress,
on_stream=on_stream,
on_stream_end=on_stream_end,
@@ -1378,7 +1398,13 @@ class AgentLoop:
return "ok"
async def _state_run(self, ctx: TurnContext) -> str:
- await self._webui_turns.publish_run_status(ctx.msg, "running")
+ if ctx.visible_run_started_at is None:
+ ctx.visible_run_started_at = time.time()
+ await self._webui_turns.publish_run_status(
+ ctx.msg,
+ "running",
+ started_at=ctx.visible_run_started_at,
+ )
result = await self._run_agent_loop(
ctx.initial_messages,
on_progress=ctx.on_progress,
@@ -1399,15 +1425,25 @@ class AgentLoop:
ctx.all_messages = all_msgs
ctx.stop_reason = stop_reason
ctx.had_injections = had_injections
+ await turn_continuation.maybe_continue_turn(ctx)
return "ok"
async def _state_save(self, ctx: TurnContext) -> str:
- if ctx.final_content is None or not ctx.final_content.strip():
+ turn_continuation.prepare_save_boundary(ctx)
+
+ if (
+ (ctx.final_content is None or not ctx.final_content.strip())
+ and not ctx.suppress_response
+ ):
ctx.final_content = EMPTY_FINAL_RESPONSE_MESSAGE
- ctx.save_skip = 1 + len(ctx.history) + (1 if ctx.user_persisted_early else 0)
-
- ctx.turn_latency_ms = max(0, int((time.time() - ctx.turn_wall_started_at) * 1000))
+ latency_started_at = (
+ ctx.visible_run_started_at
+ if turn_continuation.internal_continuation_inbound(ctx.msg.metadata)
+ and ctx.visible_run_started_at is not None
+ else ctx.turn_wall_started_at
+ )
+ ctx.turn_latency_ms = max(0, int((time.time() - latency_started_at) * 1000))
self._save_turn(
ctx.session, ctx.all_messages, ctx.save_skip,
turn_latency_ms=ctx.turn_latency_ms,
@@ -1427,6 +1463,9 @@ class AgentLoop:
return "ok"
async def _state_respond(self, ctx: TurnContext) -> str:
+ if ctx.suppress_response:
+ ctx.outbound = None
+ return "ok"
ctx.outbound = self._assemble_outbound(
ctx.msg,
ctx.final_content,
diff --git a/nanobot/session/goal_state.py b/nanobot/session/goal_state.py
index a5e382f25..2ef21bd14 100644
--- a/nanobot/session/goal_state.py
+++ b/nanobot/session/goal_state.py
@@ -43,6 +43,19 @@ def sustained_goal_active(metadata: Mapping[str, Any] | None) -> bool:
return isinstance(goal, dict) and goal.get("status") == "active"
+def sustained_goal_turn(
+ metadata: Mapping[str, Any] | None,
+ *,
+ message_metadata: Mapping[str, Any] | None = None,
+) -> bool:
+ """True when this turn should use sustained-goal runtime limits."""
+ if sustained_goal_active(metadata):
+ return True
+ if not message_metadata:
+ return False
+ return str(message_metadata.get("original_command") or "").strip() == "/goal"
+
+
def parse_goal_state(blob: Any) -> dict[str, Any] | None:
if blob is None:
return None
@@ -98,14 +111,16 @@ def runner_wall_llm_timeout_s(
session_key: str | None,
*,
metadata: Mapping[str, Any] | None = None,
+ message_metadata: Mapping[str, Any] | None = None,
) -> float | None:
"""Wall-clock cap for :class:`~nanobot.agent.runner.AgentRunner` when streaming an LLM.
- Returns ``0.0`` to disable ``asyncio.wait_for`` around the request when a sustained goal is
- active; ``None`` means use ``NANOBOT_LLM_TIMEOUT_S``. Pass in-memory ``metadata`` when the
- caller already holds :attr:`~nanobot.session.manager.Session.metadata` for this turn.
+ Returns ``0.0`` to disable ``asyncio.wait_for`` around the request when this is a
+ sustained-goal turn; ``None`` means use ``NANOBOT_LLM_TIMEOUT_S``. Pass in-memory
+ ``metadata`` when the caller already holds :attr:`~nanobot.session.manager.Session.metadata`
+ for this turn.
"""
meta: Mapping[str, Any] | None = metadata
if meta is None and session_key:
meta = sessions.get_or_create(session_key).metadata
- return 0.0 if sustained_goal_active(meta) else None
+ return 0.0 if sustained_goal_turn(meta, message_metadata=message_metadata) else None
diff --git a/nanobot/session/turn_continuation.py b/nanobot/session/turn_continuation.py
new file mode 100644
index 000000000..28c77bf64
--- /dev/null
+++ b/nanobot/session/turn_continuation.py
@@ -0,0 +1,240 @@
+"""Internal turn continuation helpers.
+
+This module keeps budget-boundary continuation policy out of ``AgentLoop``.
+The loop calls a small set of helpers; those helpers decide whether an internal
+continuation is allowed and, when it is, queue the next turn directly.
+"""
+
+from __future__ import annotations
+
+import dataclasses
+from typing import Any, Mapping, MutableMapping
+
+from loguru import logger
+
+from nanobot.session.goal_state import (
+ goal_state_runtime_lines,
+ sustained_goal_active,
+ sustained_goal_turn,
+)
+
+INTERNAL_CONTINUATION_META = "_internal_continuation"
+INTERNAL_CONTINUATION_KIND_META = "_internal_continuation_kind"
+INTERNAL_CONTINUATION_PENDING_META = "_internal_continuation_pending"
+INTERNAL_CONTINUATION_RUN_STARTED_AT_META = "_internal_continuation_run_started_at"
+
+_GOAL_CONTINUATION_KIND = "sustained_goal"
+_GOAL_CONTINUATION_SENDER = "system:continuation"
+_GOAL_CONTINUATION_ROUNDS_KEY = "_sustained_goal_continuation_rounds"
+_MAX_GOAL_CONTINUATION_ROUNDS = 12
+_STRIPPED_INBOUND_META_KEYS = {
+ "_stream_id",
+ "_stream_delta",
+ "_stream_end",
+ "_resuming",
+ INTERNAL_CONTINUATION_PENDING_META,
+}
+
+
+def internal_continuation_inbound(metadata: Mapping[str, Any] | None) -> bool:
+ """True for an inbound message created by an internal continuation policy."""
+ return bool(metadata and metadata.get(INTERNAL_CONTINUATION_META) is True)
+
+
+def internal_continuation_pending(metadata: Mapping[str, Any] | None) -> bool:
+ """True when the current turn scheduled an invisible continuation slice."""
+ return bool(metadata and metadata.get(INTERNAL_CONTINUATION_PENDING_META) is True)
+
+
+def internal_continuation_run_started_at(metadata: Mapping[str, Any] | None) -> float | None:
+ """Return the user-visible run start propagated across continuation slices."""
+ if not metadata:
+ return None
+ value = metadata.get(INTERNAL_CONTINUATION_RUN_STARTED_AT_META)
+ if not isinstance(value, int | float):
+ return None
+ started_at = float(value)
+ return started_at if started_at > 0 else None
+
+
+def should_persist_user_message(metadata: Mapping[str, Any] | None) -> bool:
+ """Return whether this inbound message should be persisted as user input."""
+ return not internal_continuation_inbound(metadata)
+
+
+def should_stream_budget_response(
+ *,
+ stop_reason: str,
+ pending_queue_available: bool,
+ session_metadata: Mapping[str, Any] | None,
+ message_metadata: Mapping[str, Any] | None = None,
+) -> bool:
+ """Return whether the budget-boundary response should be sent to the user."""
+ return not _continuation_available(
+ stop_reason=stop_reason,
+ pending_queue_available=pending_queue_available,
+ session_metadata=session_metadata,
+ message_metadata=message_metadata,
+ )
+
+
+async def maybe_continue_turn(ctx: Any) -> bool:
+ """Queue an internal continuation for *ctx* when policy allows it."""
+ if ctx.session is None or ctx.pending_queue is None:
+ return False
+ if not _continuation_available(
+ stop_reason=ctx.stop_reason,
+ pending_queue_available=True,
+ session_metadata=ctx.session.metadata,
+ message_metadata=ctx.msg.metadata,
+ ):
+ return False
+
+ metadata = _internal_continuation_metadata(
+ ctx.msg.metadata,
+ run_started_at=getattr(ctx, "visible_run_started_at", None),
+ )
+ content = _goal_continuation_prompt(ctx.session.metadata)
+ messages = _strip_terminal_assistant(ctx.all_messages, ctx.final_content)
+ _increment_goal_continuation_round(ctx.session.metadata)
+
+ logger.info("Turn budget reached; scheduling internal continuation")
+ ctx.msg.metadata[INTERNAL_CONTINUATION_PENDING_META] = True
+ ctx.final_content = ""
+ ctx.all_messages = messages
+ ctx.suppress_response = True
+ await ctx.pending_queue.put(
+ dataclasses.replace(
+ ctx.msg,
+ sender_id=_GOAL_CONTINUATION_SENDER,
+ content=content,
+ media=[],
+ metadata=metadata,
+ session_key_override=ctx.session_key,
+ )
+ )
+ return True
+
+
+def prepare_save_boundary(ctx: Any) -> None:
+ """Prepare continuation bookkeeping and the history append boundary."""
+ if ctx.session is not None:
+ clear_internal_continuation_state(ctx.session.metadata)
+
+ ctx.save_skip = _save_skip_for_turn(
+ message_metadata=ctx.msg.metadata,
+ initial_message_count=len(ctx.initial_messages),
+ history_count=len(ctx.history),
+ user_persisted_early=ctx.user_persisted_early,
+ )
+
+
+def _continuation_available(
+ *,
+ stop_reason: str,
+ pending_queue_available: bool,
+ session_metadata: Mapping[str, Any] | None,
+ message_metadata: Mapping[str, Any] | None = None,
+) -> bool:
+ if stop_reason != "max_iterations" or not pending_queue_available:
+ return False
+ return _goal_continuation_available(
+ session_metadata,
+ message_metadata=message_metadata,
+ )
+
+
+def clear_internal_continuation_state(metadata: MutableMapping[str, Any]) -> None:
+ """Reset policy bookkeeping once its owning runtime mode is inactive."""
+ if not sustained_goal_active(metadata):
+ metadata.pop(_GOAL_CONTINUATION_ROUNDS_KEY, None)
+
+
+def _save_skip_for_turn(
+ *,
+ message_metadata: Mapping[str, Any] | None,
+ initial_message_count: int,
+ history_count: int,
+ user_persisted_early: bool,
+) -> int:
+ """Return the persisted-message append boundary for this turn."""
+ if internal_continuation_inbound(message_metadata):
+ return initial_message_count
+ return 1 + history_count + (1 if user_persisted_early else 0)
+
+
+def _goal_continuation_available(
+ session_metadata: Mapping[str, Any] | None,
+ *,
+ message_metadata: Mapping[str, Any] | None = None,
+ max_rounds: int = _MAX_GOAL_CONTINUATION_ROUNDS,
+) -> bool:
+ if not sustained_goal_turn(session_metadata, message_metadata=message_metadata):
+ return False
+ if not sustained_goal_active(session_metadata):
+ return False
+ try:
+ rounds = int((session_metadata or {}).get(_GOAL_CONTINUATION_ROUNDS_KEY) or 0)
+ except (TypeError, ValueError):
+ rounds = 0
+ return rounds < max(0, max_rounds)
+
+
+def _increment_goal_continuation_round(session_metadata: MutableMapping[str, Any]) -> None:
+ try:
+ rounds = int(session_metadata.get(_GOAL_CONTINUATION_ROUNDS_KEY) or 0)
+ except (TypeError, ValueError):
+ rounds = 0
+ session_metadata[_GOAL_CONTINUATION_ROUNDS_KEY] = rounds + 1
+
+
+def _internal_continuation_metadata(
+ message_metadata: Mapping[str, Any] | None,
+ *,
+ run_started_at: float | None = None,
+) -> dict[str, Any]:
+ metadata = dict(message_metadata or {})
+ metadata[INTERNAL_CONTINUATION_META] = True
+ metadata[INTERNAL_CONTINUATION_KIND_META] = _GOAL_CONTINUATION_KIND
+ if run_started_at is not None:
+ metadata[INTERNAL_CONTINUATION_RUN_STARTED_AT_META] = float(run_started_at)
+ for key in _STRIPPED_INBOUND_META_KEYS:
+ metadata.pop(key, None)
+ return metadata
+
+
+def _goal_continuation_prompt(metadata: Mapping[str, Any] | None) -> str:
+ lines = goal_state_runtime_lines(metadata)
+ if lines:
+ goal = "\n".join(lines)
+ return (
+ "Continue the active sustained goal after the previous turn reached "
+ "its tool-call budget.\n\n"
+ f"{goal}\n\n"
+ "Continue from the saved context. Do not mention the continuation "
+ "boundary to the user. Use tools as needed, and call complete_goal "
+ "when the objective is truly finished."
+ )
+ return (
+ "Continue the active sustained goal after the previous turn reached "
+ "its tool-call budget. Continue from the saved context. Do not mention "
+ "the continuation boundary to the user. Use tools as needed, and call "
+ "complete_goal when the objective is truly finished."
+ )
+
+
+def _strip_terminal_assistant(
+ messages: list[dict[str, Any]],
+ final_content: str | None,
+) -> list[dict[str, Any]]:
+ """Drop the synthetic max-iteration assistant message before saving history."""
+ if not messages:
+ return messages
+ last = messages[-1]
+ if last.get("role") != "assistant":
+ return messages
+ if final_content is None or last.get("content") != final_content:
+ return messages
+ if last.get("tool_calls"):
+ return messages
+ return messages[:-1]
diff --git a/nanobot/session/webui_turns.py b/nanobot/session/webui_turns.py
index 864ef6a35..47614f0b1 100644
--- a/nanobot/session/webui_turns.py
+++ b/nanobot/session/webui_turns.py
@@ -178,7 +178,13 @@ def websocket_turn_wall_started_at(chat_id: str) -> float | None:
return _WEBSOCKET_TURN_WALL_STARTED_AT.get(chat_id)
-async def publish_turn_run_status(bus: MessageBus, msg: InboundMessage, status: str) -> None:
+async def publish_turn_run_status(
+ bus: MessageBus,
+ msg: InboundMessage,
+ status: str,
+ *,
+ started_at: float | None = None,
+) -> None:
"""Notify WebSocket clients while a user turn is executing (timing strip)."""
if msg.channel != "websocket":
return
@@ -189,7 +195,10 @@ async def publish_turn_run_status(bus: MessageBus, msg: InboundMessage, status:
"goal_status": status,
}
if status == "running":
- t0 = time.time()
+ if isinstance(started_at, int | float) and started_at > 0:
+ t0 = float(started_at)
+ else:
+ t0 = time.time()
meta["started_at"] = t0
_WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0
else:
@@ -300,8 +309,14 @@ class WebuiTurnCoordinator:
def discard(self, session_key: str) -> None:
self._title_contexts.pop(session_key, None)
- async def publish_run_status(self, msg: InboundMessage, status: str) -> None:
- await publish_turn_run_status(self.bus, msg, status)
+ async def publish_run_status(
+ self,
+ msg: InboundMessage,
+ status: str,
+ *,
+ started_at: float | None = None,
+ ) -> None:
+ await publish_turn_run_status(self.bus, msg, status, started_at=started_at)
async def handle_turn_end(
self,
diff --git a/tests/agent/test_loop_runner_integration.py b/tests/agent/test_loop_runner_integration.py
index 3cfe07f41..5f9c356ce 100644
--- a/tests/agent/test_loop_runner_integration.py
+++ b/tests/agent/test_loop_runner_integration.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-import asyncio
import time
from unittest.mock import AsyncMock, MagicMock, patch
@@ -48,6 +47,30 @@ async def test_loop_max_iterations_message_stays_stable(tmp_path):
)
+@pytest.mark.asyncio
+async def test_loop_goal_turn_uses_standard_iteration_budget(tmp_path):
+ loop = _make_loop(tmp_path)
+ loop.provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
+ content="working",
+ tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={})],
+ ))
+ loop.tools.get_definitions = MagicMock(return_value=[])
+ loop.tools.execute = AsyncMock(return_value="ok")
+ loop.max_iterations = 2
+
+ final_content, _, _, stop_reason, _ = await loop._run_agent_loop(
+ [],
+ metadata={"original_command": "/goal"},
+ )
+
+ assert stop_reason == "max_iterations"
+ assert loop.provider.chat_with_retry.await_count == 2
+ assert final_content == (
+ "I reached the maximum number of tool call iterations (2) "
+ "without completing the task. You can try breaking the task into smaller steps."
+ )
+
+
@pytest.mark.asyncio
async def test_loop_stream_filter_handles_think_only_prefix_without_crashing(tmp_path):
loop = _make_loop(tmp_path)
diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py
index 290cda0a6..f9847df8b 100644
--- a/tests/agent/test_loop_save_turn.py
+++ b/tests/agent/test_loop_save_turn.py
@@ -11,6 +11,10 @@ from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
from nanobot.session.goal_state import GOAL_STATE_KEY
from nanobot.session.manager import Session, SessionManager
+from nanobot.session.turn_continuation import (
+ INTERNAL_CONTINUATION_META,
+ INTERNAL_CONTINUATION_RUN_STARTED_AT_META,
+)
from nanobot.session.webui_turns import (
TITLE_GENERATION_MAX_TOKENS,
TITLE_GENERATION_REASONING_EFFORT,
@@ -560,6 +564,226 @@ async def test_process_message_does_not_duplicate_early_persisted_user_message(t
assert AgentLoop._PENDING_USER_TURN_KEY not in session.metadata
+@pytest.mark.asyncio
+async def test_internal_continuation_queues_turn_without_fake_user_history(
+ tmp_path: Path,
+) -> None:
+ loop = _make_full_loop(tmp_path)
+ loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
+ session = loop.sessions.get_or_create("feishu:c-auto")
+ session.metadata[GOAL_STATE_KEY] = {
+ "status": "active",
+ "objective": "Finish the long goal.",
+ }
+ loop.sessions.save(session)
+
+ calls: list[dict] = []
+
+ async def fake_run_agent_loop(initial_messages, *, metadata=None, **_kwargs):
+ calls.append({"initial_messages": initial_messages, "metadata": metadata})
+ if len(calls) == 1:
+ return (
+ "paused",
+ [],
+ [*initial_messages, {"role": "assistant", "content": "paused"}],
+ "max_iterations",
+ False,
+ )
+ return (
+ "done",
+ [],
+ [*initial_messages, {"role": "assistant", "content": "done"}],
+ "completed",
+ False,
+ )
+
+ loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
+ pending: asyncio.Queue[InboundMessage] = asyncio.Queue()
+
+ first = await loop._process_message(
+ InboundMessage(
+ channel="feishu",
+ sender_id="u1",
+ chat_id="c-auto",
+ content="start the goal",
+ ),
+ pending_queue=pending,
+ )
+
+ assert first is None
+ queued = pending.get_nowait()
+ assert queued.sender_id == "system:continuation"
+ assert queued.metadata[INTERNAL_CONTINUATION_META] is True
+ assert "Finish the long goal." in queued.content
+
+ session = loop.sessions.get_or_create("feishu:c-auto")
+ assert [
+ {k: v for k, v in m.items() if k in {"role", "content"}}
+ for m in session.messages
+ ] == [{"role": "user", "content": "start the goal"}]
+
+ second = await loop._process_message(queued, pending_queue=asyncio.Queue())
+
+ assert second is not None
+ assert second.content == "done"
+ session = loop.sessions.get_or_create("feishu:c-auto")
+ assert [
+ {k: v for k, v in m.items() if k in {"role", "content"}}
+ for m in session.messages
+ ] == [
+ {"role": "user", "content": "start the goal"},
+ {"role": "assistant", "content": "done"},
+ ]
+
+
+@pytest.mark.asyncio
+async def test_internal_continuation_preserves_streaming_route_metadata(
+ tmp_path: Path,
+) -> None:
+ loop = _make_full_loop(tmp_path)
+ loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
+ session = loop.sessions.get_or_create("feishu:c-stream")
+ session.metadata[GOAL_STATE_KEY] = {
+ "status": "active",
+ "objective": "Finish the streamed long goal.",
+ }
+ loop.sessions.save(session)
+
+ calls = 0
+
+ async def fake_run_agent_loop(initial_messages, *, on_stream=None, on_stream_end=None, **_kwargs):
+ nonlocal calls
+ calls += 1
+ if calls == 1:
+ return (
+ "paused",
+ [],
+ [*initial_messages, {"role": "assistant", "content": "paused"}],
+ "max_iterations",
+ False,
+ )
+ assert on_stream is not None
+ assert on_stream_end is not None
+ await on_stream("done")
+ await on_stream_end(resuming=False)
+ return (
+ "done",
+ [],
+ [*initial_messages, {"role": "assistant", "content": "done"}],
+ "completed",
+ False,
+ )
+
+ loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
+
+ await loop._dispatch(InboundMessage(
+ channel="feishu",
+ sender_id="u1",
+ chat_id="c-stream",
+ content="start the goal",
+ metadata={
+ "_wants_stream": True,
+ "message_id": "om_001",
+ "origin_message_id": "root_001",
+ "_stream_id": "old-stream",
+ },
+ ))
+
+ assert loop.bus.outbound_size == 0
+ queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
+ assert queued.metadata[INTERNAL_CONTINUATION_META] is True
+ assert queued.metadata["_wants_stream"] is True
+ assert queued.metadata["message_id"] == "om_001"
+ assert queued.metadata["origin_message_id"] == "root_001"
+ assert "_stream_id" not in queued.metadata
+
+ await loop._dispatch(queued)
+
+ outbound = []
+ while loop.bus.outbound_size:
+ outbound.append(await loop.bus.consume_outbound())
+ deltas = [m for m in outbound if m.metadata.get("_stream_delta")]
+ ends = [m for m in outbound if m.metadata.get("_stream_end")]
+ streamed_markers = [m for m in outbound if m.metadata.get("_streamed")]
+
+ assert [m.content for m in deltas] == ["done"]
+ assert len(ends) == 1
+ assert ends[0].metadata["_resuming"] is False
+ assert ends[0].metadata["message_id"] == "om_001"
+ assert ends[0].metadata["origin_message_id"] == "root_001"
+ assert isinstance(ends[0].metadata.get("_stream_id"), str)
+ assert streamed_markers and streamed_markers[-1].content == "done"
+
+
+@pytest.mark.asyncio
+async def test_websocket_internal_continuation_keeps_single_visible_run(
+ tmp_path: Path,
+) -> None:
+ loop = _make_full_loop(tmp_path)
+ loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
+ session = loop.sessions.get_or_create("websocket:c-auto")
+ session.metadata[GOAL_STATE_KEY] = {
+ "status": "active",
+ "objective": "Finish the long goal.",
+ }
+ loop.sessions.save(session)
+
+ calls = 0
+
+ async def fake_run_agent_loop(initial_messages, **_kwargs):
+ nonlocal calls
+ calls += 1
+ if calls == 1:
+ return (
+ "paused",
+ [],
+ [*initial_messages, {"role": "assistant", "content": "paused"}],
+ "max_iterations",
+ False,
+ )
+ return (
+ "done",
+ [],
+ [*initial_messages, {"role": "assistant", "content": "done"}],
+ "completed",
+ False,
+ )
+
+ loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
+
+ await loop._dispatch(InboundMessage(
+ channel="websocket",
+ sender_id="u1",
+ chat_id="c-auto",
+ content="start the goal",
+ metadata={"webui": True},
+ ))
+
+ first_outbound = []
+ while loop.bus.outbound_size:
+ first_outbound.append(await loop.bus.consume_outbound())
+ first_statuses = [m.metadata for m in first_outbound if m.metadata.get("_goal_status")]
+ assert [m["goal_status"] for m in first_statuses] == ["running"]
+ assert not [m for m in first_outbound if m.metadata.get("_turn_end")]
+ started_at = first_statuses[0]["started_at"]
+
+ queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
+ assert queued.metadata[INTERNAL_CONTINUATION_META] is True
+ assert queued.metadata[INTERNAL_CONTINUATION_RUN_STARTED_AT_META] == started_at
+
+ await loop._dispatch(queued)
+
+ second_outbound = []
+ while loop.bus.outbound_size:
+ second_outbound.append(await loop.bus.consume_outbound())
+ second_statuses = [m.metadata for m in second_outbound if m.metadata.get("_goal_status")]
+ assert [m["goal_status"] for m in second_statuses] == ["running", "idle"]
+ assert second_statuses[0]["started_at"] == started_at
+ turn_end = [m for m in second_outbound if m.metadata.get("_turn_end")]
+ assert len(turn_end) == 1
+ assert isinstance(turn_end[0].metadata.get("latency_ms"), int)
+
+
@pytest.mark.asyncio
async def test_process_message_uses_context_chat_id_for_runtime_prompt(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
diff --git a/tests/session/test_turn_continuation.py b/tests/session/test_turn_continuation.py
new file mode 100644
index 000000000..c6d58e5dc
--- /dev/null
+++ b/tests/session/test_turn_continuation.py
@@ -0,0 +1,127 @@
+"""Tests for internal turn continuation policy."""
+
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+
+import pytest
+
+from nanobot.bus.events import InboundMessage
+from nanobot.session.goal_state import GOAL_STATE_KEY
+from nanobot.session.turn_continuation import (
+ INTERNAL_CONTINUATION_KIND_META,
+ INTERNAL_CONTINUATION_META,
+ INTERNAL_CONTINUATION_PENDING_META,
+ INTERNAL_CONTINUATION_RUN_STARTED_AT_META,
+ internal_continuation_pending,
+ internal_continuation_run_started_at,
+ maybe_continue_turn,
+ should_stream_budget_response,
+)
+
+
+@pytest.mark.asyncio
+async def test_maybe_continue_turn_queues_internal_message():
+ meta = {
+ GOAL_STATE_KEY: {
+ "status": "active",
+ "objective": "Finish the migration.",
+ "ui_summary": "migration",
+ },
+ }
+ messages = [
+ {"role": "system", "content": "system"},
+ {"role": "user", "content": "start"},
+ {"role": "assistant", "content": "paused"},
+ ]
+ pending: asyncio.Queue[InboundMessage] = asyncio.Queue()
+ ctx = SimpleNamespace(
+ session=SimpleNamespace(metadata=meta),
+ msg=InboundMessage(
+ channel="feishu",
+ sender_id="u1",
+ chat_id="c1",
+ content="start",
+ metadata={
+ "message_id": "msg-1",
+ "origin_message_id": "msg-0",
+ "_wants_stream": True,
+ "_stream_id": "stream-1",
+ "_stream_delta": True,
+ "_stream_end": True,
+ "_resuming": True,
+ "webui": True,
+ },
+ ),
+ session_key="feishu:c1",
+ pending_queue=pending,
+ stop_reason="max_iterations",
+ final_content="paused",
+ all_messages=messages,
+ suppress_response=False,
+ visible_run_started_at=1234.5,
+ )
+
+ assert await maybe_continue_turn(ctx) is True
+
+ queued = pending.get_nowait()
+ assert queued.sender_id == "system:continuation"
+ assert queued.metadata[INTERNAL_CONTINUATION_META] is True
+ assert queued.metadata[INTERNAL_CONTINUATION_KIND_META] == "sustained_goal"
+ assert queued.metadata[INTERNAL_CONTINUATION_RUN_STARTED_AT_META] == 1234.5
+ assert internal_continuation_run_started_at(queued.metadata) == 1234.5
+ assert internal_continuation_pending(ctx.msg.metadata)
+ assert queued.metadata["webui"] is True
+ assert queued.metadata["message_id"] == "msg-1"
+ assert queued.metadata["origin_message_id"] == "msg-0"
+ assert queued.metadata["_wants_stream"] is True
+ assert "_stream_id" not in queued.metadata
+ assert "_stream_delta" not in queued.metadata
+ assert "_stream_end" not in queued.metadata
+ assert "_resuming" not in queued.metadata
+ assert "Finish the migration." in queued.content
+ assert ctx.all_messages == messages[:-1]
+ assert ctx.final_content == ""
+ assert ctx.suppress_response is True
+ assert ctx.msg.metadata[INTERNAL_CONTINUATION_PENDING_META] is True
+ assert meta["_sustained_goal_continuation_rounds"] == 1
+
+
+@pytest.mark.asyncio
+async def test_internal_continuation_respects_round_limit():
+ meta = {
+ GOAL_STATE_KEY: {"status": "active", "objective": "x"},
+ "_sustained_goal_continuation_rounds": 12,
+ }
+ ctx = SimpleNamespace(
+ session=SimpleNamespace(metadata=meta),
+ msg=InboundMessage(channel="feishu", sender_id="u1", chat_id="c1", content="start"),
+ session_key="feishu:c1",
+ pending_queue=asyncio.Queue(),
+ stop_reason="max_iterations",
+ final_content="paused",
+ all_messages=[],
+ )
+
+ assert should_stream_budget_response(
+ stop_reason="max_iterations",
+ pending_queue_available=True,
+ session_metadata=meta,
+ )
+ assert await maybe_continue_turn(ctx) is False
+
+
+def test_internal_continuation_requires_budget_boundary_and_queue():
+ meta = {GOAL_STATE_KEY: {"status": "active", "objective": "x"}}
+
+ assert should_stream_budget_response(
+ stop_reason="completed",
+ pending_queue_available=True,
+ session_metadata=meta,
+ )
+ assert should_stream_budget_response(
+ stop_reason="max_iterations",
+ pending_queue_available=False,
+ session_metadata=meta,
+ )
diff --git a/tests/utils/test_webui_turn_helpers.py b/tests/utils/test_webui_turn_helpers.py
index be01c5f45..cb8cbf488 100644
--- a/tests/utils/test_webui_turn_helpers.py
+++ b/tests/utils/test_webui_turn_helpers.py
@@ -31,6 +31,19 @@ async def test_publish_turn_run_status_running_records_wall_clock() -> None:
assert call.metadata.get("started_at") == t0
+@pytest.mark.asyncio
+async def test_publish_turn_run_status_reuses_explicit_wall_clock() -> None:
+ bus = MagicMock()
+ bus.publish_outbound = AsyncMock()
+ msg = InboundMessage(channel="websocket", sender_id="u", chat_id="chat-a", content="hi")
+
+ await wth.publish_turn_run_status(bus, msg, "running", started_at=1234.5)
+
+ assert wth.websocket_turn_wall_started_at("chat-a") == 1234.5
+ call = bus.publish_outbound.await_args[0][0]
+ assert call.metadata.get("started_at") == 1234.5
+
+
@pytest.mark.asyncio
async def test_publish_turn_run_status_idle_clears_wall_clock() -> None:
bus = MagicMock()
diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx
index 32becaec5..44029c036 100644
--- a/webui/src/components/thread/ThreadComposer.tsx
+++ b/webui/src/components/thread/ThreadComposer.tsx
@@ -393,6 +393,15 @@ function mcpPresetMentionPayload(preset: McpPresetInfo): OutboundMcpPresetMentio
};
}
+function RunPulseIcon() {
+ return (
+
+
+
+
+ );
+}
+
function RunElapsedStrip({
startedAt,
goalState,
@@ -586,7 +595,7 @@ function RunElapsedStrip({
aria-label={ariaLabel}
>
{displayShowTimer ? (
-
+
) : (
)}
diff --git a/webui/src/globals.css b/webui/src/globals.css
index 0afb9c334..e6a2af90f 100644
--- a/webui/src/globals.css
+++ b/webui/src/globals.css
@@ -301,6 +301,50 @@
.composer-status-strip[data-state="exit"] {
animation: composer-status-strip-exit 180ms ease-in both;
}
+ @keyframes run-pulse-dot {
+ 0%,
+ 100% {
+ transform: scale(0.9);
+ opacity: 0.76;
+ }
+ 50% {
+ transform: scale(1.08);
+ opacity: 1;
+ }
+ }
+ @keyframes run-pulse-ring {
+ 0% {
+ transform: scale(0.42);
+ opacity: 0.34;
+ }
+ 100% {
+ transform: scale(1.28);
+ opacity: 0;
+ }
+ }
+ .run-pulse-icon {
+ color: hsl(204 82% 46%);
+ }
+ .run-pulse-icon__ring,
+ .run-pulse-icon__dot {
+ display: block;
+ border-radius: 999px;
+ pointer-events: none;
+ }
+ .run-pulse-icon__ring {
+ position: absolute;
+ height: 12px;
+ width: 12px;
+ background: hsl(204 82% 46% / 0.22);
+ animation: run-pulse-ring 1.55s ease-out infinite;
+ }
+ .run-pulse-icon__dot {
+ height: 6px;
+ width: 6px;
+ background: currentColor;
+ box-shadow: 0 0 0 1px hsl(204 82% 46% / 0.14);
+ animation: run-pulse-dot 1.55s ease-in-out infinite;
+ }
@keyframes queued-prompt-row-enter {
0% {
opacity: 0;
@@ -321,6 +365,18 @@
.composer-status-strip[data-state] {
animation: none;
}
+ .run-pulse-icon,
+ .run-pulse-icon * {
+ animation: none;
+ }
+ .run-pulse-icon__ring {
+ opacity: 0.18;
+ transform: scale(1);
+ }
+ .run-pulse-icon__dot {
+ opacity: 1;
+ transform: scale(1);
+ }
.queued-prompt-row {
animation: none;
}
diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx
index 45f70387e..26cd5ce13 100644
--- a/webui/src/tests/thread-composer.test.tsx
+++ b/webui/src/tests/thread-composer.test.tsx
@@ -386,6 +386,7 @@ describe("ThreadComposer", () => {
expect(status).toHaveTextContent(/2:05/);
expect(status.parentElement).toHaveClass("composer-status-strip");
expect(status.parentElement).toHaveAttribute("data-state", "enter");
+ expect(status.querySelector(".run-pulse-icon")).not.toBeNull();
vi.useRealTimers();
});
From 76e857269db44a8c84112a77adccb577d52d7f3b Mon Sep 17 00:00:00 2001
From: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
Date: Mon, 1 Jun 2026 05:14:53 +0800
Subject: [PATCH 12/71] docs: update README news through May 30
---
README.md | 23 +++++++++++++++++++----
1 file changed, 19 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 1dbc82db8..fd50a8455 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,25 @@
## 📢 News
+- **2026-05-30** 🔐 Safer Matrix verification, bounded media downloads, clearer WebUI model timeline.
+- **2026-05-29** 🧩 Extension registry, context-window tuning, document extraction controls.
+- **2026-05-28** 🗂️ Project workspaces, access controls, steadier goals and streaming.
+- **2026-05-27** ⏱️ Codex streams respect idle timeouts during long runs.
+- **2026-05-26** 📡 Telegram webhooks, refreshed Kagi search, cleaner transport errors.
+- **2026-05-25** 🔌 Unified CLI Apps and MCP, Step Plan support, steadier sustained goals.
+- **2026-05-24** 🧰 MCP presets, richer slash actions, configurable OpenAI-compatible requests.
+- **2026-05-23** 🖼️ Zhipu image generation, longer exec windows, cleaner transcription config.
+- **2026-05-22** 🛠️ CLI Apps, more image providers, safer web redirects and edits.
+- **2026-05-21** ⚡ Novita provider, faster sidebar, smoother coding tools and Weixin replies.
+
+
+Earlier news
+
+- **2026-05-20** 📶 Signal channel, faster gateway startup, multilingual README links.
+- **2026-05-19** 🎨 Image provider registry, StepFun and Skywork, stronger WebUI controls.
+- **2026-05-18** 🖌️ Gemini and MiniMax images, Ant Ling, live file-edit activity.
+- **2026-05-17** 🌊 Smoother WebUI streaming, AutoCompact fixes, buffered CLI reasoning.
+- **2026-05-16** 🧠 Atomic Chat provider, goal-aware timeouts, safer exec URL handling.
- **2026-05-15** 🚀 Released **v0.2.0** — **`/goal`** holds sustained objectives across turns, WebUI now ships inside the wheel, image generation end to end, 5 new providers with `fallback_models`, and a real agent-loop refactor. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.0) for details.
- **2026-05-14** 🎯 **`/goal`** for long-term objectives, visible multi-step progress, long-horizon missions in chat.
- **2026-05-13** 🧠 Streaming reasoning before answers, automatic backup models, smoother plug-in reconnects.
@@ -45,10 +64,6 @@
- **2026-05-07** 📜 Locale-aware slash palette in WebUI, LAN login, faithful HTTP streaming responses.
- **2026-05-06** 🧩 Tunable tool hint, steadier voice and plug-in startups, schedules and reminders that stick.
- **2026-05-05** 🛡️ Quiet deny for unknown Telegram chats, Dream cleanup, fuller automation summaries.
-
-
-Earlier news
-
- **2026-05-04** 🔐 Safer DingTalk outbound media links, durable cron persistence, DeepSeek polish.
- **2026-05-03** ⚙️ Predictable shell allow-list behavior, isolated chats mid-reply, cleaner interactive retries.
- **2026-05-02** 🐈 LongCat support, smarter token sizing hints, clearer bundled upgrade guidance.
From b60e507010637fee29409b2037a739a162de5c79 Mon Sep 17 00:00:00 2001
From: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
Date: Mon, 1 Jun 2026 05:19:14 +0800
Subject: [PATCH 13/71] docs: sharpen README positioning
---
README.md | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index fd50a8455..0adb2dd4d 100644
--- a/README.md
+++ b/README.md
@@ -160,12 +160,13 @@
-## 💡 Key Features of nanobot
+## 💡 Why nanobot
-- **Ultra-lightweight**: stable long-running agent behavior with a small, readable core.
-- **Research-ready**: the codebase is intentionally simple enough to study, modify, and extend.
-- **Practical**: chat channels, API, memory, MCP, and deployment paths are already built in.
-- **Hackable**: you can start fast, then go deeper through repo docs instead of a monolithic landing page.
+- **Long-running by design**: goals, memory, tools, and channel context stay alive across real workflows.
+- **Meet users where they chat**: WebUI, API, Telegram, Feishu, Slack, Discord, Teams, email, and more from one agent loop.
+- **Bring your own model stack**: OpenAI-compatible providers, local LLMs, image generation, search, and fallback routing.
+- **Small core, serious surface area**: readable agent internals with MCP, memory, deployment, and automation built in.
+- **Built to be owned**: easy to inspect, customize, self-host, and extend without adopting a giant platform.
## 📦 Install
From ff809984231647406838a02acead6933fbfe05a5 Mon Sep 17 00:00:00 2001
From: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
Date: Mon, 1 Jun 2026 05:26:18 +0800
Subject: [PATCH 14/71] docs: tighten README positioning bullets
---
README.md | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 0adb2dd4d..e802caf07 100644
--- a/README.md
+++ b/README.md
@@ -162,11 +162,11 @@
## 💡 Why nanobot
-- **Long-running by design**: goals, memory, tools, and channel context stay alive across real workflows.
-- **Meet users where they chat**: WebUI, API, Telegram, Feishu, Slack, Discord, Teams, email, and more from one agent loop.
-- **Bring your own model stack**: OpenAI-compatible providers, local LLMs, image generation, search, and fallback routing.
-- **Small core, serious surface area**: readable agent internals with MCP, memory, deployment, and automation built in.
-- **Built to be owned**: easy to inspect, customize, self-host, and extend without adopting a giant platform.
+- **Persistent workflows**: goals, memory, tools, and chat context survive long-running work.
+- **Chat-native reach**: WebUI, API, Telegram, Feishu, Slack, Discord, Teams, and email.
+- **Model freedom**: OpenAI-compatible APIs, local LLMs, image generation, search, and fallbacks.
+- **Small core**: readable internals with MCP, memory, deployment, and automation built in.
+- **Own your stack**: inspect, customize, self-host, and extend without a giant platform.
## 📦 Install
From 081482b20f8b18d3e8df3bb00ffb98232f312dd1 Mon Sep 17 00:00:00 2001
From: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
Date: Mon, 1 Jun 2026 05:29:11 +0800
Subject: [PATCH 15/71] docs: refresh README opening positioning
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index e802caf07..02e563e87 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,7 @@