feat(webui): add localized slash commands

Add a session-scoped slash command palette sourced from backend command metadata, and keep welcome-page quick actions localized across all WebUI languages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xubin Ren 2026-05-06 15:54:15 +00:00 committed by Xubin Ren
parent 49c07aa45a
commit ac18a8baad
20 changed files with 1258 additions and 38 deletions

View File

@ -32,6 +32,7 @@ from websockets.http11 import Response
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.command.builtin import builtin_command_palette
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base from nanobot.config.schema import Base
from nanobot.utils.helpers import safe_filename from nanobot.utils.helpers import safe_filename
@ -553,6 +554,9 @@ class WebSocketChannel(BaseChannel):
if got == "/api/settings": if got == "/api/settings":
return self._handle_settings(request) return self._handle_settings(request)
if got == "/api/commands":
return self._handle_commands(request)
if got == "/api/settings/update": if got == "/api/settings/update":
return self._handle_settings_update(request) return self._handle_settings_update(request)
@ -708,6 +712,11 @@ class WebSocketChannel(BaseChannel):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
return _http_json_response(self._settings_payload()) return _http_json_response(self._settings_payload())
def _handle_commands(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response({"commands": builtin_command_palette()})
def _handle_settings_update(self, request: WsRequest) -> Response: def _handle_settings_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request): if not self._check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")

View File

@ -6,6 +6,7 @@ import asyncio
import os import os
import sys import sys
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass
from nanobot import __version__ from nanobot import __version__
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
@ -14,6 +15,88 @@ from nanobot.utils.helpers import build_status_content
from nanobot.utils.restart import set_restart_notice_to_env from nanobot.utils.restart import set_restart_notice_to_env
@dataclass(frozen=True)
class BuiltinCommandSpec:
command: str
title: str
description: str
icon: str
arg_hint: str = ""
def as_dict(self) -> dict[str, str]:
return {
"command": self.command,
"title": self.title,
"description": self.description,
"icon": self.icon,
"arg_hint": self.arg_hint,
}
BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
BuiltinCommandSpec(
"/new",
"New chat",
"Stop the current task and start a fresh conversation.",
"square-pen",
),
BuiltinCommandSpec(
"/stop",
"Stop current task",
"Cancel the active agent turn for this chat.",
"square",
),
BuiltinCommandSpec(
"/restart",
"Restart nanobot",
"Restart the bot process in place.",
"rotate-cw",
),
BuiltinCommandSpec(
"/status",
"Show status",
"Display runtime, provider, and channel status.",
"activity",
),
BuiltinCommandSpec(
"/history",
"Show conversation history",
"Print the last N persisted conversation messages.",
"history",
"[n]",
),
BuiltinCommandSpec(
"/dream",
"Run Dream",
"Manually trigger memory consolidation.",
"sparkles",
),
BuiltinCommandSpec(
"/dream-log",
"Show Dream log",
"Show what the last Dream consolidation changed.",
"book-open",
),
BuiltinCommandSpec(
"/dream-restore",
"Restore memory",
"Revert memory to a previous Dream snapshot.",
"undo-2",
),
BuiltinCommandSpec(
"/help",
"Show help",
"List available slash commands.",
"circle-help",
),
)
def builtin_command_palette() -> list[dict[str, str]]:
"""Return structured command metadata for UI command palettes."""
return [spec.as_dict() for spec in BUILTIN_COMMAND_SPECS]
async def cmd_stop(ctx: CommandContext) -> OutboundMessage: async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
"""Cancel all active tasks and subagents for the session.""" """Cancel all active tasks and subagents for the session."""
loop = ctx.loop loop = ctx.loop
@ -378,18 +461,12 @@ async def cmd_help(ctx: CommandContext) -> OutboundMessage:
def build_help_text() -> str: def build_help_text() -> str:
"""Build canonical help text shared across channels.""" """Build canonical help text shared across channels."""
lines = [ lines = ["🐈 nanobot commands:"]
"🐈 nanobot commands:", for spec in BUILTIN_COMMAND_SPECS:
"/new — Stop current task and start a new conversation", command = spec.command
"/stop — Stop the current task", if spec.arg_hint:
"/restart — Restart the bot", command = f"{command} {spec.arg_hint}"
"/status — Show bot status", lines.append(f"{command}{spec.description}")
"/history [n] — Show the last N conversation messages (default 10)",
"/dream — Manually trigger Dream consolidation",
"/dream-log — Show what the last Dream changed",
"/dream-restore — Revert memory to a previous state",
"/help — Show available commands",
]
return "\n".join(lines) return "\n".join(lines)

View File

