@@ -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 ;
}