mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12: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.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")
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)",
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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 m’aider 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 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"
|
"scrollToBottom": "Faire défiler vers le bas"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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": "一番下へスクロール"
|
||||||
|
|||||||
@ -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": "맨 아래로 스크롤"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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}}(已自动压缩)",
|
||||||
|
|||||||
@ -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": "捲動到底部"
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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" });
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user