@ -563,6 +563,34 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
await server_task await server_task
@pytest.mark.asyncio
async def test_commands_api_returns_slash_command_metadata(bus: MagicMock) -> None:
port = 29892
channel = _ch(bus, port=port)
channel._api_tokens["tok"] = time.monotonic() + 300
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
denied = await _http_get(f"http://127.0.0.1:{port}/api/commands")
assert denied.status_code == 401
response = await _http_get(
f"http://127.0.0.1:{port}/api/commands",
headers={"Authorization": "Bearer tok"},
)
assert response.status_code == 200
body = response.json()
commands = {row["command"]: row for row in body["commands"]}
assert commands["/stop"]["title"] == "Stop current task"
assert commands["/history"]["arg_hint"] == "[n]"
assert all("description" in row for row in body["commands"])
finally:
await channel.stop()
await server_task
def test_settings_payload_normalizes_camel_case_provider( def test_settings_payload_normalizes_camel_case_provider(
bus: MagicMock, bus: MagicMock,
monkeypatch, monkeypatch,

View File

@ -7,11 +7,21 @@ import {
type KeyboardEvent as ReactKeyboardEvent, type KeyboardEvent as ReactKeyboardEvent,
} from "react"; } from "react";
import { import {
Activity,
ArrowUp, ArrowUp,
BookOpen,
CircleHelp,
History,
ImageIcon, ImageIcon,
Loader2, Loader2,
Plus, Plus,
RotateCw,
Sparkles,
Square,
SquarePen,
Undo2,
X, X,
type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -24,6 +34,7 @@ import {
} from "@/hooks/useAttachedImages"; } from "@/hooks/useAttachedImages";
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop"; import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import type { SendImage } from "@/hooks/useNanobotStream"; import type { SendImage } from "@/hooks/useNanobotStream";
import type { SlashCommand } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is /** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
@ -43,6 +54,23 @@ interface ThreadComposerProps {
isStreaming?: boolean; isStreaming?: boolean;
modelLabel?: string | null; modelLabel?: string | null;
variant?: "thread" | "hero"; variant?: "thread" | "hero";
slashCommands?: SlashCommand[];
}
const COMMAND_ICONS: Record<string, LucideIcon> = {
activity: Activity,
"book-open": BookOpen,
"circle-help": CircleHelp,
history: History,
"rotate-cw": RotateCw,
sparkles: Sparkles,
square: Square,
"square-pen": SquarePen,
"undo-2": Undo2,
};
function slashCommandI18nKey(command: string): string {
return command.replace(/^\//, "").replace(/-/g, "_");
} }
export function ThreadComposer({ export function ThreadComposer({
@ -52,10 +80,13 @@ export function ThreadComposer({
isStreaming = false, isStreaming = false,
modelLabel = null, modelLabel = null,
variant = "thread", variant = "thread",
slashCommands = [],
}: ThreadComposerProps) { }: ThreadComposerProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const [inlineError, setInlineError] = useState<string | null>(null); const [inlineError, setInlineError] = useState<string | null>(null);
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const chipRefs = useRef(new Map<string, HTMLButtonElement>()); const chipRefs = useRef(new Map<string, HTMLButtonElement>());
@ -119,6 +150,66 @@ export function ThreadComposer({
&& !hasErrors && !hasErrors
&& (value.trim().length > 0 || readyImages.length > 0); && (value.trim().length > 0 || readyImages.length > 0);
const slashQuery = useMemo(() => {
if (disabled || slashMenuDismissed || !value.startsWith("/")) return null;
const commandToken = value.slice(1);
if (/\s/.test(commandToken)) return null;
return commandToken.toLowerCase();
}, [disabled, slashMenuDismissed, value]);
const filteredSlashCommands = useMemo(() => {
if (slashQuery === null) return [];
return slashCommands
.filter((command) => {
const haystack = [
command.command,
command.title,
command.description,
command.argHint ?? "",
t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.title`, {
defaultValue: "",
}),
t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.description`, {
defaultValue: "",
}),
].join(" ").toLowerCase();
return haystack.includes(slashQuery);
})
.slice(0, 8);
}, [slashCommands, slashQuery, t]);
const showSlashMenu = filteredSlashCommands.length > 0;
useEffect(() => {
setSelectedCommandIndex(0);
}, [slashQuery]);
useEffect(() => {
if (selectedCommandIndex >= filteredSlashCommands.length) {
setSelectedCommandIndex(0);
}
}, [filteredSlashCommands.length, selectedCommandIndex]);
const resizeTextarea = useCallback(() => {
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
el.focus();
});
}, []);
const chooseSlashCommand = useCallback(
(command: SlashCommand) => {
setValue(command.argHint ? `${command.command} ` : command.command);
setSlashMenuDismissed(true);
setInlineError(null);
resizeTextarea();
},
[resizeTextarea],
);
const submit = useCallback(() => { const submit = useCallback(() => {
if (!canSend) return; if (!canSend) return;
const trimmed = value.trim(); const trimmed = value.trim();
@ -142,16 +233,35 @@ export function ThreadComposer({
// Bubble owns the data URL copy; safe to revoke every staged blob // Bubble owns the data URL copy; safe to revoke every staged blob
// preview here without affecting the rendered message. // preview here without affecting the rendered message.
clear(); clear();
requestAnimationFrame(() => { setSlashMenuDismissed(false);
const el = textareaRef.current; resizeTextarea();
if (el) { }, [canSend, clear, onSend, readyImages, resizeTextarea, value]);
el.style.height = "auto";
el.focus();
}
});
}, [canSend, clear, onSend, readyImages, value]);
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => { const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (showSlashMenu) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedCommandIndex((idx) => (idx + 1) % filteredSlashCommands.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedCommandIndex(
(idx) => (idx - 1 + filteredSlashCommands.length) % filteredSlashCommands.length,
);
return;
}
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
e.preventDefault();
chooseSlashCommand(filteredSlashCommands[selectedCommandIndex]);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setSlashMenuDismissed(true);
return;
}
}
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault(); e.preventDefault();
submit(); submit();
@ -213,8 +323,17 @@ export function ThreadComposer({
onDragOver={onDragOver} onDragOver={onDragOver}
onDragLeave={onDragLeave} onDragLeave={onDragLeave}
onDrop={onDrop} onDrop={onDrop}
className={cn("w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")} className={cn("relative w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
> >
{showSlashMenu ? (
<SlashCommandPalette
commands={filteredSlashCommands}
selectedIndex={selectedCommandIndex}
isHero={isHero}
onHover={setSelectedCommandIndex}
onChoose={chooseSlashCommand}
/>
) : null}
<div <div
className={cn( className={cn(
"relative mx-auto flex w-full flex-col overflow-hidden transition-all duration-200", "relative mx-auto flex w-full flex-col overflow-hidden transition-all duration-200",
@ -257,7 +376,10 @@ export function ThreadComposer({
<textarea <textarea
ref={textareaRef} ref={textareaRef}
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => {
setValue(e.target.value);
setSlashMenuDismissed(false);
}}
onInput={onInput} onInput={onInput}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onPaste={onPaste} onPaste={onPaste}
@ -367,6 +489,106 @@ export function ThreadComposer({
); );
} }
interface SlashCommandPaletteProps {
commands: SlashCommand[];
selectedIndex: number;
isHero: boolean;
onHover: (index: number) => void;
onChoose: (command: SlashCommand) => void;
}
function SlashCommandPalette({
commands,
selectedIndex,
isHero,
onHover,
onChoose,
}: SlashCommandPaletteProps) {
const { t } = useTranslation();
return (
<div
role="listbox"
aria-label={t("thread.composer.slash.ariaLabel")}
className={cn(
"absolute bottom-full left-1/2 z-30 mb-2 max-h-[22rem] w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
"border-border/65 bg-popover/98 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur",
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)}
>
<div className="px-2 pb-1 pt-1 text-[11px] font-medium tracking-[0.08em] text-muted-foreground/70">
{t("thread.composer.slash.label")}
</div>
<div className="max-h-[18rem] overflow-y-auto pr-0.5">
{commands.map((command, index) => {
const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp;
const selected = index === selectedIndex;
const commandKey = slashCommandI18nKey(command.command);
const title = t(`thread.composer.slash.commands.${commandKey}.title`, {
defaultValue: command.title,
});
const description = t(`thread.composer.slash.commands.${commandKey}.description`, {
defaultValue: command.description,
});
return (
<button
key={command.command}
type="button"
role="option"
aria-selected={selected}
onMouseEnter={() => onHover(index)}
onMouseDown={(e) => {
e.preventDefault();
onChoose(command);
}}
className={cn(
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
selected
? "bg-primary/10 text-foreground"
: "text-foreground/86 hover:bg-accent/55",
)}
>
<span
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-[10px] border",
selected
? "border-primary/25 bg-primary/12 text-primary"
: "border-border/65 bg-muted/45 text-muted-foreground",
)}
>
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-baseline gap-2">
<span className="font-mono text-[13px] font-semibold text-foreground">
{command.command}
</span>
{command.argHint ? (
<span className="font-mono text-[12px] text-muted-foreground">
{command.argHint}
</span>
) : null}
<span className="truncate text-[13px] font-medium">
{title}
</span>
</span>
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
{description}
</span>
</span>
</button>
);
})}
</div>
<div className="flex items-center gap-2 px-2 pt-1.5 text-[10.5px] text-muted-foreground/70">
<span>{t("thread.composer.slash.navigateHint")}</span>
<span>{t("thread.composer.slash.selectHint")}</span>
<span>{t("thread.composer.slash.closeHint")}</span>
</div>
</div>
);
}
interface AttachmentChipProps { interface AttachmentChipProps {
image: AttachedImage; image: AttachedImage;
labelRemove: string; labelRemove: string;

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { import {
BarChart3, BarChart3,
BookOpen, BookOpen,
@ -17,7 +17,8 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport } from "@/components/thread/ThreadViewport"; import { ThreadViewport } from "@/components/thread/ThreadViewport";
import { useNanobotStream } from "@/hooks/useNanobotStream"; import { useNanobotStream } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions"; import { useSessionHistory } from "@/hooks/useSessions";
import type { ChatSummary, UIMessage } from "@/lib/types"; import { listSlashCommands } from "@/lib/api";
import type { ChatSummary, SlashCommand, UIMessage } from "@/lib/types";
import { useClient } from "@/providers/ClientProvider"; import { useClient } from "@/providers/ClientProvider";
interface ThreadShellProps { interface ThreadShellProps {
@ -66,8 +67,9 @@ export function ThreadShell({
const chatId = session?.chatId ?? null; const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null; const historyKey = session?.key ?? null;
const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey); const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey);
const { client, modelName } = useClient(); const { client, modelName, token } = useClient();
const [booting, setBooting] = useState(false); const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const pendingFirstRef = useRef<string | null>(null); const pendingFirstRef = useRef<string | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map()); const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
const lastCachedChatIdRef = useRef<string | null>(null); const lastCachedChatIdRef = useRef<string | null>(null);
@ -116,17 +118,24 @@ export function ThreadShell({
setMessages(historical); setMessages(historical);
}, [chatId, historical, setMessages]); }, [chatId, historical, setMessages]);
useEffect(() => { useLayoutEffect(() => {
if (!chatId) return; if (!chatId) {
lastCachedChatIdRef.current = null;
return;
}
if (loading) return;
// Skip the first cache write after a chat switch. During that render, // Skip the first cache write after a chat switch. During that render,
// `messages` can still belong to the previous chat until the stream hook // `messages` can still belong to the previous chat until the stream hook
// resets its local state for the new session. // resets its local state for the new session.
if (lastCachedChatIdRef.current !== chatId) { if (lastCachedChatIdRef.current !== chatId) {
lastCachedChatIdRef.current = chatId; lastCachedChatIdRef.current = chatId;
if (messages.length > 0) {
messageCacheRef.current.set(chatId, messages);
}
return; return;
} }
messageCacheRef.current.set(chatId, messages); messageCacheRef.current.set(chatId, messages);
}, [chatId, messages]); }, [chatId, loading, messages]);
useEffect(() => { useEffect(() => {
if (!chatId) return; if (!chatId) return;
@ -146,6 +155,21 @@ export function ThreadShell({
setBooting(false); setBooting(false);
}, [chatId, client, setMessages]); }, [chatId, client, setMessages]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const commands = await listSlashCommands(token);
if (!cancelled) setSlashCommands(commands);
} catch {
if (!cancelled) setSlashCommands([]);
}
})();
return () => {
cancelled = true;
};
}, [token]);
const handleWelcomeSend = useCallback( const handleWelcomeSend = useCallback(
async (content: string) => { async (content: string) => {
if (booting) return; if (booting) return;
@ -222,6 +246,7 @@ export function ThreadShell({
} }
modelLabel={toModelBadgeLabel(modelName)} modelLabel={toModelBadgeLabel(modelName)}
variant={showHeroComposer ? "hero" : "thread"} variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands}
/> />
) : ( ) : (
<ThreadComposer <ThreadComposer

View File

@ -127,6 +127,51 @@
"deepResearch": "Deep research", "deepResearch": "Deep research",
"voice": "Voice input" "voice": "Voice input"
}, },
"slash": {
"ariaLabel": "Slash commands",
"label": "commands",
"navigateHint": "↑↓ Navigate",
"selectHint": "Enter/Tab Select",
"closeHint": "Esc Close",
"commands": {
"new": {
"title": "New chat",
"description": "Stop the current task and start a fresh conversation."
},
"stop": {
"title": "Stop current task",
"description": "Cancel the active agent turn for this chat."
},
"restart": {
"title": "Restart nanobot",
"description": "Restart the bot process in place."
},
"status": {
"title": "Show status",
"description": "Display runtime, provider, and channel status."
},
"history": {
"title": "Show conversation history",
"description": "Print the last N persisted conversation messages."
},
"dream": {
"title": "Run Dream",
"description": "Manually trigger memory consolidation."
},
"dream_log": {
"title": "Show Dream log",
"description": "Show what the last Dream consolidation changed."
},
"dream_restore": {
"title": "Restore memory",
"description": "Revert memory to a previous Dream snapshot."
},
"help": {
"title": "Show help",
"description": "List available slash commands."
}
}
},
"encoding": "Encoding…", "encoding": "Encoding…",
"remove": "Remove attachment", "remove": "Remove attachment",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)", "normalizedSizeHint": "{{orig}} → {{current}} (auto)",

View File

@ -53,7 +53,34 @@
"thread": { "thread": {
"loadingConversation": "Cargando conversación…", "loadingConversation": "Cargando conversación…",
"empty": { "empty": {
"description": "Haz preguntas, continúa tu trabajo local o inicia un nuevo hilo." "description": "Haz preguntas, continúa tu trabajo local o inicia un nuevo hilo.",
"greeting": "¿Qué puedo hacer por ti?",
"quickActions": {
"plan": {
"title": "Crear un plan de proyecto",
"prompt": "Crea un plan de proyecto conciso para lo que debería construir después."
},
"analyze": {
"title": "Analizar estos datos",
"prompt": "Ayúdame a analizar estos datos y destaca los patrones más importantes."
},
"brainstorm": {
"title": "Lluvia de ideas",
"prompt": "Propón algunas ideas prácticas y sus compensaciones para este problema."
},
"code": {
"title": "Escribir código",
"prompt": "Ayúdame a escribir el código para esta tarea, empezando por el cambio útil más pequeño."
},
"summarize": {
"title": "Resumir este documento",
"prompt": "Resume este documento y enumera las conclusiones clave."
},
"more": {
"title": "Más",
"prompt": "Muéstrame algunas formas útiles en las que puedes ayudar en este workspace."
}
}
}, },
"header": { "header": {
"toggleSidebar": "Mostrar u ocultar la barra lateral" "toggleSidebar": "Mostrar u ocultar la barra lateral"
@ -77,6 +104,51 @@
"decode_failed": "No se pudo decodificar esta imagen", "decode_failed": "No se pudo decodificar esta imagen",
"too_large": "Imagen demasiado grande — prueba una más pequeña", "too_large": "Imagen demasiado grande — prueba una más pequeña",
"io": "No se pudo leer este archivo" "io": "No se pudo leer este archivo"
},
"slash": {
"ariaLabel": "Comandos slash",
"label": "comandos",
"navigateHint": "↑↓ Navegar",
"selectHint": "Enter/Tab Insertar",
"closeHint": "Esc Cerrar",
"commands": {
"new": {
"title": "Nuevo chat",
"description": "Detiene la tarea actual e inicia una conversación nueva."
},
"stop": {
"title": "Detener tarea actual",
"description": "Cancela el turno activo del agent en este chat."
},
"restart": {
"title": "Reiniciar nanobot",
"description": "Reinicia el proceso del bot en el mismo lugar."
},
"status": {
"title": "Mostrar estado",
"description": "Muestra el estado del runtime, provider y channels."
},
"history": {
"title": "Mostrar historial",
"description": "Imprime los últimos N mensajes persistidos de la conversación."
},
"dream": {
"title": "Ejecutar Dream",
"description": "Activa manualmente la consolidación de memoria."
},
"dream_log": {
"title": "Mostrar registro de Dream",
"description": "Muestra qué cambió la última consolidación Dream."
},
"dream_restore": {
"title": "Restaurar memoria",
"description": "Revierte la memoria a una instantánea Dream anterior."
},
"help": {
"title": "Mostrar ayuda",
"description": "Lista los comandos slash disponibles."
}
}
} }
}, },
"scrollToBottom": "Desplazarse al final" "scrollToBottom": "Desplazarse al final"

View File

@ -53,7 +53,34 @@
"thread": { "thread": {
"loadingConversation": "Chargement de la conversation…", "loadingConversation": "Chargement de la conversation…",
"empty": { "empty": {
"description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil." "description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil.",
"greeting": "Que puis-je faire pour vous ?",
"quickActions": {
"plan": {
"title": "Créer un plan de projet",
"prompt": "Créez un plan de projet concis pour ce que je devrais construire ensuite."
},
"analyze": {
"title": "Analyser ces données",
"prompt": "Aidez-moi à analyser ces données et à faire ressortir les tendances les plus importantes."
},
"brainstorm": {
"title": "Trouver des idées",
"prompt": "Proposez quelques idées pratiques et leurs compromis pour ce problème."
},
"code": {
"title": "Écrire du code",
"prompt": "Aidez-moi à écrire le code pour cette tâche, en commençant par le plus petit changement utile."
},
"summarize": {
"title": "Résumer ce document",
"prompt": "Résumez ce document et listez les points clés à retenir."
},
"more": {
"title": "Plus",
"prompt": "Montrez-moi quelques façons utiles dont vous pouvez maider dans cet espace de travail."
}
}
}, },
"header": { "header": {
"toggleSidebar": "Afficher ou masquer la barre latérale" "toggleSidebar": "Afficher ou masquer la barre latérale"
@ -77,6 +104,51 @@
"decode_failed": "Impossible de décoder cette image", "decode_failed": "Impossible de décoder cette image",
"too_large": "Image trop grande — essayez-en une plus petite", "too_large": "Image trop grande — essayez-en une plus petite",
"io": "Impossible de lire ce fichier" "io": "Impossible de lire ce fichier"
},
"slash": {
"ariaLabel": "Commandes slash",
"label": "commandes",
"navigateHint": "↑↓ Naviguer",
"selectHint": "Entrée/Tab Insérer",
"closeHint": "Échap Fermer",
"commands": {
"new": {
"title": "Nouvelle discussion",
"description": "Arrêter la tâche en cours et démarrer une nouvelle conversation."
},
"stop": {
"title": "Arrêter la tâche en cours",
"description": "Annuler le tour agent actif pour cette discussion."
},
"restart": {
"title": "Redémarrer nanobot",
"description": "Redémarrer le processus du bot sur place."
},
"status": {
"title": "Afficher létat",
"description": "Afficher létat du runtime, du provider et des channels."
},
"history": {
"title": "Afficher lhistorique",
"description": "Afficher les N derniers messages persistés de la conversation."
},
"dream": {
"title": "Lancer Dream",
"description": "Déclencher manuellement la consolidation de la mémoire."
},
"dream_log": {
"title": "Afficher le journal Dream",
"description": "Afficher ce que la dernière consolidation Dream a changé."
},
"dream_restore": {
"title": "Restaurer la mémoire",
"description": "Revenir à un instantané Dream précédent."
},
"help": {
"title": "Afficher laide",
"description": "Lister les commandes slash disponibles."
}
}
} }
}, },
"scrollToBottom": "Faire défiler vers le bas" "scrollToBottom": "Faire défiler vers le bas"

View File

@ -53,7 +53,34 @@
"thread": { "thread": {
"loadingConversation": "Memuat percakapan…", "loadingConversation": "Memuat percakapan…",
"empty": { "empty": {
"description": "Ajukan pertanyaan, lanjutkan pekerjaan lokal, atau mulai thread baru." "description": "Ajukan pertanyaan, lanjutkan pekerjaan lokal, atau mulai thread baru.",
"greeting": "Apa yang bisa saya bantu?",
"quickActions": {
"plan": {
"title": "Buat rencana proyek",
"prompt": "Buat rencana proyek ringkas untuk apa yang sebaiknya saya bangun berikutnya."
},
"analyze": {
"title": "Analisis data ini",
"prompt": "Bantu saya menganalisis data ini dan soroti pola yang paling penting."
},
"brainstorm": {
"title": "Brainstorm ide",
"prompt": "Brainstorm beberapa ide praktis dan tradeoff untuk masalah ini."
},
"code": {
"title": "Tulis kode",
"prompt": "Bantu saya menulis kode untuk tugas ini, mulai dari perubahan berguna yang paling kecil."
},
"summarize": {
"title": "Ringkas dokumen ini",
"prompt": "Ringkas dokumen ini dan daftar poin-poin utamanya."
},
"more": {
"title": "Lainnya",
"prompt": "Tunjukkan beberapa cara berguna Anda dapat membantu di workspace ini."
}
}
}, },
"header": { "header": {
"toggleSidebar": "Tampilkan atau sembunyikan sidebar" "toggleSidebar": "Tampilkan atau sembunyikan sidebar"
@ -77,6 +104,51 @@
"decode_failed": "Tidak dapat mendekode gambar ini", "decode_failed": "Tidak dapat mendekode gambar ini",
"too_large": "Gambar terlalu besar — coba yang lebih kecil", "too_large": "Gambar terlalu besar — coba yang lebih kecil",
"io": "Tidak dapat membaca file ini" "io": "Tidak dapat membaca file ini"
},
"slash": {
"ariaLabel": "Perintah slash",
"label": "perintah",
"navigateHint": "↑↓ Pilih",
"selectHint": "Enter/Tab Sisipkan",
"closeHint": "Esc Tutup",
"commands": {
"new": {
"title": "Obrolan baru",
"description": "Hentikan tugas saat ini dan mulai percakapan baru."
},
"stop": {
"title": "Hentikan tugas saat ini",
"description": "Batalkan giliran agent yang sedang aktif di chat ini."
},
"restart": {
"title": "Mulai ulang nanobot",
"description": "Mulai ulang proses bot di tempat."
},
"status": {
"title": "Tampilkan status",
"description": "Tampilkan status runtime, provider, dan channel."
},
"history": {
"title": "Tampilkan riwayat",
"description": "Cetak N pesan percakapan tersimpan terbaru."
},
"dream": {
"title": "Jalankan Dream",
"description": "Picu konsolidasi memori secara manual."
},
"dream_log": {
"title": "Tampilkan log Dream",
"description": "Tampilkan perubahan dari konsolidasi Dream terakhir."
},
"dream_restore": {
"title": "Pulihkan memori",
"description": "Kembalikan memori ke snapshot Dream sebelumnya."
},
"help": {
"title": "Tampilkan bantuan",
"description": "Daftar perintah slash yang tersedia."
}
}
} }
}, },
"scrollToBottom": "Gulir ke bawah" "scrollToBottom": "Gulir ke bawah"

View File

@ -53,7 +53,34 @@
"thread": { "thread": {
"loadingConversation": "会話を読み込み中…", "loadingConversation": "会話を読み込み中…",
"empty": { "empty": {
"description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。" "description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。",
"greeting": "何をお手伝いしましょうか?",
"quickActions": {
"plan": {
"title": "プロジェクト計画を作成",
"prompt": "次に作るものについて、簡潔なプロジェクト計画を作成してください。"
},
"analyze": {
"title": "このデータを分析",
"prompt": "このデータを分析し、最も重要なパターンを指摘してください。"
},
"brainstorm": {
"title": "アイデアを出す",
"prompt": "この問題について、実用的なアイデアとトレードオフをいくつか出してください。"
},
"code": {
"title": "コードを書く",
"prompt": "このタスクのコードを書くのを手伝ってください。まず最小限の有用な変更から始めてください。"
},
"summarize": {
"title": "この文書を要約",
"prompt": "この文書を要約し、重要なポイントを列挙してください。"
},
"more": {
"title": "その他",
"prompt": "このワークスペースであなたが手伝える便利な方法をいくつか見せてください。"
}
}
}, },
"header": { "header": {
"toggleSidebar": "サイドバーを切り替える" "toggleSidebar": "サイドバーを切り替える"
@ -77,6 +104,51 @@
"decode_failed": "この画像をデコードできません", "decode_failed": "この画像をデコードできません",
"too_large": "画像が大きすぎます。小さいものを選んでください", "too_large": "画像が大きすぎます。小さいものを選んでください",
"io": "このファイルを読み込めません" "io": "このファイルを読み込めません"
},
"slash": {
"ariaLabel": "スラッシュコマンド",
"label": "コマンド",
"navigateHint": "↑↓ 選択",
"selectHint": "Enter/Tab 入力",
"closeHint": "Esc 閉じる",
"commands": {
"new": {
"title": "新しいチャット",
"description": "現在のタスクを停止して、新しい会話を開始します。"
},
"stop": {
"title": "現在のタスクを停止",
"description": "このチャットで実行中の agent ターンをキャンセルします。"
},
"restart": {
"title": "nanobot を再起動",
"description": "bot プロセスをその場で再起動します。"
},
"status": {
"title": "ステータスを表示",
"description": "ランタイム、provider、channel の状態を表示します。"
},
"history": {
"title": "会話履歴を表示",
"description": "保存済みの直近 N 件の会話メッセージを表示します。"
},
"dream": {
"title": "Dream を実行",
"description": "メモリ統合を手動で開始します。"
},
"dream_log": {
"title": "Dream ログを表示",
"description": "直近の Dream 統合で変更された内容を表示します。"
},
"dream_restore": {
"title": "メモリを復元",
"description": "以前の Dream スナップショットへメモリを戻します。"
},
"help": {
"title": "ヘルプを表示",
"description": "利用可能なスラッシュコマンドを一覧表示します。"
}
}
} }
}, },
"scrollToBottom": "一番下へスクロール" "scrollToBottom": "一番下へスクロール"

View File

@ -53,7 +53,34 @@
"thread": { "thread": {
"loadingConversation": "대화 불러오는 중…", "loadingConversation": "대화 불러오는 중…",
"empty": { "empty": {
"description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다." "description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다.",
"greeting": "무엇을 도와드릴까요?",
"quickActions": {
"plan": {
"title": "프로젝트 계획 만들기",
"prompt": "다음에 만들 것에 대한 간결한 프로젝트 계획을 작성해 주세요."
},
"analyze": {
"title": "이 데이터 분석하기",
"prompt": "이 데이터를 분석하고 가장 중요한 패턴을 짚어 주세요."
},
"brainstorm": {
"title": "아이디어 브레인스토밍",
"prompt": "이 문제에 대한 실용적인 아이디어와 트레이드오프를 몇 가지 제안해 주세요."
},
"code": {
"title": "코드 작성하기",
"prompt": "이 작업을 위한 코드를 작성해 주세요. 가장 작은 유용한 변경부터 시작해 주세요."
},
"summarize": {
"title": "문서 요약하기",
"prompt": "이 문서를 요약하고 핵심 내용을 정리해 주세요."
},
"more": {
"title": "더 보기",
"prompt": "이 워크스페이스에서 도와줄 수 있는 유용한 방법을 몇 가지 보여 주세요."
}
}
}, },
"header": { "header": {
"toggleSidebar": "사이드바 전환" "toggleSidebar": "사이드바 전환"
@ -77,6 +104,51 @@
"decode_failed": "이 이미지를 디코딩할 수 없습니다", "decode_failed": "이 이미지를 디코딩할 수 없습니다",
"too_large": "이미지가 너무 큽니다. 더 작은 걸로 시도해 주세요", "too_large": "이미지가 너무 큽니다. 더 작은 걸로 시도해 주세요",
"io": "이 파일을 읽을 수 없습니다" "io": "이 파일을 읽을 수 없습니다"
},
"slash": {
"ariaLabel": "슬래시 명령",
"label": "명령",
"navigateHint": "↑↓ 선택",
"selectHint": "Enter/Tab 입력",
"closeHint": "Esc 닫기",
"commands": {
"new": {
"title": "새 채팅",
"description": "현재 작업을 중지하고 새 대화를 시작합니다."
},
"stop": {
"title": "현재 작업 중지",
"description": "이 채팅에서 실행 중인 agent 턴을 취소합니다."
},
"restart": {
"title": "nanobot 재시작",
"description": "bot 프로세스를 제자리에서 재시작합니다."
},
"status": {
"title": "상태 보기",
"description": "런타임, provider, channel 상태를 표시합니다."
},
"history": {
"title": "대화 기록 보기",
"description": "저장된 최근 N개의 대화 메시지를 출력합니다."
},
"dream": {
"title": "Dream 실행",
"description": "메모리 정리를 수동으로 트리거합니다."
},
"dream_log": {
"title": "Dream 로그 보기",
"description": "마지막 Dream 정리에서 변경된 내용을 표시합니다."
},
"dream_restore": {
"title": "메모리 복원",
"description": "이전 Dream 스냅샷으로 메모리를 되돌립니다."
},
"help": {
"title": "도움말 보기",
"description": "사용 가능한 슬래시 명령을 나열합니다."
}
}
} }
}, },
"scrollToBottom": "맨 아래로 스크롤" "scrollToBottom": "맨 아래로 스크롤"

View File

@ -53,7 +53,34 @@
"thread": { "thread": {
"loadingConversation": "Đang tải cuộc trò chuyện…", "loadingConversation": "Đang tải cuộc trò chuyện…",
"empty": { "empty": {
"description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới." "description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới.",
"greeting": "Tôi có thể giúp gì cho bạn?",
"quickActions": {
"plan": {
"title": "Tạo kế hoạch dự án",
"prompt": "Tạo một kế hoạch dự án ngắn gọn cho việc tôi nên xây dựng tiếp theo."
},
"analyze": {
"title": "Phân tích dữ liệu này",
"prompt": "Giúp tôi phân tích dữ liệu này và chỉ ra các mẫu quan trọng nhất."
},
"brainstorm": {
"title": "Động não ý tưởng",
"prompt": "Động não vài ý tưởng thực tế và các đánh đổi cho vấn đề này."
},
"code": {
"title": "Viết mã",
"prompt": "Giúp tôi viết mã cho nhiệm vụ này, bắt đầu từ thay đổi hữu ích nhỏ nhất."
},
"summarize": {
"title": "Tóm tắt tài liệu này",
"prompt": "Tóm tắt tài liệu này và liệt kê các ý chính."
},
"more": {
"title": "Thêm",
"prompt": "Cho tôi xem vài cách hữu ích mà bạn có thể giúp trong workspace này."
}
}
}, },
"header": { "header": {
"toggleSidebar": "Bật/tắt thanh bên" "toggleSidebar": "Bật/tắt thanh bên"
@ -77,6 +104,51 @@
"decode_failed": "Không thể giải mã ảnh này", "decode_failed": "Không thể giải mã ảnh này",
"too_large": "Ảnh quá lớn — hãy thử ảnh nhỏ hơn", "too_large": "Ảnh quá lớn — hãy thử ảnh nhỏ hơn",
"io": "Không thể đọc tệp này" "io": "Không thể đọc tệp này"
},
"slash": {
"ariaLabel": "Lệnh slash",
"label": "lệnh",
"navigateHint": "↑↓ Chọn",
"selectHint": "Enter/Tab Chèn",
"closeHint": "Esc Đóng",
"commands": {
"new": {
"title": "Cuộc trò chuyện mới",
"description": "Dừng tác vụ hiện tại và bắt đầu một cuộc trò chuyện mới."
},
"stop": {
"title": "Dừng tác vụ hiện tại",
"description": "Hủy lượt agent đang chạy trong cuộc trò chuyện này."
},
"restart": {
"title": "Khởi động lại nanobot",
"description": "Khởi động lại tiến trình bot tại chỗ."
},
"status": {
"title": "Hiển thị trạng thái",
"description": "Hiển thị trạng thái runtime, provider và channel."
},
"history": {
"title": "Hiển thị lịch sử",
"description": "In N tin nhắn hội thoại đã lưu gần nhất."
},
"dream": {
"title": "Chạy Dream",
"description": "Kích hoạt thủ công quá trình hợp nhất bộ nhớ."
},
"dream_log": {
"title": "Hiển thị nhật ký Dream",
"description": "Hiển thị những gì lần hợp nhất Dream gần nhất đã thay đổi."
},
"dream_restore": {
"title": "Khôi phục bộ nhớ",
"description": "Đưa bộ nhớ về một snapshot Dream trước đó."
},
"help": {
"title": "Hiển thị trợ giúp",
"description": "Liệt kê các lệnh slash có sẵn."
}
}
} }
}, },
"scrollToBottom": "Cuộn xuống cuối" "scrollToBottom": "Cuộn xuống cuối"

View File

@ -115,6 +115,51 @@
"deepResearch": "深度研究", "deepResearch": "深度研究",
"voice": "语音输入" "voice": "语音输入"
}, },
"slash": {
"ariaLabel": "斜杠命令",
"label": "命令",
"navigateHint": "↑↓ 选择",
"selectHint": "Enter/Tab 填入",
"closeHint": "Esc 关闭",
"commands": {
"new": {
"title": "新建对话",
"description": "停止当前任务,并开始一个新的对话。"
},
"stop": {
"title": "停止当前任务",
"description": "取消这个对话中正在运行的 agent 回合。"
},
"restart": {
"title": "重启 nanobot",
"description": "原地重启 bot 进程。"
},
"status": {
"title": "查看状态",
"description": "显示运行时、provider 和 channel 状态。"
},
"history": {
"title": "查看对话历史",
"description": "打印最近 N 条已持久化的对话消息。"
},
"dream": {
"title": "运行 Dream",
"description": "手动触发记忆整理。"
},
"dream_log": {
"title": "查看 Dream 日志",
"description": "查看上一次 Dream 整理改变了什么。"
},
"dream_restore": {
"title": "恢复记忆",
"description": "将记忆恢复到之前的 Dream 快照。"
},
"help": {
"title": "查看帮助",
"description": "列出可用的斜杠命令。"
}
}
},
"encoding": "处理中…", "encoding": "处理中…",
"remove": "移除附件", "remove": "移除附件",
"normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)", "normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)",

View File

@ -53,7 +53,34 @@
"thread": { "thread": {
"loadingConversation": "正在載入對話…", "loadingConversation": "正在載入對話…",
"empty": { "empty": {
"description": "你可以提問、延續本地工作,或是開始新的執行緒。" "description": "你可以提問、延續本地工作,或是開始新的執行緒。",
"greeting": "我可以幫你做什麼?",
"quickActions": {
"plan": {
"title": "建立專案計畫",
"prompt": "幫我為接下來要做的事情寫一份簡潔的專案計畫。"
},
"analyze": {
"title": "分析這些資料",
"prompt": "幫我分析這些資料,並指出最重要的模式。"
},
"brainstorm": {
"title": "腦力激盪想法",
"prompt": "圍繞這個問題腦力激盪幾個實用方案,並說明取捨。"
},
"code": {
"title": "撰寫程式碼",
"prompt": "幫我為這個任務撰寫程式碼,先從最小可用改動開始。"
},
"summarize": {
"title": "總結這份文件",
"prompt": "幫我總結這份文件,並列出關鍵重點。"
},
"more": {
"title": "更多",
"prompt": "展示幾個你在這個工作區裡可以幫我的實用方式。"
}
}
}, },
"header": { "header": {
"toggleSidebar": "切換側邊欄" "toggleSidebar": "切換側邊欄"
@ -77,6 +104,51 @@
"decode_failed": "無法解碼這張圖片", "decode_failed": "無法解碼這張圖片",
"too_large": "圖片太大,請換一張小一點的", "too_large": "圖片太大,請換一張小一點的",
"io": "無法讀取這個檔案" "io": "無法讀取這個檔案"
},
"slash": {
"ariaLabel": "斜線命令",
"label": "命令",
"navigateHint": "↑↓ 選擇",
"selectHint": "Enter/Tab 填入",
"closeHint": "Esc 關閉",
"commands": {
"new": {
"title": "新增對話",
"description": "停止目前任務,並開始新的對話。"
},
"stop": {
"title": "停止目前任務",
"description": "取消這個對話中正在執行的 agent 回合。"
},
"restart": {
"title": "重新啟動 nanobot",
"description": "原地重新啟動 bot 進程。"
},
"status": {
"title": "查看狀態",
"description": "顯示執行環境、provider 和 channel 狀態。"
},
"history": {
"title": "查看對話歷史",
"description": "列印最近 N 則已持久化的對話訊息。"
},
"dream": {
"title": "執行 Dream",
"description": "手動觸發記憶整理。"
},
"dream_log": {
"title": "查看 Dream 日誌",
"description": "查看上一次 Dream 整理變更了什麼。"
},
"dream_restore": {
"title": "恢復記憶",
"description": "將記憶恢復到之前的 Dream 快照。"
},
"help": {
"title": "查看說明",
"description": "列出可用的斜線命令。"
}
}
} }
}, },
"scrollToBottom": "捲動到底部" "scrollToBottom": "捲動到底部"

View File

@ -1,4 +1,4 @@
import type { ChatSummary, SettingsPayload, SettingsUpdate } from "./types"; import type { ChatSummary, SettingsPayload, SettingsUpdate, SlashCommand } from "./types";
export class ApiError extends Error { export class ApiError extends Error {
status: number; status: number;
@ -114,6 +114,27 @@ export async function fetchSettings(
return request<SettingsPayload>(`${base}/api/settings`, token); return request<SettingsPayload>(`${base}/api/settings`, token);
} }
export async function listSlashCommands(
token: string,
base: string = "",
): Promise<SlashCommand[]> {
type Row = {
command: string;
title: string;
description: string;
icon: string;
arg_hint?: string;
};
const body = await request<{ commands: Row[] }>(`${base}/api/commands`, token);
return body.commands.map((command) => ({
command: command.command,
title: command.title,
description: command.description,
icon: command.icon,
argHint: command.arg_hint ?? "",
}));
}
export async function updateSettings( export async function updateSettings(
token: string, token: string,
update: SettingsUpdate, update: SettingsUpdate,

View File

@ -89,6 +89,14 @@ export interface SettingsUpdate {
provider?: string; provider?: string;
} }
export interface SlashCommand {
command: string;
title: string;
description: string;
icon: string;
argHint?: string;
}
export type ConnectionStatus = export type ConnectionStatus =
| "idle" | "idle"
| "connecting" | "connecting"

View File

@ -1,6 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { deleteSession, fetchSessionMessages, listSessions, updateSettings } from "@/lib/api"; import {
deleteSession,
fetchSessionMessages,
listSessions,
listSlashCommands,
updateSettings,
} from "@/lib/api";
describe("webui API helpers", () => { describe("webui API helpers", () => {
beforeEach(() => { beforeEach(() => {
@ -72,4 +78,37 @@ describe("webui API helpers", () => {
}, },
]); ]);
}); });
it("maps slash command metadata from the commands endpoint", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
commands: [
{
command: "/history",
title: "Show conversation history",
description: "Print the last N messages.",
icon: "history",
arg_hint: "[n]",
},
],
}),
} as Response);
await expect(listSlashCommands("tok")).resolves.toEqual([
{
command: "/history",
title: "Show conversation history",
description: "Print the last N messages.",
icon: "history",
argHint: "[n]",
},
]);
expect(fetch).toHaveBeenCalledWith(
"/api/commands",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
}); });

View File

@ -4,6 +4,9 @@ import { describe, expect, it, vi } from "vitest";
import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ThreadComposer } from "@/components/thread/ThreadComposer"; import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { resources } from "@/i18n";
const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
describe("webui i18n", () => { describe("webui i18n", () => {
it("switches UI copy and document locale through the language switcher", async () => { it("switches UI copy and document locale through the language switcher", async () => {
@ -41,4 +44,16 @@ describe("webui i18n", () => {
expect(screen.getByLabelText("メッセージ入力欄")).toBeInTheDocument(); expect(screen.getByLabelText("メッセージ入力欄")).toBeInTheDocument();
}); });
it("keeps welcome quick actions localized for every registered locale", () => {
for (const resource of Object.values(resources)) {
const empty = resource.common.thread.empty;
expect(empty.greeting).toBeTruthy();
for (const key of QUICK_ACTION_KEYS) {
const action = empty.quickActions[key as keyof typeof empty.quickActions];
expect(action.title).toBeTruthy();
expect(action.prompt).toBeTruthy();
}
}
});
}); });

View File

@ -1,7 +1,24 @@
import { render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { ThreadComposer } from "@/components/thread/ThreadComposer"; import { ThreadComposer } from "@/components/thread/ThreadComposer";
import type { SlashCommand } from "@/lib/types";
const COMMANDS: SlashCommand[] = [
{
command: "/stop",
title: "Stop current task",
description: "Cancel the active agent turn.",
icon: "square",
},
{
command: "/history",
title: "Show conversation history",
description: "Print the last N persisted messages.",
icon: "history",
argHint: "[n]",
},
];
describe("ThreadComposer", () => { describe("ThreadComposer", () => {
it("renders a readonly hero model composer when provided", () => { it("renders a readonly hero model composer when provided", () => {
@ -43,4 +60,35 @@ describe("ThreadComposer", () => {
expect(screen.getByRole("button", { name: "Attach image" }).className).toContain("bg-card"); expect(screen.getByRole("button", { name: "Attach image" }).className).toContain("bg-card");
expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground"); expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground");
}); });
it("opens a slash command palette and inserts the selected command", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
slashCommands={COMMANDS}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "/" } });
expect(screen.getByRole("listbox", { name: "Slash commands" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /\/stop/i })).toHaveAttribute(
"aria-selected",
"true",
);
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(screen.getByRole("option", { name: /\/history/i })).toHaveAttribute(
"aria-selected",
"true",
);
fireEvent.keyDown(input, { key: "Enter" });
expect(input).toHaveValue("/history ");
expect(onSend).not.toHaveBeenCalled();
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
});
}); });

View File

@ -434,6 +434,138 @@ describe("ThreadShell", () => {
}); });
}); });
it("keeps live assistant replies after visiting the blank new-chat page", async () => {
const client = makeClient();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("websocket%3Achat-a/messages")) {
return httpJson({
key: "websocket:chat-a",
created_at: null,
updated_at: null,
// Simulate a stale history response that has not persisted the
// just-received assistant reply yet.
messages: [{ role: "user", content: "hello" }],
});
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
const { rerender } = render(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
await waitFor(() => expect(screen.getByText("hello")).toBeInTheDocument());
await act(async () => {
client._emitChat("chat-a", {
event: "message",
chat_id: "chat-a",
text: "live assistant reply",
});
});
expect(screen.getByText("live assistant reply")).toBeInTheDocument();
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
});
expect(screen.queryByText("live assistant reply")).not.toBeInTheDocument();
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
});
await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument());
});
it("does not open slash commands on the blank welcome page", async () => {
const client = makeClient();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/commands")) {
return httpJson({
commands: [
{
command: "/stop",
title: "Stop current task",
description: "Cancel the active agent turn.",
icon: "square",
},
],
});
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
render(
wrap(
client,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
"/api/commands",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
));
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "/" },
});
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
});
it("surfaces a dismissible banner when the stream reports message_too_big", async () => { it("surfaces a dismissible banner when the stream reports message_too_big", async () => {
const client = makeClient(); const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a"); const onNewChat = vi.fn().mockResolvedValue("chat-a");
@ -454,6 +586,7 @@ describe("ThreadShell", () => {
// No banner yet: only appears once the client emits a matching error. // No banner yet: only appears once the client emits a matching error.
expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByRole("alert")).not.toBeInTheDocument();
await act(async () => {});
await act(async () => { await act(async () => {
client._emitError({ kind: "message_too_big" }); client._emitError({ kind: "message_too_big" });
}); });
@ -485,6 +618,7 @@ describe("ThreadShell", () => {
), ),
); );
await act(async () => {});
await act(async () => { await act(async () => {
client._emitError({ kind: "message_too_big" }); client._emitError({ kind: "message_too_big" });
}); });