Revert "feat(ui): add chat infrastructure modules (slice 1 of dashboard-v2)"

This reverts commit 5a659b0b61.
This commit is contained in:
Val Alexander
2026-03-09 18:47:44 -05:00
parent 9f0a64f855
commit 6b87489890
11 changed files with 7 additions and 1196 deletions

View File

@@ -314,60 +314,6 @@ function sanitizeChatHistoryContentBlock(block: unknown): { block: unknown; chan
return { block: changed ? entry : block, changed }; 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<string, number> | undefined {
if (!raw || typeof raw !== "object") {
return undefined;
}
const u = raw as Record<string, unknown>;
const out: Record<string, number> = {};
// 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<string, unknown>;
const total = toFiniteNumber(c.total);
return total !== undefined ? { total } : undefined;
}
function sanitizeChatHistoryMessage(message: unknown): { message: unknown; changed: boolean } { function sanitizeChatHistoryMessage(message: unknown): { message: unknown; changed: boolean } {
if (!message || typeof message !== "object") { if (!message || typeof message !== "object") {
return { message, changed: false }; return { message, changed: false };
@@ -379,38 +325,13 @@ function sanitizeChatHistoryMessage(message: unknown): { message: unknown; chang
delete entry.details; delete entry.details;
changed = true; changed = true;
} }
if ("usage" in entry) {
// Keep usage/cost so the chat UI can render per-message token and cost badges. delete entry.usage;
// Only retain usage/cost on assistant messages and validate numeric fields to prevent UI crashes. changed = true;
if (entry.role !== "assistant") { }
if ("usage" in entry) { if ("cost" in entry) {
delete entry.usage; delete entry.cost;
changed = true; 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 (typeof entry.content === "string") { if (typeof entry.content === "string") {

View File

@@ -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 () => { test("chat.history strips inline directives from displayed message text", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => { await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
await connectOk(ws); await connectOk(ws);

View File

@@ -1 +0,0 @@
export { exportChatMarkdown } from "./chat/export.ts";

View File

@@ -1,49 +0,0 @@
const PREFIX = "openclaw:deleted:";
export class DeletedMessages {
private key: string;
private _keys = new Set<string>();
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]));
}
}

View File

@@ -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<string, unknown>;
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);
}

View File

@@ -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;
}
}

View File

@@ -1,61 +0,0 @@
const PREFIX = "openclaw:pinned:";
export class PinnedMessages {
private key: string;
private _indices = new Set<number>();
constructor(sessionKey: string) {
this.key = PREFIX + sessionKey;
this.load();
}
get indices(): Set<number> {
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]));
}
}

View File

@@ -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 <agentId>", 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",
});
});
});

View File

@@ -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<SlashCommandResult> {
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<SlashCommandResult> {
try {
const health = await client.request<HealthSummary>("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<SlashCommandResult> {
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<SlashCommandResult> {
if (!args) {
try {
const sessions = await client.request<SessionsListResult>("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<SlashCommandResult> {
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<SlashCommandResult> {
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<SlashCommandResult> {
try {
const sessions = await client.request<SessionsListResult>("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<SlashCommandResult> {
try {
const result = await client.request<AgentsListResult>("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<SlashCommandResult> {
const target = args.trim();
if (!target) {
return { content: "Usage: `/kill <id|all>`" };
}
try {
const sessions = await client.request<SessionsListResult>("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<string>();
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);
}

View File

@@ -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: "<name>",
icon: "brain",
category: "model",
executeLocal: true,
},
{
name: "think",
description: "Set thinking level",
args: "<level>",
icon: "brain",
category: "model",
executeLocal: true,
argOptions: ["off", "low", "medium", "high"],
},
{
name: "verbose",
description: "Toggle verbose mode",
args: "<on|off|full>",
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: "<id|all>",
icon: "x",
category: "agents",
executeLocal: true,
},
{
name: "skill",
description: "Run a skill",
args: "<name>",
icon: "zap",
category: "tools",
},
{
name: "steer",
description: "Steer a sub-agent",
args: "<id> <msg>",
icon: "send",
category: "agents",
},
];
const CATEGORY_ORDER: SlashCommandCategory[] = ["session", "model", "tools", "agents"];
export const CATEGORY_LABELS: Record<SlashCommandCategory, string> = {
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 };
}

View File

@@ -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<string, unknown>;
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()
);
}