// 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 ( ); } 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 && ( )} {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) => (
{formatWorkspaceBytes(item.size)}
))}
)}
{preview && (
{preview.path}
{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"} {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"} {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.
)} {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.
)} {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 (
{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" ? ( ) : (
Thread metadata

          
)}
); } function Stat({ label, value, mono }) { return (
{label}
{value}
); } function TabBtn({ children, active, onClick }) { return ( ); } window.RightPanel = RightPanel;