server/chat-widgets.ts
Rama main · réplica de lectura del repositorio original.
// A bot's widget tree is normalised, scrubbed of secrets and given its text fallback here, once; everything after reads the stored message.
import {
parseWidgetInput, resolveWidgetAction, widgetActions, widgetFields, widgetToText, WIDGET_LIMITS,
type WidgetAction, type WidgetNode, type WidgetPayload, type WidgetRoot, type WidgetSettled,
} from "../shared/widget.ts";
import type { MausColor } from "../shared/wire.ts";
import type { ChatApps } from "./chat-apps.ts";
import type { BotFolders } from "./pages/bundle.ts";
import { createOptionsCard } from "./options-card.ts";
import { isSecretName, mask, redactSecretsInText } from "./redact.ts";
import type { Message } from "./store.ts";
export interface AgentCardStore {
groupByThread(threadId: string): unknown;
messagesFor(threadId: string): Message[];
appendMessage(threadId: string, message: Omit<Message, "id" | "at">): Message;
patchMessage(threadId: string, messageId: string, patch: Partial<Message>): Message | null;
}
export type AgentCardResult =
/** `masked`: something in the widget read as a secret and was stored masked. `leftOut`: a folder page's sentence naming
* the files it left out. */
| { ok: true; messageId: string; masked?: true; leftOut?: string }
| { ok: false; status: 400 | 403 | 404 | 409; error: string };
const NO_SUCH_WIDGET = "No widget with that id in this conversation.";
const EMPTY_FALLBACK = "[widget]";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
/** A room's row names the bot that wrote it. A direct thread's never does: there a bot-authored row carrying `from` is
* replayed as another bot's line. */
export function speakerIn(store: Pick<AgentCardStore, "groupByThread">, threadId: string, bot: { id: string; name: string; color: MausColor }): Pick<Message, "from"> {
return store.groupByThread(threadId) ? { from: { botId: bot.id, name: bot.name, color: bot.color } } : {};
}
/** Every string loses its secrets. Under an action's `payload` and a chart's `data` rows the keys are the bot's, so they are
* scrubbed too, and a key naming a secret (api_key, db_password) masks its value whatever it looks like. */
function redactValue(value: unknown, botKeys: boolean): unknown {
if (typeof value === "string") return redactSecretsInText(value);
if (Array.isArray(value)) return value.map((item) => redactValue(item, botKeys));
if (!isRecord(value)) return value;
const named = botKeys && typeof value.name === "string" && typeof value.value === "string" && isSecretName(value.name);
return Object.fromEntries(Object.entries(value).map(([key, inner]) => [
botKeys ? redactSecretsInText(key) : key,
botKeys && typeof inner === "string" && (isSecretName(key) || (named && key === "value"))
? mask(inner)
: redactValue(inner, botKeys || key === "payload"),
]));
}
/** `type` and `id` stay as written, so a held secret spelling part of one can never rename a node or break a press's ref. */
function redactNode(node: WidgetNode): WidgetNode {
const out: WidgetNode = { type: node.type, id: node.id };
for (const [key, value] of Object.entries(node)) {
if (key === "type" || key === "id") continue;
out[key] = key === "children" && Array.isArray(value)
? (value as WidgetNode[]).map(redactNode)
: redactValue(value, key === "data");
}
return out;
}
/** Two labels that held different secrets of one length now read the same, and a press could no longer say which it meant. */
function stillDistinct(root: WidgetRoot): boolean {
const labels = widgetActions(root).filter((entry) => !entry.client).map((entry) => entry.label);
const names = widgetFields(root).map((field) => field.name);
return new Set(labels).size === labels.length && new Set(names).size === names.length;
}
function stable(value: unknown): string {
return JSON.stringify(value, (_key, inner: unknown) => (isRecord(inner)
? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
: inner));
}
function clip(text: string): string {
return text.length <= WIDGET_LIMITS.fallback ? text : `${text.slice(0, WIDGET_LIMITS.fallback - 1).trimEnd()}…`;
}
type CanonicalWidget =
| { ok: true; root: WidgetRoot; text: string; masked: boolean }
| { ok: false; status: 400; error: string };
/** Scrubbed before clipped: half a key no longer looks like one. */
function canonicalWidget(input: Record<string, unknown>): CanonicalWidget {
const parsed = parseWidgetInput(input.widget);
if (!parsed.ok) return { ok: false, status: 400, error: parsed.error };
const given = input.fallback_text;
if (given !== undefined && given !== null && typeof given !== "string") {
return { ok: false, status: 400, error: "fallback_text: must be a string." };
}
const root = redactNode(parsed.value);
if (!stillDistinct(root)) {
return { ok: false, status: 400, error: "widget: two actions or fields read the same once secrets were removed; give each a plain, distinct label and name." };
}
const text = clip(redactSecretsInText(given?.trim() || widgetToText(root)));
// The bot is told when the scrub changed what it wrote: a payload field it named `key` for an id reads back masked.
return { ok: true, root, text: text || EMPTY_FALLBACK, masked: stable(root) !== stable(parsed.value) };
}
/** The route has already checked that the conversation is the calling bot's own. */
export interface AgentCardArgs {
store: AgentCardStore;
bot: { id: string; name: string; color: MausColor };
threadId: string;
input: unknown;
/** The row a widget call wrote: the calling session made it, so it is never offered back to it as unseen. */
authored?(messageId: string): void;
/** The server built this card from records, not the bot. */
serverCard?: true;
apps?: Pick<ChatApps, "createAppCard" | "recordMcpAppCall">;
/** The bot's own folders, for a page shown from a folder. */
folders?: BotFolders;
}
export type McpAppCardInput = { tool: "mcp_app" } & Record<string, unknown>;
export function createAgentCard(args: AgentCardArgs & { input: McpAppCardInput }): Promise<AgentCardResult>;
export function createAgentCard(args: AgentCardArgs & { folders: BotFolders }): AgentCardResult | Promise<AgentCardResult>;
export function createAgentCard(args: AgentCardArgs): AgentCardResult;
export function createAgentCard(args: AgentCardArgs): AgentCardResult | Promise<AgentCardResult> {
const { store, bot, threadId, input } = args;
if (!isRecord(input) || input.tool === undefined) return createOptionsCard(args);
if (input.tool === "mcp_app") {
if (!args.apps?.recordMcpAppCall) return Promise.resolve({ ok: false, status: 400, error: "MCP apps are not available on this computer." });
return args.apps.recordMcpAppCall({ bot, threadId, input, ...(args.authored ? { authored: args.authored } : {}) });
}
if (input.tool === "show_html" || input.tool === "update_html") {
if (!args.apps) return { ok: false, status: 400, error: "Pages are not available on this computer." };
return args.apps.createAppCard({ bot, threadId, input, ...(args.authored ? { authored: args.authored } : {}), ...(args.folders ? { folders: args.folders } : {}) });
}
if (input.tool !== "show_widget" && input.tool !== "update_widget") {
return { ok: false, status: 400, error: "tool: must be show_widget or update_widget." };
}
if (input.tool === "show_widget") {
const widget = canonicalWidget(input);
if (!widget.ok) return widget;
const message = store.appendMessage(threadId, { role: "bot", kind: "widget", text: widget.text, widget: { v: 1, root: widget.root }, ...speakerIn(store, threadId, bot), ...(args.serverCard ? { serverCard: true as const } : {}) });
args.authored?.(message.id);
return { ok: true, messageId: message.id, ...(widget.masked ? { masked: true as const } : {}) };
}
const existing = store.messagesFor(threadId).find((message) => message.id === input.widget_id);
// A server-built card keeps `serverCard` through an edit, and that mark is what replay and presses trust; only the server
// may edit it, and a bot is told nothing more than that the id is missing.
if (!existing || existing.kind !== "widget" || !existing.widget || (existing.from && existing.from.botId !== bot.id) ||
(existing.serverCard && !args.serverCard)) {
return { ok: false, status: 404, error: NO_SUCH_WIDGET };
}
if (existing.widget.settled) return { ok: false, status: 409, error: "The person already answered that widget. Show a new one." };
const widget = canonicalWidget(input);
if (!widget.ok) return widget;
// A new tree renumbers its nodes: the revision tells a press on the old one apart.
const rev = (existing.widget.rev ?? 0) + 1;
const patched = store.patchMessage(threadId, existing.id, { text: widget.text, widget: { v: 1, root: widget.root, rev } });
if (!patched) return { ok: false, status: 404, error: NO_SUCH_WIDGET };
args.authored?.(patched.id);
return { ok: true, messageId: patched.id, ...(widget.masked ? { masked: true as const } : {}) };
}
function storedWidget(store: Pick<AgentCardStore, "messagesFor">, threadId: string, messageId: string): WidgetPayload | undefined {
return store.messagesFor(threadId).find((message) => message.id === messageId && message.kind === "widget")?.widget;
}
export type WidgetPress =
| {
ok: true;
messageId: string;
text: string;
label: string;
action: WidgetAction;
/** This press folds the widget (confirm, cancel, submit) and has not yet. */
settles: boolean;
/** Call once the message is accepted, with its `queueId` when it was queued. */
settle(queueId?: string): void;
}
| { ok: false; status: 400 | 404 | 409; error: string };
const BAD_PRESS = "widgetAction must be {messageId, ref, values?, other?, rev?}, with values an object of text and on/off fields and other an object of text.";
const STALE_PRESS = "This widget changed. Check it and press again.";
function isValues(value: unknown): value is Record<string, string | boolean> | undefined | null {
if (value === undefined || value === null) return true;
return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string" || typeof entry === "boolean");
}
function isOther(value: unknown): value is Record<string, string> | undefined | null {
if (value === undefined || value === null) return true;
return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string");
}
function isRevision(value: unknown): value is number | undefined | null {
return value === undefined || value === null || (typeof value === "number" && Number.isSafeInteger(value) && value >= 0);
}
/** Writes nothing until `settle()`. A press on an older `rev` is refused (its ref may now be another button); a retry with
* the settling press's `sendId` resolves as if still live, and its `settle()` does nothing. */
export function prepareWidgetPress(
store: Pick<AgentCardStore, "messagesFor" | "patchMessage">,
threadId: string,
widgetAction: unknown,
sendId: string | undefined,
): WidgetPress {
if (!isRecord(widgetAction)) return { ok: false, status: 400, error: BAD_PRESS };
const { messageId, ref, values, other, rev } = widgetAction;
if (typeof messageId !== "string" || !/^[\w-]+$/.test(messageId) ||
typeof ref !== "string" || ref.length < 1 || ref.length > 64 || !isValues(values) || !isOther(other) || !isRevision(rev)) {
return { ok: false, status: 400, error: BAD_PRESS };
}
const stored = () => storedWidget(store, threadId, messageId);
const payload = stored();
if (!payload?.root) return { ok: false, status: 404, error: "That widget is not in this conversation." };
const retry = sendId !== undefined && payload.settled?.sendId === sendId;
if (!retry && !payload.settled && (rev ?? 0) !== (payload.rev ?? 0)) return { ok: false, status: 409, error: STALE_PRESS };
const resolved = resolveWidgetAction(retry ? { v: 1, root: payload.root } : payload, ref, values ?? undefined, other ?? undefined);
if (!resolved.ok) return resolved;
const { label, action, tone } = resolved;
const sent = { ...(Object.keys(resolved.values).length > 0 ? { values: resolved.values } : {}), ...(resolved.other ? { other: resolved.other } : {}) };
return {
ok: true,
messageId,
text: resolved.text,
label,
action,
settles: Boolean(tone) && !retry,
settle: (queueId) => {
// Read again: two requests for one press can both get here, and the bot may have replaced the tree meanwhile.
const current = stored();
if (retry || !tone || !current || current.settled) return;
const settled: WidgetSettled = { ref, label, tone, at: Date.now(), ...(sendId ? { sendId } : {}), ...sent, ...(queueId ? { queueId } : {}) };
store.patchMessage(threadId, messageId, { widget: { ...current, settled } });
},
};
}
/** A cancelled queued press never reached the bot, so the widget is live again. */
export function reopenCancelledPress(store: Pick<AgentCardStore, "messagesFor" | "patchMessage">, threadId: string, queueId: string): void {
const waiting = store.messagesFor(threadId).filter((message) =>
message.kind === "widget" && message.widget?.settled?.queueId === queueId);
for (const message of waiting) {
const { settled: _settled, ...live } = message.widget!;
store.patchMessage(threadId, message.id, { widget: live });
}
}