feat(ui): add chat infrastructure modules (slice 1/3 of dashboard-v2) (#41497)
* feat(ui): add chat infrastructure modules (slice 1 of dashboard-v2) New self-contained chat modules extracted from dashboard-v2-structure: - chat/slash-commands.ts: slash command definitions and completions - chat/slash-command-executor.ts: execute slash commands via gateway RPC - chat/slash-command-executor.node.test.ts: test coverage - chat/speech.ts: speech-to-text (STT) support - chat/input-history.ts: per-session input history navigation - chat/pinned-messages.ts: pinned message management - chat/deleted-messages.ts: deleted message tracking - chat/export.ts: shared exportChatMarkdown helper - chat-export.ts: re-export shim for backwards compat Gateway fix: - Restore usage/cost stripping in chat.history sanitization - Add test coverage for sanitization behavior These modules are additive and tree-shaken — no existing code imports them yet. They will be wired in subsequent slices. * Update ui/src/ui/chat/export.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix(ui): address review feedback on chat infra slice - export.ts: handle array content blocks (Claude API format) instead of silently exporting empty strings - slash-command-executor.ts: restrict /kill all to current session's subagent subtree instead of all sessions globally - slash-command-executor.ts: only count truly aborted runs (check aborted !== false) in /kill summary * fix: scope /kill <id> to current session subtree and preserve usage.cost in chat.history - Restrict /kill <id> matching to only subagents belonging to the current session's agent subtree (P1 review feedback) - Preserve nested usage.cost in chat.history sanitization so cost badges remain available (P2 review feedback) * fix(ui): tighten slash kill scoping * fix(ui): support legacy slash kill scopes * fix(ci): repair pr branch checks * Gateway: harden chat abort and export * UI: align slash commands with session tree scope * UI: resolve session aliases for slash command lookups * Update .gitignore * Cron: use shared nested lane resolver --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
@@ -186,6 +186,8 @@ export type GatewayTailscaleConfig = {
|
||||
};
|
||||
|
||||
export type GatewayRemoteConfig = {
|
||||
/** Whether remote gateway surfaces are enabled. Default: true when absent. */
|
||||
enabled?: boolean;
|
||||
/** Remote Gateway WebSocket URL (ws:// or wss://). */
|
||||
url?: string;
|
||||
/** Transport for macOS remote connections (ssh tunnel or direct WS). */
|
||||
|
||||
@@ -6,6 +6,8 @@ export type ChatAbortControllerEntry = {
|
||||
sessionKey: string;
|
||||
startedAtMs: number;
|
||||
expiresAtMs: number;
|
||||
ownerConnId?: string;
|
||||
ownerDeviceId?: string;
|
||||
};
|
||||
|
||||
export function isChatStopCommandText(text: string): boolean {
|
||||
|
||||
147
src/gateway/server-methods/chat.abort-authorization.test.ts
Normal file
147
src/gateway/server-methods/chat.abort-authorization.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { chatHandlers } from "./chat.js";
|
||||
|
||||
function createActiveRun(sessionKey: string, owner?: { connId?: string; deviceId?: string }) {
|
||||
const now = Date.now();
|
||||
return {
|
||||
controller: new AbortController(),
|
||||
sessionId: `${sessionKey}-session`,
|
||||
sessionKey,
|
||||
startedAtMs: now,
|
||||
expiresAtMs: now + 30_000,
|
||||
ownerConnId: owner?.connId,
|
||||
ownerDeviceId: owner?.deviceId,
|
||||
};
|
||||
}
|
||||
|
||||
function createContext(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
chatAbortControllers: new Map(),
|
||||
chatRunBuffers: new Map(),
|
||||
chatDeltaSentAt: new Map(),
|
||||
chatAbortedRuns: new Map<string, number>(),
|
||||
removeChatRun: vi
|
||||
.fn()
|
||||
.mockImplementation((run: string) => ({ sessionKey: "main", clientRunId: run })),
|
||||
agentRunSeq: new Map<string, number>(),
|
||||
broadcast: vi.fn(),
|
||||
nodeSendToSession: vi.fn(),
|
||||
logGateway: { warn: vi.fn() },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function invokeChatAbort(params: {
|
||||
context: ReturnType<typeof createContext>;
|
||||
request: { sessionKey: string; runId?: string };
|
||||
client?: {
|
||||
connId?: string;
|
||||
connect?: {
|
||||
device?: { id?: string };
|
||||
scopes?: string[];
|
||||
};
|
||||
} | null;
|
||||
}) {
|
||||
const respond = vi.fn();
|
||||
await chatHandlers["chat.abort"]({
|
||||
params: params.request,
|
||||
respond: respond as never,
|
||||
context: params.context as never,
|
||||
req: {} as never,
|
||||
client: (params.client ?? null) as never,
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
return respond;
|
||||
}
|
||||
|
||||
describe("chat.abort authorization", () => {
|
||||
it("rejects explicit run aborts from other clients", async () => {
|
||||
const context = createContext({
|
||||
chatAbortControllers: new Map([
|
||||
["run-1", createActiveRun("main", { connId: "conn-owner", deviceId: "dev-owner" })],
|
||||
]),
|
||||
});
|
||||
|
||||
const respond = await invokeChatAbort({
|
||||
context,
|
||||
request: { sessionKey: "main", runId: "run-1" },
|
||||
client: {
|
||||
connId: "conn-other",
|
||||
connect: { device: { id: "dev-other" }, scopes: ["operator.write"] },
|
||||
},
|
||||
});
|
||||
|
||||
const [ok, payload, error] = respond.mock.calls.at(-1) ?? [];
|
||||
expect(ok).toBe(false);
|
||||
expect(payload).toBeUndefined();
|
||||
expect(error).toMatchObject({ code: "INVALID_REQUEST", message: "unauthorized" });
|
||||
expect(context.chatAbortControllers.has("run-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows the same paired device to abort after reconnecting", async () => {
|
||||
const context = createContext({
|
||||
chatAbortControllers: new Map([
|
||||
["run-1", createActiveRun("main", { connId: "conn-old", deviceId: "dev-1" })],
|
||||
]),
|
||||
});
|
||||
|
||||
const respond = await invokeChatAbort({
|
||||
context,
|
||||
request: { sessionKey: "main", runId: "run-1" },
|
||||
client: {
|
||||
connId: "conn-new",
|
||||
connect: { device: { id: "dev-1" }, scopes: ["operator.write"] },
|
||||
},
|
||||
});
|
||||
|
||||
const [ok, payload] = respond.mock.calls.at(-1) ?? [];
|
||||
expect(ok).toBe(true);
|
||||
expect(payload).toMatchObject({ aborted: true, runIds: ["run-1"] });
|
||||
expect(context.chatAbortControllers.has("run-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("only aborts session-scoped runs owned by the requester", async () => {
|
||||
const context = createContext({
|
||||
chatAbortControllers: new Map([
|
||||
["run-mine", createActiveRun("main", { deviceId: "dev-1" })],
|
||||
["run-other", createActiveRun("main", { deviceId: "dev-2" })],
|
||||
]),
|
||||
});
|
||||
|
||||
const respond = await invokeChatAbort({
|
||||
context,
|
||||
request: { sessionKey: "main" },
|
||||
client: {
|
||||
connId: "conn-1",
|
||||
connect: { device: { id: "dev-1" }, scopes: ["operator.write"] },
|
||||
},
|
||||
});
|
||||
|
||||
const [ok, payload] = respond.mock.calls.at(-1) ?? [];
|
||||
expect(ok).toBe(true);
|
||||
expect(payload).toMatchObject({ aborted: true, runIds: ["run-mine"] });
|
||||
expect(context.chatAbortControllers.has("run-mine")).toBe(false);
|
||||
expect(context.chatAbortControllers.has("run-other")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows operator.admin clients to bypass owner checks", async () => {
|
||||
const context = createContext({
|
||||
chatAbortControllers: new Map([
|
||||
["run-1", createActiveRun("main", { connId: "conn-owner", deviceId: "dev-owner" })],
|
||||
]),
|
||||
});
|
||||
|
||||
const respond = await invokeChatAbort({
|
||||
context,
|
||||
request: { sessionKey: "main", runId: "run-1" },
|
||||
client: {
|
||||
connId: "conn-admin",
|
||||
connect: { device: { id: "dev-admin" }, scopes: ["operator.admin"] },
|
||||
},
|
||||
});
|
||||
|
||||
const [ok, payload] = respond.mock.calls.at(-1) ?? [];
|
||||
expect(ok).toBe(true);
|
||||
expect(payload).toMatchObject({ aborted: true, runIds: ["run-1"] });
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
} from "../../utils/message-channel.js";
|
||||
import {
|
||||
abortChatRunById,
|
||||
abortChatRunsForSessionKey,
|
||||
type ChatAbortControllerEntry,
|
||||
type ChatAbortOps,
|
||||
isChatStopCommandText,
|
||||
@@ -33,6 +32,7 @@ import {
|
||||
} from "../chat-abort.js";
|
||||
import { type ChatImageContent, parseMessageWithAttachments } from "../chat-attachments.js";
|
||||
import { stripEnvelopeFromMessage, stripEnvelopeFromMessages } from "../chat-sanitize.js";
|
||||
import { ADMIN_SCOPE } from "../method-scopes.js";
|
||||
import {
|
||||
GATEWAY_CLIENT_CAPS,
|
||||
GATEWAY_CLIENT_MODES,
|
||||
@@ -83,6 +83,12 @@ type AbortedPartialSnapshot = {
|
||||
abortOrigin: AbortOrigin;
|
||||
};
|
||||
|
||||
type ChatAbortRequester = {
|
||||
connId?: string;
|
||||
deviceId?: string;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
const CHAT_HISTORY_TEXT_MAX_CHARS = 12_000;
|
||||
const CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES = 128 * 1024;
|
||||
const CHAT_HISTORY_OVERSIZED_PLACEHOLDER = "[chat.history omitted: message too large]";
|
||||
@@ -314,6 +320,68 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve nested usage.cost when present
|
||||
if ("cost" in u && u.cost != null && typeof u.cost === "object") {
|
||||
const sanitizedCost = sanitizeCost(u.cost);
|
||||
if (sanitizedCost) {
|
||||
(out as Record<string, unknown>).cost = sanitizedCost;
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -325,13 +393,38 @@ function sanitizeChatHistoryMessage(message: unknown): { message: unknown; chang
|
||||
delete entry.details;
|
||||
changed = true;
|
||||
}
|
||||
if ("usage" in entry) {
|
||||
delete entry.usage;
|
||||
changed = true;
|
||||
}
|
||||
if ("cost" in entry) {
|
||||
delete entry.cost;
|
||||
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 (typeof entry.content === "string") {
|
||||
@@ -597,12 +690,12 @@ function appendAssistantTranscriptMessage(params: {
|
||||
function collectSessionAbortPartials(params: {
|
||||
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
|
||||
chatRunBuffers: Map<string, string>;
|
||||
sessionKey: string;
|
||||
runIds: ReadonlySet<string>;
|
||||
abortOrigin: AbortOrigin;
|
||||
}): AbortedPartialSnapshot[] {
|
||||
const out: AbortedPartialSnapshot[] = [];
|
||||
for (const [runId, active] of params.chatAbortControllers) {
|
||||
if (active.sessionKey !== params.sessionKey) {
|
||||
if (!params.runIds.has(runId)) {
|
||||
continue;
|
||||
}
|
||||
const text = params.chatRunBuffers.get(runId);
|
||||
@@ -664,23 +757,104 @@ function createChatAbortOps(context: GatewayRequestContext): ChatAbortOps {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOptionalText(value?: string | null): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function resolveChatAbortRequester(
|
||||
client: GatewayRequestHandlerOptions["client"],
|
||||
): ChatAbortRequester {
|
||||
const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
|
||||
return {
|
||||
connId: normalizeOptionalText(client?.connId),
|
||||
deviceId: normalizeOptionalText(client?.connect?.device?.id),
|
||||
isAdmin: scopes.includes(ADMIN_SCOPE),
|
||||
};
|
||||
}
|
||||
|
||||
function canRequesterAbortChatRun(
|
||||
entry: ChatAbortControllerEntry,
|
||||
requester: ChatAbortRequester,
|
||||
): boolean {
|
||||
if (requester.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
const ownerDeviceId = normalizeOptionalText(entry.ownerDeviceId);
|
||||
const ownerConnId = normalizeOptionalText(entry.ownerConnId);
|
||||
if (!ownerDeviceId && !ownerConnId) {
|
||||
return true;
|
||||
}
|
||||
if (ownerDeviceId && requester.deviceId && ownerDeviceId === requester.deviceId) {
|
||||
return true;
|
||||
}
|
||||
if (ownerConnId && requester.connId && ownerConnId === requester.connId) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveAuthorizedRunIdsForSession(params: {
|
||||
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
|
||||
sessionKey: string;
|
||||
requester: ChatAbortRequester;
|
||||
}) {
|
||||
const authorizedRunIds: string[] = [];
|
||||
let matchedSessionRuns = 0;
|
||||
for (const [runId, active] of params.chatAbortControllers) {
|
||||
if (active.sessionKey !== params.sessionKey) {
|
||||
continue;
|
||||
}
|
||||
matchedSessionRuns += 1;
|
||||
if (canRequesterAbortChatRun(active, params.requester)) {
|
||||
authorizedRunIds.push(runId);
|
||||
}
|
||||
}
|
||||
return {
|
||||
matchedSessionRuns,
|
||||
authorizedRunIds,
|
||||
};
|
||||
}
|
||||
|
||||
function abortChatRunsForSessionKeyWithPartials(params: {
|
||||
context: GatewayRequestContext;
|
||||
ops: ChatAbortOps;
|
||||
sessionKey: string;
|
||||
abortOrigin: AbortOrigin;
|
||||
stopReason?: string;
|
||||
requester: ChatAbortRequester;
|
||||
}) {
|
||||
const { matchedSessionRuns, authorizedRunIds } = resolveAuthorizedRunIdsForSession({
|
||||
chatAbortControllers: params.context.chatAbortControllers,
|
||||
sessionKey: params.sessionKey,
|
||||
requester: params.requester,
|
||||
});
|
||||
if (authorizedRunIds.length === 0) {
|
||||
return {
|
||||
aborted: false,
|
||||
runIds: [],
|
||||
unauthorized: matchedSessionRuns > 0,
|
||||
};
|
||||
}
|
||||
const authorizedRunIdSet = new Set(authorizedRunIds);
|
||||
const snapshots = collectSessionAbortPartials({
|
||||
chatAbortControllers: params.context.chatAbortControllers,
|
||||
chatRunBuffers: params.context.chatRunBuffers,
|
||||
sessionKey: params.sessionKey,
|
||||
runIds: authorizedRunIdSet,
|
||||
abortOrigin: params.abortOrigin,
|
||||
});
|
||||
const res = abortChatRunsForSessionKey(params.ops, {
|
||||
sessionKey: params.sessionKey,
|
||||
stopReason: params.stopReason,
|
||||
});
|
||||
const runIds: string[] = [];
|
||||
for (const runId of authorizedRunIds) {
|
||||
const res = abortChatRunById(params.ops, {
|
||||
runId,
|
||||
sessionKey: params.sessionKey,
|
||||
stopReason: params.stopReason,
|
||||
});
|
||||
if (res.aborted) {
|
||||
runIds.push(runId);
|
||||
}
|
||||
}
|
||||
const res = { aborted: runIds.length > 0, runIds, unauthorized: false };
|
||||
if (res.aborted) {
|
||||
persistAbortedPartials({
|
||||
context: params.context,
|
||||
@@ -802,7 +976,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
verboseLevel,
|
||||
});
|
||||
},
|
||||
"chat.abort": ({ params, respond, context }) => {
|
||||
"chat.abort": ({ params, respond, context, client }) => {
|
||||
if (!validateChatAbortParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
@@ -820,6 +994,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
};
|
||||
|
||||
const ops = createChatAbortOps(context);
|
||||
const requester = resolveChatAbortRequester(client);
|
||||
|
||||
if (!runId) {
|
||||
const res = abortChatRunsForSessionKeyWithPartials({
|
||||
@@ -828,7 +1003,12 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
sessionKey: rawSessionKey,
|
||||
abortOrigin: "rpc",
|
||||
stopReason: "rpc",
|
||||
requester,
|
||||
});
|
||||
if (res.unauthorized) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"));
|
||||
return;
|
||||
}
|
||||
respond(true, { ok: true, aborted: res.aborted, runIds: res.runIds });
|
||||
return;
|
||||
}
|
||||
@@ -846,6 +1026,10 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!canRequesterAbortChatRun(active, requester)) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"));
|
||||
return;
|
||||
}
|
||||
|
||||
const partialText = context.chatRunBuffers.get(runId);
|
||||
const res = abortChatRunById(ops, {
|
||||
@@ -987,7 +1171,12 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
sessionKey: rawSessionKey,
|
||||
abortOrigin: "stop-command",
|
||||
stopReason: "stop",
|
||||
requester: resolveChatAbortRequester(client),
|
||||
});
|
||||
if (res.unauthorized) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"));
|
||||
return;
|
||||
}
|
||||
respond(true, { ok: true, aborted: res.aborted, runIds: res.runIds });
|
||||
return;
|
||||
}
|
||||
@@ -1017,6 +1206,8 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
sessionKey: rawSessionKey,
|
||||
startedAtMs: now,
|
||||
expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }),
|
||||
ownerConnId: normalizeOptionalText(client?.connId),
|
||||
ownerDeviceId: normalizeOptionalText(client?.connect?.device?.id),
|
||||
});
|
||||
const ackPayload = {
|
||||
runId: clientRunId,
|
||||
|
||||
@@ -273,6 +273,37 @@ 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);
|
||||
|
||||
@@ -810,6 +810,7 @@ export function listSessionsFromStore(params: {
|
||||
const model = resolvedModel.model ?? DEFAULT_MODEL;
|
||||
return {
|
||||
key,
|
||||
spawnedBy: entry?.spawnedBy,
|
||||
entry,
|
||||
kind: classifySessionKey(key, entry),
|
||||
label: entry?.label,
|
||||
|
||||
@@ -15,6 +15,7 @@ export type GatewaySessionsDefaults = {
|
||||
|
||||
export type GatewaySessionRow = {
|
||||
key: string;
|
||||
spawnedBy?: string;
|
||||
kind: "direct" | "group" | "global" | "unknown";
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
|
||||
1
ui/src/ui/chat-export.ts
Normal file
1
ui/src/ui/chat-export.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { exportChatMarkdown } from "./chat/export.ts";
|
||||
49
ui/src/ui/chat/deleted-messages.ts
Normal file
49
ui/src/ui/chat/deleted-messages.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
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]));
|
||||
}
|
||||
}
|
||||
38
ui/src/ui/chat/export.node.test.ts
Normal file
38
ui/src/ui/chat/export.node.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildChatExportFilename, buildChatMarkdown, sanitizeFilenameComponent } from "./export.ts";
|
||||
|
||||
describe("chat export hardening", () => {
|
||||
it("escapes raw HTML in exported markdown content and labels", () => {
|
||||
const markdown = buildChatMarkdown(
|
||||
[
|
||||
{
|
||||
role: "assistant",
|
||||
content: "<img src=x onerror=alert(1)><script>alert(2)</script>",
|
||||
timestamp: Date.UTC(2026, 2, 11, 12, 0, 0),
|
||||
},
|
||||
],
|
||||
"Bot </script><script>alert(3)</script>",
|
||||
);
|
||||
|
||||
expect(markdown).toContain(
|
||||
"# Chat with Bot </script><script>alert(3)</script>",
|
||||
);
|
||||
expect(markdown).toContain(
|
||||
"## Bot </script><script>alert(3)</script> (2026-03-11T12:00:00.000Z)",
|
||||
);
|
||||
expect(markdown).toContain(
|
||||
"<img src=x onerror=alert(1)><script>alert(2)</script>",
|
||||
);
|
||||
expect(markdown).not.toContain("<script>");
|
||||
expect(markdown).not.toContain("<img");
|
||||
});
|
||||
|
||||
it("sanitizes the downloaded filename component", () => {
|
||||
expect(sanitizeFilenameComponent("../NUL\t<script>alert(1)</script>")).toBe(
|
||||
"NUL scriptalert1-script",
|
||||
);
|
||||
expect(buildChatExportFilename("../NUL\t<script>alert(1)</script>", 123)).toBe(
|
||||
"chat-NUL scriptalert1-script-123.md",
|
||||
);
|
||||
});
|
||||
});
|
||||
68
ui/src/ui/chat/export.ts
Normal file
68
ui/src/ui/chat/export.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Export chat history as markdown file.
|
||||
*/
|
||||
export function escapeHtmlInMarkdown(text: string): string {
|
||||
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
|
||||
export function normalizeSingleLineLabel(label: string, fallback = "Assistant"): string {
|
||||
const normalized = label.replace(/[\r\n\t]+/g, " ").trim();
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
export function sanitizeFilenameComponent(input: string): string {
|
||||
const normalized = normalizeSingleLineLabel(input, "assistant").normalize("NFKC");
|
||||
const sanitized = normalized
|
||||
.replace(/[\\/]/g, "-")
|
||||
.replace(/[^a-zA-Z0-9 _.-]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/-+/g, "-")
|
||||
.trim()
|
||||
.replace(/^[.-]+/, "")
|
||||
.slice(0, 50);
|
||||
return sanitized || "assistant";
|
||||
}
|
||||
|
||||
export function buildChatMarkdown(messages: unknown[], assistantNameRaw: string): string | null {
|
||||
const assistantName = escapeHtmlInMarkdown(normalizeSingleLineLabel(assistantNameRaw));
|
||||
const history = Array.isArray(messages) ? messages : [];
|
||||
if (history.length === 0) {
|
||||
return null;
|
||||
}
|
||||
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 = escapeHtmlInMarkdown(
|
||||
typeof m.content === "string"
|
||||
? m.content
|
||||
: Array.isArray(m.content)
|
||||
? (m.content as Array<{ type?: string; text?: string }>)
|
||||
.filter((b) => b?.type === "text" && typeof b.text === "string")
|
||||
.map((b) => b.text)
|
||||
.join("")
|
||||
: "",
|
||||
);
|
||||
const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : "";
|
||||
lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, "");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function buildChatExportFilename(assistantNameRaw: string, now = Date.now()): string {
|
||||
return `chat-${sanitizeFilenameComponent(assistantNameRaw)}-${now}.md`;
|
||||
}
|
||||
|
||||
export function exportChatMarkdown(messages: unknown[], assistantName: string): void {
|
||||
const markdown = buildChatMarkdown(messages, assistantName);
|
||||
if (!markdown) {
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([markdown], { type: "text/markdown" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = buildChatExportFilename(assistantName);
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
49
ui/src/ui/chat/input-history.ts
Normal file
49
ui/src/ui/chat/input-history.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
61
ui/src/ui/chat/pinned-messages.ts
Normal file
61
ui/src/ui/chat/pinned-messages.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
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]));
|
||||
}
|
||||
}
|
||||
381
ui/src/ui/chat/slash-command-executor.node.test.ts
Normal file
381
ui/src/ui/chat/slash-command-executor.node.test.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
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, overrides?: Partial<GatewaySessionRow>): GatewaySessionRow {
|
||||
return {
|
||||
key,
|
||||
spawnedBy: overrides?.spawnedBy,
|
||||
kind: "direct",
|
||||
updatedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
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", { spawnedBy: "main" }),
|
||||
row("agent:main:subagent:parent", { spawnedBy: "main" }),
|
||||
row("agent:main:subagent:parent:subagent:child", {
|
||||
spawnedBy: "agent:main:subagent:parent",
|
||||
}),
|
||||
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 3 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",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(4, "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", { spawnedBy: "agent:main:main" }),
|
||||
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
|
||||
row("agent:other:subagent:three", { spawnedBy: "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",
|
||||
"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",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not exact-match a session key outside the current subagent subtree", async () => {
|
||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
sessions: [
|
||||
row("agent:main:subagent:parent", { spawnedBy: "agent:main:main" }),
|
||||
row("agent:main:subagent:parent:subagent:child", {
|
||||
spawnedBy: "agent:main:subagent:parent",
|
||||
}),
|
||||
row("agent:main:subagent:sibling", { spawnedBy: "agent:main: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:subagent:parent",
|
||||
"kill",
|
||||
"agent:main:subagent:sibling",
|
||||
);
|
||||
|
||||
expect(result.content).toBe(
|
||||
"No matching sub-agent sessions found for `agent:main:subagent:sibling`.",
|
||||
);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
|
||||
});
|
||||
|
||||
it("returns a no-op summary when matching sessions have no active runs", async () => {
|
||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
sessions: [
|
||||
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
|
||||
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "chat.abort") {
|
||||
return { ok: true, aborted: false };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const result = await executeSlashCommand(
|
||||
{ request } as unknown as GatewayBrowserClient,
|
||||
"agent:main:main",
|
||||
"kill",
|
||||
"all",
|
||||
);
|
||||
|
||||
expect(result.content).toBe("No active sub-agent runs to abort.");
|
||||
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",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats the legacy main session key as the default agent scope", async () => {
|
||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
sessions: [
|
||||
row("main"),
|
||||
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
|
||||
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
|
||||
row("agent:other:subagent:three", { spawnedBy: "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,
|
||||
"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:two",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not abort unrelated same-agent subagents from another root session", async () => {
|
||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
sessions: [
|
||||
row("agent:main:main"),
|
||||
row("agent:main:subagent:mine", { spawnedBy: "agent:main:main" }),
|
||||
row("agent:main:subagent:mine:subagent:child", {
|
||||
spawnedBy: "agent:main:subagent:mine",
|
||||
}),
|
||||
row("agent:main:subagent:other-root", {
|
||||
spawnedBy: "agent:main:discord:dm:alice",
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
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:mine",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
|
||||
sessionKey: "agent:main:subagent:mine:subagent:child",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeSlashCommand directives", () => {
|
||||
it("resolves the legacy main alias for bare /model", async () => {
|
||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
defaults: { model: "default-model" },
|
||||
sessions: [
|
||||
row("agent:main:main", {
|
||||
model: "gpt-4.1-mini",
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "models.list") {
|
||||
return {
|
||||
models: [{ id: "gpt-4.1-mini" }, { id: "gpt-4.1" }],
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const result = await executeSlashCommand(
|
||||
{ request } as unknown as GatewayBrowserClient,
|
||||
"main",
|
||||
"model",
|
||||
"",
|
||||
);
|
||||
|
||||
expect(result.content).toBe(
|
||||
"**Current model:** `gpt-4.1-mini`\n**Available:** `gpt-4.1-mini`, `gpt-4.1`",
|
||||
);
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "models.list", {});
|
||||
});
|
||||
|
||||
it("resolves the legacy main alias for /usage", async () => {
|
||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
sessions: [
|
||||
row("agent:main:main", {
|
||||
model: "gpt-4.1-mini",
|
||||
inputTokens: 1200,
|
||||
outputTokens: 300,
|
||||
totalTokens: 1500,
|
||||
contextTokens: 4000,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const result = await executeSlashCommand(
|
||||
{ request } as unknown as GatewayBrowserClient,
|
||||
"main",
|
||||
"usage",
|
||||
"",
|
||||
);
|
||||
|
||||
expect(result.content).toBe(
|
||||
"**Session Usage**\nInput: **1.2k** tokens\nOutput: **300** tokens\nTotal: **1.5k** tokens\nContext: **30%** of 4k\nModel: `gpt-4.1-mini`",
|
||||
);
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
|
||||
});
|
||||
|
||||
it("reports the current thinking level for bare /think", async () => {
|
||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
sessions: [
|
||||
row("agent:main:main", {
|
||||
modelProvider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "models.list") {
|
||||
return {
|
||||
models: [{ id: "gpt-4.1-mini", provider: "openai", reasoning: true }],
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const result = await executeSlashCommand(
|
||||
{ request } as unknown as GatewayBrowserClient,
|
||||
"agent:main:main",
|
||||
"think",
|
||||
"",
|
||||
);
|
||||
|
||||
expect(result.content).toBe(
|
||||
"Current thinking level: low.\nOptions: off, minimal, low, medium, high, adaptive.",
|
||||
);
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "models.list", {});
|
||||
});
|
||||
|
||||
it("accepts minimal and xhigh thinking levels", async () => {
|
||||
const request = vi.fn().mockResolvedValueOnce({ ok: true }).mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const minimal = await executeSlashCommand(
|
||||
{ request } as unknown as GatewayBrowserClient,
|
||||
"agent:main:main",
|
||||
"think",
|
||||
"minimal",
|
||||
);
|
||||
const xhigh = await executeSlashCommand(
|
||||
{ request } as unknown as GatewayBrowserClient,
|
||||
"agent:main:main",
|
||||
"think",
|
||||
"xhigh",
|
||||
);
|
||||
|
||||
expect(minimal.content).toBe("Thinking level set to **minimal**.");
|
||||
expect(xhigh.content).toBe("Thinking level set to **xhigh**.");
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sessions.patch", {
|
||||
key: "agent:main:main",
|
||||
thinkingLevel: "minimal",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "sessions.patch", {
|
||||
key: "agent:main:main",
|
||||
thinkingLevel: "xhigh",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the current verbose level for bare /verbose", async () => {
|
||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
sessions: [row("agent:main:main", { verboseLevel: "full" })],
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const result = await executeSlashCommand(
|
||||
{ request } as unknown as GatewayBrowserClient,
|
||||
"agent:main:main",
|
||||
"verbose",
|
||||
"",
|
||||
);
|
||||
|
||||
expect(result.content).toBe("Current verbose level: full.\nOptions: on, full, off.");
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
|
||||
});
|
||||
});
|
||||
545
ui/src/ui/chat/slash-command-executor.ts
Normal file
545
ui/src/ui/chat/slash-command-executor.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* Client-side execution engine for slash commands.
|
||||
* Calls gateway RPC methods and returns formatted results.
|
||||
*/
|
||||
|
||||
import type { ModelCatalogEntry } from "../../../../src/agents/model-catalog.js";
|
||||
import { resolveThinkingDefault } from "../../../../src/agents/model-selection.js";
|
||||
import {
|
||||
formatThinkingLevels,
|
||||
normalizeThinkLevel,
|
||||
normalizeVerboseLevel,
|
||||
} from "../../../../src/auto-reply/thinking.js";
|
||||
import type { HealthSummary } from "../../../../src/commands/health.js";
|
||||
import type { OpenClawConfig } from "../../../../src/config/config.js";
|
||||
import {
|
||||
DEFAULT_AGENT_ID,
|
||||
DEFAULT_MAIN_KEY,
|
||||
isSubagentSessionKey,
|
||||
parseAgentSessionKey,
|
||||
} from "../../../../src/routing/session-key.js";
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { AgentsListResult, GatewaySessionRow, 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, models] = await Promise.all([
|
||||
client.request<SessionsListResult>("sessions.list", {}),
|
||||
client.request<{ models: ModelCatalogEntry[] }>("models.list", {}),
|
||||
]);
|
||||
const session = resolveCurrentSession(sessions, sessionKey);
|
||||
const model = session?.model || sessions?.defaults?.model || "default";
|
||||
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 rawLevel = args.trim();
|
||||
if (!rawLevel) {
|
||||
try {
|
||||
const { session, models } = await loadThinkingCommandState(client, sessionKey);
|
||||
return {
|
||||
content: formatDirectiveOptions(
|
||||
`Current thinking level: ${resolveCurrentThinkingLevel(session, models)}.`,
|
||||
formatThinkingLevels(session?.modelProvider, session?.model),
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
return { content: `Failed to get thinking level: ${String(err)}` };
|
||||
}
|
||||
}
|
||||
|
||||
const level = normalizeThinkLevel(rawLevel);
|
||||
if (!level) {
|
||||
try {
|
||||
const session = await loadCurrentSession(client, sessionKey);
|
||||
return {
|
||||
content: `Unrecognized thinking level "${rawLevel}". Valid levels: ${formatThinkingLevels(session?.modelProvider, session?.model)}.`,
|
||||
};
|
||||
} catch (err) {
|
||||
return { content: `Failed to validate thinking level: ${String(err)}` };
|
||||
}
|
||||
}
|
||||
|
||||
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 rawLevel = args.trim();
|
||||
if (!rawLevel) {
|
||||
try {
|
||||
const session = await loadCurrentSession(client, sessionKey);
|
||||
return {
|
||||
content: formatDirectiveOptions(
|
||||
`Current verbose level: ${normalizeVerboseLevel(session?.verboseLevel) ?? "off"}.`,
|
||||
"on, full, off",
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
return { content: `Failed to get verbose level: ${String(err)}` };
|
||||
}
|
||||
}
|
||||
|
||||
const level = normalizeVerboseLevel(rawLevel);
|
||||
if (!level) {
|
||||
return {
|
||||
content: `Unrecognized verbose level "${rawLevel}". Valid levels: off, on, full.`,
|
||||
};
|
||||
}
|
||||
|
||||
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 = resolveCurrentSession(sessions, 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<{ aborted?: boolean }>("chat.abort", { sessionKey: key }),
|
||||
),
|
||||
);
|
||||
const rejected = results.filter((entry) => entry.status === "rejected");
|
||||
const successCount = results.filter(
|
||||
(entry) =>
|
||||
entry.status === "fulfilled" && (entry.value as { aborted?: boolean })?.aborted !== false,
|
||||
).length;
|
||||
if (successCount === 0) {
|
||||
if (rejected.length === 0) {
|
||||
return {
|
||||
content:
|
||||
target.toLowerCase() === "all"
|
||||
? "No active sub-agent runs to abort."
|
||||
: `No active runs matched \`${target}\`.`,
|
||||
};
|
||||
}
|
||||
throw rejected[0]?.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 normalizedCurrentSessionKey = currentSessionKey.trim().toLowerCase();
|
||||
const currentParsed = parseAgentSessionKey(normalizedCurrentSessionKey);
|
||||
const currentAgentId =
|
||||
currentParsed?.agentId ??
|
||||
(normalizedCurrentSessionKey === DEFAULT_MAIN_KEY ? DEFAULT_AGENT_ID : undefined);
|
||||
const sessionIndex = buildSessionIndex(sessions);
|
||||
for (const session of sessions) {
|
||||
const key = session?.key?.trim();
|
||||
if (!key || !isSubagentSessionKey(key)) {
|
||||
continue;
|
||||
}
|
||||
const normalizedKey = key.toLowerCase();
|
||||
const parsed = parseAgentSessionKey(normalizedKey);
|
||||
const belongsToCurrentSession = isWithinCurrentSessionSubtree(
|
||||
normalizedKey,
|
||||
normalizedCurrentSessionKey,
|
||||
sessionIndex,
|
||||
currentAgentId,
|
||||
parsed?.agentId,
|
||||
);
|
||||
const isMatch =
|
||||
(normalizedTarget === "all" && belongsToCurrentSession) ||
|
||||
(belongsToCurrentSession && normalizedKey === normalizedTarget) ||
|
||||
(belongsToCurrentSession &&
|
||||
((parsed?.agentId ?? "") === normalizedTarget ||
|
||||
normalizedKey.endsWith(`:subagent:${normalizedTarget}`) ||
|
||||
normalizedKey === `subagent:${normalizedTarget}`));
|
||||
if (isMatch) {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
function isWithinCurrentSessionSubtree(
|
||||
candidateSessionKey: string,
|
||||
currentSessionKey: string,
|
||||
sessionIndex: Map<string, GatewaySessionRow>,
|
||||
currentAgentId: string | undefined,
|
||||
candidateAgentId: string | undefined,
|
||||
): boolean {
|
||||
if (!currentAgentId || candidateAgentId !== currentAgentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentAliases = resolveEquivalentSessionKeys(currentSessionKey, currentAgentId);
|
||||
const seen = new Set<string>();
|
||||
let parentSessionKey = normalizeSessionKey(sessionIndex.get(candidateSessionKey)?.spawnedBy);
|
||||
while (parentSessionKey && !seen.has(parentSessionKey)) {
|
||||
if (currentAliases.has(parentSessionKey)) {
|
||||
return true;
|
||||
}
|
||||
seen.add(parentSessionKey);
|
||||
parentSessionKey = normalizeSessionKey(sessionIndex.get(parentSessionKey)?.spawnedBy);
|
||||
}
|
||||
|
||||
// Older gateways may not include spawnedBy on session rows yet; keep prefix
|
||||
// matching for nested subagent sessions as a compatibility fallback.
|
||||
return isSubagentSessionKey(currentSessionKey)
|
||||
? candidateSessionKey.startsWith(`${currentSessionKey}:subagent:`)
|
||||
: false;
|
||||
}
|
||||
|
||||
function buildSessionIndex(sessions: GatewaySessionRow[]): Map<string, GatewaySessionRow> {
|
||||
const index = new Map<string, GatewaySessionRow>();
|
||||
for (const session of sessions) {
|
||||
const normalizedKey = normalizeSessionKey(session?.key);
|
||||
if (!normalizedKey) {
|
||||
continue;
|
||||
}
|
||||
index.set(normalizedKey, session);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function normalizeSessionKey(key?: string | null): string | undefined {
|
||||
const normalized = key?.trim().toLowerCase();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function resolveEquivalentSessionKeys(
|
||||
currentSessionKey: string,
|
||||
currentAgentId: string | undefined,
|
||||
): Set<string> {
|
||||
const keys = new Set<string>([currentSessionKey]);
|
||||
if (currentAgentId === DEFAULT_AGENT_ID) {
|
||||
const canonicalDefaultMain = `agent:${DEFAULT_AGENT_ID}:main`;
|
||||
if (currentSessionKey === DEFAULT_MAIN_KEY) {
|
||||
keys.add(canonicalDefaultMain);
|
||||
} else if (currentSessionKey === canonicalDefaultMain) {
|
||||
keys.add(DEFAULT_MAIN_KEY);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function formatDirectiveOptions(text: string, options: string): string {
|
||||
return `${text}\nOptions: ${options}.`;
|
||||
}
|
||||
|
||||
async function loadCurrentSession(
|
||||
client: GatewayBrowserClient,
|
||||
sessionKey: string,
|
||||
): Promise<GatewaySessionRow | undefined> {
|
||||
const sessions = await client.request<SessionsListResult>("sessions.list", {});
|
||||
return resolveCurrentSession(sessions, sessionKey);
|
||||
}
|
||||
|
||||
function resolveCurrentSession(
|
||||
sessions: SessionsListResult | undefined,
|
||||
sessionKey: string,
|
||||
): GatewaySessionRow | undefined {
|
||||
const normalizedSessionKey = normalizeSessionKey(sessionKey);
|
||||
const currentAgentId =
|
||||
parseAgentSessionKey(normalizedSessionKey ?? "")?.agentId ??
|
||||
(normalizedSessionKey === DEFAULT_MAIN_KEY ? DEFAULT_AGENT_ID : undefined);
|
||||
const aliases = normalizedSessionKey
|
||||
? resolveEquivalentSessionKeys(normalizedSessionKey, currentAgentId)
|
||||
: new Set<string>();
|
||||
return sessions?.sessions?.find((session: GatewaySessionRow) => {
|
||||
const key = normalizeSessionKey(session.key);
|
||||
return key ? aliases.has(key) : false;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadThinkingCommandState(client: GatewayBrowserClient, sessionKey: string) {
|
||||
const [sessions, models] = await Promise.all([
|
||||
client.request<SessionsListResult>("sessions.list", {}),
|
||||
client.request<{ models: ModelCatalogEntry[] }>("models.list", {}),
|
||||
]);
|
||||
return {
|
||||
session: resolveCurrentSession(sessions, sessionKey),
|
||||
models: models?.models ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCurrentThinkingLevel(
|
||||
session: GatewaySessionRow | undefined,
|
||||
models: ModelCatalogEntry[],
|
||||
): string {
|
||||
const persisted = normalizeThinkLevel(session?.thinkingLevel);
|
||||
if (persisted) {
|
||||
return persisted;
|
||||
}
|
||||
if (!session?.modelProvider || !session.model) {
|
||||
return "off";
|
||||
}
|
||||
return resolveThinkingDefault({
|
||||
cfg: {} as OpenClawConfig,
|
||||
provider: session.modelProvider,
|
||||
model: session.model,
|
||||
catalog: models,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
26
ui/src/ui/chat/slash-commands.node.test.ts
Normal file
26
ui/src/ui/chat/slash-commands.node.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseSlashCommand } from "./slash-commands.ts";
|
||||
|
||||
describe("parseSlashCommand", () => {
|
||||
it("parses commands with an optional colon separator", () => {
|
||||
expect(parseSlashCommand("/think: high")).toMatchObject({
|
||||
command: { name: "think" },
|
||||
args: "high",
|
||||
});
|
||||
expect(parseSlashCommand("/think:high")).toMatchObject({
|
||||
command: { name: "think" },
|
||||
args: "high",
|
||||
});
|
||||
expect(parseSlashCommand("/help:")).toMatchObject({
|
||||
command: { name: "help" },
|
||||
args: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("still parses space-delimited commands", () => {
|
||||
expect(parseSlashCommand("/verbose full")).toMatchObject({
|
||||
command: { name: "verbose" },
|
||||
args: "full",
|
||||
});
|
||||
});
|
||||
});
|
||||
222
ui/src/ui/chat/slash-commands.ts
Normal file
222
ui/src/ui/chat/slash-commands.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
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: "circle",
|
||||
category: "session",
|
||||
executeLocal: true,
|
||||
},
|
||||
{
|
||||
name: "reset",
|
||||
description: "Reset current session",
|
||||
icon: "loader",
|
||||
category: "session",
|
||||
executeLocal: true,
|
||||
},
|
||||
{
|
||||
name: "compact",
|
||||
description: "Compact session context",
|
||||
icon: "loader",
|
||||
category: "session",
|
||||
executeLocal: true,
|
||||
},
|
||||
{
|
||||
name: "stop",
|
||||
description: "Stop current run",
|
||||
icon: "x",
|
||||
category: "session",
|
||||
executeLocal: true,
|
||||
},
|
||||
{
|
||||
name: "clear",
|
||||
description: "Clear chat history",
|
||||
icon: "x",
|
||||
category: "session",
|
||||
executeLocal: true,
|
||||
},
|
||||
{
|
||||
name: "focus",
|
||||
description: "Toggle focus mode",
|
||||
icon: "search",
|
||||
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", "minimal", "low", "medium", "high", "xhigh", "adaptive"],
|
||||
},
|
||||
{
|
||||
name: "verbose",
|
||||
description: "Toggle verbose mode",
|
||||
args: "<on|off|full>",
|
||||
icon: "fileCode",
|
||||
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: "arrowDown",
|
||||
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: "zap",
|
||||
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`, `/command args...`, and `/command: args...`.
|
||||
*/
|
||||
export function parseSlashCommand(text: string): ParsedSlashCommand | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = trimmed.slice(1);
|
||||
const firstSeparator = body.search(/[\s:]/u);
|
||||
const name = firstSeparator === -1 ? body : body.slice(0, firstSeparator);
|
||||
let remainder = firstSeparator === -1 ? "" : body.slice(firstSeparator).trimStart();
|
||||
if (remainder.startsWith(":")) {
|
||||
remainder = remainder.slice(1).trimStart();
|
||||
}
|
||||
const args = remainder.trim();
|
||||
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const command = SLASH_COMMANDS.find((cmd) => cmd.name === name.toLowerCase());
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { command, args };
|
||||
}
|
||||
225
ui/src/ui/chat/speech.ts
Normal file
225
ui/src/ui/chat/speech.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 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()
|
||||
);
|
||||
}
|
||||
@@ -395,6 +395,7 @@ export type AgentsFilesSetResult = {
|
||||
|
||||
export type GatewaySessionRow = {
|
||||
key: string;
|
||||
spawnedBy?: string;
|
||||
kind: "direct" | "group" | "global" | "unknown";
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
|
||||
Reference in New Issue
Block a user