// 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 && (
{expanded ? "▾" : "▸"}
)}
{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 (
);
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function inlineMarkdown(text) {
let s = escapeHtml(text);
s = s.replace(/!\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g, (_m, alt, url) => (
``
));
s = s.replace(/`([^`]+)`/g, "$1");
s = s.replace(/\*\*([^*]+)\*\*/g, "$1");
s = s.replace(/\*([^*\n]+)\*/g, "$1");
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1');
return s;
}
function isMarkdownTableDivider(line) {
return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line || "");
}
function renderMarkdownTable(lines) {
const rows = lines.map((line) => line.trim().replace(/^\||\|$/g, "").split("|").map((cell) => cell.trim()));
const head = rows[0] || [];
const hasDivider = lines.length > 1 && isMarkdownTableDivider(lines[1]);
const body = rows.slice(hasDivider ? 2 : 1);
return (
"
" +
head.map((cell) => `
${inlineMarkdown(cell)}
`).join("") +
"
" +
body.map((row) => (
"
" + row.map((cell) => `
${inlineMarkdown(cell)}
`).join("") + "
"
)).join("") +
"
"
);
}
function renderMarkdownPreview(md) {
const lines = String(md || "").replace(/\r\n/g, "\n").split("\n");
const out = [];
let para = [];
let list = [];
let code = null;
let table = [];
let quote = [];
const flushPara = () => {
if (!para.length) return;
out.push(`
${inlineMarkdown(para.join(" "))}
`);
para = [];
};
const flushList = () => {
if (!list.length) return;
out.push("
" + list.map((item) => `
${inlineMarkdown(item)}
`).join("") + "
");
list = [];
};
const flushTable = () => {
if (!table.length) return;
out.push(renderMarkdownTable(table));
table = [];
};
const flushQuote = () => {
if (!quote.length) return;
out.push("
" + quote.map((item) => `
${inlineMarkdown(item)}
`).join("") + "
");
quote = [];
};
const flushBlocks = () => { flushPara(); flushList(); flushTable(); flushQuote(); };
for (const raw of lines) {
const line = raw.replace(/\s+$/g, "");
if (code) {
if (/^```/.test(line)) {
out.push(`
{/* Inline alert banners — populated from non-fatal events the user
should notice (currently just `agent.max_rounds_hit`). */}
{(msg.alerts || []).map((a, i) => (
{/* HITL: inline human-input form when the run paused on `ask_human`. */}
{msg.awaitingInput && (
onHumanInput?.(values)}
/>
)}
{/* Live (non-submitted) tool approval card stays at the bottom. */}
{msg.toolApproval && !msg.toolApproval.submitted && (
onToolApproval?.(decision, detail)}
/>
)}
{/* PR C: download links for any output assets the run produced
(PPT / DOCX / XLSX / arbitrary files written under
workspace/output/ by run_script). result.md is
excluded by the producer — its content is `msg.content`. */}
{!msg.streaming && (msg.outputAssets || []).length > 0 && (
{msg.outputAssets.map((a) => (
))}
)}
{/* Activated-skills footer — small muted line below the reply so
the user can see which skills the run actually loaded. */}
{!msg.streaming && (msg.activatedSkills || []).length > 0 && (