// Right column: observability — runs, tool timing, AG-UI JSON
function WorkspaceSnapshotDownload({ workspace }) {
const [busy, setBusy] = React.useState(false);
const assetId = workspace?.snapshot_asset_id;
if (!assetId) return null;
const download = async (e) => {
e.preventDefault();
if (busy) return;
setBusy(true);
try {
const { url } = await window.api.createAssetDownloadUrl(assetId);
window.open(url, "_blank", "noopener,noreferrer");
} catch (err) {
alert("Download workspace snapshot failed: " + (err.message || err));
} finally {
setBusy(false);
}
};
return (
{busy ? "准备中…" : "下载全部"}
);
}
function WorkspaceTab({ runs, workspace }) {
const workspaceRuns = [...runs]
.filter((run) => run.workspace)
.reverse();
const latestRunId = workspaceRuns[0]?.runId || null;
const [selectedRunId, setSelectedRunId] = React.useState(latestRunId);
React.useEffect(() => {
if (!workspaceRuns.some((run) => run.runId === selectedRunId)) {
setSelectedRunId(latestRunId);
}
}, [latestRunId, selectedRunId, workspaceRuns.length]);
const currentRun = workspaceRuns.find((run) => run.runId === selectedRunId)
|| workspaceRuns[0]
|| null;
const current = currentRun?.workspace || workspace || null;
const runId = currentRun?.runId || null;
const [files, setFiles] = React.useState([]);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState("");
const [syncWarnings, setSyncWarnings] = React.useState([]);
const [preview, setPreview] = React.useState(null);
const [downloading, setDownloading] = React.useState("");
const refreshSequence = React.useRef(0);
const previewSequence = React.useRef(0);
React.useEffect(() => {
refreshSequence.current += 1;
previewSequence.current += 1;
setFiles([]);
setPreview(null);
setError("");
}, [runId]);
React.useEffect(() => {
if (!runId) {
return undefined;
}
let cancelled = false;
let timer = null;
const refresh = async () => {
const requestSequence = ++refreshSequence.current;
try {
setLoading(true);
const result = await window.api.listWorkspaceFiles(runId);
if (!cancelled && requestSequence === refreshSequence.current) {
setFiles(result.items || []);
setSyncWarnings([
...(result.truncated ? [{ path: "workspace", error: "文件超过 1,000 个,当前清单已截断" }] : []),
...(result.failures || []),
]);
setError("");
}
} catch (err) {
if (!cancelled && requestSequence === refreshSequence.current) {
setError(err.message || String(err));
}
} finally {
if (!cancelled && requestSequence === refreshSequence.current) {
setLoading(false);
if (currentRun?.running) timer = setTimeout(refresh, 10_000);
}
}
};
refresh();
return () => {
cancelled = true;
refreshSequence.current += 1;
if (timer) clearTimeout(timer);
};
}, [runId, !!currentRun?.running, current?.manifest_revision]);
const openMarkdown = async (item) => {
const requestSequence = ++previewSequence.current;
try {
setPreview({ path: item.path, loading: true, content: "" });
const result = await window.api.readWorkspaceMarkdown(runId, item.path);
if (requestSequence === previewSequence.current) {
setPreview({ path: item.path, loading: false, content: result.content || "" });
}
} catch (err) {
if (requestSequence === previewSequence.current) {
setPreview({ path: item.path, loading: false, error: err.message || String(err), content: "" });
}
}
};
const downloadFile = async (item) => {
if (downloading) return;
setDownloading(item.path);
try {
await window.api.downloadWorkspaceFile(runId, item.path);
} catch (err) {
alert("下载工作区文件失败:" + (err.message || err));
} finally {
setDownloading("");
}
};
if (!current && workspaceRuns.length === 0) {
const hydrationError = runs.find((run) => run.workspaceLoadError)?.workspaceLoadError;
return (
{hydrationError ? "工作区加载失败" : "暂无工作区"}
{hydrationError ? "请刷新页面重试。" : "沙箱生成文件后,会自动同步到这里。"}
);
}
return (
工作区文件
{files.length} 个文件 · {(error || current?.workspace_sync_error || current?.workspace_sync_failed) ? "同步失败" : currentRun?.running ? "正在同步" : "已同步"}
{workspaceRuns.length > 1 && (
setSelectedRunId(event.target.value)}
aria-label="选择运行记录"
>
{workspaceRuns.map((run, index) => (
{index === 0 ? "最新 · " : ""}{String(run.runId).slice(-8)}
))}
)}
{currentRun?.running && }
{(current?.snapshot_error || current?.snapshot_failed) && (
完整工作区快照生成失败,单个文件仍可下载。
)}
{loading && files.length === 0 ? (
正在读取对象存储清单…
) : (error || current?.workspace_sync_error || current?.workspace_sync_failed) && files.length === 0 ? (
工作区文件同步失败,请稍后重试。
) : files.length === 0 ? (
沙箱生成文件后会实时同步到这里。
) : (
{(error || current?.workspace_sync_error || current?.workspace_sync_failed) && (
部分文件同步失败,请稍后重试。
)}
{syncWarnings.length > 0 && (
{syncWarnings.slice(0, 3).map((item) => `${item.path}: ${item.error}`).join(";")}
)}
{files.map((item) => (
item.markdown && openMarkdown(item)}
title={item.markdown ? `打开 ${item.path}` : item.path}
>
{item.path}
{formatWorkspaceBytes(item.size)}
downloadFile(item)} title="下载文件">
{downloading === item.path ? "…" : ""}
))}
)}
{preview && (
{preview.path}
downloadFile({ path: preview.path })} title="下载">
{ previewSequence.current += 1; setPreview(null); }} title="关闭">
{preview.loading ?
加载 Markdown…
: preview.error ?
{preview.error}
:
}
)}
);
}
function formatWorkspaceBytes(size) {
const value = Number(size || 0);
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
function TimelineRow({ step }) {
const kind = step.kind; // "tool" | "llm" | "final"
return (
{kind}
{step.label}
{step.durationLabel || fmtMs(step.durMs)}
);
}
function summarizeToolBatches(steps) {
const summarized = [];
for (let index = 0; index < (steps || []).length;) {
const step = steps[index];
if (step.kind !== "tool" || !step.batchId || Number(step.batchSize) <= 1) {
summarized.push(step);
index += 1;
continue;
}
const batch = [];
while (index < steps.length && steps[index].kind === "tool" && steps[index].batchId === step.batchId) {
batch.push(steps[index]);
index += 1;
}
const names = [...new Set(batch.map((item) => item.label))];
const name = names.length === 1
? `${names[0]} × ${batch.length}`
: `${names.join(", ")} · ${batch.length} calls`;
summarized.push({
...step,
label: name,
durationLabel: `${fmtMs(step.durMs)} batch`,
batchCallCount: batch.length,
});
}
return summarized;
}
function formatTurnTokens(value) {
if (!Number.isFinite(value)) return "—";
if (value >= 1000) return `${(value / 1000).toFixed(value >= 10000 ? 0 : 1)}k`;
return String(value);
}
function formatTurnCost(value) {
if (!Number.isFinite(value)) return "cost —";
return `$${value.toFixed(value > 0 && value < 0.0001 ? 8 : 4)}`;
}
function turnUsage(run) {
const output = run?.output || {};
const valueOrNull = (value) => (
value === null || value === undefined || value === "" || !Number.isFinite(Number(value))
? null
: Number(value)
);
return {
inputTokens: valueOrNull(output.inputTokens),
outputTokens: valueOrNull(output.outputTokens),
costUsd: valueOrNull(output.costUsd),
};
}
function JsonBlock({ title, data, defaultOpen = false }) {
const [open, setOpen] = React.useState(defaultOpen);
const [copied, setCopied] = React.useState(false);
const txt = React.useMemo(() => JSON.stringify(data, null, 2), [data]);
const copy = (e) => {
e.stopPropagation();
window.copyText?.(txt);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
};
return (
setOpen(!open)}>
{title}
{copied ? "copied" : "copy"}
{open && (
)}
);
}
function safeStringify(value, space = 2) {
try {
return JSON.stringify(value, null, space);
} catch (err) {
return String(value ?? "");
}
}
function fullText(value) {
return typeof value === "string" ? value : safeStringify(value);
}
function modelPartText(part) {
if (!part) return "";
if (typeof part.text === "string") return part.text;
if (part.text != null) return fullText(part.text);
if (part.function_call) return fullText(part.function_call);
if (part.function_response) return fullText(part.function_response);
return fullText(part);
}
function flattenModelTools(tools) {
if (!Array.isArray(tools)) return [];
const out = [];
tools.forEach((tool, toolIndex) => {
const decls = tool?.function_declarations;
if (Array.isArray(decls) && decls.length) {
decls.forEach((decl, declIndex) => out.push({ ...decl, _toolIndex: toolIndex, _declIndex: declIndex }));
} else {
out.push({
name: tool?.name || `tool_${toolIndex + 1}`,
description: tool?.description,
parameters: tool?.parameters || tool,
_toolIndex: toolIndex,
_declIndex: 0,
});
}
});
return out;
}
function MiniJsonBlock({ value }) {
return {fullText(value)} ;
}
function ModelMessagesList({ messages }) {
if (!Array.isArray(messages) || !messages.length) {
return No input messages captured yet.
;
}
return (
{messages.map((msg, index) => {
const parts = Array.isArray(msg?.parts) ? msg.parts : [];
return (
Message {index + 1}
{msg?.role || "unknown"}
{
window.copyText?.(safeStringify(msg));
}}>copy
{parts.length ? parts.map((part, partIndex) => (
{modelPartText(part)}
)) : (
)}
);
})}
);
}
function modelSystemInstruction(input) {
return input?.system_instruction ?? input?.config?.system_instruction ?? "";
}
function ModelSystemInstruction({ value }) {
if (!value) return No system instruction captured yet.
;
return {fullText(value)} ;
}
function ModelToolsList({ tools }) {
const flat = flattenModelTools(tools);
if (!flat.length) return No tools advertised yet.
;
return (
{flat.map((tool, index) => (
{index + 1}. {tool.name || "tool"}
{
window.copyText?.(safeStringify(tool));
}}>copy
{tool.description && {fullText(tool.description)} }
))}
);
}
function ModelIOBlock({ run, defaultOpen = false }) {
const [open, setOpen] = React.useState(defaultOpen);
const displayModelIO = run.modelIO || [];
const copyAll = (e) => {
e.stopPropagation();
window.copyText?.(JSON.stringify(displayModelIO || [], null, 2));
};
return (
setOpen(!open)}>
Model IO
{displayModelIO.length > 0 && {displayModelIO.length} }
{displayModelIO.length > 0 && copy }
{open && (
{displayModelIO.length === 0 && (
{run.running ? "Waiting for model IO…" : "No model IO captured for this run."}
)}
{displayModelIO.map((call, index) => (
Round {call.round_index} · {call.model}
{call.completed_at_ms ? `${call.latency_ms ?? "—"}ms` : "running"}
prompt {call.usage?.prompt_token_count ?? call.usage?.input_tokens ?? "—"}
output {call.usage?.candidates_token_count ?? call.usage?.output_tokens ?? "—"}
tools {countModelTools(call.input?.tools)}
System instruction
Input messages
Tools
Output
{call.completed_at_ms
?
: Waiting for model output…
}
))}
)}
);
}
function countModelTools(tools) {
let count = 0;
(tools || []).forEach((tool) => {
count += (tool.function_declarations || []).length;
if (tool.name && !tool.function_declarations) count += 1;
});
return count;
}
function RunCard({ run, index, defaultOpen, onLoadDetail, onLoadModelIO }) {
const canLoadDetail = run.historical && !run.running && !run.detailLoaded && !run.pendingBackendRunId;
const canLoadModelIO = run.historical && !run.running && run.detailLoaded && !run.modelIOLoaded && !run.pendingBackendRunId;
const usage = turnUsage(run);
const hasUsage = usage.inputTokens !== null || usage.outputTokens !== null || usage.costUsd !== null;
const timelineSteps = summarizeToolBatches(run.steps || []);
return (
Turn {index + 1} {run.running && · running… }
thread {run.threadId}
run {run.runId}
{hasUsage && (
in {formatTurnTokens(usage.inputTokens)} · out {formatTurnTokens(usage.outputTokens)} · {formatTurnCost(usage.costUsd)}
)}
{run.running
? ● live
: {fmtMs(run.totalMs)} }
{canLoadDetail && (
Historical run details are loaded on demand.
onLoadDetail?.(run.runId)}
>
{run.detailLoading ? "Loading…" : "展开过程"}
)}
{run.detailError && (
Failed to load details: {run.detailError}
)}
{timelineSteps.map((s, i) => )}
{(run.detailLoaded || run.running) && (
)}
{canLoadModelIO && (
Model IO can be large and is loaded separately.
onLoadModelIO?.(run.runId)}
>
{run.modelIOLoading ? "Loading…" : "加载 Model IO"}
)}
{run.modelIOError && (
Failed to load Model IO: {run.modelIOError}
)}
{run.runId && !run.pendingBackendRunId && (run.running || run.modelIOLoaded) && (
)}
{run.output && (
)}
);
}
// PR pg-system-prompt-preview: collapsible block showing the prompt the
// LLM sees for the current agent + session. Fetches block 1 (SYSTEM_PRELUDE)
// from /v1/system-prompt-prelude on first expand, caches on window so
// subsequent renders + agent switches don't refetch. Block 2 (activated
// skills) is rebuilt every render from sessionSkills + window.SKILLS —
// stays in sync when the user toggles a skill chip in the composer.
function SystemPromptSection({ agent, sessionSkills }) {
const [open, setOpen] = React.useState(false);
const [prelude, setPrelude] = React.useState(window.__SYSTEM_PRELUDE__ || null);
const [activatedHeader, setActivatedHeader] = React.useState(
window.__SYSTEM_PROMPT_HEADER__
|| "--- Activated skills (call `load_skill` for full instructions) ---"
);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState(null);
// Fetch on first expand — cheap (single constant + tiny string) so we
// don't bother prefetching on mount; that way an agent that's never
// opened the panel costs zero.
React.useEffect(() => {
if (!open || prelude || loading) return;
setLoading(true);
window.api.getSystemPromptPrelude()
.then((r) => {
window.__SYSTEM_PRELUDE__ = r.system_prelude;
window.__SYSTEM_PROMPT_HEADER__ = r.activated_skills_header;
setPrelude(r.system_prelude);
if (r.activated_skills_header) setActivatedHeader(r.activated_skills_header);
})
.catch((err) => setError(err.message || String(err)))
.finally(() => setLoading(false));
}, [open, prelude, loading]);
// Block 2: rebuild every render from session state. window.SKILLS is
// [{id, name, description, ...}] with `.id === .name` (api-client.jsx),
// so we can match sessionSkills (names) directly.
const activeSkillEntries = React.useMemo(() => {
const all = window.SKILLS || [];
return (sessionSkills || [])
.map((n) => all.find((s) => s.name === n || s.id === n))
.filter(Boolean);
}, [sessionSkills, agent && agent.id]);
const block2 = activeSkillEntries.length
? activatedHeader + "\n" +
activeSkillEntries.map((s) => `- ${s.name}: ${s.description || ""}`).join("\n")
: activatedHeader + "\n(no skills active)";
const fullPrompt = prelude
? (prelude + "\n\n" + block2)
: "";
return (
setOpen((v) => !v)}
aria-expanded={open}
>
System prompt
{open ? "(click to collapse)" : "what the LLM literally sees"}
{open && (
{loading &&
Loading…
}
{error &&
Failed to load: {error}
}
{!loading && !error && fullPrompt && (
{fullPrompt}
)}
)}
);
}
function RightPanel({ runs, threadId, agent, workspace, sessionSkills, onLoadRunDetail, onLoadRunModelIO }) {
const [tab, setTab] = React.useState("runs"); // runs | workspace | thread
const totalRunMs = runs.reduce((s, r) => s + (r.totalMs || 0), 0);
const totalTools = runs.reduce((s, r) => s + (r.steps || []).filter((x) => x.kind === "tool").length, 0);
const live = runs.some((r) => r.running);
return (
Observability
{live && (
LIVE
)}
{/* PR pg-system-prompt-preview: collapsible "what the LLM literally
sees" panel at the very top of Observability. Default collapsed
so it doesn't dominate; click the header to expand. Shows block 1
(SYSTEM_PRELUDE from honesty.py, fetched once and cached on
window.__SYSTEM_PRELUDE__) + block 2 (activated skills block
composed from sessionSkills + window.SKILLS descriptions). Block
3 (runtime facts) and block 4 (file attachments) are per-run /
per-message → not shown in this stable preview. */}
setTab("runs")}>Runs
setTab("workspace")}>Workspace
setTab("thread")}>Thread
{tab === "runs" ? (
runs.length === 0 ? (
暂无运行
发送消息后,每一轮的 thread / run id、工具调用、AG-UI 事件都会出现在这里。
) : (
[...runs].reverse().map((run, ri) => (
))
)
) : tab === "workspace" ? (
) : (
)}
);
}
function Stat({ label, value, mono }) {
return (
);
}
function TabBtn({ children, active, onClick }) {
return (
{children}
);
}
window.RightPanel = RightPanel;