feat(runtime): establish miniapp kernel and sdk design

This commit is contained in:
2026-08-05 00:46:16 +08:00
parent d8aa087bc8
commit 9fb8cd1598
86 changed files with 4615 additions and 1364 deletions
+90
View File
@@ -0,0 +1,90 @@
/**
* Chat Core App 的 Host 装配器。
*
* Chat 的样式、Shell、Renderer 和 DOM 上下文都属于 Chat 自己;Runtime Host
* 只负责根据 app_scope 选择这个装配器,main.ts 不再直接依赖 Chat 的内部模块。
*/
import "@/core-apps/chat/styles/app.css";
import "@/core-apps/chat/styles/capability-card.css";
import "@/core-apps/chat/styles/execution-progress.css";
import { LineUpRuntime } from "@/runtime/coordination/lineup-runtime";
import type { ChatRuntimeSDK } from "@/runtime/app-management/app-sdk";
import { mountChatShell } from "@/core-apps/chat/chat-shell";
import { RendererRegistry } from "@/core-apps/chat/renderer-registry";
import {
TrustedDOMRendererContext,
type CapabilityInteractionHandlers,
type TaskInteractionHandlers,
type ToolInteractionHandlers,
} from "@/core-apps/chat/trusted-dom-renderers";
export type ChatAppHostOptions = {
toolInteractions?: ToolInteractionHandlers;
taskInteractions?: TaskInteractionHandlers;
capabilityInteractions?: CapabilityInteractionHandlers;
};
export type MountedChatApp = {
app_scope: "chat";
sdk: ChatRuntimeSDK;
messages: HTMLElement;
loginView: HTMLElement;
chatView: HTMLElement;
presence: HTMLElement;
rendererContext: TrustedDOMRendererContext;
rendererRegistry: RendererRegistry<TrustedDOMRendererContext>;
};
function query<T extends Element>(root: ParentNode, selector: string): T {
const found = root.querySelector<T>(selector);
if (!found) throw new Error(`Missing Chat element: ${selector}`);
return found;
}
/** Mounts the trusted Chat implementation selected by the Runtime App Host. */
export function mountChatApp(
runtime: LineUpRuntime,
root: HTMLElement,
options: ChatAppHostOptions,
): MountedChatApp {
mountChatShell(root);
const messages = query<HTMLElement>(root, "#messages");
const loginView = query<HTMLElement>(root, "#login-view");
const chatView = query<HTMLElement>(root, "#chat-view");
const presence = query<HTMLElement>(root, "#presence");
const rendererContext = new TrustedDOMRendererContext(
messages,
presence,
options.toolInteractions,
options.taskInteractions,
options.capabilityInteractions,
);
const rendererRegistry = new RendererRegistry<TrustedDOMRendererContext>();
registerChatRenderers(rendererRegistry);
return {
app_scope: "chat",
sdk: runtime.openApp("chat"),
messages,
loginView,
chatView,
presence,
rendererContext,
rendererRegistry,
};
}
function registerChatRenderers(registry: RendererRegistry<TrustedDOMRendererContext>): void {
registry.register("user-message", (item, context) => context.renderUserMessage(item));
registry.register("markdown", (item, context) => context.renderMarkdown(item));
registry.register("agent-status", (item, context) => context.renderAgentStatus(item));
registry.register("execution-summary", (item, context) => context.renderExecutionTrace(item));
registry.register("progress", (item, context) => context.renderProgress(item));
registry.register("error", (item, context) => context.renderError(item));
registry.register("tool-call", (item, context) => context.renderToolCall(item));
registry.register("tool-result", (item, context) => context.renderToolResult(item));
registry.register("tool-cancel", (item, context) => context.renderToolCancel(item));
registry.register("surface", (item, context) => context.renderSurface(item));
registry.register("app-call", (item, context) => context.renderAppCall(item));
registry.register("fallback", (item, context) => context.renderFallback(item));
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Chat Core App 的可信 DOM 外壳。
*
* 这里只声明登录与聊天页面结构;Agent 消息、状态和用户动作都必须经过
* ChatRuntimeSDK,而不能在页面中直接接触 Runtime 的通信或存储实现。
*/
export function mountChatShell(root: HTMLElement): void {
root.innerHTML = `
<main class="shell">
<section id="login-view" class="login-view">
<div class="brand"><span>✦</span><div><p>LINEUP</p><h1>你的 AI 协作空间</h1></div></div>
<form id="login-form" class="login-card" novalidate>
<label>AppServer 地址<input id="api" inputmode="url" placeholder="http://100.x.y.z:8090" required /></label>
<label>手机号<input id="phone" inputmode="tel" autocomplete="tel" placeholder="请输入手机号" required /></label>
<label>验证码<input id="code" inputmode="numeric" autocomplete="one-time-code" placeholder="请输入验证码" required /></label>
<p id="login-error" class="error" role="alert"></p>
<button id="login-submit" type="submit">进入 LineUp</button>
</form>
</section>
<section id="chat-view" class="chat-view" hidden>
<header class="chat-header"><div class="agent"><span class="avatar">✦</span><div><strong>AI Agent</strong><small id="presence">正在连接…</small></div></div><div><button id="surface-demo" class="quiet" type="button">启用任务面板</button><button id="logout" class="quiet">退出</button></div></header>
<section id="messages" class="messages" aria-live="polite"></section>
<form id="message-form" class="composer"><textarea id="message-input" rows="1" placeholder="问问你的 AI 搭档…"></textarea><button id="send" type="submit">发送</button></form>
</section>
</main>`;
}
@@ -0,0 +1,24 @@
/**
* Product-facing Interaction App mode boundary. `chat` remains the wire and
* directory compatibility name, while IM is now explicitly one Interaction
* mode alongside Audio and Video. Modes have no Transport or Host privilege.
*/
export type InteractionMode = "im" | "audio" | "video";
export type InteractionModeRecord = {
mode: InteractionMode;
enabled: boolean;
requires_permission?: string;
};
export class InteractionModeRegistry {
private readonly modes = new Map<InteractionMode, InteractionModeRecord>([
["im", { mode: "im", enabled: true }],
["audio", { mode: "audio", enabled: false, requires_permission: "media.microphone" }],
["video", { mode: "video", enabled: false, requires_permission: "media.camera" }],
]);
public list(): readonly InteractionModeRecord[] { return [...this.modes.values()].map(mode => ({ ...mode })); }
public get(mode: InteractionMode): InteractionModeRecord { return { ...(this.modes.get(mode) ?? { mode, enabled: false }) }; }
/** Only a trusted Runtime installation/configuration path can enable a mode. */
public configure(mode: InteractionMode, enabled: boolean): void { this.modes.set(mode, { ...this.get(mode), enabled }); }
}
@@ -0,0 +1,15 @@
import type { ChatRuntimeSDK } from "@/runtime/app-management/app-sdk";
import { InteractionModeRegistry, type InteractionMode } from "@/core-apps/chat/interaction-mode-registry";
/** Restricted Interaction App projection: IM today, Audio/Video when enabled. */
export class InteractionRuntime {
public readonly modes = new InteractionModeRegistry();
private current: InteractionMode = "im";
public constructor(public readonly sdk: ChatRuntimeSDK) {}
public activeMode(): InteractionMode { return this.current; }
public selectMode(mode: InteractionMode): boolean {
if (!this.modes.get(mode).enabled) return false;
this.current = mode;
return true;
}
}
@@ -0,0 +1,42 @@
/**
* Chat 的可信 Renderer 注册表。
*
* 只允许已由 Runtime/协议层验证过的 ConversationItem 进入对应 Renderer,防止未校验的
* 原始 Agent payload 直接驱动 DOM。
*/
import type { ConversationItem } from "@/runtime/protocol/lineup-v1";
export type ConversationItemKind = ConversationItem["kind"];
export type ItemOfKind<K extends ConversationItemKind> = Extract<ConversationItem, { kind: K }>;
export type Renderer<Context, K extends ConversationItemKind> = (item: ItemOfKind<K>, context: Context) => void;
/**
* Trusted renderer dispatch only. Renderers receive a typed display item and
* view context; they receive no Transport Adapter, capability handler, or
* mutable protocol payload, so rendering cannot be the route for a network
* request or privileged action.
*/
export class RendererRegistry<Context> {
// The generic type is checked at registration; erasure here lets one map
// store handlers whose input is intentionally narrowed by `kind`.
private readonly renderers = new Map<ConversationItemKind, unknown>();
public register<K extends ConversationItemKind>(kind: K, renderer: Renderer<Context, K>): () => void {
this.renderers.set(kind, renderer);
return () => {
if (this.renderers.get(kind) === renderer) this.renderers.delete(kind);
};
}
/** Returns false when no trusted renderer has been registered for this item. */
public render(item: ConversationItem, context: Context): boolean {
const renderer = this.renderers.get(item.kind) as ((value: ConversationItem, rendererContext: Context) => void) | undefined;
if (!renderer) return false;
renderer(item, context);
return true;
}
public has(kind: ConversationItemKind): boolean {
return this.renderers.has(kind);
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,23 @@
/* Chat 中可信 Capability 确认卡片的视觉状态;样式不授予或执行任何系统能力。 */
.capability-card {
display: grid;
justify-self: start;
gap: .7rem;
width: min(100%, 32rem);
padding: 1rem;
border: 1px solid #537362;
border-radius: .9rem;
background: #1c2c24;
}
.capability-card > p { margin: 0; color: #bfd1c6; line-height: 1.45; }
.capability-risk, .capability-status { color: #aac5b5; }
.capability-actions { display: flex; flex-wrap: wrap; gap: .5rem; }
.capability-actions button {
border: 0; border-radius: .65rem; padding: .8rem 1rem;
background: #a9dfc4; color: #102018; font-weight: 750; cursor: pointer;
}
.capability-actions .secondary-action { background: #395447; color: #e2f1e8; }
.capability-card[data-status="completed"] { border-color: #89c7a6; }
.capability-card[data-status="rejected"], .capability-card[data-status="expired"],
.capability-card[data-status="failed"], .capability-card[data-status="unsupported"] { border-color: #587064; }
.capability-card button:disabled { opacity: .5; cursor: not-allowed; }
@@ -0,0 +1,45 @@
/* Chat 内受限执行摘要卡片:只展示 Runtime/Adapter 已归约的公开步骤。 */
.execution-progress-card {
justify-self: start;
width: min(100%, 32rem);
border: 1px solid #577563;
border-radius: .85rem;
background: #182820;
color: #cde3d5;
}
.execution-progress-card summary {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: .8rem;
padding: .72rem .85rem;
cursor: pointer;
list-style: none;
}
.execution-progress-card summary::-webkit-details-marker { display: none; }
.execution-progress-card summary::before { content: ""; font-size: 1.2rem; color: #a9dfc4; transition: transform .15s ease; }
.execution-progress-card[open] summary::before { transform: rotate(90deg); }
.execution-progress-card[data-status="running"] summary::before { content: "◌"; transform: none; }
.execution-progress-heading strong { font-size: .9rem; }
.execution-progress-duration { color: #9cbbaa; white-space: nowrap; }
.execution-progress-stages {
display: grid;
gap: .34rem;
margin: 0;
padding: 0 .9rem .85rem 2rem;
color: #b9d1c2;
font-size: .82rem;
}
.execution-progress-stages li::marker { color: #85caa2; }
.execution-progress-stages li[data-status="completed"] { color: #d5f0df; }
.execution-progress-stages li[data-status="running"] { color: #d7e6a4; }
.execution-progress-card[data-status="failed"] { border-color: #82625e; }
.execution-progress-card[data-status="failed"] .execution-progress-stages li[data-status="failed"] { color: #ffb2aa; }
/* A trace belongs to the final Agent turn, not to the global message stream. */
.message-agent-content { display: grid; gap: .45rem; min-width: 0; }
.message-agent-content > .execution-progress-card { width: min(100%, 32rem); }
@@ -0,0 +1,727 @@
/**
* Chat Core App 的可信 DOM 渲染实现。
*
* 本模块只把受限的表现模型转成页面节点;Markdown 必须经过 sanitizer,交互卡和能力卡
* 只能调用 SDK 提供的受限回调,不能取得 Transport、Store 或 Tauri 特权对象。
*/
import DOMPurify from "dompurify";
import { marked } from "marked";
import type {
ActionGroup,
ConfirmDefinition,
ConversationItem,
DeliveryState,
InputForm,
JsonObject,
ToolCallRequest,
} from "@/runtime/protocol/lineup-v1";
import type { ToolCallRecord } from "@/runtime/coordination/tool-call-state";
import type { TaskRecord } from "@/runtime/coordination/task-state";
import type { ExecutionProgressSummary } from "@/runtime/coordination/execution-progress-state";
import type { ArtifactRecord } from "@/runtime/artifacts/artifact-state";
import type { CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state";
export type ToolInteractionHandlers = {
submit: (callID: string, submission: JsonObject) => boolean;
cancel: (callID: string, reason: string) => boolean;
expire: (callID: string) => boolean;
read: (callID: string) => ToolCallRecord | undefined;
loadDraft: (callID: string) => JsonObject | undefined;
saveDraft: (callID: string, values: JsonObject) => void;
clearDraft: (callID: string) => void;
};
export type TaskInteractionHandlers = {
requestCancel: (operationID: string) => boolean;
read: (operationID: string) => TaskRecord | undefined;
};
export type CapabilityInteractionHandlers = {
approve: (callID: string) => Promise<CapabilityCallRecord | undefined>;
reject: (callID: string) => CapabilityCallRecord | undefined;
expire: (callID: string) => CapabilityCallRecord | undefined;
read: (callID: string) => CapabilityCallRecord | undefined;
};
/**
* Trusted presentation context for the first-party chat shell. It owns only
* DOM nodes and display-local state; it deliberately has no Store, Transport,
* Kernel, Tauri bridge, or capability reference.
*/
export class TrustedDOMRendererContext {
private readonly userRows = new Map<string, HTMLElement>();
private readonly toolCards = new Map<string, { card: HTMLElement; status: HTMLElement; controls: readonly (HTMLButtonElement | HTMLInputElement | HTMLTextAreaElement)[] }>();
private readonly taskCards = new Map<string, { card: HTMLElement; title: HTMLElement; progress: HTMLProgressElement; status: HTMLElement; cancel: HTMLButtonElement }>();
private readonly capabilityCards = new Map<string, { card: HTMLElement; status: HTMLElement; approve: HTMLButtonElement; reject: HTMLButtonElement }>();
private readonly executionCards = new Map<string, HTMLDetailsElement>();
private readonly agentReplyGroups = new Map<string, HTMLElement>();
private readonly executionSummaries = new Map<string, ExecutionProgressSummary>();
private readonly expirationTimers = new Set<number>();
private readonly executionTicker: number;
public constructor(
private readonly messages: HTMLElement,
private readonly presence: HTMLElement,
private readonly toolInteractions?: ToolInteractionHandlers,
private readonly taskInteractions?: TaskInteractionHandlers,
private readonly capabilityInteractions?: CapabilityInteractionHandlers,
) {
// Status envelopes are sparse. Keep elapsed time honest between them
// without persisting a ticking value or treating it as Agent progress.
this.executionTicker = window.setInterval(() => this.refreshExecutionDurations(), 1_000);
}
public reset(): void {
this.userRows.clear();
this.toolCards.clear();
this.taskCards.clear();
this.capabilityCards.clear();
this.executionCards.clear();
this.agentReplyGroups.clear();
this.executionSummaries.clear();
for (const timer of this.expirationTimers) window.clearTimeout(timer);
this.expirationTimers.clear();
}
public renderUserMessage(item: Extract<ConversationItem, { kind: "user-message" }>): void {
const row = this.appendBubble("human", item.text, false, deliveryLabel(item.delivery));
if (item.id) this.userRows.set(item.id, row);
}
public renderMarkdown(item: Extract<ConversationItem, { kind: "markdown" }>): void {
const row = this.appendBubble("agent", item.markdown, true);
if (item.execution_id) {
row.dataset.executionId = item.execution_id;
this.agentReplyGroups.set(item.execution_id, row);
this.attachExecutionCard(item.execution_id);
}
}
public renderAgentStatus(item: Extract<ConversationItem, { kind: "agent-status" }>): void {
// Status detail is never displayed as thought content or an operation.
this.setPresence(item.status === "thinking" ? "正在处理你的请求" : "已同步,等待消息");
}
public renderExecutionTrace(_item: Extract<ConversationItem, { kind: "execution-summary" }>): void {
// main.ts projects the validated trace into the existing summary card.
}
public renderProgress(item: Extract<ConversationItem, { kind: "progress" }>): void {
if (item.task) {
this.renderTaskProgress(this.taskInteractions?.read(item.task.operation_id) ?? {
...item.task,
conversation_id: item.envelope?.conversation_id ?? "",
updated_at: new Date().toISOString(),
});
return;
}
const percent = Math.max(0, Math.min(100, item.percent));
this.renderSystemMessage(`${item.title || "Agent 任务"}${percent}%${item.status ? ` · ${item.status}` : ""}`);
}
public renderError(item: Extract<ConversationItem, { kind: "error" }>): void {
this.renderSystemMessage(`Agent 错误:${item.message}`);
}
/** Renders only protocol-validated public steps; raw tool data never reaches this card. */
public renderExecutionSummary(summary: ExecutionProgressSummary): void {
this.executionSummaries.set(summary.id, summary);
// A completed reply which performed no publicly summarizable operation
// must not leave a misleading empty ledger frame behind. A later genuine
// trace still recreates and attaches the card under this same reply.
if (summary.status !== "running" && summary.steps.length === 0) {
const empty = this.executionCards.get(summary.id);
empty?.remove();
this.executionCards.delete(summary.id);
return;
}
const current = this.executionCards.get(summary.id);
if (current) {
this.updateExecutionSummary(current, summary);
return;
}
const card = document.createElement("details");
card.className = "execution-progress-card";
card.dataset.executionId = summary.id;
const heading = document.createElement("summary");
heading.className = "execution-progress-heading";
const title = document.createElement("strong");
const duration = document.createElement("small");
duration.className = "execution-progress-duration";
heading.append(title, duration);
const steps = document.createElement("ul");
steps.className = "execution-progress-stages";
card.append(heading, steps);
this.executionCards.set(summary.id, card);
this.updateExecutionSummary(card, summary);
this.attachExecutionCard(summary.id);
if (!card.isConnected) this.messages.append(card);
this.scrollLatest();
}
/** Only terminal summaries survive a page restore; live animation never does. */
public restoreExecutionSummaries(summaries: readonly ExecutionProgressSummary[]): void {
for (const summary of summaries) {
if (summary.status !== "running") this.renderExecutionSummary(summary);
}
}
public renderToolCall(item: Extract<ConversationItem, { kind: "tool-call" }>): void {
const record = this.toolInteractions?.read(item.call.call_id);
if (item.call.tool === "choice" && item.call.action_group) {
this.renderActionGroup(item.call, item.call.action_group, record);
return;
}
if (item.call.tool === "confirm" && item.call.confirm) {
this.renderConfirmCard(item.call, item.call.confirm, record);
return;
}
if (item.call.tool === "input" && item.call.form) {
this.renderInputForm(item.call, item.call.form, record);
return;
}
this.renderSystemMessage("收到无法安全展示的标准交互请求。");
}
public renderToolResult(item: Extract<ConversationItem, { kind: "tool-result" }>): void {
const entry = this.toolCards.get(item.result.call_id);
if (!entry) return;
this.updateToolCardFinal(entry, item.result.status, item.result.error);
}
public renderToolCancel(item: Extract<ConversationItem, { kind: "tool-cancel" }>): void {
const entry = this.toolCards.get(item.cancel.call_id);
if (!entry) return;
this.updateToolCardFinal(entry, "cancelled", item.cancel.reason);
}
public renderSurface(item: Extract<ConversationItem, { kind: "surface" }>): void {
this.renderSystemMessage(`收到扩展界面请求(${item.envelope.type});Mini Runtime Surface Host 将在下一步接入。`);
}
/**
* A capability card is native chrome, never an Agent-provided form. The
* request arguments deliberately do not cross this presentation boundary:
* URL, clipboard text, local path and Artifact content cannot be displayed
* or copied from the confirmation UI.
*/
public renderAppCall(item: Extract<ConversationItem, { kind: "app-call" }>): void {
const callID = typeof item.envelope.payload.call_id === "string" ? item.envelope.payload.call_id : "";
const record = callID ? this.capabilityInteractions?.read(callID) : undefined;
if (!record) {
this.renderSystemMessage("收到无法安全确认的 App 能力请求。");
return;
}
const existing = this.capabilityCards.get(record.call_id);
if (existing) { this.updateCapabilityCard(existing, record); return; }
const card = document.createElement("article");
card.className = "capability-card";
card.dataset.callId = record.call_id;
const heading = document.createElement("strong");
const reason = document.createElement("p");
const risk = document.createElement("small");
risk.className = "capability-risk";
const status = document.createElement("small");
status.className = "capability-status";
const controls = document.createElement("div");
controls.className = "capability-actions";
const reject = document.createElement("button");
reject.type = "button"; reject.className = "secondary-action"; reject.textContent = "拒绝";
const approve = document.createElement("button");
approve.type = "button"; approve.textContent = "允许一次";
const entry = { card, status, approve, reject };
reject.addEventListener("click", () => {
const next = this.capabilityInteractions?.reject(record.call_id);
if (next) this.updateCapabilityCard(entry, next);
});
approve.addEventListener("click", () => {
approve.disabled = true; reject.disabled = true;
void this.capabilityInteractions?.approve(record.call_id).then(next => {
if (next) this.updateCapabilityCard(entry, next);
});
});
heading.textContent = capabilityLabel(record.request.capability);
reason.textContent = record.request.reason;
risk.textContent = capabilityRiskText(record.request.capability);
controls.append(reject, approve);
card.append(heading, reason, risk, status, controls);
this.capabilityCards.set(record.call_id, entry);
this.updateCapabilityCard(entry, record);
this.messages.append(card);
this.scrollLatest();
const delay = Date.parse(record.request.expires_at) - Date.now();
if (delay > 0 && record.status === "pending") {
const timer = window.setTimeout(() => {
this.expirationTimers.delete(timer);
const next = this.capabilityInteractions?.expire(record.call_id);
if (next) this.updateCapabilityCard(entry, next);
}, delay);
this.expirationTimers.add(timer);
}
}
/** Native Artifact display. It deliberately has no download URL or local reference. */
public renderArtifact(record: ArtifactRecord): void {
const card = document.createElement("article");
card.className = "artifact-card";
card.dataset.artifactId = record.artifact_id;
const name = document.createElement("strong");
name.textContent = record.name;
const metadata = document.createElement("small");
metadata.textContent = `${record.mime_type} · ${formatBytes(record.size_bytes)}`;
const integrity = document.createElement("small");
integrity.className = "artifact-integrity";
integrity.dataset.integrity = record.integrity;
integrity.textContent = record.integrity === "verified" ? "完整性已验证" : record.integrity === "failed" ? "完整性校验失败" : "完整性未验证";
card.append(name, metadata, integrity);
this.messages.append(card);
this.scrollLatest();
}
public renderFallback(item: Extract<ConversationItem, { kind: "fallback" }>): void {
this.renderSystemMessage(item.reason === "unsupported_type"
? `暂不支持的 Agent 内容:${item.envelope?.type ?? "unknown"}`
: "收到无法安全展示的消息。");
}
public renderSystemMessage(message: string): void {
const item = document.createElement("p");
item.className = "system";
item.textContent = message;
this.messages.append(item);
this.scrollLatest();
}
/** Updates the visible local echo without requiring DOM access in App Shell. */
public updateUserDelivery(localID: string, delivery: DeliveryState): void {
const meta = this.userRows.get(localID)?.querySelector<HTMLElement>(".meta");
if (!meta) return;
const time = meta.textContent?.split(" · ")[0] ?? "";
meta.textContent = `${time} · ${deliveryLabel(delivery)}`;
}
public setPresence(value: string): void {
this.presence.textContent = value;
}
private appendBubble(role: "human" | "agent", content: string, markdown = false, delivery = ""): HTMLElement {
const row = document.createElement("article");
row.className = `message-row ${role}`;
if (role === "agent") {
const avatar = document.createElement("span");
avatar.className = "message-avatar";
avatar.textContent = "✦";
row.append(avatar);
}
const bubble = document.createElement("div");
bubble.className = "bubble";
if (markdown) {
bubble.classList.add("markdown");
bubble.innerHTML = DOMPurify.sanitize(marked.parse(content, { gfm: true, breaks: true }) as string, {
FORBID_TAGS: ["style", "form", "input", "button"],
});
bubble.querySelectorAll("a").forEach(link => {
link.target = "_blank";
link.rel = "noopener noreferrer";
});
} else {
bubble.textContent = content;
}
const meta = document.createElement("small");
meta.className = "meta";
meta.textContent = `${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}${delivery ? ` · ${delivery}` : ""}`;
bubble.append(meta);
if (role === "agent") {
const content = document.createElement("div");
content.className = "message-agent-content";
content.append(bubble);
row.append(content);
} else {
row.append(bubble);
}
this.messages.append(row);
this.scrollLatest();
return row;
}
private renderTaskProgress(task: TaskRecord): void {
const existing = this.taskCards.get(task.operation_id);
if (existing) {
this.updateTaskCard(existing, task);
return;
}
const card = document.createElement("article");
card.className = "task-card";
card.dataset.operationId = task.operation_id;
const title = document.createElement("strong");
const progress = document.createElement("progress");
progress.max = 100;
const status = document.createElement("small");
status.className = "task-card-status";
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "secondary-action";
cancel.textContent = "请求取消";
cancel.addEventListener("click", () => {
if (!this.taskInteractions?.requestCancel(task.operation_id)) return;
const current = this.taskInteractions.read(task.operation_id);
if (current) this.updateTaskCard(this.taskCards.get(task.operation_id)!, current);
});
const entry = { card, title, progress, status, cancel };
card.append(title, progress, status, cancel);
this.taskCards.set(task.operation_id, entry);
this.updateTaskCard(entry, task);
this.messages.append(card);
this.scrollLatest();
}
private updateTaskCard(entry: { card: HTMLElement; title: HTMLElement; progress: HTMLProgressElement; status: HTMLElement; cancel: HTMLButtonElement }, task: TaskRecord): void {
entry.card.dataset.status = task.status;
entry.title.textContent = task.title;
entry.progress.value = Math.max(0, Math.min(100, task.percent));
const requested = task.cancel_requested_at !== undefined && task.status !== "cancelled";
entry.status.textContent = requested ? `取消请求已记录 · ${task.percent}%` : `${task.status} · ${task.percent}%`;
entry.cancel.hidden = !task.cancellable || task.status === "completed" || task.status === "failed" || task.status === "cancelled";
entry.cancel.disabled = requested;
}
private updateCapabilityCard(entry: { card: HTMLElement; status: HTMLElement; approve: HTMLButtonElement; reject: HTMLButtonElement }, record: CapabilityCallRecord): void {
entry.card.dataset.status = record.status;
const active = record.status === "pending";
entry.approve.hidden = !active;
entry.reject.hidden = !active;
entry.approve.disabled = !active;
entry.reject.disabled = !active;
entry.status.textContent = capabilityStatusText(record.status);
}
private renderActionGroup(call: ToolCallRequest, group: ActionGroup, record?: ToolCallRecord): void {
const card = document.createElement("article");
card.className = "action-group-card";
card.dataset.callId = call.call_id;
const heading = document.createElement("strong");
heading.textContent = call.title || "请选择";
const detail = document.createElement("p");
detail.textContent = call.prompt;
const actions = document.createElement("div");
actions.className = `action-group-actions ${group.mode}`;
const status = document.createElement("small");
status.className = "action-group-status";
const locked = this.applyCallStatus(card, status, call, record);
const buttons: HTMLButtonElement[] = [];
const selected = new Set<string>();
const submit = (actionIDs: readonly string[]) => {
const accepted = this.toolInteractions?.submit(call.call_id, { 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 = locked;
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);
this.toolCards.set(call.call_id, { card, status, controls: buttons });
this.messages.append(card);
this.scheduleExpiry(card, status, call, buttons);
this.scrollLatest();
}
private renderConfirmCard(call: ToolCallRequest, definition: ConfirmDefinition, record?: ToolCallRecord): void {
const card = document.createElement("article");
card.className = "tool-card confirm-card";
card.dataset.callId = call.call_id;
const heading = document.createElement("strong");
heading.textContent = call.title || "请确认";
const detail = document.createElement("p");
detail.textContent = call.prompt;
const actions = document.createElement("div");
actions.className = "tool-card-actions";
const approve = document.createElement("button");
approve.type = "button";
approve.textContent = definition.approve_label;
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "secondary-action";
cancel.textContent = definition.cancel_label;
const status = document.createElement("small");
status.className = "tool-card-status";
const buttons = [approve, cancel];
const locked = this.applyCallStatus(card, status, call, record);
for (const button of buttons) button.disabled = locked;
approve.addEventListener("click", () => {
if (!this.toolInteractions?.submit(call.call_id, { confirmed: true })) return this.markInteractionUnavailable(status);
this.markSubmitted(card, status, buttons);
});
cancel.addEventListener("click", () => {
if (!this.toolInteractions?.cancel(call.call_id, "用户取消确认")) return this.markInteractionUnavailable(status);
this.markCancelled(card, status, buttons);
});
actions.append(approve, cancel);
card.append(heading, detail, actions, status);
this.toolCards.set(call.call_id, { card, status, controls: buttons });
this.messages.append(card);
this.scheduleExpiry(card, status, call, buttons);
this.scrollLatest();
}
private renderInputForm(call: ToolCallRequest, form: InputForm, record?: ToolCallRecord): void {
const card = document.createElement("article");
card.className = "tool-card input-form-card";
card.dataset.callId = call.call_id;
const heading = document.createElement("strong");
heading.textContent = call.title || "填写信息";
const detail = document.createElement("p");
detail.textContent = call.prompt;
const nativeForm = document.createElement("form");
nativeForm.noValidate = true;
const errors = document.createElement("p");
errors.className = "tool-card-error";
errors.setAttribute("role", "alert");
const draft = this.toolInteractions?.loadDraft(call.call_id) ?? {};
const fields = new Map<string, HTMLInputElement | HTMLTextAreaElement>();
for (const definition of form.fields) {
const label = document.createElement("label");
label.textContent = definition.label;
const control = definition.type === "textarea" ? document.createElement("textarea") : document.createElement("input");
if (control instanceof HTMLInputElement) control.type = definition.type === "number" ? "number" : "text";
control.name = definition.id;
control.placeholder = definition.placeholder ?? "";
const prior = draft[definition.id];
control.value = typeof prior === "string" ? prior : "";
control.addEventListener("input", () => this.toolInteractions?.saveDraft(call.call_id, this.readFormValues(fields)));
fields.set(definition.id, control);
label.append(control);
nativeForm.append(label);
}
const actions = document.createElement("div");
actions.className = "tool-card-actions";
const submit = document.createElement("button");
submit.type = "submit";
submit.textContent = form.submit_label;
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "secondary-action";
cancel.textContent = form.cancel_label;
const status = document.createElement("small");
status.className = "tool-card-status";
const controls = [...fields.values(), submit, cancel];
const locked = this.applyCallStatus(card, status, call, record);
for (const control of controls) control.disabled = locked;
nativeForm.addEventListener("submit", event => {
event.preventDefault();
const values = this.readFormValues(fields);
const problem = validateFormValues(form, values);
if (problem) { errors.textContent = problem; return; }
if (!this.toolInteractions?.submit(call.call_id, { values })) return this.markInteractionUnavailable(status);
errors.textContent = "";
this.markSubmitted(card, status, controls);
});
cancel.addEventListener("click", () => {
if (!this.toolInteractions?.cancel(call.call_id, "用户取消输入")) return this.markInteractionUnavailable(status);
this.markCancelled(card, status, controls);
});
actions.append(submit, cancel);
nativeForm.append(errors, actions);
card.append(heading, detail, nativeForm, status);
this.toolCards.set(call.call_id, { card, status, controls });
this.messages.append(card);
this.scheduleExpiry(card, status, call, controls);
this.scrollLatest();
}
private applyCallStatus(card: HTMLElement, status: HTMLElement, call: ToolCallRequest, record?: ToolCallRecord): boolean {
const effective = record?.status ?? (call.expires_at && Date.parse(call.expires_at) <= Date.now() ? "expired" : "pending");
if (effective === "pending") { status.textContent = "等待你的操作"; return false; }
card.dataset.status = effective;
status.textContent = effective === "submitted" ? "已提交,等待 Agent 确认" : effective === "cancelled" ? "已取消" : effective === "expired" ? "该交互已过期" : effective === "completed" ? "已完成" : "处理失败";
return true;
}
private scheduleExpiry(card: HTMLElement, status: HTMLElement, call: ToolCallRequest, controls: readonly HTMLButtonElement[] | readonly (HTMLInputElement | HTMLTextAreaElement | HTMLButtonElement)[]): void {
if (!call.expires_at || Date.parse(call.expires_at) <= Date.now()) return;
const timer = window.setTimeout(() => {
this.expirationTimers.delete(timer);
if (!this.toolInteractions?.expire(call.call_id)) return;
card.dataset.status = "expired";
status.textContent = "该交互已过期";
for (const control of controls) control.disabled = true;
}, Math.max(0, Date.parse(call.expires_at) - Date.now()) + 1);
this.expirationTimers.add(timer);
}
private readFormValues(fields: ReadonlyMap<string, HTMLInputElement | HTMLTextAreaElement>): JsonObject {
return Object.fromEntries([...fields].map(([id, control]) => [id, control.value]));
}
private markSubmitted(card: HTMLElement, status: HTMLElement, controls: readonly (HTMLButtonElement | HTMLInputElement | HTMLTextAreaElement)[]): void {
card.dataset.status = "submitted";
status.textContent = "已提交,等待 Agent 确认";
for (const control of controls) control.disabled = true;
}
private markCancelled(card: HTMLElement, status: HTMLElement, controls: readonly (HTMLButtonElement | HTMLInputElement | HTMLTextAreaElement)[]): void {
card.dataset.status = "cancelled";
status.textContent = "已取消";
for (const control of controls) control.disabled = true;
}
private markInteractionUnavailable(status: HTMLElement): void {
status.textContent = "该交互已结束或已提交";
}
private updateToolCardFinal(entry: { card: HTMLElement; status: HTMLElement; controls: readonly (HTMLButtonElement | HTMLInputElement | HTMLTextAreaElement)[] }, finalStatus: "completed" | "failed" | "cancelled" | "expired", error?: string): void {
entry.card.dataset.status = finalStatus;
entry.status.textContent = finalStatus === "completed"
? "已完成"
: finalStatus === "cancelled"
? "已取消"
: finalStatus === "expired"
? "该交互已过期"
: `处理失败${error ? `${error}` : ""}`;
for (const control of entry.controls) control.disabled = true;
}
private updateExecutionSummary(card: HTMLDetailsElement, summary: ExecutionProgressSummary): void {
card.dataset.status = summary.status;
card.open = summary.status === "running";
const elapsed = executionElapsed(summary);
const heading = card.querySelector<HTMLElement>(".execution-progress-heading strong");
const duration = card.querySelector<HTMLElement>(".execution-progress-duration");
const steps = card.querySelector<HTMLUListElement>(".execution-progress-stages");
if (!heading || !duration || !steps) return;
heading.textContent = summary.status === "running"
? "正在执行操作"
: summary.status === "completed"
? "已完成处理 · 查看过程摘要"
: "处理未完成 · 查看过程摘要";
duration.textContent = `${elapsed}`;
steps.replaceChildren(...summary.steps.map(step => {
const row = document.createElement("li");
row.dataset.status = step.status;
row.textContent = `${step.status === "running" ? "◌" : step.status === "completed" ? "✓" : "×"} ${step.title}`;
return row;
}));
}
/** Moves a turn's safe ledger card under its matching final Agent reply. */
private attachExecutionCard(executionID: string): void {
const card = this.executionCards.get(executionID);
const reply = this.agentReplyGroups.get(executionID);
const container = reply?.querySelector<HTMLElement>(".message-agent-content");
if (card && container && card.parentElement !== container) container.append(card);
}
private refreshExecutionDurations(): void {
for (const [id, summary] of this.executionSummaries) {
if (summary.status !== "running") continue;
const card = this.executionCards.get(id);
if (card) this.updateExecutionSummary(card, summary);
}
}
private scrollLatest(): void {
this.messages.scrollTop = this.messages.scrollHeight;
}
}
function executionElapsed(summary: ExecutionProgressSummary): number {
const end = summary.status === "running" ? Date.now() : Date.parse(summary.finished_at ?? summary.updated_at);
const start = Date.parse(summary.started_at);
return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, Math.round((end - start) / 1_000)) : 0;
}
function deliveryLabel(delivery: DeliveryState): string {
if (delivery.status === "local_pending") return "发送中";
if (delivery.status === "failed") return "发送失败";
return "已发送";
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function capabilityLabel(capability: CapabilityCallRecord["request"]["capability"]): string {
switch (capability) {
case "app.open_url": return "打开外部网站";
case "clipboard.write": return "写入剪贴板";
case "device.pick_file": return "选择一个文件";
case "artifact.save": return "保存已验证的文件";
}
}
function capabilityRiskText(capability: CapabilityCallRecord["request"]["capability"]): string {
return capability === "device.pick_file"
? "这会打开系统文件选择器;只有你选择的文件元数据会返回给 Agent。"
: "此操作仅在你本次明确允许后执行一次。";
}
function capabilityStatusText(status: CapabilityCallRecord["status"]): string {
switch (status) {
case "pending": return "等待你的确认";
case "approved":
case "executing": return "正在执行已允许的操作…";
case "completed": return "已完成";
case "rejected": return "你已拒绝此操作";
case "cancelled": return "操作已取消";
case "expired": return "确认已过期";
case "unsupported": return "此客户端当前不支持该操作";
case "failed": return "操作未能完成";
}
}
function validateFormValues(form: InputForm, values: JsonObject): string | undefined {
for (const field of form.fields) {
const value = values[field.id];
const text = typeof value === "string" ? value.trim() : "";
if (field.required && !text) return `请填写“${field.label}”。`;
if (!text) continue;
if (field.min_length !== undefined && text.length < field.min_length) return `${field.label}”至少需要 ${field.min_length} 个字符。`;
if (field.max_length !== undefined && text.length > field.max_length) return `${field.label}”最多允许 ${field.max_length} 个字符。`;
if (field.type === "number" && !Number.isFinite(Number(text))) return `${field.label}”必须是有效数字。`;
if (field.pattern && !(new RegExp(field.pattern, "u")).test(text)) return `${field.label}”格式不正确。`;
}
return undefined;
}