feat: add trusted action groups

This commit is contained in:
2026-08-03 15:34:17 +08:00
parent c384368f66
commit bddf15a2d2
9 changed files with 173 additions and 11 deletions
+80 -1
View File
@@ -1,10 +1,14 @@
import DOMPurify from "dompurify";
import { marked } from "marked";
import type {
ActionGroup,
ConversationItem,
DeliveryState,
JsonObject,
} from "../protocol";
export type ToolActionSubmitter = (callID: string, submission: JsonObject) => boolean;
/**
* Trusted presentation context for the first-party chat shell. It owns only
* DOM nodes and display-local state; it deliberately has no Store, Transport,
@@ -17,6 +21,7 @@ export class TrustedDOMRendererContext {
public constructor(
private readonly messages: HTMLElement,
private readonly presence: HTMLElement,
private readonly submitToolAction?: ToolActionSubmitter,
) {}
public reset(): void {
@@ -53,7 +58,11 @@ export class TrustedDOMRendererContext {
}
public renderToolCall(item: Extract<ConversationItem, { kind: "tool-call" }>): void {
this.renderSystemMessage(`收到 ${item.call.tool} 标准交互请求;交互卡片将在 M1-02 / M1-03 接入。`);
if (item.call.tool === "choice" && item.call.action_group) {
this.renderActionGroup(item.call.call_id, item.call.title, item.call.prompt, item.call.action_group, item.call.expires_at);
return;
}
this.renderSystemMessage(`收到 ${item.call.tool} 标准交互请求;交互卡片将在 M1-03 接入。`);
}
public renderToolResult(item: Extract<ConversationItem, { kind: "tool-result" }>): void {
@@ -131,6 +140,76 @@ export class TrustedDOMRendererContext {
return row;
}
private renderActionGroup(callID: string, title: string, prompt: string, group: ActionGroup, expiresAt?: string): void {
const card = document.createElement("article");
card.className = "action-group-card";
card.dataset.callId = callID;
const heading = document.createElement("strong");
heading.textContent = title || "请选择";
const detail = document.createElement("p");
detail.textContent = prompt;
const actions = document.createElement("div");
actions.className = `action-group-actions ${group.mode}`;
const status = document.createElement("small");
status.className = "action-group-status";
const expired = expiresAt !== undefined && Date.parse(expiresAt) <= Date.now();
status.textContent = expired ? "该交互已过期" : "等待你的选择";
const buttons: HTMLButtonElement[] = [];
const selected = new Set<string>();
const submit = (actionIDs: readonly string[]) => {
const accepted = this.submitToolAction?.(callID, { action_ids: [...actionIDs] }) ?? false;
if (!accepted) {
status.textContent = "该交互已结束或已提交";
return;
}
card.dataset.status = "submitted";
status.textContent = "已提交,等待 Agent 确认";
for (const button of buttons) button.disabled = true;
};
for (const action of group.actions) {
const button = document.createElement("button");
button.type = "button";
button.className = "action-group-action";
button.textContent = action.label;
button.title = action.description ?? action.label;
button.disabled = expired;
button.addEventListener("click", () => {
if (group.mode !== "multi-choice") {
submit([action.id]);
return;
}
if (selected.has(action.id)) selected.delete(action.id);
else selected.add(action.id);
button.setAttribute("aria-pressed", String(selected.has(action.id)));
submitButton.disabled = selected.size === 0;
});
buttons.push(button);
actions.append(button);
}
let submitButton: HTMLButtonElement;
if (group.mode === "multi-choice") {
submitButton = document.createElement("button");
submitButton.type = "button";
submitButton.className = "action-group-submit";
submitButton.textContent = "提交选择";
submitButton.disabled = true;
submitButton.addEventListener("click", () => submit([...selected]));
buttons.push(submitButton);
} else {
// It is only referenced by the multi-choice click handler above.
submitButton = document.createElement("button");
}
card.append(heading, detail, actions);
if (group.mode === "multi-choice") card.append(submitButton);
card.append(status);
if (expired) card.dataset.status = "expired";
this.messages.append(card);
this.scrollLatest();
}
private showThinking(detail: string): void {
this.thinking?.remove();
this.thinking = this.appendBubble("agent", detail);