Files
openclaw/src/canvas/widget-tool.ts
Peter Steinberger 36fbd869ed refactor(agents): eliminate export name collisions (#121768)
* refactor(auth): consolidate profile helper owners

* refactor(agents): consolidate subagent registry reads

* refactor(agents): disambiguate runtime placement helpers

* refactor(agents): disambiguate helper exports

* chore(scripts): burn export collision debt

* test(agents): follow moved subagent read owner
2026-08-10 22:24:33 -07:00

229 lines
10 KiB
TypeScript

/** Agent-facing inline chat widget tool. */
import { createHash } from "node:crypto";
import { Type } from "typebox";
import type { BoardWidgetPutResult } from "../../packages/gateway-protocol/src/index.js";
import { optionalStringEnum } from "../agents/schema/string-enum.js";
import { type AnyAgentTool, jsonResult, readToolStringParam } from "../agents/tools/common.js";
import {
callInProcessGatewayTool,
type InProcessGatewayCaller,
} from "../agents/tools/in-process-gateway.js";
import { normalizeBoardWidgetDeclared } from "../boards/board-capabilities.js";
import { assertWidgetHtmlSize, WidgetHtmlInputError } from "../plugin-sdk/widget-html.js";
import { createCanvasDocument } from "./documents.js";
import { buildWidgetDocument } from "./wrap.js";
const SHOW_WIDGET_REQUIRED_CLIENT_CAPS = ["inline-widgets"];
const WIDGET_CODE_MAX_CHARS = 262_144;
const PINNED_WIDGET_MAX_UTF8_BYTES = 256 * 1024;
const WIDGET_MAX_PER_SCOPE = 32;
const ShowWidgetToolSchema = Type.Object({
title: Type.String(),
widget_code: Type.String(),
name: Type.Optional(
Type.String({
pattern: "^[a-z0-9][a-z0-9._-]{0,63}$",
description: "Stable dashboard widget name when pinning",
}),
),
pin: Type.Optional(
Type.Boolean({ description: "Also pin this widget to the session dashboard" }),
),
tab: Type.Optional(
Type.String({ pattern: "^[a-z0-9-]{1,40}$", description: "Dashboard tab slug" }),
),
size: optionalStringEnum(["sm", "md", "lg", "xl", "full"] as const, {
description: "Dashboard size: sm, md, lg, xl, or full",
}),
presentation: optionalStringEnum(["card", "full-bleed", "frameless"] as const, {
description: "Pinned dashboard frame: card, full-bleed, or frameless",
}),
after: Type.Optional(
Type.String({
pattern: "^[a-z0-9][a-z0-9._-]{0,63}$",
description: "Place after this dashboard widget name",
}),
),
capabilities: Type.Optional(
Type.Object({
netOrigins: Type.Optional(
Type.Array(Type.String(), {
description: "Exact HTTPS origins the pinned widget may fetch after approval",
}),
),
tools: Type.Optional(
Type.Array(Type.String(), {
description:
"Pinned widget host tools, such as prompt, sessions.list, or cron.trigger:<jobId>",
}),
),
}),
),
});
type ShowWidgetToolOptions = {
sessionId?: string;
agentId?: string;
agentSessionKey?: string;
stateDir?: string;
callGateway?: InProcessGatewayCaller;
};
function slugWidgetName(title: string): string {
const slug = title
.normalize("NFKD")
.replace(/[\u0300-\u036f]/gu, "")
.toLowerCase()
.replace(/[^a-z0-9]+/gu, "-")
.replace(/^-+|-+$/gu, "");
if (slug && slug.length <= 64) {
return slug;
}
const suffix = createHash("sha256").update(title).digest("hex").slice(0, 8);
const prefix = (slug || "widget").slice(0, 55).replace(/-+$/gu, "") || "widget";
return `${prefix}-${suffix}`;
}
function generatedWidgetIdentity(title: string, preferredName: string) {
const key = createHash("sha256").update(title.trim().normalize("NFC")).digest("hex");
const prefix = preferredName.slice(0, 55).replace(/-+$/gu, "") || "widget";
return {
source: "show_widget" as const,
key,
fallbackName: `${prefix}-${key.slice(0, 8)}`,
};
}
function boardWidgetTitle(title: string): string | undefined {
const normalized = title.trim();
return normalized ? Array.from(normalized).slice(0, 80).join("") : undefined;
}
function resolveRetentionScope(options: ShowWidgetToolOptions): string {
const scope = options.sessionId
? `session:${options.sessionId}`
: `agent:${options.agentId ?? "default"}`;
return createHash("sha256").update(scope).digest("hex");
}
function assertPinnedWidgetDocumentSize(html: string): void {
if (Buffer.byteLength(html, "utf8") > PINNED_WIDGET_MAX_UTF8_BYTES) {
throw new WidgetHtmlInputError(
`pin exceeds effective dashboard budget (${PINNED_WIDGET_MAX_UTF8_BYTES} UTF-8 bytes after wrapping)`,
);
}
}
/** Creates a self-contained widget hosted by OpenClaw core. */
export function createShowWidgetTool(options: ShowWidgetToolOptions = {}): AnyAgentTool {
const gatewayCall = options.callGateway ?? callInProcessGatewayTool;
return {
label: "Show Widget",
name: "show_widget",
description:
'Show interactive self-contained HTML or SVG widget on the user\'s current surface. Set pin=true to also place it on this session\'s dashboard; use name for a stable widget id, tab for a tab slug, size sm|md|lg|xl|full, presentation card|full-bleed|frameless, and after for a sibling widget anchor. Dashboard widgets auto-fit their content height until the user resizes them. Pinned widgets may declare capabilities.netOrigins and capabilities.tools for operator approval. Inline everything; no external resources unless an exact HTTPS origin is declared and granted. Dashboard host APIs: openclaw.prompt.send(text), openclaw.state.emit(payload), openclaw.data.read(bindingId, params?), and openclaw.cron.trigger(jobId). Pre-themed: bare button, input, select, textarea, table, code, h1-h3 already styled — write minimal HTML. Helper classes: .card, .badge (.ok/.warn/.danger/.info), .metric, .muted, .row; button.primary = the one main action. Theme vars (auto light/dark, live host sync): --surface --card --elevated --text --text-strong --muted --border --border-strong --accent (links/focus/highlight) --accent-fill (primary bg) --accent-fg --ok --warn --danger --info (each with -subtle tint) --radius --font-body --font-mono. Colors ONLY via these vars — never hex/rgb/hsl, no own color palette; layout-only custom vars fine. Page background stays transparent. Pattern: <div class="card"><div class="muted">Uptime</div><div class="metric">18d</div></div> <span class="badge ok">connected</span>. Web chat: sendPrompt(text) sends text as the user\'s message — wire to buttons, suffix label with ↗; works only after a real click inside the widget (never call automatically; slash commands rejected).',
parameters: ShowWidgetToolSchema,
requiredClientCaps: SHOW_WIDGET_REQUIRED_CLIENT_CAPS,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const title = readToolStringParam(params, "title", { required: true });
const rawWidgetCode = readToolStringParam(params, "widget_code", {
required: true,
trim: false,
});
if (!rawWidgetCode.trim()) {
throw new WidgetHtmlInputError("widget_code required");
}
assertWidgetHtmlSize(rawWidgetCode, WIDGET_CODE_MAX_CHARS, {
inputName: "widget_code",
unit: "characters",
});
const shouldPin = params.pin === true;
const capabilities = normalizeBoardWidgetDeclared(
params.capabilities as { netOrigins?: string[]; tools?: string[] } | undefined,
);
if (capabilities && !shouldPin) {
throw new WidgetHtmlInputError("capabilities require pin=true");
}
const pinSessionKey = shouldPin ? options.agentSessionKey?.trim() : undefined;
if (shouldPin && !pinSessionKey) {
throw new WidgetHtmlInputError("pin requires an agent session");
}
const widgetCode = rawWidgetCode.trim();
const wrappedDocument = buildWidgetDocument(title, widgetCode);
let pinnedText = "";
let pinnedWidgetName: string | undefined;
if (pinSessionKey) {
const sessionKey = pinSessionKey;
const explicitName = readToolStringParam(params, "name");
const name = explicitName ?? slugWidgetName(title);
const tab = readToolStringParam(params, "tab");
const size = readToolStringParam(params, "size");
const presentation = readToolStringParam(params, "presentation");
const after = readToolStringParam(params, "after");
const pinnedTitle = boardWidgetTitle(title);
assertPinnedWidgetDocumentSize(
buildWidgetDocument(pinnedTitle ?? name, widgetCode, {
connectOrigins: capabilities?.netOrigins,
}),
);
const snapshot = await gatewayCall<BoardWidgetPutResult>("board.widget.put", {
sessionKey,
name,
...(pinnedTitle ? { title: pinnedTitle } : {}),
// The Gateway owns the board document shell so agent-authored bytes
// can never run before its user-activation and bridge bootstrap.
content: { kind: "html", html: widgetCode },
...(presentation ? { presentation } : {}),
...(capabilities ? { declared: capabilities } : {}),
...(!explicitName ? { generatedIdentity: generatedWidgetIdentity(title, name) } : {}),
...(tab || size || after
? {
placement: {
...(tab ? { tabId: tab } : {}),
...(size ? { size } : {}),
...(after ? { after } : {}),
},
}
: {}),
});
pinnedWidgetName = snapshot.resolvedWidgetName;
const widget = snapshot.widgets.find(
(candidate) => candidate.name === snapshot.resolvedWidgetName,
);
pinnedText = `; pinned to dashboard tab ${widget?.tabId ?? tab ?? "main"} as ${
snapshot.resolvedWidgetName
}${size ? ` (${size})` : ""}`;
}
// Pin first: placement validation can fail, and a rejected board write
// must not materialize or prune the bounded inline-document store.
const document = await createCanvasDocument(
{
kind: "html_bundle",
title,
entrypoint: { type: "html", value: wrappedDocument },
surface: "assistant_message",
retentionScope: resolveRetentionScope(options),
// Direct navigation must not run widget script as the Control UI origin.
cspSandbox: "scripts",
},
{
stateDir: options.stateDir,
maxDocumentsPerScope: WIDGET_MAX_PER_SCOPE,
},
);
return jsonResult({
kind: "canvas",
presentation: { target: "assistant_message", title, sandbox: "scripts" },
view: {
id: document.id,
url: document.entryUrl,
...(pinnedWidgetName ? { boardWidgetName: pinnedWidgetName } : {}),
},
text: `Widget hosted at ${document.entryUrl}${pinnedText}`,
});
},
};
}