mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 14:23:58 +00:00
feat(webui): improve slash command actions
This commit is contained in:
parent
3f0098839e
commit
92915ea424
@ -21,6 +21,7 @@ import {
|
||||
Activity,
|
||||
ArrowUp,
|
||||
BookOpen,
|
||||
Brain,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
@ -30,6 +31,7 @@ import {
|
||||
Loader2,
|
||||
Plus,
|
||||
RotateCw,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Square,
|
||||
SquarePen,
|
||||
@ -98,9 +100,11 @@ interface ThreadComposerProps {
|
||||
const COMMAND_ICONS: Record<string, LucideIcon> = {
|
||||
activity: Activity,
|
||||
"book-open": BookOpen,
|
||||
brain: Brain,
|
||||
"circle-help": CircleHelp,
|
||||
history: History,
|
||||
"rotate-cw": RotateCw,
|
||||
shield: Shield,
|
||||
sparkles: Sparkles,
|
||||
square: Square,
|
||||
"square-pen": SquarePen,
|
||||
@ -113,7 +117,9 @@ const IMAGE_ASPECT_RATIOS: ImageAspectRatio[] = ["auto", "1:1", "3:4", "9:16", "
|
||||
const SLASH_PALETTE_GAP_PX = 8;
|
||||
const SLASH_PALETTE_MAX_HEIGHT_PX = 288;
|
||||
const SLASH_PALETTE_MIN_HEIGHT_PX = 144;
|
||||
const SLASH_PALETTE_CHROME_PX = 40;
|
||||
const SLASH_PALETTE_CHROME_PX = 12;
|
||||
const SLASH_RECENTS_STORAGE_KEY = "nanobot.webui.slashCommandRecents";
|
||||
const SLASH_RECENTS_LIMIT = 5;
|
||||
|
||||
type SlashPalettePlacement = "above" | "below";
|
||||
|
||||
@ -132,10 +138,41 @@ type MentionCandidate =
|
||||
| { kind: "cli"; name: string; app: CliAppInfo }
|
||||
| { kind: "mcp"; name: string; preset: McpPresetInfo };
|
||||
|
||||
interface SlashPaletteCommand extends SlashCommand {
|
||||
detail: string;
|
||||
badge?: string;
|
||||
recent: boolean;
|
||||
}
|
||||
|
||||
function slashCommandI18nKey(command: string): string {
|
||||
return command.replace(/^\//, "").replace(/-/g, "_");
|
||||
}
|
||||
|
||||
function readSlashRecents(): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SLASH_RECENTS_STORAGE_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item): item is string => typeof item === "string").slice(0, SLASH_RECENTS_LIMIT)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function storeSlashRecents(commands: string[]): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
SLASH_RECENTS_STORAGE_KEY,
|
||||
JSON.stringify(commands.slice(0, SLASH_RECENTS_LIMIT)),
|
||||
);
|
||||
} catch {
|
||||
// localStorage may be unavailable in private contexts; command insertion still works.
|
||||
}
|
||||
}
|
||||
|
||||
function scrollNearestOverflowParent(target: EventTarget | null, deltaY: number) {
|
||||
if (!(target instanceof Element) || deltaY === 0) return;
|
||||
let el: HTMLElement | null = target.parentElement;
|
||||
@ -450,6 +487,7 @@ export function ThreadComposer({
|
||||
const [uncontrolledImageMode, setUncontrolledImageMode] = useState(false);
|
||||
const [imageAspectRatio, setImageAspectRatio] = useState<ImageAspectRatio>("auto");
|
||||
const [aspectMenuOpen, setAspectMenuOpen] = useState(false);
|
||||
const [recentSlashCommands, setRecentSlashCommands] = useState<string[]>(() => readSlashRecents());
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@ -534,26 +572,86 @@ export function ThreadComposer({
|
||||
return commandToken.toLowerCase();
|
||||
}, [disabled, slashMenuDismissed, value]);
|
||||
|
||||
const filteredSlashCommands = useMemo(() => {
|
||||
const visibleSlashCommands = useMemo(() => {
|
||||
if (!(isStreaming && onStop)) return slashCommands;
|
||||
if (slashCommands.some((command) => command.command === "/stop")) return slashCommands;
|
||||
return [
|
||||
{
|
||||
command: "/stop",
|
||||
title: "Stop current task",
|
||||
description: "Cancel the active agent turn for this chat.",
|
||||
icon: "square",
|
||||
},
|
||||
...slashCommands,
|
||||
];
|
||||
}, [isStreaming, onStop, slashCommands]);
|
||||
|
||||
const filteredSlashCommands = useMemo<SlashPaletteCommand[]>(() => {
|
||||
if (slashQuery === null) return [];
|
||||
return slashCommands
|
||||
const withDetails = visibleSlashCommands
|
||||
.filter((command) => {
|
||||
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,
|
||||
});
|
||||
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: "",
|
||||
}),
|
||||
title,
|
||||
description,
|
||||
].join(" ").toLowerCase();
|
||||
return haystack.includes(slashQuery);
|
||||
})
|
||||
.map((command) => {
|
||||
const commandKey = slashCommandI18nKey(command.command);
|
||||
const description = t(`thread.composer.slash.commands.${commandKey}.description`, {
|
||||
defaultValue: command.description,
|
||||
});
|
||||
let detail = description;
|
||||
let badge: string | undefined;
|
||||
if (command.command === "/model" && modelLabel) {
|
||||
detail = modelLabel;
|
||||
badge = t("thread.composer.slash.badges.current");
|
||||
} else if (command.command === "/goal") {
|
||||
detail = goalState?.active
|
||||
? t("thread.composer.slash.details.goalActive")
|
||||
: t("thread.composer.slash.details.goalReady");
|
||||
} else if (command.command === "/stop" && isStreaming) {
|
||||
detail = t("thread.composer.slash.details.stopRunning");
|
||||
} else if (command.command === "/history") {
|
||||
detail = t("thread.composer.slash.details.history");
|
||||
}
|
||||
return {
|
||||
...command,
|
||||
detail,
|
||||
badge,
|
||||
recent: recentSlashCommands.includes(command.command),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (isStreaming) {
|
||||
if (a.command === "/stop") return -1;
|
||||
if (b.command === "/stop") return 1;
|
||||
}
|
||||
if (slashQuery !== "") return 0;
|
||||
const aRecent = recentSlashCommands.indexOf(a.command);
|
||||
const bRecent = recentSlashCommands.indexOf(b.command);
|
||||
if (aRecent !== -1 || bRecent !== -1) {
|
||||
if (aRecent === -1) return 1;
|
||||
if (bRecent === -1) return -1;
|
||||
return aRecent - bRecent;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return withDetails
|
||||
.slice(0, 8);
|
||||
}, [slashCommands, slashQuery, t]);
|
||||
}, [goalState?.active, isStreaming, modelLabel, recentSlashCommands, slashQuery, t, visibleSlashCommands]);
|
||||
|
||||
const showSlashMenu = filteredSlashCommands.length > 0;
|
||||
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
|
||||
@ -746,13 +844,30 @@ export function ThreadComposer({
|
||||
|
||||
const chooseSlashCommand = useCallback(
|
||||
(command: SlashCommand) => {
|
||||
const nextRecents = [
|
||||
command.command,
|
||||
...recentSlashCommands.filter((item) => item !== command.command),
|
||||
].slice(0, SLASH_RECENTS_LIMIT);
|
||||
setRecentSlashCommands(nextRecents);
|
||||
storeSlashRecents(nextRecents);
|
||||
|
||||
if (command.command === "/stop" && isStreaming && onStop) {
|
||||
onStop();
|
||||
setValue("");
|
||||
setSlashMenuDismissed(true);
|
||||
setCliAppMenuDismissed(false);
|
||||
setInlineError(null);
|
||||
resizeTextarea();
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(command.argHint ? `${command.command} ` : command.command);
|
||||
setSlashMenuDismissed(true);
|
||||
setCliAppMenuDismissed(false);
|
||||
setInlineError(null);
|
||||
resizeTextarea();
|
||||
},
|
||||
[resizeTextarea],
|
||||
[isStreaming, onStop, recentSlashCommands, resizeTextarea],
|
||||
);
|
||||
|
||||
const chooseMentionCandidate = useCallback(
|
||||
@ -1307,12 +1422,12 @@ function ComposerCliMentionOverlay({
|
||||
);
|
||||
}
|
||||
interface SlashCommandPaletteProps {
|
||||
commands: SlashCommand[];
|
||||
commands: SlashPaletteCommand[];
|
||||
selectedIndex: number;
|
||||
layout: SlashPaletteLayout;
|
||||
isHero: boolean;
|
||||
onHover: (index: number) => void;
|
||||
onChoose: (command: SlashCommand) => void;
|
||||
onChoose: (command: SlashPaletteCommand) => void;
|
||||
}
|
||||
|
||||
interface CliAppMentionPaletteProps {
|
||||
@ -1532,14 +1647,11 @@ function SlashCommandPalette({
|
||||
className={cn(
|
||||
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
|
||||
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
|
||||
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)]",
|
||||
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.16)]",
|
||||
"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="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
|
||||
{commands.map((command, index) => {
|
||||
const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp;
|
||||
@ -1563,49 +1675,42 @@ function SlashCommandPalette({
|
||||
onChoose(command);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
|
||||
"flex min-h-[44px] w-full items-center gap-3 rounded-[13px] px-3 py-2 text-left transition-colors",
|
||||
selected
|
||||
? "bg-primary/10 text-foreground"
|
||||
: "text-foreground/86 hover:bg-accent/55",
|
||||
? "bg-foreground/[0.065] text-foreground dark:bg-white/[0.09]"
|
||||
: "text-foreground/86 hover:bg-foreground/[0.045] dark:hover:bg-white/[0.065]",
|
||||
)}
|
||||
>
|
||||
<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",
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center text-muted-foreground transition-colors",
|
||||
selected && "text-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 className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span className="min-w-0 truncate text-[13.5px] font-semibold tracking-normal text-foreground">
|
||||
{title}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
|
||||
{description}
|
||||
<span className="min-w-0 truncate text-[13px] text-muted-foreground">
|
||||
{command.detail || description}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-2 flex shrink-0 items-center gap-1.5">
|
||||
{command.badge || command.recent ? (
|
||||
<span className="hidden rounded-full bg-foreground/[0.055] px-2 py-1 text-[11px] font-medium text-muted-foreground sm:inline-flex">
|
||||
{command.badge ?? t("thread.composer.slash.badges.recent")}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="font-mono text-[12px] text-muted-foreground/60">
|
||||
{command.argHint ? `${command.command} ${command.argHint}` : command.command}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -40,6 +40,20 @@ function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
|
||||
return scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages));
|
||||
}
|
||||
|
||||
function sameMessageShape(a: UIMessage, b: UIMessage): boolean {
|
||||
return (
|
||||
a.role === b.role
|
||||
&& (a.kind ?? "") === (b.kind ?? "")
|
||||
&& a.content === b.content
|
||||
);
|
||||
}
|
||||
|
||||
function isStaleThreadSnapshot(current: UIMessage[], snapshot: UIMessage[]): boolean {
|
||||
if (current.length === 0 || snapshot.length >= current.length) return false;
|
||||
if (snapshot.length === 0) return true;
|
||||
return snapshot.every((message, index) => sameMessageShape(current[index], message));
|
||||
}
|
||||
|
||||
interface ThreadShellProps {
|
||||
session: ChatSummary | null;
|
||||
title: string;
|
||||
@ -219,19 +233,28 @@ export function ThreadShell({
|
||||
// canonical replay arrives (e.g. after ``session_updated`` refresh), prefer it
|
||||
// so rendering converges to the same shape as a manual refresh.
|
||||
setMessages((prev) => {
|
||||
const normalizedHistory = projectWebuiThreadMessages(historical);
|
||||
const keepLiveMessages = (messagesToKeep: UIMessage[]) => {
|
||||
const projected = projectWebuiThreadMessages(messagesToKeep);
|
||||
messageCacheRef.current.set(chatId, projected);
|
||||
return projected;
|
||||
};
|
||||
if (hasNewCanonicalHistory && historical.length > 0) {
|
||||
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
|
||||
pendingCanonicalHydrateRef.current.delete(chatId);
|
||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||
const normalized = projectWebuiThreadMessages(historical);
|
||||
messageCacheRef.current.set(chatId, normalized);
|
||||
return normalized;
|
||||
messageCacheRef.current.set(chatId, normalizedHistory);
|
||||
return normalizedHistory;
|
||||
}
|
||||
if (cached && cached.length > 0) return projectWebuiThreadMessages(cached);
|
||||
if (historical.length === 0 && prev.length > 0) return projectWebuiThreadMessages(prev);
|
||||
if (cached && cached.length > 0) {
|
||||
const normalizedCached = projectWebuiThreadMessages(cached);
|
||||
if (isStaleThreadSnapshot(prev, normalizedCached)) return keepLiveMessages(prev);
|
||||
return normalizedCached;
|
||||
}
|
||||
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
|
||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||
const next = projectWebuiThreadMessages(historical);
|
||||
if (historical.length > 0) messageCacheRef.current.set(chatId, next);
|
||||
return next;
|
||||
if (normalizedHistory.length > 0) messageCacheRef.current.set(chatId, normalizedHistory);
|
||||
return normalizedHistory;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loading, chatId, historical, historyVersion]);
|
||||
|
||||
@ -534,6 +534,16 @@
|
||||
"navigateHint": "↑↓ Navigate",
|
||||
"selectHint": "Enter/Tab Select",
|
||||
"closeHint": "Esc Close",
|
||||
"badges": {
|
||||
"current": "Current",
|
||||
"recent": "Recent"
|
||||
},
|
||||
"details": {
|
||||
"goalActive": "Goal is running",
|
||||
"goalReady": "Start a sustained objective",
|
||||
"history": "Show recent messages",
|
||||
"stopRunning": "Running now"
|
||||
},
|
||||
"commands": {
|
||||
"new": {
|
||||
"title": "New chat",
|
||||
@ -551,6 +561,10 @@
|
||||
"title": "Show status",
|
||||
"description": "Display runtime, provider, and channel status."
|
||||
},
|
||||
"model": {
|
||||
"title": "Model",
|
||||
"description": "Show or switch the active model preset."
|
||||
},
|
||||
"history": {
|
||||
"title": "Show conversation history",
|
||||
"description": "Print the last N persisted conversation messages."
|
||||
@ -574,6 +588,10 @@
|
||||
"help": {
|
||||
"title": "Show help",
|
||||
"description": "List available slash commands."
|
||||
},
|
||||
"pairing": {
|
||||
"title": "Pairing",
|
||||
"description": "Manage pairing requests."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -551,6 +551,10 @@
|
||||
"title": "Mostrar estado",
|
||||
"description": "Muestra el estado del runtime, provider y channels."
|
||||
},
|
||||
"model": {
|
||||
"title": "Modelo",
|
||||
"description": "Muestra o cambia el preset de modelo activo."
|
||||
},
|
||||
"history": {
|
||||
"title": "Mostrar historial",
|
||||
"description": "Imprime los últimos N mensajes persistidos de la conversación."
|
||||
@ -574,7 +578,21 @@
|
||||
"help": {
|
||||
"title": "Mostrar ayuda",
|
||||
"description": "Lista los comandos slash disponibles."
|
||||
},
|
||||
"pairing": {
|
||||
"title": "Emparejamiento",
|
||||
"description": "Gestiona solicitudes de emparejamiento."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"current": "Actual",
|
||||
"recent": "Reciente"
|
||||
},
|
||||
"details": {
|
||||
"goalActive": "El objetivo está en curso",
|
||||
"goalReady": "Iniciar un objetivo sostenido",
|
||||
"history": "Mostrar mensajes recientes",
|
||||
"stopRunning": "En ejecución"
|
||||
}
|
||||
},
|
||||
"encoding": "Procesando…",
|
||||
|
||||
@ -551,6 +551,10 @@
|
||||
"title": "Afficher l’état",
|
||||
"description": "Afficher l’état du runtime, du provider et des channels."
|
||||
},
|
||||
"model": {
|
||||
"title": "Modèle",
|
||||
"description": "Afficher ou changer le préréglage de modèle actif."
|
||||
},
|
||||
"history": {
|
||||
"title": "Afficher l’historique",
|
||||
"description": "Afficher les N derniers messages persistés de la conversation."
|
||||
@ -574,7 +578,21 @@
|
||||
"help": {
|
||||
"title": "Afficher l’aide",
|
||||
"description": "Lister les commandes slash disponibles."
|
||||
},
|
||||
"pairing": {
|
||||
"title": "Appairage",
|
||||
"description": "Gérer les demandes d’appairage."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"current": "Actuel",
|
||||
"recent": "Récent"
|
||||
},
|
||||
"details": {
|
||||
"goalActive": "L’objectif est en cours",
|
||||
"goalReady": "Démarrer un objectif durable",
|
||||
"history": "Afficher les messages récents",
|
||||
"stopRunning": "En cours"
|
||||
}
|
||||
},
|
||||
"encoding": "Traitement…",
|
||||
|
||||
@ -551,6 +551,10 @@
|
||||
"title": "Tampilkan status",
|
||||
"description": "Tampilkan status runtime, provider, dan channel."
|
||||
},
|
||||
"model": {
|
||||
"title": "Model",
|
||||
"description": "Tampilkan atau ganti preset model aktif."
|
||||
},
|
||||
"history": {
|
||||
"title": "Tampilkan riwayat",
|
||||
"description": "Cetak N pesan percakapan tersimpan terbaru."
|
||||
@ -574,7 +578,21 @@
|
||||
"help": {
|
||||
"title": "Tampilkan bantuan",
|
||||
"description": "Daftar perintah slash yang tersedia."
|
||||
},
|
||||
"pairing": {
|
||||
"title": "Pemasangan",
|
||||
"description": "Kelola permintaan pemasangan."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"current": "Saat ini",
|
||||
"recent": "Terbaru"
|
||||
},
|
||||
"details": {
|
||||
"goalActive": "Tujuan sedang berjalan",
|
||||
"goalReady": "Mulai tujuan berkelanjutan",
|
||||
"history": "Tampilkan pesan terbaru",
|
||||
"stopRunning": "Sedang berjalan"
|
||||
}
|
||||
},
|
||||
"encoding": "Memproses…",
|
||||
|
||||
@ -551,6 +551,10 @@
|
||||
"title": "ステータスを表示",
|
||||
"description": "ランタイム、provider、channel の状態を表示します。"
|
||||
},
|
||||
"model": {
|
||||
"title": "モデル",
|
||||
"description": "有効なモデルプリセットを表示または切り替えます。"
|
||||
},
|
||||
"history": {
|
||||
"title": "会話履歴を表示",
|
||||
"description": "保存済みの直近 N 件の会話メッセージを表示します。"
|
||||
@ -574,7 +578,21 @@
|
||||
"help": {
|
||||
"title": "ヘルプを表示",
|
||||
"description": "利用可能なスラッシュコマンドを一覧表示します。"
|
||||
},
|
||||
"pairing": {
|
||||
"title": "ペアリング",
|
||||
"description": "ペアリングリクエストを管理します。"
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"current": "現在",
|
||||
"recent": "最近"
|
||||
},
|
||||
"details": {
|
||||
"goalActive": "目標が実行中",
|
||||
"goalReady": "継続的な目標を開始",
|
||||
"history": "最近のメッセージを表示",
|
||||
"stopRunning": "実行中"
|
||||
}
|
||||
},
|
||||
"encoding": "処理中…",
|
||||
|
||||
@ -551,6 +551,10 @@
|
||||
"title": "상태 보기",
|
||||
"description": "런타임, provider, channel 상태를 표시합니다."
|
||||
},
|
||||
"model": {
|
||||
"title": "모델",
|
||||
"description": "활성 모델 프리셋을 보거나 전환합니다."
|
||||
},
|
||||
"history": {
|
||||
"title": "대화 기록 보기",
|
||||
"description": "저장된 최근 N개의 대화 메시지를 출력합니다."
|
||||
@ -574,7 +578,21 @@
|
||||
"help": {
|
||||
"title": "도움말 보기",
|
||||
"description": "사용 가능한 슬래시 명령을 나열합니다."
|
||||
},
|
||||
"pairing": {
|
||||
"title": "페어링",
|
||||
"description": "페어링 요청을 관리합니다."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"current": "현재",
|
||||
"recent": "최근"
|
||||
},
|
||||
"details": {
|
||||
"goalActive": "목표 실행 중",
|
||||
"goalReady": "지속 목표 시작",
|
||||
"history": "최근 메시지 보기",
|
||||
"stopRunning": "실행 중"
|
||||
}
|
||||
},
|
||||
"encoding": "처리 중…",
|
||||
|
||||
@ -551,6 +551,10 @@
|
||||
"title": "Hiển thị trạng thái",
|
||||
"description": "Hiển thị trạng thái runtime, provider và channel."
|
||||
},
|
||||
"model": {
|
||||
"title": "Mô hình",
|
||||
"description": "Hiển thị hoặc chuyển preset mô hình đang hoạt động."
|
||||
},
|
||||
"history": {
|
||||
"title": "Hiển thị lịch sử",
|
||||
"description": "In N tin nhắn hội thoại đã lưu gần nhất."
|
||||
@ -574,7 +578,21 @@
|
||||
"help": {
|
||||
"title": "Hiển thị trợ giúp",
|
||||
"description": "Liệt kê các lệnh slash có sẵn."
|
||||
},
|
||||
"pairing": {
|
||||
"title": "Ghép nối",
|
||||
"description": "Quản lý yêu cầu ghép nối."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"current": "Hiện tại",
|
||||
"recent": "Gần đây"
|
||||
},
|
||||
"details": {
|
||||
"goalActive": "Mục tiêu đang chạy",
|
||||
"goalReady": "Bắt đầu mục tiêu duy trì",
|
||||
"history": "Hiển thị tin nhắn gần đây",
|
||||
"stopRunning": "Đang chạy"
|
||||
}
|
||||
},
|
||||
"encoding": "Đang xử lý…",
|
||||
|
||||
@ -533,6 +533,16 @@
|
||||
"navigateHint": "↑↓ 选择",
|
||||
"selectHint": "Enter/Tab 填入",
|
||||
"closeHint": "Esc 关闭",
|
||||
"badges": {
|
||||
"current": "当前",
|
||||
"recent": "最近"
|
||||
},
|
||||
"details": {
|
||||
"goalActive": "目标正在运行",
|
||||
"goalReady": "开始一个持续目标",
|
||||
"history": "查看最近消息",
|
||||
"stopRunning": "正在运行"
|
||||
},
|
||||
"commands": {
|
||||
"new": {
|
||||
"title": "新建对话",
|
||||
@ -550,6 +560,10 @@
|
||||
"title": "查看状态",
|
||||
"description": "显示运行时、服务商和通道状态。"
|
||||
},
|
||||
"model": {
|
||||
"title": "模型",
|
||||
"description": "查看或切换当前模型预设。"
|
||||
},
|
||||
"history": {
|
||||
"title": "查看对话历史",
|
||||
"description": "打印最近 N 条已持久化的对话消息。"
|
||||
@ -573,6 +587,10 @@
|
||||
"help": {
|
||||
"title": "查看帮助",
|
||||
"description": "列出可用的斜杠命令。"
|
||||
},
|
||||
"pairing": {
|
||||
"title": "配对",
|
||||
"description": "管理配对请求。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -551,6 +551,10 @@
|
||||
"title": "查看狀態",
|
||||
"description": "顯示執行環境、provider 和 channel 狀態。"
|
||||
},
|
||||
"model": {
|
||||
"title": "模型",
|
||||
"description": "查看或切換目前模型預設。"
|
||||
},
|
||||
"history": {
|
||||
"title": "查看對話歷史",
|
||||
"description": "列印最近 N 則已持久化的對話訊息。"
|
||||
@ -574,7 +578,21 @@
|
||||
"help": {
|
||||
"title": "查看說明",
|
||||
"description": "列出可用的斜線命令。"
|
||||
},
|
||||
"pairing": {
|
||||
"title": "配對",
|
||||
"description": "管理配對請求。"
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"current": "目前",
|
||||
"recent": "最近"
|
||||
},
|
||||
"details": {
|
||||
"goalActive": "目標正在執行",
|
||||
"goalReady": "開始一個持續目標",
|
||||
"history": "查看最近訊息",
|
||||
"stopRunning": "正在執行"
|
||||
}
|
||||
},
|
||||
"encoding": "處理中…",
|
||||
|
||||
@ -8,6 +8,20 @@ import { resources } from "@/i18n";
|
||||
|
||||
const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
|
||||
const IMAGE_QUICK_ACTION_KEYS = ["icon", "sticker", "poster", "product", "portrait", "edit"];
|
||||
const SLASH_COMMAND_KEYS = [
|
||||
"new",
|
||||
"stop",
|
||||
"restart",
|
||||
"status",
|
||||
"model",
|
||||
"history",
|
||||
"dream",
|
||||
"dream_log",
|
||||
"dream_restore",
|
||||
"goal",
|
||||
"help",
|
||||
"pairing",
|
||||
];
|
||||
const SETTINGS_NAV_KEYS = [
|
||||
"overview",
|
||||
"appearance",
|
||||
@ -18,6 +32,33 @@ const SETTINGS_NAV_KEYS = [
|
||||
"advanced",
|
||||
];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function flattenResource(value: unknown, prefix = ""): Map<string, unknown> {
|
||||
const out = new Map<string, unknown>();
|
||||
if (!isRecord(value)) return out;
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
if (isRecord(child)) {
|
||||
for (const [childPath, childValue] of flattenResource(child, path)) {
|
||||
out.set(childPath, childValue);
|
||||
}
|
||||
} else {
|
||||
out.set(path, child);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function interpolationKeys(value: unknown): string[] {
|
||||
if (typeof value !== "string") return [];
|
||||
return Array.from(value.matchAll(/{{\s*([\w.-]+)\s*}}/g))
|
||||
.map((match) => match[1])
|
||||
.sort();
|
||||
}
|
||||
|
||||
describe("webui i18n", () => {
|
||||
it("switches UI copy and document locale through the language switcher", async () => {
|
||||
const user = userEvent.setup();
|
||||
@ -72,6 +113,46 @@ describe("webui i18n", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every locale aligned with the English resource shape", () => {
|
||||
const reference = flattenResource(resources.en.common);
|
||||
for (const [locale, resource] of Object.entries(resources)) {
|
||||
if (locale === "en") continue;
|
||||
const current = flattenResource(resource.common);
|
||||
const missing = Array.from(reference.keys()).filter((key) => !current.has(key));
|
||||
const extra = Array.from(current.keys()).filter((key) => !reference.has(key));
|
||||
const interpolationMismatches = Array.from(reference.entries())
|
||||
.filter(([key]) => current.has(key))
|
||||
.filter(([key, value]) =>
|
||||
interpolationKeys(value).join(",") !== interpolationKeys(current.get(key)).join(",")
|
||||
)
|
||||
.map(([key]) => key);
|
||||
|
||||
expect({ locale, missing, extra, interpolationMismatches }).toEqual({
|
||||
locale,
|
||||
missing: [],
|
||||
extra: [],
|
||||
interpolationMismatches: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps slash commands localized for every registered locale", () => {
|
||||
for (const resource of Object.values(resources)) {
|
||||
const slash = resource.common.thread.composer.slash;
|
||||
expect(slash.badges.current).toBeTruthy();
|
||||
expect(slash.badges.recent).toBeTruthy();
|
||||
expect(slash.details.goalActive).toBeTruthy();
|
||||
expect(slash.details.goalReady).toBeTruthy();
|
||||
expect(slash.details.history).toBeTruthy();
|
||||
expect(slash.details.stopRunning).toBeTruthy();
|
||||
for (const key of SLASH_COMMAND_KEYS) {
|
||||
const command = slash.commands[key as keyof typeof slash.commands];
|
||||
expect(command.title).toBeTruthy();
|
||||
expect(command.description).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps settings navigation localized for every registered locale", () => {
|
||||
for (const resource of Object.values(resources)) {
|
||||
const common = resource.common;
|
||||
|
||||
@ -115,6 +115,7 @@ const ORIGINAL_INNER_HEIGHT = window.innerHeight;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.localStorage.clear();
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: ORIGINAL_INNER_HEIGHT,
|
||||
configurable: true,
|
||||
@ -258,6 +259,80 @@ describe("ThreadComposer", () => {
|
||||
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders slash commands as direct actions with current status", () => {
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Type your message..."
|
||||
modelLabel="deepseek-v4-pro"
|
||||
slashCommands={[
|
||||
{
|
||||
command: "/model",
|
||||
title: "Switch model preset",
|
||||
description: "Show or switch the active model preset.",
|
||||
icon: "brain",
|
||||
argHint: "[preset]",
|
||||
},
|
||||
COMMANDS[1],
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "/" },
|
||||
});
|
||||
|
||||
expect(screen.getByRole("option", { name: /Model deepseek-v4-pro/i })).toBeInTheDocument();
|
||||
expect(screen.getByText("Current")).toBeInTheDocument();
|
||||
expect(screen.getByText("/model [preset]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prioritizes stop as an immediate slash action while streaming", () => {
|
||||
const onStop = vi.fn();
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
onStop={onStop}
|
||||
isStreaming
|
||||
placeholder="Type your message..."
|
||||
slashCommands={[COMMANDS[1]]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, { target: { value: "/" } });
|
||||
|
||||
expect(screen.getByRole("option", { name: /Stop current task/i })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("orders recent slash commands first for the blank slash menu", () => {
|
||||
window.localStorage.setItem("nanobot.webui.slashCommandRecents", JSON.stringify(["/history"]));
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Type your message..."
|
||||
slashCommands={COMMANDS}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "/" },
|
||||
});
|
||||
|
||||
expect(screen.getByRole("option", { name: /\/history/i })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByText("Recent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the CLI app mention palette and inserts the selected app", () => {
|
||||
const onSend = vi.fn();
|
||||
render(
|
||||
|
||||
@ -340,6 +340,84 @@ describe("ThreadShell", () => {
|
||||
expect(screen.queryByText("What can I do for you?")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps a live first command reply when the initial history snapshot is stale", async () => {
|
||||
const client = makeClient();
|
||||
const onCreateChat = vi.fn().mockResolvedValue("chat-new");
|
||||
let resolveThread:
|
||||
| ((value: { ok: boolean; status: number; json: () => Promise<unknown> }) => void)
|
||||
| null = null;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("websocket%3Achat-new/webui-thread")) {
|
||||
return new Promise((resolve) => {
|
||||
resolveThread = resolve;
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={null}
|
||||
title="nanobot"
|
||||
onToggleSidebar={() => {}}
|
||||
onCreateChat={onCreateChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "/model" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1));
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-new")}
|
||||
title="Chat chat-new"
|
||||
onToggleSidebar={() => {}}
|
||||
onCreateChat={onCreateChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.sendMessage).toHaveBeenCalledWith("chat-new", "/model", undefined),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
client._emitChat("chat-new", {
|
||||
event: "message",
|
||||
chat_id: "chat-new",
|
||||
text: "## Model\n- Current model: `Ring-2.6-1T`",
|
||||
});
|
||||
});
|
||||
expect(screen.getByText(/Current model/)).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
resolveThread?.(
|
||||
httpJson(transcriptFromSimpleMessages([{ role: "user", content: "/model" }])),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Current model/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("sends quick action prompts from the empty thread landing", async () => {
|
||||
const client = makeClient();
|
||||
const onNewChat = vi.fn().mockResolvedValue("chat-a");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user