// Middle column: chat surface + composer with skill/MCP chips function ToolInvocation({ inv }) { const skill = SKILLS.find((s) => s.id === inv.toolId) || MCPS.find((s) => s.id === inv.toolId); const color = skill?.color || "#0d0d0d"; const initials = skill?.initials || "T"; const [expanded, setExpanded] = React.useState(false); // One-line preview vs full pretty-print. We keep an inline preview // in the head so short args (load_skill {"skill_name":"..."}) stay // dense; long args (skill_write_file with a `content` blob) get // clamp+ellipsis with click-to-expand below. const argsCompact = "(" + JSON.stringify(inv.args) + ")"; const argsPretty = JSON.stringify(inv.args, null, 2); const isLong = argsCompact.length > 80; const onToggle = (e) => { if (!isLong) return; e.stopPropagation(); setExpanded((v) => !v); }; return (
{initials}
{inv.name}
{inv.round != null && ( round {inv.round} )}
{argsCompact}
{isLong && ( )}
{fmtMs(inv.durMs)}
{expanded && (
{argsPretty}
)} {inv.preview &&
{inv.preview}
} {/* pr-tool-error-visibility-ui: when the tool errored, render the full result_text (backend caps at 1024 chars + "…[truncated, total N chars]"). Without this the user only sees "× error (66 B)" and has to dig in /traces to find out WHAT failed. Plain
 + monospace so JSON /
          stack traces / "ERROR: script not found:" remain readable. */}
      {inv.isError && inv.resultText && (
        
{inv.resultText}
)}
); } // PR C: chip that resolves an asset's download URL on click + opens it. // We don't pre-fetch the URL because each download URL may be short-lived // (per `create_asset_download_url` semantics), and a chat with N file // outputs shouldn't burn N network calls just to render. function AssetDownloadChip({ asset }) { const [busy, setBusy] = React.useState(false); const sizeHuman = (() => { const s = asset.size || 0; if (s < 1024) return `${s} B`; if (s < 1024 * 1024) return `${(s / 1024).toFixed(1)} KB`; return `${(s / 1024 / 1024).toFixed(1)} MB`; })(); const onClick = async (e) => { e.preventDefault(); if (busy) return; setBusy(true); try { const { url } = await window.api.createAssetDownloadUrl(asset.asset_id); window.open(url, "_blank", "noopener,noreferrer"); } catch (err) { alert("Download failed: " + (err.message || err)); } finally { setBusy(false); } }; return ( {asset.filename} {sizeHuman} ); } // HITL (docs/HIL-design.md 场景1): the agent paused via `ask_human` and the // run is `awaiting_input`. We render the requested fields as a small inline // form inside the assistant bubble; submitting calls `onSubmit(values)` which // (app.jsx) resumes the paused run with the human's answers. The composer is // disabled meanwhile (busy gate), so this form is the only way forward. function HumanInputForm({ spec, onSubmit }) { const fields = spec.fields || []; const [values, setValues] = React.useState(() => Object.fromEntries(fields.map((f) => [f.name, ""])) ); const [submitted, setSubmitted] = React.useState(false); const setField = (name, v) => setValues((prev) => ({ ...prev, [name]: v })); const submit = () => { if (submitted) return; setSubmitted(true); onSubmit(values); }; const onKey = (e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); submit(); } }; return (
需要你的输入才能继续
{spec.prompt &&
{spec.prompt}
}
{fields.map((f) => ( ))}
); } function ToolApprovalCard({ spec, onDecision }) { const interruptionType = spec?.interruption_type || spec?.kind || "tool_approval"; if (spec?.submitted) { const dec = spec.submittedDecision; const reason = spec.submittedReason; const summary = dec === "reject" ? "已拒绝" + (reason ? `:${reason}` : "") : dec === "modify" ? "已修改并提交" : "已批准"; const isForm = (spec.interruption_type === "form_submit"); const form = spec?.content?.form_config || {}; const fields = Array.isArray(form.data) ? form.data : []; const vals = spec.submittedValues || {}; return (
{isForm ? (form.title || "补充信息") : summary} {isForm && ( {summary} )}
{isForm && fields.length > 0 && dec !== "reject" && (
{fields.map((f, i) => { const key = f.parameter || f.name || f.title || `field_${i}`; const v = vals[key]; if (!v) return null; const selectedValues = Array.isArray(v) ? v : [v]; const selectedLabels = selectedValues.map( (value) => Array.isArray(f.options) ? (f.options.find((o) => o.value === value)?.label || value) : value, ); return (
{f.title || key}: {selectedLabels.join("、")}
); })}
)}
); } if (interruptionType === "form_submit") { return ; } if (interruptionType === "guider_template_approval") { return ; } const argsText = spec?.content?.arguments_preview || "{}"; const tool = spec?.tool || {}; const [editedArgs, setEditedArgs] = React.useState(argsText); const [reason, setReason] = React.useState(""); const [submitted, setSubmitted] = React.useState(false); const decide = (decision) => { if (submitted) return; let modifiedArgs = null; if (decision === "modify") { try { modifiedArgs = JSON.parse(editedArgs || "{}"); } catch (err) { alert("修改后的参数不是合法 JSON"); return; } } setSubmitted(true); onDecision?.(decision, { modified_args: modifiedArgs, rejection_reason: reason || undefined, }); }; return (
工具调用需要审批
{tool.name || spec?.content?.tool_name || "tool"} {tool.source ? · {tool.source}{tool.owner_id ? `/${tool.owner_id}` : ""} : null}