mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 08:02:30 +00:00
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:
parent
49c07aa45a
commit
ac18a8baad
@ -32,6 +32,7 @@ from websockets.http11 import Response
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
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.schema import Base
|
||||
from nanobot.utils.helpers import safe_filename
|
||||
@ -553,6 +554,9 @@ class WebSocketChannel(BaseChannel):
|
||||
if got == "/api/settings":
|
||||
return self._handle_settings(request)
|
||||
|
||||
if got == "/api/commands":
|
||||
return self._handle_commands(request)
|
||||
|
||||
if got == "/api/settings/update":
|
||||
return self._handle_settings_update(request)
|
||||
|
||||
@ -708,6 +712,11 @@ class WebSocketChannel(BaseChannel):
|
||||
return _http_error(401, "Unauthorized")
|
||||
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:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
|
||||
@ -6,6 +6,7 @@ import asyncio
|
||||
import os
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
|
||||
from nanobot import __version__
|
||||
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
|
||||
|
||||
|
||||
@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:
|
||||
"""Cancel all active tasks and subagents for the session."""
|
||||
loop = ctx.loop
|
||||
@ -378,18 +461,12 @@ async def cmd_help(ctx: CommandContext) -> OutboundMessage:
|
||||
|
||||
def build_help_text() -> str:
|
||||
"""Build canonical help text shared across channels."""
|
||||
lines = [
|
||||
"🐈 nanobot commands:",
|
||||
"/new — Stop current task and start a new conversation",
|
||||
"/stop — Stop the current task",
|
||||
"/restart — Restart the bot",
|
||||
"/status — Show bot status",
|
||||
"/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",
|
||||
]
|
||||
lines = ["🐈 nanobot commands:"]
|
||||
for spec in BUILTIN_COMMAND_SPECS:
|
||||
command = spec.command
|
||||
if spec.arg_hint:
|
||||
command = f"{command} {spec.arg_hint}"
|
||||
lines.append(f"{command} — {spec.description}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
@ -563,6 +563,34 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
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(
|
||||
bus: MagicMock,
|
||||
monkeypatch,
|
||||
|
||||
@ -7,11 +7,21 @@ import {
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
} from "react";
|
||||
import {
|
||||
Activity,
|
||||
ArrowUp,
|
||||
BookOpen,
|
||||
CircleHelp,
|
||||
History,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
Plus,
|
||||
RotateCw,
|
||||
Sparkles,
|
||||
Square,
|
||||
SquarePen,
|
||||
Undo2,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -24,6 +34,7 @@ import {
|
||||
} from "@/hooks/useAttachedImages";
|
||||
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
|
||||
import type { SendImage } from "@/hooks/useNanobotStream";
|
||||
import type { SlashCommand } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
|
||||
@ -43,6 +54,23 @@ interface ThreadComposerProps {
|
||||
isStreaming?: boolean;
|
||||
modelLabel?: string | null;
|
||||
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({
|
||||
@ -52,10 +80,13 @@ export function ThreadComposer({
|
||||
isStreaming = false,
|
||||
modelLabel = null,
|
||||
variant = "thread",
|
||||
slashCommands = [],
|
||||
}: ThreadComposerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState("");
|
||||
const [inlineError, setInlineError] = useState<string | null>(null);
|
||||
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
|
||||
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const chipRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
@ -119,6 +150,66 @@ export function ThreadComposer({
|
||||
&& !hasErrors
|
||||
&& (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(() => {
|
||||
if (!canSend) return;
|
||||
const trimmed = value.trim();
|
||||
@ -142,16 +233,35 @@ export function ThreadComposer({
|
||||
// Bubble owns the data URL copy; safe to revoke every staged blob
|
||||
// preview here without affecting the rendered message.
|
||||
clear();
|
||||
requestAnimationFrame(() => {
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
el.style.height = "auto";
|
||||
el.focus();
|
||||
}
|
||||
});
|
||||
}, [canSend, clear, onSend, readyImages, value]);
|
||||
setSlashMenuDismissed(false);
|
||||
resizeTextarea();
|
||||
}, [canSend, clear, onSend, readyImages, resizeTextarea, value]);
|
||||
|
||||
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) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
@ -213,8 +323,17 @@ export function ThreadComposer({
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
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
|
||||
className={cn(
|
||||
"relative mx-auto flex w-full flex-col overflow-hidden transition-all duration-200",
|
||||
@ -257,7 +376,10 @@ export function ThreadComposer({
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
setSlashMenuDismissed(false);
|
||||
}}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
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 {
|
||||
image: AttachedImage;
|
||||
labelRemove: string;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
@ -17,7 +17,8 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
|
||||
import { ThreadViewport } from "@/components/thread/ThreadViewport";
|
||||
import { useNanobotStream } from "@/hooks/useNanobotStream";
|
||||
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";
|
||||
|
||||
interface ThreadShellProps {
|
||||
@ -66,8 +67,9 @@ export function ThreadShell({
|
||||
const chatId = session?.chatId ?? null;
|
||||
const historyKey = session?.key ?? null;
|
||||
const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey);
|
||||
const { client, modelName } = useClient();
|
||||
const { client, modelName, token } = useClient();
|
||||
const [booting, setBooting] = useState(false);
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||
const pendingFirstRef = useRef<string | null>(null);
|
||||
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
||||
const lastCachedChatIdRef = useRef<string | null>(null);
|
||||
@ -116,17 +118,24 @@ export function ThreadShell({
|
||||
setMessages(historical);
|
||||
}, [chatId, historical, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId) return;
|
||||
useLayoutEffect(() => {
|
||||
if (!chatId) {
|
||||
lastCachedChatIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (loading) return;
|
||||
// Skip the first cache write after a chat switch. During that render,
|
||||
// `messages` can still belong to the previous chat until the stream hook
|
||||
// resets its local state for the new session.
|
||||
if (lastCachedChatIdRef.current !== chatId) {
|
||||
lastCachedChatIdRef.current = chatId;
|
||||
if (messages.length > 0) {
|
||||
messageCacheRef.current.set(chatId, messages);
|
||||
}
|
||||
return;
|
||||
}
|
||||
messageCacheRef.current.set(chatId, messages);
|
||||
}, [chatId, messages]);
|
||||
}, [chatId, loading, messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId) return;
|
||||
@ -146,6 +155,21 @@ export function ThreadShell({
|
||||
setBooting(false);
|
||||
}, [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(
|
||||
async (content: string) => {
|
||||
if (booting) return;
|
||||
@ -222,6 +246,7 @@ export function ThreadShell({
|
||||
}
|
||||
modelLabel={toModelBadgeLabel(modelName)}
|
||||
variant={showHeroComposer ? "hero" : "thread"}
|
||||
slashCommands={slashCommands}
|
||||
/>
|
||||
) : (
|
||||
<ThreadComposer
|
||||
|
||||
@ -127,6 +127,51 @@
|
||||
"deepResearch": "Deep research",
|
||||
"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…",
|
||||
"remove": "Remove attachment",
|
||||
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
|
||||
|
||||
@ -53,7 +53,34 @@
|
||||
"thread": {
|
||||
"loadingConversation": "Cargando conversación…",
|
||||
"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": {
|
||||
"toggleSidebar": "Mostrar u ocultar la barra lateral"
|
||||
@ -77,6 +104,51 @@
|
||||
"decode_failed": "No se pudo decodificar esta imagen",
|
||||
"too_large": "Imagen demasiado grande — prueba una más pequeña",
|
||||
"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"
|
||||
|
||||
@ -53,7 +53,34 @@
|
||||
"thread": {
|
||||
"loadingConversation": "Chargement de la conversation…",
|
||||
"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 m’aider dans cet espace de travail."
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "Afficher ou masquer la barre latérale"
|
||||
@ -77,6 +104,51 @@
|
||||
"decode_failed": "Impossible de décoder cette image",
|
||||
"too_large": "Image trop grande — essayez-en une plus petite",
|
||||
"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 l’historique",
|
||||
"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 l’aide",
|
||||
"description": "Lister les commandes slash disponibles."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scrollToBottom": "Faire défiler vers le bas"
|
||||
|
||||
@ -53,7 +53,34 @@
|
||||
"thread": {
|
||||
"loadingConversation": "Memuat percakapan…",
|
||||
"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": {
|
||||
"toggleSidebar": "Tampilkan atau sembunyikan sidebar"
|
||||
@ -77,6 +104,51 @@
|
||||
"decode_failed": "Tidak dapat mendekode gambar ini",
|
||||
"too_large": "Gambar terlalu besar — coba yang lebih kecil",
|
||||
"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"
|
||||
|
||||
@ -53,7 +53,34 @@
|
||||
"thread": {
|
||||
"loadingConversation": "会話を読み込み中…",
|
||||
"empty": {
|
||||
"description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。"
|
||||
"description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。",
|
||||
"greeting": "何をお手伝いしましょうか?",
|
||||
"quickActions": {
|
||||
"plan": {
|
||||
"title": "プロジェクト計画を作成",
|
||||
"prompt": "次に作るものについて、簡潔なプロジェクト計画を作成してください。"
|
||||
},
|
||||
"analyze": {
|
||||
"title": "このデータを分析",
|
||||
"prompt": "このデータを分析し、最も重要なパターンを指摘してください。"
|
||||
},
|
||||
"brainstorm": {
|
||||
"title": "アイデアを出す",
|
||||
"prompt": "この問題について、実用的なアイデアとトレードオフをいくつか出してください。"
|
||||
},
|
||||
"code": {
|
||||
"title": "コードを書く",
|
||||
"prompt": "このタスクのコードを書くのを手伝ってください。まず最小限の有用な変更から始めてください。"
|
||||
},
|
||||
"summarize": {
|
||||
"title": "この文書を要約",
|
||||
"prompt": "この文書を要約し、重要なポイントを列挙してください。"
|
||||
},
|
||||
"more": {
|
||||
"title": "その他",
|
||||
"prompt": "このワークスペースであなたが手伝える便利な方法をいくつか見せてください。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "サイドバーを切り替える"
|
||||
@ -77,6 +104,51 @@
|
||||
"decode_failed": "この画像をデコードできません",
|
||||
"too_large": "画像が大きすぎます。小さいものを選んでください",
|
||||
"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": "一番下へスクロール"
|
||||
|
||||
@ -53,7 +53,34 @@
|
||||
"thread": {
|
||||
"loadingConversation": "대화 불러오는 중…",
|
||||
"empty": {
|
||||
"description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다."
|
||||
"description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다.",
|
||||
"greeting": "무엇을 도와드릴까요?",
|
||||
"quickActions": {
|
||||
"plan": {
|
||||
"title": "프로젝트 계획 만들기",
|
||||
"prompt": "다음에 만들 것에 대한 간결한 프로젝트 계획을 작성해 주세요."
|
||||
},
|
||||
"analyze": {
|
||||
"title": "이 데이터 분석하기",
|
||||
"prompt": "이 데이터를 분석하고 가장 중요한 패턴을 짚어 주세요."
|
||||
},
|
||||
"brainstorm": {
|
||||
"title": "아이디어 브레인스토밍",
|
||||
"prompt": "이 문제에 대한 실용적인 아이디어와 트레이드오프를 몇 가지 제안해 주세요."
|
||||
},
|
||||
"code": {
|
||||
"title": "코드 작성하기",
|
||||
"prompt": "이 작업을 위한 코드를 작성해 주세요. 가장 작은 유용한 변경부터 시작해 주세요."
|
||||
},
|
||||
"summarize": {
|
||||
"title": "문서 요약하기",
|
||||
"prompt": "이 문서를 요약하고 핵심 내용을 정리해 주세요."
|
||||
},
|
||||
"more": {
|
||||
"title": "더 보기",
|
||||
"prompt": "이 워크스페이스에서 도와줄 수 있는 유용한 방법을 몇 가지 보여 주세요."
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "사이드바 전환"
|
||||
@ -77,6 +104,51 @@
|
||||
"decode_failed": "이 이미지를 디코딩할 수 없습니다",
|
||||
"too_large": "이미지가 너무 큽니다. 더 작은 걸로 시도해 주세요",
|
||||
"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": "맨 아래로 스크롤"
|
||||
|
||||
@ -53,7 +53,34 @@
|
||||
"thread": {
|
||||
"loadingConversation": "Đang tải cuộc trò chuyện…",
|
||||
"empty": {
|
||||
"description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới."
|
||||
"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": {
|
||||
"toggleSidebar": "Bật/tắt thanh bên"
|
||||
@ -77,6 +104,51 @@
|
||||
"decode_failed": "Không thể giải mã ảnh này",
|
||||
"too_large": "Ảnh quá lớn — hãy thử ảnh nhỏ hơn",
|
||||
"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"
|
||||
|
||||
@ -115,6 +115,51 @@
|
||||
"deepResearch": "深度研究",
|
||||
"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": "处理中…",
|
||||
"remove": "移除附件",
|
||||
"normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)",
|
||||
|
||||
@ -53,7 +53,34 @@
|
||||
"thread": {
|
||||
"loadingConversation": "正在載入對話…",
|
||||
"empty": {
|
||||
"description": "你可以提問、延續本地工作,或是開始新的執行緒。"
|
||||
"description": "你可以提問、延續本地工作,或是開始新的執行緒。",
|
||||
"greeting": "我可以幫你做什麼?",
|
||||
"quickActions": {
|
||||
"plan": {
|
||||
"title": "建立專案計畫",
|
||||
"prompt": "幫我為接下來要做的事情寫一份簡潔的專案計畫。"
|
||||
},
|
||||
"analyze": {
|
||||
"title": "分析這些資料",
|
||||
"prompt": "幫我分析這些資料,並指出最重要的模式。"
|
||||
},
|
||||
"brainstorm": {
|
||||
"title": "腦力激盪想法",
|
||||
"prompt": "圍繞這個問題腦力激盪幾個實用方案,並說明取捨。"
|
||||
},
|
||||
"code": {
|
||||
"title": "撰寫程式碼",
|
||||
"prompt": "幫我為這個任務撰寫程式碼,先從最小可用改動開始。"
|
||||
},
|
||||
"summarize": {
|
||||
"title": "總結這份文件",
|
||||
"prompt": "幫我總結這份文件,並列出關鍵重點。"
|
||||
},
|
||||
"more": {
|
||||
"title": "更多",
|
||||
"prompt": "展示幾個你在這個工作區裡可以幫我的實用方式。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "切換側邊欄"
|
||||
@ -77,6 +104,51 @@
|
||||
"decode_failed": "無法解碼這張圖片",
|
||||
"too_large": "圖片太大,請換一張小一點的",
|
||||
"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": "捲動到底部"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { ChatSummary, SettingsPayload, SettingsUpdate } from "./types";
|
||||
import type { ChatSummary, SettingsPayload, SettingsUpdate, SlashCommand } from "./types";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
@ -114,6 +114,27 @@ export async function fetchSettings(
|
||||
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(
|
||||
token: string,
|
||||
update: SettingsUpdate,
|
||||
|
||||
@ -89,6 +89,14 @@ export interface SettingsUpdate {
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export interface SlashCommand {
|
||||
command: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
argHint?: string;
|
||||
}
|
||||
|
||||
export type ConnectionStatus =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
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", () => {
|
||||
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" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,6 +4,9 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
import { resources } from "@/i18n";
|
||||
|
||||
const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
|
||||
|
||||
describe("webui i18n", () => {
|
||||
it("switches UI copy and document locale through the language switcher", async () => {
|
||||
@ -41,4 +44,16 @@ describe("webui i18n", () => {
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 { 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", () => {
|
||||
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: "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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 () => {
|
||||
const client = makeClient();
|
||||
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.
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
|
||||
await act(async () => {});
|
||||
await act(async () => {
|
||||
client._emitError({ kind: "message_too_big" });
|
||||
});
|
||||
@ -485,6 +618,7 @@ describe("ThreadShell", () => {
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
await act(async () => {
|
||||
client._emitError({ kind: "message_too_big" });
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user