feat: add trusted action groups
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user