mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Revert "feat(ui): add chat infrastructure modules (slice 1 of dashboard-v2)"
This reverts commit 5a659b0b61.
This commit is contained in:
@@ -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<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 } {
|
||||
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") {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { exportChatMarkdown } from "./chat/export.ts";
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user