diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 291e323b671..71669080382 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -314,60 +314,6 @@ function sanitizeChatHistoryContentBlock(block: unknown): { block: unknown; chan return { block: changed ? entry : block, changed }; } -/** - * Validate that a value is a finite number, returning undefined otherwise. - */ -function toFiniteNumber(x: unknown): number | undefined { - return typeof x === "number" && Number.isFinite(x) ? x : undefined; -} - -/** - * Sanitize usage metadata to ensure only finite numeric fields are included. - * Prevents UI crashes from malformed transcript JSON. - */ -function sanitizeUsage(raw: unknown): Record | undefined { - if (!raw || typeof raw !== "object") { - return undefined; - } - const u = raw as Record; - const out: Record = {}; - - // Whitelist known usage fields and validate they're finite numbers - const knownFields = [ - "input", - "output", - "totalTokens", - "inputTokens", - "outputTokens", - "cacheRead", - "cacheWrite", - "cache_read_input_tokens", - "cache_creation_input_tokens", - ]; - - for (const k of knownFields) { - const n = toFiniteNumber(u[k]); - if (n !== undefined) { - out[k] = n; - } - } - - return Object.keys(out).length > 0 ? out : undefined; -} - -/** - * Sanitize cost metadata to ensure only finite numeric fields are included. - * Prevents UI crashes from calling .toFixed() on non-numbers. - */ -function sanitizeCost(raw: unknown): { total?: number } | undefined { - if (!raw || typeof raw !== "object") { - return undefined; - } - const c = raw as Record; - const total = toFiniteNumber(c.total); - return total !== undefined ? { total } : undefined; -} - function sanitizeChatHistoryMessage(message: unknown): { message: unknown; changed: boolean } { if (!message || typeof message !== "object") { return { message, changed: false }; @@ -379,38 +325,13 @@ function sanitizeChatHistoryMessage(message: unknown): { message: unknown; chang delete entry.details; changed = true; } - - // Keep usage/cost so the chat UI can render per-message token and cost badges. - // Only retain usage/cost on assistant messages and validate numeric fields to prevent UI crashes. - if (entry.role !== "assistant") { - if ("usage" in entry) { - delete entry.usage; - changed = true; - } - if ("cost" in entry) { - delete entry.cost; - changed = true; - } - } else { - // Validate and sanitize usage/cost for assistant messages - if ("usage" in entry) { - const sanitized = sanitizeUsage(entry.usage); - if (sanitized) { - entry.usage = sanitized; - } else { - delete entry.usage; - } - changed = true; - } - if ("cost" in entry) { - const sanitized = sanitizeCost(entry.cost); - if (sanitized) { - entry.cost = sanitized; - } else { - delete entry.cost; - } - changed = true; - } + if ("usage" in entry) { + delete entry.usage; + changed = true; + } + if ("cost" in entry) { + delete entry.cost; + changed = true; } if (typeof entry.content === "string") { diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index ca1e2c09402..2e76e1a5de1 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -273,37 +273,6 @@ describe("gateway server chat", () => { }); }); - test("chat.history preserves usage and cost metadata for assistant messages", async () => { - await withGatewayChatHarness(async ({ ws, createSessionDir }) => { - await connectOk(ws); - - const sessionDir = await createSessionDir(); - await writeMainSessionStore(); - - await writeMainSessionTranscript(sessionDir, [ - JSON.stringify({ - message: { - role: "assistant", - timestamp: Date.now(), - content: [{ type: "text", text: "hello" }], - usage: { input: 12, output: 5, totalTokens: 17 }, - cost: { total: 0.0123 }, - details: { debug: true }, - }, - }), - ]); - - const messages = await fetchHistoryMessages(ws); - expect(messages).toHaveLength(1); - expect(messages[0]).toMatchObject({ - role: "assistant", - usage: { input: 12, output: 5, totalTokens: 17 }, - cost: { total: 0.0123 }, - }); - expect(messages[0]).not.toHaveProperty("details"); - }); - }); - test("chat.history strips inline directives from displayed message text", async () => { await withGatewayChatHarness(async ({ ws, createSessionDir }) => { await connectOk(ws); diff --git a/ui/src/ui/chat-export.ts b/ui/src/ui/chat-export.ts deleted file mode 100644 index ed5bbf931f8..00000000000 --- a/ui/src/ui/chat-export.ts +++ /dev/null @@ -1 +0,0 @@ -export { exportChatMarkdown } from "./chat/export.ts"; diff --git a/ui/src/ui/chat/deleted-messages.ts b/ui/src/ui/chat/deleted-messages.ts deleted file mode 100644 index fd3916d78c7..00000000000 --- a/ui/src/ui/chat/deleted-messages.ts +++ /dev/null @@ -1,49 +0,0 @@ -const PREFIX = "openclaw:deleted:"; - -export class DeletedMessages { - private key: string; - private _keys = new Set(); - - constructor(sessionKey: string) { - this.key = PREFIX + sessionKey; - this.load(); - } - - has(key: string): boolean { - return this._keys.has(key); - } - - delete(key: string): void { - this._keys.add(key); - this.save(); - } - - restore(key: string): void { - this._keys.delete(key); - this.save(); - } - - clear(): void { - this._keys.clear(); - this.save(); - } - - private load(): void { - try { - const raw = localStorage.getItem(this.key); - if (!raw) { - return; - } - const arr = JSON.parse(raw); - if (Array.isArray(arr)) { - this._keys = new Set(arr.filter((s) => typeof s === "string")); - } - } catch { - // ignore - } - } - - private save(): void { - localStorage.setItem(this.key, JSON.stringify([...this._keys])); - } -} diff --git a/ui/src/ui/chat/export.ts b/ui/src/ui/chat/export.ts deleted file mode 100644 index 31e15e592e2..00000000000 --- a/ui/src/ui/chat/export.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Export chat history as markdown file. - */ -export function exportChatMarkdown(messages: unknown[], assistantName: string): void { - const history = Array.isArray(messages) ? messages : []; - if (history.length === 0) { - return; - } - const lines: string[] = [`# Chat with ${assistantName}`, ""]; - for (const msg of history) { - const m = msg as Record; - const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool"; - const content = typeof m.content === "string" ? m.content : ""; - const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : ""; - lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, ""); - } - const blob = new Blob([lines.join("\n")], { type: "text/markdown" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `chat-${assistantName}-${Date.now()}.md`; - link.click(); - URL.revokeObjectURL(url); -} diff --git a/ui/src/ui/chat/input-history.ts b/ui/src/ui/chat/input-history.ts deleted file mode 100644 index 34d8806d072..00000000000 --- a/ui/src/ui/chat/input-history.ts +++ /dev/null @@ -1,49 +0,0 @@ -const MAX = 50; - -export class InputHistory { - private items: string[] = []; - private cursor = -1; - - push(text: string): void { - const trimmed = text.trim(); - if (!trimmed) { - return; - } - if (this.items[this.items.length - 1] === trimmed) { - return; - } - this.items.push(trimmed); - if (this.items.length > MAX) { - this.items.shift(); - } - this.cursor = -1; - } - - up(): string | null { - if (this.items.length === 0) { - return null; - } - if (this.cursor < 0) { - this.cursor = this.items.length - 1; - } else if (this.cursor > 0) { - this.cursor--; - } - return this.items[this.cursor] ?? null; - } - - down(): string | null { - if (this.cursor < 0) { - return null; - } - this.cursor++; - if (this.cursor >= this.items.length) { - this.cursor = -1; - return null; - } - return this.items[this.cursor] ?? null; - } - - reset(): void { - this.cursor = -1; - } -} diff --git a/ui/src/ui/chat/pinned-messages.ts b/ui/src/ui/chat/pinned-messages.ts deleted file mode 100644 index 4914b0db32a..00000000000 --- a/ui/src/ui/chat/pinned-messages.ts +++ /dev/null @@ -1,61 +0,0 @@ -const PREFIX = "openclaw:pinned:"; - -export class PinnedMessages { - private key: string; - private _indices = new Set(); - - constructor(sessionKey: string) { - this.key = PREFIX + sessionKey; - this.load(); - } - - get indices(): Set { - return this._indices; - } - - has(index: number): boolean { - return this._indices.has(index); - } - - pin(index: number): void { - this._indices.add(index); - this.save(); - } - - unpin(index: number): void { - this._indices.delete(index); - this.save(); - } - - toggle(index: number): void { - if (this._indices.has(index)) { - this.unpin(index); - } else { - this.pin(index); - } - } - - clear(): void { - this._indices.clear(); - this.save(); - } - - private load(): void { - try { - const raw = localStorage.getItem(this.key); - if (!raw) { - return; - } - const arr = JSON.parse(raw); - if (Array.isArray(arr)) { - this._indices = new Set(arr.filter((n) => typeof n === "number")); - } - } catch { - // ignore - } - } - - private save(): void { - localStorage.setItem(this.key, JSON.stringify([...this._indices])); - } -} diff --git a/ui/src/ui/chat/slash-command-executor.node.test.ts b/ui/src/ui/chat/slash-command-executor.node.test.ts deleted file mode 100644 index 706bfed0c3c..00000000000 --- a/ui/src/ui/chat/slash-command-executor.node.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { GatewayBrowserClient } from "../gateway.ts"; -import type { GatewaySessionRow } from "../types.ts"; -import { executeSlashCommand } from "./slash-command-executor.ts"; - -function row(key: string): GatewaySessionRow { - return { - key, - kind: "direct", - updatedAt: null, - }; -} - -describe("executeSlashCommand /kill", () => { - it("aborts every sub-agent session for /kill all", async () => { - const request = vi.fn(async (method: string, _payload?: unknown) => { - if (method === "sessions.list") { - return { - sessions: [ - row("main"), - row("agent:main:subagent:one"), - row("agent:main:subagent:parent:subagent:child"), - row("agent:other:main"), - ], - }; - } - if (method === "chat.abort") { - return { ok: true, aborted: true }; - } - throw new Error(`unexpected method: ${method}`); - }); - - const result = await executeSlashCommand( - { request } as unknown as GatewayBrowserClient, - "agent:main:main", - "kill", - "all", - ); - - expect(result.content).toBe("Aborted 2 sub-agent sessions."); - expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); - expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { - sessionKey: "agent:main:subagent:one", - }); - expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { - sessionKey: "agent:main:subagent:parent:subagent:child", - }); - }); - - it("aborts matching sub-agent sessions for /kill ", async () => { - const request = vi.fn(async (method: string, _payload?: unknown) => { - if (method === "sessions.list") { - return { - sessions: [ - row("agent:main:subagent:one"), - row("agent:main:subagent:two"), - row("agent:other:subagent:three"), - ], - }; - } - if (method === "chat.abort") { - return { ok: true, aborted: true }; - } - throw new Error(`unexpected method: ${method}`); - }); - - const result = await executeSlashCommand( - { request } as unknown as GatewayBrowserClient, - "agent:main:main", - "kill", - "main", - ); - - expect(result.content).toBe("Aborted 2 matching sub-agent sessions for `main`."); - expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); - expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { - sessionKey: "agent:main:subagent:one", - }); - expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { - sessionKey: "agent:main:subagent:two", - }); - }); -}); diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts deleted file mode 100644 index 3392095c7c1..00000000000 --- a/ui/src/ui/chat/slash-command-executor.ts +++ /dev/null @@ -1,370 +0,0 @@ -/** - * Client-side execution engine for slash commands. - * Calls gateway RPC methods and returns formatted results. - */ - -import { isSubagentSessionKey, parseAgentSessionKey } from "../../../../src/routing/session-key.js"; -import type { GatewayBrowserClient } from "../gateway.ts"; -import type { - AgentsListResult, - GatewaySessionRow, - HealthSummary, - ModelCatalogEntry, - SessionsListResult, -} from "../types.ts"; -import { SLASH_COMMANDS } from "./slash-commands.ts"; - -export type SlashCommandResult = { - /** Markdown-formatted result to display in chat. */ - content: string; - /** Side-effect action the caller should perform after displaying the result. */ - action?: - | "refresh" - | "export" - | "new-session" - | "reset" - | "stop" - | "clear" - | "toggle-focus" - | "navigate-usage"; -}; - -export async function executeSlashCommand( - client: GatewayBrowserClient, - sessionKey: string, - commandName: string, - args: string, -): Promise { - switch (commandName) { - case "help": - return executeHelp(); - case "status": - return await executeStatus(client); - case "new": - return { content: "Starting new session...", action: "new-session" }; - case "reset": - return { content: "Resetting session...", action: "reset" }; - case "stop": - return { content: "Stopping current run...", action: "stop" }; - case "clear": - return { content: "Chat history cleared.", action: "clear" }; - case "focus": - return { content: "Toggled focus mode.", action: "toggle-focus" }; - case "compact": - return await executeCompact(client, sessionKey); - case "model": - return await executeModel(client, sessionKey, args); - case "think": - return await executeThink(client, sessionKey, args); - case "verbose": - return await executeVerbose(client, sessionKey, args); - case "export": - return { content: "Exporting session...", action: "export" }; - case "usage": - return await executeUsage(client, sessionKey); - case "agents": - return await executeAgents(client); - case "kill": - return await executeKill(client, sessionKey, args); - default: - return { content: `Unknown command: \`/${commandName}\`` }; - } -} - -// ── Command Implementations ── - -function executeHelp(): SlashCommandResult { - const lines = ["**Available Commands**\n"]; - let currentCategory = ""; - - for (const cmd of SLASH_COMMANDS) { - const cat = cmd.category ?? "session"; - if (cat !== currentCategory) { - currentCategory = cat; - lines.push(`**${cat.charAt(0).toUpperCase() + cat.slice(1)}**`); - } - const argStr = cmd.args ? ` ${cmd.args}` : ""; - const local = cmd.executeLocal ? "" : " *(agent)*"; - lines.push(`\`/${cmd.name}${argStr}\` — ${cmd.description}${local}`); - } - - lines.push("\nType `/` to open the command menu."); - return { content: lines.join("\n") }; -} - -async function executeStatus(client: GatewayBrowserClient): Promise { - try { - const health = await client.request("health", {}); - const status = health.ok ? "Healthy" : "Degraded"; - const agentCount = health.agents?.length ?? 0; - const sessionCount = health.sessions?.count ?? 0; - const lines = [ - `**System Status:** ${status}`, - `**Agents:** ${agentCount}`, - `**Sessions:** ${sessionCount}`, - `**Default Agent:** ${health.defaultAgentId || "none"}`, - ]; - if (health.durationMs) { - lines.push(`**Response:** ${health.durationMs}ms`); - } - return { content: lines.join("\n") }; - } catch (err) { - return { content: `Failed to fetch status: ${String(err)}` }; - } -} - -async function executeCompact( - client: GatewayBrowserClient, - sessionKey: string, -): Promise { - try { - await client.request("sessions.compact", { key: sessionKey }); - return { content: "Context compacted successfully.", action: "refresh" }; - } catch (err) { - return { content: `Compaction failed: ${String(err)}` }; - } -} - -async function executeModel( - client: GatewayBrowserClient, - sessionKey: string, - args: string, -): Promise { - if (!args) { - try { - const sessions = await client.request("sessions.list", {}); - const session = sessions?.sessions?.find((s: GatewaySessionRow) => s.key === sessionKey); - const model = session?.model || sessions?.defaults?.model || "default"; - const models = await client.request<{ models: ModelCatalogEntry[] }>("models.list", {}); - const available = models?.models?.map((m: ModelCatalogEntry) => m.id) ?? []; - const lines = [`**Current model:** \`${model}\``]; - if (available.length > 0) { - lines.push( - `**Available:** ${available - .slice(0, 10) - .map((m: string) => `\`${m}\``) - .join(", ")}${available.length > 10 ? ` +${available.length - 10} more` : ""}`, - ); - } - return { content: lines.join("\n") }; - } catch (err) { - return { content: `Failed to get model info: ${String(err)}` }; - } - } - - try { - await client.request("sessions.patch", { key: sessionKey, model: args.trim() }); - return { content: `Model set to \`${args.trim()}\`.`, action: "refresh" }; - } catch (err) { - return { content: `Failed to set model: ${String(err)}` }; - } -} - -async function executeThink( - client: GatewayBrowserClient, - sessionKey: string, - args: string, -): Promise { - const valid = ["off", "low", "medium", "high"]; - const level = args.trim().toLowerCase(); - - if (!level) { - return { - content: `Usage: \`/think <${valid.join("|")}>\``, - }; - } - if (!valid.includes(level)) { - return { - content: `Invalid thinking level \`${level}\`. Choose: ${valid.map((v) => `\`${v}\``).join(", ")}`, - }; - } - - try { - await client.request("sessions.patch", { key: sessionKey, thinkingLevel: level }); - return { - content: `Thinking level set to **${level}**.`, - action: "refresh", - }; - } catch (err) { - return { content: `Failed to set thinking level: ${String(err)}` }; - } -} - -async function executeVerbose( - client: GatewayBrowserClient, - sessionKey: string, - args: string, -): Promise { - const valid = ["on", "off", "full"]; - const level = args.trim().toLowerCase(); - - if (!level) { - return { - content: `Usage: \`/verbose <${valid.join("|")}>\``, - }; - } - if (!valid.includes(level)) { - return { - content: `Invalid verbose level \`${level}\`. Choose: ${valid.map((v) => `\`${v}\``).join(", ")}`, - }; - } - - try { - await client.request("sessions.patch", { key: sessionKey, verboseLevel: level }); - return { - content: `Verbose mode set to **${level}**.`, - action: "refresh", - }; - } catch (err) { - return { content: `Failed to set verbose mode: ${String(err)}` }; - } -} - -async function executeUsage( - client: GatewayBrowserClient, - sessionKey: string, -): Promise { - try { - const sessions = await client.request("sessions.list", {}); - const session = sessions?.sessions?.find((s: GatewaySessionRow) => s.key === sessionKey); - if (!session) { - return { content: "No active session." }; - } - const input = session.inputTokens ?? 0; - const output = session.outputTokens ?? 0; - const total = session.totalTokens ?? input + output; - const ctx = session.contextTokens ?? 0; - const pct = ctx > 0 ? Math.round((input / ctx) * 100) : null; - - const lines = [ - "**Session Usage**", - `Input: **${fmtTokens(input)}** tokens`, - `Output: **${fmtTokens(output)}** tokens`, - `Total: **${fmtTokens(total)}** tokens`, - ]; - if (pct !== null) { - lines.push(`Context: **${pct}%** of ${fmtTokens(ctx)}`); - } - if (session.model) { - lines.push(`Model: \`${session.model}\``); - } - return { content: lines.join("\n") }; - } catch (err) { - return { content: `Failed to get usage: ${String(err)}` }; - } -} - -async function executeAgents(client: GatewayBrowserClient): Promise { - try { - const result = await client.request("agents.list", {}); - const agents = result?.agents ?? []; - if (agents.length === 0) { - return { content: "No agents configured." }; - } - const lines = [`**Agents** (${agents.length})\n`]; - for (const agent of agents) { - const isDefault = agent.id === result?.defaultId; - const name = agent.identity?.name || agent.name || agent.id; - const marker = isDefault ? " *(default)*" : ""; - lines.push(`- \`${agent.id}\` — ${name}${marker}`); - } - return { content: lines.join("\n") }; - } catch (err) { - return { content: `Failed to list agents: ${String(err)}` }; - } -} - -async function executeKill( - client: GatewayBrowserClient, - sessionKey: string, - args: string, -): Promise { - const target = args.trim(); - if (!target) { - return { content: "Usage: `/kill `" }; - } - try { - const sessions = await client.request("sessions.list", {}); - const matched = resolveKillTargets(sessions?.sessions ?? [], sessionKey, target); - if (matched.length === 0) { - return { - content: - target.toLowerCase() === "all" - ? "No active sub-agent sessions found." - : `No matching sub-agent sessions found for \`${target}\`.`, - }; - } - - const results = await Promise.allSettled( - matched.map((key) => client.request("chat.abort", { sessionKey: key })), - ); - const successCount = results.filter((entry) => entry.status === "fulfilled").length; - if (successCount === 0) { - const firstFailure = results.find((entry) => entry.status === "rejected"); - throw firstFailure?.reason ?? new Error("abort failed"); - } - - if (target.toLowerCase() === "all") { - return { - content: - successCount === matched.length - ? `Aborted ${successCount} sub-agent session${successCount === 1 ? "" : "s"}.` - : `Aborted ${successCount} of ${matched.length} sub-agent sessions.`, - }; - } - - return { - content: - successCount === matched.length - ? `Aborted ${successCount} matching sub-agent session${successCount === 1 ? "" : "s"} for \`${target}\`.` - : `Aborted ${successCount} of ${matched.length} matching sub-agent sessions for \`${target}\`.`, - }; - } catch (err) { - return { content: `Failed to abort: ${String(err)}` }; - } -} - -function resolveKillTargets( - sessions: GatewaySessionRow[], - currentSessionKey: string, - target: string, -): string[] { - const normalizedTarget = target.trim().toLowerCase(); - if (!normalizedTarget) { - return []; - } - - const keys = new Set(); - const currentParsed = parseAgentSessionKey(currentSessionKey); - for (const session of sessions) { - const key = session?.key?.trim(); - if (!key || !isSubagentSessionKey(key)) { - continue; - } - const normalizedKey = key.toLowerCase(); - const parsed = parseAgentSessionKey(normalizedKey); - const isMatch = - normalizedTarget === "all" || - normalizedKey === normalizedTarget || - (parsed?.agentId ?? "") === normalizedTarget || - normalizedKey.endsWith(`:subagent:${normalizedTarget}`) || - normalizedKey === `subagent:${normalizedTarget}` || - (currentParsed?.agentId != null && - parsed?.agentId === currentParsed.agentId && - normalizedKey.endsWith(`:subagent:${normalizedTarget}`)); - if (isMatch) { - keys.add(key); - } - } - return [...keys]; -} - -function fmtTokens(n: number): string { - if (n >= 1_000_000) { - return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; - } - if (n >= 1_000) { - return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`; - } - return String(n); -} diff --git a/ui/src/ui/chat/slash-commands.ts b/ui/src/ui/chat/slash-commands.ts deleted file mode 100644 index d26a82e1544..00000000000 --- a/ui/src/ui/chat/slash-commands.ts +++ /dev/null @@ -1,217 +0,0 @@ -import type { IconName } from "../icons.ts"; - -export type SlashCommandCategory = "session" | "model" | "agents" | "tools"; - -export type SlashCommandDef = { - name: string; - description: string; - args?: string; - icon?: IconName; - category?: SlashCommandCategory; - /** When true, the command is executed client-side via RPC instead of sent to the agent. */ - executeLocal?: boolean; - /** Fixed argument choices for inline hints. */ - argOptions?: string[]; - /** Keyboard shortcut hint shown in the menu (display only). */ - shortcut?: string; -}; - -export const SLASH_COMMANDS: SlashCommandDef[] = [ - // ── Session ── - { - name: "new", - description: "Start a new session", - icon: "plus", - category: "session", - executeLocal: true, - }, - { - name: "reset", - description: "Reset current session", - icon: "refresh", - category: "session", - executeLocal: true, - }, - { - name: "compact", - description: "Compact session context", - icon: "loader", - category: "session", - executeLocal: true, - }, - { - name: "stop", - description: "Stop current run", - icon: "stop", - category: "session", - executeLocal: true, - }, - { - name: "clear", - description: "Clear chat history", - icon: "trash", - category: "session", - executeLocal: true, - }, - { - name: "focus", - description: "Toggle focus mode", - icon: "eye", - category: "session", - executeLocal: true, - }, - - // ── Model ── - { - name: "model", - description: "Show or set model", - args: "", - icon: "brain", - category: "model", - executeLocal: true, - }, - { - name: "think", - description: "Set thinking level", - args: "", - icon: "brain", - category: "model", - executeLocal: true, - argOptions: ["off", "low", "medium", "high"], - }, - { - name: "verbose", - description: "Toggle verbose mode", - args: "", - icon: "terminal", - category: "model", - executeLocal: true, - argOptions: ["on", "off", "full"], - }, - - // ── Tools ── - { - name: "help", - description: "Show available commands", - icon: "book", - category: "tools", - executeLocal: true, - }, - { - name: "status", - description: "Show system status", - icon: "barChart", - category: "tools", - executeLocal: true, - }, - { - name: "export", - description: "Export session to Markdown", - icon: "download", - category: "tools", - executeLocal: true, - }, - { - name: "usage", - description: "Show token usage", - icon: "barChart", - category: "tools", - executeLocal: true, - }, - - // ── Agents ── - { - name: "agents", - description: "List agents", - icon: "monitor", - category: "agents", - executeLocal: true, - }, - { - name: "kill", - description: "Abort sub-agents", - args: "", - icon: "x", - category: "agents", - executeLocal: true, - }, - { - name: "skill", - description: "Run a skill", - args: "", - icon: "zap", - category: "tools", - }, - { - name: "steer", - description: "Steer a sub-agent", - args: " ", - icon: "send", - category: "agents", - }, -]; - -const CATEGORY_ORDER: SlashCommandCategory[] = ["session", "model", "tools", "agents"]; - -export const CATEGORY_LABELS: Record = { - session: "Session", - model: "Model", - agents: "Agents", - tools: "Tools", -}; - -export function getSlashCommandCompletions(filter: string): SlashCommandDef[] { - const lower = filter.toLowerCase(); - const commands = lower - ? SLASH_COMMANDS.filter( - (cmd) => cmd.name.startsWith(lower) || cmd.description.toLowerCase().includes(lower), - ) - : SLASH_COMMANDS; - return commands.toSorted((a, b) => { - const ai = CATEGORY_ORDER.indexOf(a.category ?? "session"); - const bi = CATEGORY_ORDER.indexOf(b.category ?? "session"); - if (ai !== bi) { - return ai - bi; - } - // Exact prefix matches first - if (lower) { - const aExact = a.name.startsWith(lower) ? 0 : 1; - const bExact = b.name.startsWith(lower) ? 0 : 1; - if (aExact !== bExact) { - return aExact - bExact; - } - } - return 0; - }); -} - -export type ParsedSlashCommand = { - command: SlashCommandDef; - args: string; -}; - -/** - * Parse a message as a slash command. Returns null if it doesn't match. - * Supports `/command` and `/command args...`. - */ -export function parseSlashCommand(text: string): ParsedSlashCommand | null { - const trimmed = text.trim(); - if (!trimmed.startsWith("/")) { - return null; - } - - const spaceIdx = trimmed.indexOf(" "); - const name = spaceIdx === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIdx); - const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim(); - - if (!name) { - return null; - } - - const command = SLASH_COMMANDS.find((cmd) => cmd.name === name.toLowerCase()); - if (!command) { - return null; - } - - return { command, args }; -} diff --git a/ui/src/ui/chat/speech.ts b/ui/src/ui/chat/speech.ts deleted file mode 100644 index 4db4e6944a1..00000000000 --- a/ui/src/ui/chat/speech.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * Browser-native speech services: STT via SpeechRecognition, TTS via SpeechSynthesis. - * Falls back gracefully when APIs are unavailable. - */ - -// ─── STT (Speech-to-Text) ─── - -type SpeechRecognitionEvent = Event & { - results: SpeechRecognitionResultList; - resultIndex: number; -}; - -type SpeechRecognitionErrorEvent = Event & { - error: string; - message?: string; -}; - -interface SpeechRecognitionInstance extends EventTarget { - continuous: boolean; - interimResults: boolean; - lang: string; - start(): void; - stop(): void; - abort(): void; - onresult: ((event: SpeechRecognitionEvent) => void) | null; - onerror: ((event: SpeechRecognitionErrorEvent) => void) | null; - onend: (() => void) | null; - onstart: (() => void) | null; -} - -type SpeechRecognitionCtor = new () => SpeechRecognitionInstance; - -function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null { - const w = globalThis as Record; - return (w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null) as SpeechRecognitionCtor | null; -} - -export function isSttSupported(): boolean { - return getSpeechRecognitionCtor() !== null; -} - -export type SttCallbacks = { - onTranscript: (text: string, isFinal: boolean) => void; - onStart?: () => void; - onEnd?: () => void; - onError?: (error: string) => void; -}; - -let activeRecognition: SpeechRecognitionInstance | null = null; - -export function startStt(callbacks: SttCallbacks): boolean { - const Ctor = getSpeechRecognitionCtor(); - if (!Ctor) { - callbacks.onError?.("Speech recognition is not supported in this browser"); - return false; - } - - stopStt(); - - const recognition = new Ctor(); - recognition.continuous = true; - recognition.interimResults = true; - recognition.lang = navigator.language || "en-US"; - - recognition.addEventListener("start", () => callbacks.onStart?.()); - - recognition.addEventListener("result", (event) => { - const speechEvent = event as unknown as SpeechRecognitionEvent; - let interimTranscript = ""; - let finalTranscript = ""; - - for (let i = speechEvent.resultIndex; i < speechEvent.results.length; i++) { - const result = speechEvent.results[i]; - if (!result?.[0]) { - continue; - } - const transcript = result[0].transcript; - if (result.isFinal) { - finalTranscript += transcript; - } else { - interimTranscript += transcript; - } - } - - if (finalTranscript) { - callbacks.onTranscript(finalTranscript, true); - } else if (interimTranscript) { - callbacks.onTranscript(interimTranscript, false); - } - }); - - recognition.addEventListener("error", (event) => { - const speechEvent = event as unknown as SpeechRecognitionErrorEvent; - if (speechEvent.error === "aborted" || speechEvent.error === "no-speech") { - return; - } - callbacks.onError?.(speechEvent.error); - }); - - recognition.addEventListener("end", () => { - if (activeRecognition === recognition) { - activeRecognition = null; - } - callbacks.onEnd?.(); - }); - - activeRecognition = recognition; - recognition.start(); - return true; -} - -export function stopStt(): void { - if (activeRecognition) { - const r = activeRecognition; - activeRecognition = null; - try { - r.stop(); - } catch { - // already stopped - } - } -} - -export function isSttActive(): boolean { - return activeRecognition !== null; -} - -// ─── TTS (Text-to-Speech) ─── - -export function isTtsSupported(): boolean { - return "speechSynthesis" in globalThis; -} - -let currentUtterance: SpeechSynthesisUtterance | null = null; - -export function speakText( - text: string, - opts?: { - onStart?: () => void; - onEnd?: () => void; - onError?: (error: string) => void; - }, -): boolean { - if (!isTtsSupported()) { - opts?.onError?.("Speech synthesis is not supported in this browser"); - return false; - } - - stopTts(); - - const cleaned = stripMarkdown(text); - if (!cleaned.trim()) { - return false; - } - - const utterance = new SpeechSynthesisUtterance(cleaned); - utterance.rate = 1.0; - utterance.pitch = 1.0; - - utterance.addEventListener("start", () => opts?.onStart?.()); - utterance.addEventListener("end", () => { - if (currentUtterance === utterance) { - currentUtterance = null; - } - opts?.onEnd?.(); - }); - utterance.addEventListener("error", (e) => { - if (currentUtterance === utterance) { - currentUtterance = null; - } - if (e.error === "canceled" || e.error === "interrupted") { - return; - } - opts?.onError?.(e.error); - }); - - currentUtterance = utterance; - speechSynthesis.speak(utterance); - return true; -} - -export function stopTts(): void { - if (currentUtterance) { - currentUtterance = null; - } - if (isTtsSupported()) { - speechSynthesis.cancel(); - } -} - -export function isTtsSpeaking(): boolean { - return isTtsSupported() && speechSynthesis.speaking; -} - -/** Strip common markdown syntax for cleaner speech output. */ -function stripMarkdown(text: string): string { - return ( - text - // code blocks - .replace(/```[\s\S]*?```/g, "") - // inline code - .replace(/`[^`]+`/g, "") - // images - .replace(/!\[.*?\]\(.*?\)/g, "") - // links → keep text - .replace(/\[([^\]]+)\]\(.*?\)/g, "$1") - // headings - .replace(/^#{1,6}\s+/gm, "") - // bold/italic - .replace(/\*{1,3}(.*?)\*{1,3}/g, "$1") - .replace(/_{1,3}(.*?)_{1,3}/g, "$1") - // blockquotes - .replace(/^>\s?/gm, "") - // horizontal rules - .replace(/^[-*_]{3,}\s*$/gm, "") - // list markers - .replace(/^\s*[-*+]\s+/gm, "") - .replace(/^\s*\d+\.\s+/gm, "") - // HTML tags - .replace(/<[^>]+>/g, "") - // collapse whitespace - .replace(/\n{3,}/g, "\n\n") - .trim() - ); -}