// Playground root — wires the design's three-column shell to the real
// Baizhi backend.
//
// Boot sequence:
// 1. App mounts in a "loading" state (renders a small splash).
// 2. useEffect loads skills first, then agents/mcps in parallel. Agent
// decoration needs window.SKILLS to translate backend skill_ids.
// 3. On resolve we set React state AND mirror onto window.SKILLS/MCPS/
// INITIAL_AGENTS so child components that read those globals at render
// time (per the original design) see real data.
// 4. App becomes interactive.
//
// Run flow:
// send() → enqueue + push placeholder UI rows → startAgentRun (api-client)
// drives polling and dispatches translated events back through callbacks
// that mutate the same React state slots the mock used (`runs`, `messages`).
const { useState, useEffect, useRef, useMemo, useCallback } = React;
// ----------- column resizer -----------
// A 5px hit-zone between the three panels. Mouse drag resizes the adjacent
// column by writing to CSS variables on `.cols`. Widths are clamped so the
// middle (chat) column never collapses below `MIN_MID_W`, and persisted to
// localStorage so the user's layout survives reload.
const LEFT_BOUNDS = { min: 200, max: 520 };
const RIGHT_BOUNDS = { min: 260, max: 640 };
const MIN_MID_W = 360;
function mergeModelIOCalls(calls, incoming) {
if (!incoming?.call_id) return calls || [];
const next = [...(calls || [])];
const idx = next.findIndex((c) => c.call_id === incoming.call_id);
const previous = idx >= 0 ? next[idx] : {};
const normalized = {
...previous,
...incoming,
input: incoming.input ?? previous.input ?? {},
output: incoming.output ?? previous.output ?? {},
usage: incoming.usage ?? previous.usage ?? {},
};
if (idx >= 0) next[idx] = normalized;
else next.push(normalized);
next.sort((a, b) => {
const roundDelta = (a.round_index || 0) - (b.round_index || 0);
if (roundDelta) return roundDelta;
return (a.started_at_ms || 0) - (b.started_at_ms || 0);
});
return next;
}
function numericUsageValue(...values) {
for (const value of values) {
if (value === null || value === undefined || value === "") continue;
const number = Number(value);
if (Number.isFinite(number)) return number;
}
return null;
}
function catalogTurnCost(model, inputTokens, outputTokens) {
const pricing = (window.MODELS || []).find((item) => item.id === model)?.pricing;
if (!pricing || inputTokens === null || outputTokens === null) return null;
const inputRate = Number(pricing.input_per_million);
const outputRate = Number(pricing.output_per_million);
if (!Number.isFinite(inputRate) || !Number.isFinite(outputRate)) return null;
return (inputTokens * inputRate + outputTokens * outputRate) / 1_000_000;
}
function llmTimelineStep(payload, { model = null, usage = {} } = {}) {
const inputTokens = numericUsageValue(
payload?.input_tokens, usage?.input_tokens, usage?.prompt_token_count, usage?.prompt_tokens,
);
const outputTokens = numericUsageValue(
payload?.output_tokens, usage?.output_tokens, usage?.candidates_token_count, usage?.completion_tokens,
);
const resolvedModel = payload?.model || model;
const costUsd = numericUsageValue(payload?.cost_usd, usage?.cost_usd)
?? catalogTurnCost(resolvedModel, inputTokens, outputTokens);
return {
kind: "llm",
label: `LLM · round ${payload?.round_index || "?"}${resolvedModel ? ` (${resolvedModel})` : ""}`,
durMs: payload?.duration_ms || 1,
inputTokens,
outputTokens,
costUsd,
};
}
function eventStreamAuthQuery() {
let tok = null;
try { tok = localStorage.getItem("baizhi.auth.token"); } catch {}
if (tok) return `token=${encodeURIComponent(tok)}`;
if (!location.pathname.startsWith("/login")) location.replace("/login");
return "token=";
}
function trimRunContext(context) {
const out = {};
for (const [key, value] of Object.entries(context || {})) {
const normalizedKey = String(key || "").trim();
const normalizedValue = String(value || "").trim();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(normalizedKey) || !normalizedValue) continue;
out[normalizedKey] = normalizedValue;
}
return out;
}
function reasoningFromRunEvents(events) {
let active = false;
let id = null;
let content = "";
for (const ev of events || []) {
const payload = ev.payload || {};
if (ev.type === "thinking.started") {
active = true;
id = payload.thinking_id || id;
} else if (ev.type === "thinking.delta") {
id = payload.thinking_id || id;
content += payload.delta || "";
} else if (ev.type === "thinking.completed") {
active = false;
id = payload.thinking_id || id;
content = payload.text || content;
}
}
return active || content.trim() ? { active, id, content } : null;
}
function roundOfLlmId(id) {
const m = /llm_(\d+)/.exec(id || "");
return m ? parseInt(m[1], 10) : null;
}
function toolInvocationsFromRunEvents(events) {
const starts = new Map();
const ordered = [];
const seenAgUi = new Set();
const ensureInvocation = (id, name = "") => {
const key = id || `${name || "tool"}_${ordered.length + 1}`;
let inv = starts.get(key);
if (!inv) {
inv = {
toolId: name || "tool",
name: name || "tool",
args: {},
durMs: 0,
preview: "running...",
running: true,
round: null,
};
starts.set(key, inv);
ordered.push(inv);
}
if (name && (!inv.name || inv.name === "tool")) {
inv.name = name;
inv.toolId = name;
}
return inv;
};
for (const ev of events || []) {
const p = ev.payload || ev || {};
const type = ev.type || p.type;
if (type === "TOOL_CALL_START") {
const id = p.toolCallId;
if (id) seenAgUi.add(id);
const inv = ensureInvocation(id, p.toolCallName);
inv.round = roundOfLlmId(p.parentMessageId) ?? inv.round;
} else if (type === "TOOL_CALL_ARGS") {
const inv = ensureInvocation(p.toolCallId, p.toolCallName);
try {
inv.args = JSON.parse(p.delta || "{}");
} catch {
inv.args = {};
}
} else if (type === "TOOL_CALL_RESULT") {
const content = (p.content && typeof p.content === "object") ? p.content : {};
const resultText = content.result ?? p.message ?? "";
const isError = content.is_error ?? p.isError ?? false;
const resultBytes = content.result_bytes || new Blob([resultText]).size;
const dur = content.duration_ms || p.durationMs || 0;
const inv = ensureInvocation(p.toolCallId, p.toolCallName);
inv.running = false;
inv.durMs = dur;
inv.preview = isError ? `✗ error (${resultBytes} B)` : `✓ ${resultBytes} B`;
inv.isError = !!isError;
inv.resultText = resultText || null;
} else if (type === "tool.started") {
const id = p.tool_call_id;
if (id && seenAgUi.has(id)) continue;
const inv = ensureInvocation(id, p.tool_name);
inv.args = p.arguments || inv.args || {};
inv.round = roundOfLlmId(p.llm_round_id) ?? inv.round;
} else if (type === "tool.completed") {
const id = p.tool_call_id;
if (id && seenAgUi.has(id)) continue;
const inv = ensureInvocation(id, p.tool_name);
const resultBytes = p.result_bytes || new Blob([p.result_text || ""]).size;
inv.running = false;
inv.durMs = p.duration_ms || 0;
inv.preview = p.is_error ? `✗ error (${resultBytes} B)` : `✓ ${resultBytes} B`;
inv.isError = !!p.is_error;
inv.resultText = p.result_text || null;
}
}
return ordered.filter((inv) => {
if (!inv.name) return false;
const args = inv.args || {};
const internalFinalWrite = inv.name === "write_file" && inv.round == null && Object.keys(args).length === 0;
return !internalFinalWrite;
});
}
function Gutter({ side, leftW, rightW, setLeftW, setRightW, onReset }) {
const onMouseDown = (e) => {
e.preventDefault();
const startX = e.clientX;
const startLeft = leftW;
const startRight = rightW;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const onMove = (ev) => {
const dx = ev.clientX - startX;
const winW = window.innerWidth;
if (side === "left") {
const proposed = startLeft + dx;
const maxByMid = winW - startRight - MIN_MID_W - 10;
const next = Math.max(LEFT_BOUNDS.min, Math.min(LEFT_BOUNDS.max, Math.min(maxByMid, proposed)));
setLeftW(next);
} else {
const proposed = startRight - dx;
const maxByMid = winW - startLeft - MIN_MID_W - 10;
const next = Math.max(RIGHT_BOUNDS.min, Math.min(RIGHT_BOUNDS.max, Math.min(maxByMid, proposed)));
setRightW(next);
}
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
};
return (
);
}
function MarkdownDefaultPicker({ value, onChange }) {
return (
Markdown
);
}
function App() {
const DEFAULT_AGENT_NAME = "英威腾-线索Agent-V1-本地开发版本";
const resolveDefaultAgentId = (agentItems) => (
agentItems.find((item) => item.name === DEFAULT_AGENT_NAME)?.id
|| agentItems[0]?.id
|| null
);
// Persisted column widths
const [leftW, setLeftW] = useState(() => {
const v = parseInt(localStorage.getItem("baizhi.playground.leftW") || "");
return Number.isFinite(v) ? v : 288;
});
const [rightW, setRightW] = useState(() => {
const v = parseInt(localStorage.getItem("baizhi.playground.rightW") || "");
return Number.isFinite(v) ? v : 380;
});
useEffect(() => { localStorage.setItem("baizhi.playground.leftW", String(leftW)); }, [leftW]);
useEffect(() => { localStorage.setItem("baizhi.playground.rightW", String(rightW)); }, [rightW]);
const [markdownDefaultMode, setMarkdownDefaultMode] = useState(() => {
const v = localStorage.getItem("baizhi.playground.markdownDefaultMode");
return v === "source" ? "source" : "preview";
});
useEffect(() => {
localStorage.setItem("baizhi.playground.markdownDefaultMode", markdownDefaultMode);
}, [markdownDefaultMode]);
// Double-click a gutter resets to default
const resetWidths = () => { setLeftW(288); setRightW(380); };
const [agents, setAgents] = useState([]);
const [skills, setSkills] = useState([]);
const [mcps, setMcps] = useState([]);
// PR-S.17d · topbar 上 Users 入口 admin-gate
const [isAdmin, setIsAdmin] = useState(false);
useEffect(() => {
window.api.authMe()
.then((m) => setIsAdmin(!!m.is_admin))
.catch(() => {});
}, []);
const [localTools, setLocalTools] = useState([]);
const [activeId, setActiveId] = useState(null);
// Chat sessions combine the selected agent's skills with the current user's
// bound skills. The runtime de-duplicates collisions by template/name.
const [bindings, setBindings] = useState({ skill_ids: [], mcp_ids: [] });
// This chat's active skill / MCP set. Skills start from the de-duplicated
// agent/user union; MCPs and local tools start from the agent defaults.
// Switching agent / thread resets to the agent defaults.
const [sessionSkills, setSessionSkills] = useState([]); // string[] of skill_ids
const [sessionMcps, setSessionMcps] = useState([]); // string[] of mcp_ids
const [sessionLocalTools, setSessionLocalTools] = useState([]); // string[] of model-facing tool names
const sessionToolScopeRef = useRef(null);
const [runContextByAgent, setRunContextByAgent] = useState({});
const runContext = activeId ? (runContextByAgent[activeId] || {}) : {};
const setRunContext = useCallback((nextContext) => {
if (!activeId) return;
setRunContextByAgent((prev) => ({
...prev,
[activeId]: nextContext || {},
}));
}, [activeId]);
const [threadId, setThreadId] = useState(rid("thread"));
const [threadModelOverrides, setThreadModelOverrides] = useState({});
const [messages, setMessages] = useState([]);
const [runs, setRuns] = useState([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState("");
const volatileThreadIdsRef = useRef({});
// Helpers: per-agent thread id lives in localStorage so refresh /
// accidental close doesn't drop chat history. Clear button is the ONLY
// way to discard (rotates the thread id, both locally and remotely).
const threadKey = (agentId) => `baizhi.thread.${agentId}`;
const getOrCreateThread = (agentId) => {
if (!agentId) return rid("thread");
const fresh = rid("thread");
try {
const stored = localStorage.getItem(threadKey(agentId));
if (stored) {
volatileThreadIdsRef.current[agentId] = stored;
return stored;
}
localStorage.setItem(threadKey(agentId), fresh);
} catch (err) {
console.error("thread persistence unavailable", err);
if (volatileThreadIdsRef.current[agentId]) return volatileThreadIdsRef.current[agentId];
}
volatileThreadIdsRef.current[agentId] = fresh;
return fresh;
};
// Backend stores `{role, content, run_id?, output_assets?}`; UI message
// shape needs id + time. toolInvocations / events are live-only and the
// run's spans live on /v1/traces if the user wants to dig in. Old
// assistant turns just display content. PR thread-asset-rehydrate:
// assistant messages now carry `output_assets` so the download chip
// survives a page refresh (mirror of the live `outputAssets` field set
// in the run-complete handler below). Filter out result.md — its
// contents are already in `content`.
const rehydrateMessage = (m, i) => ({
id: rid("msg"),
role: m.role || "assistant",
content: m.content || "",
time: "",
historical: true,
runId: m.run_id || null,
outputAssets: (m.output_assets || []).filter(
(a) => a.filename !== "result.md"
),
});
const rehydrateMessages = async (items) => (items || []).map(rehydrateMessage);
const runCardFromTraceDetail = (detail) => {
const run = detail?.run || {};
const summary = detail?.summary || {};
const events = detail?.events || [];
const usage = run.usage || summary.usage || {};
const modelIO = [...(detail?.model_io || [])].sort((a, b) => {
const roundDelta = (a.round_index || 0) - (b.round_index || 0);
if (roundDelta) return roundDelta;
return (a.started_at_ms || 0) - (b.started_at_ms || 0);
});
const modelIOFromEvents = events
.filter((e) => ["model_io.started", "model_io.completed", "model_io.failed"].includes(e.type))
.map((e) => e.payload || {});
const mergedModelIO = modelIO.length
? modelIO
: modelIOFromEvents.reduce((acc, call) => mergeModelIOCalls(acc, call), []);
const modelIOByRound = new Map(mergedModelIO.map((call) => [call.round_index, call]));
const steps = [];
for (const e of events) {
const p = e.payload || {};
if (e.type === "llm.completed") {
const call = modelIOByRound.get(p.round_index);
steps.push(llmTimelineStep(p, { model: call?.model, usage: call?.usage }));
} else if (e.type === "tool.completed") {
steps.push({
kind: "tool",
label: p.tool_name || p["tool.name"] || "tool",
durMs: p.duration_ms || p["tool.duration_ms"] || 1,
batchId: p.tool_batch_id,
batchSize: p.tool_batch_size,
});
} else if (e.type === "TOOL_CALL_RESULT") {
steps.push({
kind: "tool",
label: p.toolCallName || p.toolCallId || "tool",
durMs: p.content?.duration_ms || 1,
batchId: p.content?.tool_batch_id,
batchSize: p.content?.tool_batch_size,
});
}
}
const started = run.started_at || summary.started_at || run.queued_at || 0;
const completed = run.completed_at || summary.completed_at || 0;
const totalMs = summary.duration_ms || (
started && completed ? Math.max(0, Math.round((completed - started) * 1000)) : 0
);
const status = run.status || summary.status || "completed";
steps.push({ kind: "final", label: `RUN_FINISHED · ${status}`, durMs: 1 });
return {
runId: run.run_id || summary.run_id,
threadId: run.thread_id || summary.thread_id,
workspace: run.workspace || summary.workspace || null,
running: false,
historical: true,
detailLoaded: true,
detailLoading: false,
steps,
events,
totalMs,
modelIO: mergedModelIO,
output: {
runId: run.run_id || summary.run_id,
threadId: run.thread_id || summary.thread_id,
llmRounds: usage.llm_turns ?? usage.llm_rounds ?? mergedModelIO.length ?? 0,
toolCalls: usage.tool_calls ?? steps.filter((s) => s.kind === "tool").length,
totalDurationMs: totalMs,
finishReason: status,
inputTokens: usage.input_tokens ?? usage.prompt_tokens ?? usage.prompt_token_count ?? 0,
outputTokens: usage.output_tokens ?? usage.completion_tokens ?? usage.candidates_token_count ?? 0,
costUsd: usage.cost_usd ?? null,
},
};
};
const runCardFromEvents = (runId, events, base = {}) => {
const steps = [];
let workspaceFromEvents = base.workspace || null;
for (const e of events || []) {
const p = e.payload || {};
if (e.type === "llm.completed") {
steps.push(llmTimelineStep(p, { model: p.model || base.model || agent?.model }));
} else if (e.type === "tool.completed") {
steps.push({
kind: "tool",
label: p.tool_name || p["tool.name"] || "tool",
durMs: p.duration_ms || p["tool.duration_ms"] || 1,
batchId: p.tool_batch_id,
batchSize: p.tool_batch_size,
});
} else if (e.type === "TOOL_CALL_RESULT") {
steps.push({
kind: "tool",
label: p.toolCallName || p.toolCallId || "tool",
durMs: p.content?.duration_ms || 1,
batchId: p.content?.tool_batch_id,
batchSize: p.content?.tool_batch_size,
});
} else if (e.type === "workspace.prepared") {
workspaceFromEvents = { ...(workspaceFromEvents || {}), ...p };
} else if (e.type === "workspace.files_synced") {
workspaceFromEvents = {
...(workspaceFromEvents || {}),
manifest_object_key: p.manifest_key,
manifest_revision: p.revision || 0,
workspace_sync_error: null,
};
} else if (e.type === "workspace.sync_failed") {
workspaceFromEvents = {
...(workspaceFromEvents || {}),
workspace_sync_error: p.message || "workspace sync failed",
};
} else if (e.type === "workspace.snapshot_saved") {
workspaceFromEvents = {
...(workspaceFromEvents || {}),
snapshot_object_key: p.object_key,
snapshot_asset_id: p.asset_id || null,
snapshot_filename: p.filename || "workspace-snapshot.tar.gz",
snapshot_size: p.size || 0,
snapshot_saved: !!p.saved,
snapshot_error: p.error || null,
};
}
}
const done = [...(events || [])].reverse().find((e) => e.type === "run.completed" || e.type === "run.failed");
const status = done?.type === "run.failed" ? "failed" : "completed";
steps.push({ kind: "final", label: `RUN_FINISHED · ${status}`, durMs: 1 });
return {
...base,
runId,
threadId: base.threadId || threadId,
workspace: workspaceFromEvents,
running: false,
historical: true,
detailLoaded: true,
detailLoading: false,
modelIOLoaded: false,
modelIOLoading: false,
steps,
events,
totalMs: base.totalMs || 0,
output: base.output || {
runId,
threadId: base.threadId || threadId,
finishReason: status,
},
};
};
const rehydrateRunsFromMessages = async (messages, persistedRuns, loadError = null) => {
const byRunId = new Map();
for (const msg of messages || []) {
if (msg.role !== "assistant" || !msg.runId || byRunId.has(msg.runId)) continue;
byRunId.set(msg.runId, msg);
}
const persisted = [...(persistedRuns || [])].reverse();
if (!persisted.length && loadError) {
return [...byRunId.keys()].map((runId) => ({
runId,
threadId,
workspace: null,
workspaceLoadError: loadError,
running: false,
status: "historical",
historical: true,
steps: [],
events: [],
totalMs: 0,
}));
}
return persisted.map((run) => {
const runId = run.run_id;
const msg = byRunId.get(runId) || {};
return {
runId,
threadId: run.thread_id || msg.threadId || threadId,
workspace: run.workspace || null,
running: ["queued", "running", "awaiting_input", "interrupted_max_rounds", "interrupted_context_budget"].includes(run.status),
status: run.status || "historical",
historical: true,
detailLoaded: false,
detailLoading: false,
steps: [{
kind: "final",
label: `RUN_${String(run.status || "historical").toUpperCase()} · historical`,
durMs: 1,
}],
events: [],
totalMs: 0,
output: {
runId,
threadId: msg.threadId || threadId,
finishReason: "historical",
preview: (msg.content || "").slice(0, 300),
inputTokens: run.usage?.input_tokens ?? run.usage?.prompt_tokens ?? 0,
outputTokens: run.usage?.output_tokens ?? run.usage?.completion_tokens ?? 0,
costUsd: run.usage?.cost_usd ?? null,
},
};
});
};
const [busy, setBusy] = useState(false);
const [loaded, setLoaded] = useState(false);
const [loadError, setLoadError] = useState(null);
const runHandleRef = useRef(null);
// HITL (docs/HIL-design.md): while a run is paused on `ask_human`, the driver
// hands us a `respond` fn (via onAwaitingInput) that resumes the segment loop
// when invoked. We stash it here so the inline form (rendered in the chat by
// chat-panel) can submit the human's answer. Only one run is active at a time
// (busy gate), so a single ref suffices.
const humanRespondRef = useRef(null);
const toolApprovalRespondRef = useRef(null);
const isCancelledRunError = (err) => (
err?.message === "cancelled" || err?.name === "AbortError"
);
const finishCancelledUi = (totalMs = null, stats = null) => {
localStorage.removeItem("baizhi.active_run_id");
setRuns((prev) => {
if (!prev.length) return prev;
const next = [...prev];
const idx = next.length - 1;
const current = next[idx];
if (!current?.running) return prev;
next[idx] = {
...current,
running: false,
totalMs: totalMs ?? current.totalMs ?? 0,
steps: [...(current.steps || []), { kind: "final", label: "RUN_FINISHED · cancelled", durMs: 1 }],
};
return next;
});
setMessages((prev) => {
if (!prev.length) return prev;
const next = [...prev];
const idx = next.length - 1;
const current = next[idx];
if (!current?.streaming) return prev;
next[idx] = {
...current,
streaming: false,
reasoning: current.reasoning ? { ...current.reasoning, active: false } : current.reasoning,
content: current.content || "(已取消)",
status: "cancelled",
errorText: null,
...(stats ? { stats } : {}),
};
return next;
});
setBusy(false);
};
// ---- Run reconnection ----
// When the user refreshes the page, discover any runs still active on the
// backend and re-attach via SSE. The SSE endpoint replays all events +
// continues streaming; dispatchEvent dedups via seenEventIds so already-seen
// events are not re-processed.
const reattachToRun = useCallback(async (activeRun) => {
setBusy(true);
// Create run placeholder in the right panel
const placeholder = {
runId: activeRun.run_id, threadId: activeRun.thread_id,
running: true, steps: [], events: [], totalMs: 0,
};
setRuns((r) => [...r, placeholder]);
// Reconstruct chat messages from the run's persisted data
const userMsg = {
id: rid("msg"), role: "user",
content: activeRun.message || "", time: "",
};
const asstMsg = {
id: rid("msg"), role: "assistant", content: "", time: "",
streaming: true, toolInvocations: [], stats: null,
reasoning: { active: false, content: "" },
};
setMessages((m) => [...m, userMsg, asstMsg]);
const t0 = performance.now();
let llmRounds = 0;
let toolCalls = 0;
let inputTokens = 0;
let outputTokens = 0;
const openLlmRounds = new Map();
const openToolCalls = new Map();
const suppressedToolCalls = new Set();
const seenEventIds = new Set();
const receivedEvents = [];
let sawAgUiTextContent = false;
const agUiToolStarts = new Map();
const agUiToolStarted = new Set();
const agUiToolResults = new Set();
const roundOf = (rid) => {
const m = /llm_(\d+)/.exec(rid || "");
return m ? parseInt(m[1], 10) : null;
};
const writeRun = (mutator) => {
setRuns((prev) => {
const next = [...prev];
const idx = next.length - 1;
next[idx] = mutator(next[idx]);
return next;
});
};
const writeAssistant = (mutator) => {
setMessages((prev) => {
const next = [...prev];
const idx = next.length - 1;
next[idx] = mutator(next[idx]);
return next;
});
};
// Open SSE connection — the endpoint replays all past events + continues
// streaming live ones. Late joiners are fully supported.
const url = `/v1/runs/${activeRun.run_id}/events?stream=1&${eventStreamAuthQuery()}`;
let es = null;
let reconnectTimer = null;
let streamSettled = false;
const closeCurrent = () => {
if (!es) return;
try { es.close(); } catch {}
es = null;
};
const clearReconnect = () => {
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
};
const cleanup = () => {
streamSettled = true;
clearReconnect();
closeCurrent();
localStorage.removeItem("baizhi.active_run_id");
setBusy(false);
runHandleRef.current = null;
};
const dispatchEvent = (evt) => {
const eid = evt && evt.event_id;
if (eid !== undefined && eid !== null) {
if (seenEventIds.has(eid)) return;
seenEventIds.add(eid);
}
receivedEvents.push(evt);
const t = evt.type;
const p = evt.payload || {};
if (["workspace.prepared", "workspace.files_synced", "workspace.sync_failed", "workspace.snapshot_saved"].includes(t)) {
writeRun((r) => ({
...r,
workspace: {
...(r.workspace || {}),
...(t === "workspace.prepared" ? p : t === "workspace.files_synced" ? {
manifest_object_key: p.manifest_key,
manifest_revision: p.revision || 0,
workspace_sync_error: null,
} : t === "workspace.sync_failed" ? {
workspace_sync_error: p.message || "workspace sync failed",
} : {
snapshot_object_key: p.object_key,
snapshot_asset_id: p.asset_id || null,
snapshot_filename: p.filename || "workspace-snapshot.tar.gz",
snapshot_size: p.size || 0,
snapshot_saved: !!p.saved,
snapshot_error: p.error || null,
}),
},
events: [...r.events, { type: t, payload: p, timestamp: Date.now() }],
}));
} else if (t === "TEXT_MESSAGE_CONTENT") {
sawAgUiTextContent = true;
writeAssistant((m) => ({ ...m, content: (m.content || "") + (p.delta || "") }));
writeRun((r) => ({
...r,
events: [...r.events, { type: "TEXT_MESSAGE_CONTENT", delta: p.delta, timestamp: Date.now() }],
}));
} else if (t === "TOOL_CALL_START") {
const toolCallId = p.toolCallId;
if (toolCallId) {
agUiToolStarted.add(toolCallId);
agUiToolStarts.set(toolCallId, {
tool_name: p.toolCallName,
tool_call_id: toolCallId,
arguments: {},
llm_round_id: p.parentMessageId,
dispatched: false,
});
}
} else if (t === "TOOL_CALL_ARGS") {
const started = agUiToolStarts.get(p.toolCallId);
if (started && !started.dispatched) {
let args = {};
try { args = JSON.parse(p.delta || "{}"); } catch {}
started.arguments = args;
started.dispatched = true;
const round = roundOf(started.llm_round_id);
openToolCalls.set(started.tool_call_id, { name: started.tool_name, started: performance.now(), args, round });
writeRun((r) => ({
...r,
events: [...r.events,
{ type: "TOOL_CALL_START", toolCallId: started.tool_call_id, toolCallName: started.tool_name, timestamp: Date.now() },
{ type: "TOOL_CALL_ARGS", toolCallId: started.tool_call_id, toolCallName: started.tool_name, delta: JSON.stringify(args || {}), timestamp: Date.now() },
{ type: "TOOL_CALL_END", toolCallId: started.tool_call_id, timestamp: Date.now() },
],
}));
writeAssistant((m) => ({
...m,
toolInvocations: [...(m.toolInvocations || []), {
toolId: started.tool_name, name: started.tool_name,
args: args || {}, durMs: 0,
preview: "running…", running: true, round,
}],
}));
}
} else if (t === "TOOL_CALL_RESULT") {
const toolCallId = p.toolCallId;
if (toolCallId) agUiToolResults.add(toolCallId);
const open = openToolCalls.get(toolCallId);
const content = (p.content && typeof p.content === "object") ? p.content : {};
const resultText = content.result ?? p.message ?? "";
const isError = content.is_error ?? p.isError ?? false;
const dur = content.duration_ms || p.durationMs || (open ? performance.now() - open.started : 0);
openToolCalls.delete(toolCallId);
toolCalls += 1;
const resultBytes = content.result_bytes || new Blob([resultText]).size;
const preview = isError
? `✗ error (${resultBytes} B)`
: `✓ ${resultBytes} B`;
writeRun((r) => ({
...r,
steps: [...r.steps, {
kind: "tool", label: p.toolCallName, durMs: dur,
batchId: content.tool_batch_id,
batchSize: content.tool_batch_size,
}],
events: [...r.events, {
type: "TOOL_CALL_RESULT",
messageId: p.messageId || `tool_${toolCallId}`,
toolCallId,
role: "tool",
content: {
result: resultText || "",
is_error: !!isError,
result_bytes: resultBytes,
duration_ms: Math.round(dur),
tool_batch_id: content.tool_batch_id,
tool_batch_size: content.tool_batch_size,
tool_timing_scope: content.tool_timing_scope,
},
timestamp: Date.now(),
}],
}));
writeAssistant((m) => {
const inv = [...(m.toolInvocations || [])];
for (let i = inv.length - 1; i >= 0; i--) {
if (inv[i].running && inv[i].name === p.toolCallName) {
inv[i] = {
...inv[i], running: false, durMs: dur, preview,
isError: !!isError, resultText: resultText || null,
};
break;
}
}
return { ...m, toolInvocations: inv };
});
} else if (t === "llm.started") {
openLlmRounds.set(p.round_id, performance.now());
} else if (t === "llm.completed") {
const started = openLlmRounds.get(p.round_id) ?? performance.now();
openLlmRounds.delete(p.round_id);
const dur = p.duration_ms || (performance.now() - started);
llmRounds += 1;
inputTokens += (p.input_tokens || 0);
outputTokens += (p.output_tokens || 0);
writeRun((r) => ({
...r,
steps: [...r.steps, llmTimelineStep({ ...p, duration_ms: dur }, { model: agent?.model || "agent" })],
output: {
...(r.output || {}),
inputTokens,
outputTokens,
costUsd: (r.output?.costUsd ?? 0) + (numericUsageValue(p.cost_usd) ?? 0),
},
}));
} else if (t === "model_io.started" || t === "model_io.completed" || t === "model_io.failed") {
writeRun((r) => ({
...r,
modelIO: mergeModelIOCalls(r.modelIO, p),
}));
} else if (t === "tool.started") {
if (agUiToolStarted.has(p.tool_call_id)) return;
if (p.tool_name === "write_file" && p.llm_round_id === "llm_final") {
suppressedToolCalls.add(p.tool_call_id);
return;
}
const round = roundOf(p.llm_round_id);
openToolCalls.set(p.tool_call_id, { name: p.tool_name, started: performance.now(), args: p.arguments, round });
writeRun((r) => ({
...r,
events: [...r.events,
{ type: "TOOL_CALL_START", toolCallId: p.tool_call_id, toolCallName: p.tool_name, timestamp: Date.now() },
{ type: "TOOL_CALL_ARGS", toolCallId: p.tool_call_id, toolCallName: p.tool_name, delta: JSON.stringify(p.arguments || {}), timestamp: Date.now() },
{ type: "TOOL_CALL_END", toolCallId: p.tool_call_id, timestamp: Date.now() },
],
}));
writeAssistant((m) => ({
...m,
toolInvocations: [...(m.toolInvocations || []), {
toolId: p.tool_name, name: p.tool_name,
args: p.arguments || {}, durMs: 0,
preview: "running…", running: true, round,
}],
}));
} else if (t === "tool.completed") {
if (agUiToolResults.has(p.tool_call_id)) return;
if (suppressedToolCalls.has(p.tool_call_id)) {
suppressedToolCalls.delete(p.tool_call_id);
return;
}
const open = openToolCalls.get(p.tool_call_id);
const dur = p.duration_ms || (open ? performance.now() - open.started : 0);
openToolCalls.delete(p.tool_call_id);
toolCalls += 1;
const preview = p.is_error
? `✗ error (${p.result_bytes || 0} B)`
: `✓ ${p.result_bytes || 0} B`;
writeRun((r) => ({
...r,
steps: [...r.steps, {
kind: "tool", label: p.tool_name, durMs: dur,
batchId: p.tool_batch_id,
batchSize: p.tool_batch_size,
}],
events: [...r.events, {
type: "TOOL_CALL_RESULT",
messageId: `tool_${p.tool_call_id}`,
toolCallId: p.tool_call_id,
role: "tool",
content: {
result: p.result_text || "",
is_error: !!p.is_error,
result_bytes: p.result_bytes || 0,
duration_ms: Math.round(dur),
tool_batch_id: p.tool_batch_id,
tool_batch_size: p.tool_batch_size,
tool_timing_scope: p.tool_timing_scope,
},
timestamp: Date.now(),
}],
}));
writeAssistant((m) => {
const inv = [...(m.toolInvocations || [])];
for (let i = inv.length - 1; i >= 0; i--) {
if (inv[i].running && inv[i].name === p.tool_name) {
inv[i] = {
...inv[i], running: false, durMs: dur, preview,
isError: !!p.is_error, resultText: p.result_text || null,
};
break;
}
}
return { ...m, toolInvocations: inv };
});
} else if (t === "text.delta") {
if (sawAgUiTextContent) return;
writeAssistant((m) => ({ ...m, content: (m.content || "") + (p.delta || "") }));
writeRun((r) => ({
...r,
events: [...r.events, { type: "TEXT_MESSAGE_CONTENT", delta: p.delta, timestamp: Date.now() }],
}));
} else if (t === "REASONING_START" || t === "REASONING_MESSAGE_START") {
writeAssistant((m) => ({
...m,
reasoning: {
active: true,
id: p.messageId,
content: m.reasoning?.content || "",
},
}));
writeRun((r) => ({
...r,
events: [...r.events, {
type: t,
messageId: p.messageId,
role: p.role,
timestamp: Date.now(),
}],
}));
} else if (t === "REASONING_MESSAGE_CONTENT") {
writeAssistant((m) => ({
...m,
reasoning: {
active: true,
id: p.messageId,
content: (m.reasoning?.content || "") + (p.delta || ""),
},
}));
writeRun((r) => ({
...r,
events: [...r.events, {
type: "REASONING_MESSAGE_CONTENT",
messageId: p.messageId,
delta: p.delta || "",
timestamp: Date.now(),
}],
}));
} else if (t === "REASONING_MESSAGE_END" || t === "REASONING_END") {
writeAssistant((m) => ({
...m,
reasoning: {
active: false,
id: p.messageId,
content: m.reasoning?.content || "",
},
}));
writeRun((r) => ({
...r,
events: [...r.events, {
type: t,
messageId: p.messageId,
timestamp: Date.now(),
}],
}));
} else if (t === "skill.activated") {
writeAssistant((m) => ({
...m, activatedSkills: [...(m.activatedSkills || []), p.skill_name],
}));
} else if (t === "run.awaiting_input" && (p.response_route === "tool_approval" || p.interruption_type === "tool_approval")) {
writeAssistant((m) => ({ ...m, toolApproval: p }));
}
};
const handleData = (e) => {
let evt;
try { evt = JSON.parse(e.data); } catch { return; }
dispatchEvent(evt);
};
const finalize = async (knownRun = null) => {
if (streamSettled) return;
try {
const finalRun = knownRun || await window.api.getRun(activeRun.run_id);
if (finalRun.status === "queued" || finalRun.status === "running") {
scheduleReconnect();
return;
}
streamSettled = true;
clearReconnect();
closeCurrent();
const events = receivedEvents;
const totalMs = performance.now() - t0;
const finalEvent = events.find((e) => e.type === "text.delta");
const fallbackText = !finalEvent
? events.filter((e) => e.type === "text.delta").map((e) => e.payload.delta).join("")
: null;
writeRun((r) => ({
...r, runId: finalRun.run_id, running: false, totalMs,
workspace: finalRun.workspace || r.workspace || null,
events: [...r.events, {
type: "RUN_FINISHED", threadId: finalRun.thread_id,
runId: finalRun.run_id, timestamp: Date.now(),
output: { status: finalRun.status, error: finalRun.error },
}],
steps: [...r.steps, { kind: "final", label: `RUN_FINISHED · ${finalRun.status}`, durMs: 1 }],
output: {
runId: finalRun.run_id, threadId: finalRun.thread_id,
llmRounds, toolCalls, totalDurationMs: Math.round(totalMs),
finishReason: finalRun.status, inputTokens, outputTokens,
},
}));
writeAssistant((m) => ({
...m, streaming: false,
reasoning: m.reasoning ? { ...m.reasoning, active: false } : m.reasoning,
content: fallbackText || m.content || (finalRun.error ? `(run ${finalRun.status}: ${finalRun.error})` : ""),
status: finalRun.status, errorText: finalRun.error || null,
stats: { llmRounds, toolCalls, totalMs, tokensIn: inputTokens, tokensOut: outputTokens },
outputAssets: (finalRun.output_assets || []).filter((a) => a.filename !== "result.md"),
}));
} catch (err) {
writeRun((r) => ({ ...r, running: false, totalMs: performance.now() - t0 }));
writeAssistant((m) => ({
...m, streaming: false,
reasoning: m.reasoning ? { ...m.reasoning, active: false } : m.reasoning,
content: `(重新连接失败:${err.message || err})`,
}));
}
cleanup();
};
const KNOWN_EVENTS = [
"run.queued", "skill.activated", "skill.asset.prepared",
"session.created", "session.reused", "workspace.prepared", "workspace.files_synced", "workspace.sync_failed", "workspace.snapshot_saved",
"sandbox.prepared", "sandbox.released",
"llm.started", "llm.completed",
"model_io.started", "model_io.completed", "model_io.failed",
"ui.card", "asset.created", "run.cancelled",
"RUN_STARTED", "RUN_FINISHED", "RUN_ERROR",
"TEXT_MESSAGE_START", "TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_END",
"TOOL_CALL_START", "TOOL_CALL_ARGS", "TOOL_CALL_END", "TOOL_CALL_RESULT",
"REASONING_START", "REASONING_MESSAGE_START", "REASONING_MESSAGE_CONTENT",
"REASONING_MESSAGE_END", "REASONING_END",
"agent.skill_tool_required", "agent.max_rounds_hit", "agent.cancelled",
"run.awaiting_input", "run.resumed",
];
const scheduleReconnect = () => {
if (streamSettled || reconnectTimer !== null) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
openEventSource();
}, 1000);
};
const openEventSource = () => {
if (streamSettled) return;
closeCurrent();
try { es = new EventSource(url); }
catch (err) {
console.warn("playground: SSE reconnect failed", err);
scheduleReconnect();
return;
}
const current = es;
for (const t of KNOWN_EVENTS) current.addEventListener(t, handleData);
current.addEventListener("done", () => { finalize(); });
current.addEventListener("error", () => {
if (streamSettled || current !== es) return;
if (current.readyState === EventSource.CLOSED) {
closeCurrent();
finalize();
}
});
};
// Expose cancel handle so the Stop button works during reattachment
runHandleRef.current = {
promise: new Promise(() => {}), // never resolves until SSE closes
cancel: () => {
const totalMs = performance.now() - t0;
cleanup();
window.api.cancelRun(activeRun.run_id, "user_abort").catch(() => {});
finishCancelledUi(totalMs, { llmRounds, toolCalls, totalMs, tokensIn: inputTokens, tokensOut: outputTokens });
},
};
openEventSource();
}, []);
// Initial load
useEffect(() => {
(async () => {
try {
const skillsList = await api.listSkills({ scope: "mine", runnable: true });
window.SKILLS = skillsList;
const [agentsList, mcpsList, localToolsList, modelsResp, tb] = await Promise.all([
api.listAgents(),
api.listMcpServers(),
api.listLocalTools().catch(() => []),
api.listModels().catch(() => null), // 后端不可达时退回 bootstrap MODELS
api.getUserBindings().catch(() => ({ skill_ids: [], mcp_ids: [] })),
]);
// Mirror onto globals BEFORE we surface the data via state — child
// components consult these at render time.
window.MCPS = mcpsList;
window.LOCAL_TOOLS = localToolsList;
window.INITIAL_AGENTS = agentsList;
// LLM 窗口的权威来源是后端;覆盖 bootstrap MODELS,Playground 显示真实 context_window。
if (modelsResp && Array.isArray(modelsResp.items) && modelsResp.items.length) {
window.MODELS = modelsResp.items;
}
if (modelsResp && Array.isArray(modelsResp.providers) && modelsResp.providers.length) {
window.MODEL_PROVIDERS = modelsResp.providers;
}
setSkills(skillsList);
setMcps(mcpsList);
setLocalTools(localToolsList);
setAgents(agentsList);
setBindings(tb || { skill_ids: [], mcp_ids: [] });
const defaultAgentId = resolveDefaultAgentId(agentsList);
setActiveId(defaultAgentId);
setLoaded(true);
// Check for active runs to reattach after page refresh
try {
const activeRuns = await api.listActiveRuns();
if (activeRuns && activeRuns.length > 0) {
const match = activeRuns.find((r) => r.agent_id === defaultAgentId);
if (match) {
reattachToRun(match);
}
}
} catch (err) {
console.warn("playground: active run check failed", err);
}
} catch (err) {
console.error("playground: initial load failed", err);
setLoadError(err.message || String(err));
setLoaded(true);
}
})();
}, []);
// Whenever the React state for skills/mcps/agents changes, keep the
// window globals in sync so any component still reading them sees latest.
useEffect(() => { window.SKILLS = skills; }, [skills]);
useEffect(() => { window.MCPS = mcps; }, [mcps]);
useEffect(() => { window.LOCAL_TOOLS = localTools; }, [localTools]);
useEffect(() => { window.INITIAL_AGENTS = agents; }, [agents]);
const agent = useMemo(() => agents.find((a) => a.id === activeId), [agents, activeId]);
const threadModelOverride = threadModelOverrides[threadId] || {};
const modelAgent = useMemo(() => {
if (!agent) return agent;
return {
...agent,
modelProvider: threadModelOverride.provider ?? agent.modelProvider,
model: threadModelOverride.model_name ?? agent.model,
temperature: threadModelOverride.temperature ?? agent.temperature,
top_p: threadModelOverride.top_p ?? agent.top_p,
max_tokens: threadModelOverride.max_tokens ?? agent.max_tokens,
reasoning_enabled: threadModelOverride.reasoning_enabled ?? agent.reasoning_enabled,
reasoning_visible: threadModelOverride.reasoning_visible ?? agent.reasoning_visible,
};
}, [agent, threadModelOverride]);
const effectiveSkillItems = useMemo(() => {
if (!agent) return [];
const byKey = new Map();
const allSkillItems = [...(agent.skills || []), ...(skills || [])];
const referencedTemplateIds = new Set(
allSkillItems
.map((item) => item?.forked_from?.template_id)
.filter(Boolean)
);
const keyFor = (item) => {
const templateId = item?.forked_from?.template_id;
if (templateId) return `template:${templateId}`;
const id = item?.id || item?.skill_id;
// A personal skill can be the source template for an agent-owned fork
// while having no forked_from field itself. Canonicalize that source
// skill to the same template identity as its derived instance.
if (id && referencedTemplateIds.has(id)) return `template:${id}`;
return `name:${item?.name || id}`;
};
for (const item of agent.skills || []) {
const id = item?.id || item?.skill_id;
const key = keyFor(item);
if (!id || byKey.has(key)) continue;
byKey.set(key, { ...item, source: "agent" });
}
// User bindings are available to every Playground chat. A user-owned
// fork of the same template intentionally replaces the agent-owned one.
for (const item of skills || []) {
const id = item?.id || item?.skill_id;
if (!id) continue;
byKey.set(keyFor(item), { ...item, source: "user" });
}
return Array.from(byKey.values()).filter((item) => item.id || item.skill_id);
}, [agent, skills]);
// Chat sessions use the de-duplicated union of agent and user-bound skills.
// Only a real agent/thread switch restores defaults. Catalog or agent-config
// refreshes preserve explicit deselections and only remove selections that
// are no longer allowed.
useEffect(() => {
if (!agent) {
sessionToolScopeRef.current = null;
setSessionSkills([]);
setSessionMcps([]);
setSessionLocalTools([]);
return;
}
const scopeKey = `${activeId}:${threadId}`;
if (sessionToolScopeRef.current !== scopeKey) {
sessionToolScopeRef.current = scopeKey;
setSessionSkills(effectiveSkillItems.map((s) => s.id || s.skill_id));
setSessionMcps([...new Set(agent.enabledMCPs || [])]);
setSessionLocalTools([...new Set(agent.enabledLocalTools || agent.localToolNames || [])]);
return;
}
const allowedSkills = new Set(effectiveSkillItems.map((s) => s.id || s.skill_id));
const allowedMcps = new Set(agent.enabledMCPs || []);
const allowedLocalTools = new Set(agent.enabledLocalTools || agent.localToolNames || []);
setSessionSkills((selected) => selected.filter((id) => allowedSkills.has(id)));
setSessionMcps((selected) => selected.filter((id) => allowedMcps.has(id)));
setSessionLocalTools((selected) => selected.filter((id) => allowedLocalTools.has(id)));
}, [activeId, threadId, effectiveSkillItems, agent && agent.enabledMCPs, agent && agent.enabledLocalTools, agent && agent.localToolNames]);
// Session toggle handlers only narrow/restore tools already configured on
// the selected agent. They never add user/global tools into this chat.
const toggleSessionSkill = (id) => setSessionSkills((p) => {
const allowed = new Set(effectiveSkillItems.map((s) => s.id || s.skill_id));
if (!allowed.has(id)) return p;
return p.includes(id) ? p.filter((x) => x !== id) : [...p, id];
});
const toggleSessionMcp = (id) => {
setSessionMcps((p) => {
if (!(agent?.enabledMCPs || []).includes(id)) return p;
return p.includes(id) ? p.filter((x) => x !== id) : [...p, id];
});
};
const toggleSessionLocalTool = (id) => setSessionLocalTools((p) => {
const allowed = agent?.enabledLocalTools || agent?.localToolNames || [];
if (!allowed.includes(id)) return p;
return p.includes(id) ? p.filter((x) => x !== id) : [...p, id];
});
// ---- agent CRUD (Playground left panel) ----
const patchAgentLocal = (id, patch) => {
setAgents((prev) => prev.map((a) => {
if (a.id !== id) return a;
const next = { ...a, ...patch };
api.persistAgentUi(next);
return next;
}));
};
const updateAgent = async (patch) => {
if (!agent) return;
// Optimistic local update (covers UI-only fields like model/temperature).
patchAgentLocal(agent.id, patch);
// If the patch touches a backend-persisted field, send PATCH.
// MCP simplification(2026-05-26):`enabledMCPs` 现在也走 backend(`agent.enabled_mcp_servers`)。
const touchesBackend = "name" in patch || "system" in patch || "promptVariables" in patch || "maxAgentRounds" in patch || "enabledSkills" in patch || "enabledMCPs" in patch || "enabledLocalTools" in patch;
if (!touchesBackend) return;
try {
const merged = { ...agent, ...patch };
// Ensure any newly-enabled skills are actually installed for the
// tenant; install lazily on first use so the user doesn't have to.
await ensureSkillsInstalled(merged.enabledSkills || []);
const updated = await api.updateAgent(agent.id, {
name: merged.name,
system_prompt: merged.system || "",
prompt_variables: merged.promptVariables || [],
max_agent_rounds: merged.maxAgentRounds || 0,
skill_ids: merged.enabledSkills || [],
enabled_mcp_servers: merged.enabledMCPs || [],
local_tool_names: merged.enabledLocalTools || merged.localToolNames || [],
});
// Re-apply: backend wins on persisted fields, UI overrides on the rest.
setAgents((prev) => prev.map((a) => a.id === updated.id ? { ...a, ...updated } : a));
} catch (err) {
console.warn("updateAgent: backend save failed (kept local change)", err);
}
};
const updateThreadModelConfig = (patch) => {
if (!agent || !threadId) return;
const wireKeys = {
modelProvider: "provider",
model: "model_name",
temperature: "temperature",
top_p: "top_p",
max_tokens: "max_tokens",
reasoning_enabled: "reasoning_enabled",
reasoning_visible: "reasoning_visible",
};
const agentDefaults = {
provider: agent.modelProvider,
model_name: agent.model,
temperature: agent.temperature,
top_p: agent.top_p,
max_tokens: agent.max_tokens,
reasoning_enabled: agent.reasoning_enabled,
reasoning_visible: agent.reasoning_visible,
};
setThreadModelOverrides((previous) => {
const nextOverride = { ...(previous[threadId] || {}) };
for (const [uiKey, value] of Object.entries(patch)) {
const wireKey = wireKeys[uiKey];
if (!wireKey) continue;
if (Object.is(value, agentDefaults[wireKey])) delete nextOverride[wireKey];
else nextOverride[wireKey] = value;
}
const next = { ...previous };
if (Object.keys(nextOverride).length) next[threadId] = nextOverride;
else delete next[threadId];
return next;
});
};
const resetThreadModelConfig = () => {
setThreadModelOverrides((previous) => {
const next = { ...previous };
delete next[threadId];
return next;
});
};
// PR pg-new-agent-to-manage: route "+ New agent" to the Manage page
// instead of inline-creating an "Agent N" placeholder. Old behavior
// created an agent with a generic name + empty system prompt; user had
// to immediately rename + re-fill it from Manage anyway. Going straight
// to Manage with ?new=agent removes the half-step.
const addAgent = () => {
window.location.href = "/manage?new=agent";
};
const selectAgent = (id) => {
setThreadModelOverrides({});
setActiveId(id);
// Don't wipe messages here — the useEffect on activeId reloads the
// chosen agent's persisted thread + history. Just reset the in-memory
// run cards (those are live-only) so the right panel starts clean.
setRuns([]);
};
const loadRunDetail = async (runId) => {
if (!runId) return;
setRuns((prev) => prev.map((r) => (
r.runId === runId ? { ...r, detailLoading: true, detailError: null } : r
)));
try {
const events = await window.api.getTraceEvents(runId, { include_model_events: false });
const existing = runs.find((r) => r.runId === runId) || {};
const card = runCardFromEvents(runId, events, existing);
setRuns((prev) => prev.map((r) => (
r.runId === runId
? { ...r, ...card, detailLoaded: true, detailLoading: false, detailError: null }
: r
)));
} catch (err) {
const msg = err.message || String(err);
setRuns((prev) => prev.map((r) => (
r.runId === runId ? { ...r, detailLoading: false, detailError: msg } : r
)));
}
};
const loadRunModelIO = async (runId) => {
if (!runId) return;
setRuns((prev) => prev.map((r) => (
r.runId === runId ? { ...r, modelIOLoading: true, modelIOError: null } : r
)));
try {
const modelIO = await window.api.getTraceModelIO(runId);
setRuns((prev) => prev.map((r) => (
r.runId === runId
? { ...r, modelIO, modelIOLoaded: true, modelIOLoading: false, modelIOError: null }
: r
)));
} catch (err) {
const msg = err.message || String(err);
setRuns((prev) => prev.map((r) => (
r.runId === runId ? { ...r, modelIOLoading: false, modelIOError: msg } : r
)));
}
};
const activateThread = (nextThreadId) => {
if (!activeId) return false;
volatileThreadIdsRef.current[activeId] = nextThreadId;
try {
localStorage.setItem(threadKey(activeId), nextThreadId);
} catch (err) {
console.error("failed to persist thread id", err);
window.alert("浏览器存储不可用,Thread ID 仅在当前页面内有效。");
}
setThreadId(nextThreadId);
setRunContextByAgent((prev) => {
const next = { ...prev };
delete next[activeId];
return next;
});
setMessages([]);
setRuns([]);
return true;
};
const clearThread = () => {
// Rotate the persisted id and let the next message start a fresh thread.
activateThread(rid("thread"));
};
const changeThreadId = (rawThreadId) => {
if (!activeId) return threadId;
if (busy) {
window.alert("当前任务运行中,结束或停止后才能切换 Thread ID。");
return threadId;
}
const requested = String(rawThreadId || "").trim();
const nextThreadId = requested || rid("thread");
if (nextThreadId === threadId) return threadId;
return activateThread(nextThreadId) ? nextThreadId : threadId;
};
// Rehydrate whenever the selected agent or thread changes. A user-entered
// thread id is therefore a real context switch, not a cosmetic label.
useEffect(() => {
if (!activeId || !threadId) return;
const persistedThreadId = getOrCreateThread(activeId);
if (persistedThreadId !== threadId) {
setThreadId(persistedThreadId);
return;
}
let cancelled = false;
setHistoryLoading(true);
setHistoryError("");
(async () => {
try {
const [messagesResult, runsResult] = await Promise.allSettled([
window.api.listThreadMessages(threadId),
window.api.listThreadRuns(threadId, 100),
]);
if (cancelled) return;
const items = messagesResult.status === "fulfilled" ? messagesResult.value : [];
if (messagesResult.status === "rejected") {
console.warn("listThreadMessages failed", messagesResult.reason);
setHistoryError("历史消息加载失败,工作区文件仍可查看。");
}
const hydrated = await rehydrateMessages(items);
if (cancelled) return;
setMessages(hydrated);
const persistedRuns = runsResult.status === "fulfilled" ? runsResult.value : [];
let workspaceLoadError = null;
if (runsResult.status === "rejected") {
workspaceLoadError = runsResult.reason?.message || String(runsResult.reason);
console.warn("listThreadRuns failed", runsResult.reason);
setHistoryError((current) => current || "历史工作区加载失败,请重试。");
}
const hydratedRuns = await rehydrateRunsFromMessages(
hydrated, persistedRuns, workspaceLoadError
);
if (cancelled) return;
setRuns((prev) => {
const active = (prev || []).filter((r) => r.running);
const activeIds = new Set(active.map((r) => r.runId));
return [
...hydratedRuns.filter((r) => !activeIds.has(r.runId)),
...active,
];
});
} catch (err) {
if (!cancelled) {
console.warn("listThreadMessages failed", err);
setMessages([]);
setRuns((prev) => (prev || []).filter((r) => r.running));
}
} finally {
if (!cancelled) setHistoryLoading(false);
}
})();
return () => { cancelled = true; };
}, [activeId, threadId]);
// Install any selected skill IDs not yet installed for the tenant. Skips silently
// for skills with no `latest_version` (drafts that have never been
// published) and surfaces a clear error rather than the cryptic backend
// "skill version not found".
const ensureSkillsInstalled = async (skillIds) => {
const missing = [];
for (const skillId of skillIds) {
const sk = skills.find((s) => s.skill_id === skillId || s.id === skillId || s.name === skillId);
if (!sk) continue;
if (sk.installed_version) continue;
if (!sk.latest_version) {
missing.push(sk.name || skillId);
continue;
}
try {
await api.installSkill(sk.skill_id, sk.latest_version);
} catch (err) {
console.warn("install skill failed", skillId, err);
}
}
if (missing.length) {
throw new Error(`Skills missing published versions: ${missing.join(", ")}. Publish them in Manager first.`);
}
// Refresh the skills list so the UI shows updated installed_version.
const fresh = await api.listSkills({ scope: "mine" });
window.SKILLS = fresh;
setSkills(fresh);
};
const send = async (text, attachments = []) => {
if (busy || !agent) return;
setBusy(true);
// PR chat-uploads-workspace: `attachments` is a list of asset_ids from
// prior uploadAttachment calls (collected by Composer). Threaded straight
// through to startAgentRun → backend resolves + materializes into
// workspace/input/.
const userMsg = { id: rid("msg"), role: "user", content: text, time: nowHHMMSS(), attachments };
const asstMsg = {
id: rid("msg"), role: "assistant", content: "", time: nowHHMMSS(),
streaming: true, toolInvocations: [], stats: null,
reasoning: { active: false, content: "" },
};
setMessages((m) => [...m, userMsg, asstMsg]);
const t0 = performance.now();
const placeholder = {
runId: rid("run"), threadId,
pendingBackendRunId: true,
running: true, steps: [], events: [], totalMs: 0,
};
setRuns((r) => [...r, placeholder]);
// Per-run mutable state we mutate via callbacks. We replace the LAST
// run entry in setRuns each callback so React sees a new array.
let llmRounds = 0;
let toolCalls = 0;
let inputTokens = 0;
let outputTokens = 0;
const openLlmRounds = new Map(); // roundId → start perf time
const openToolCalls = new Map(); // toolCallId → {name, started}
// Tool-call ids we suppress from the chat (the runtime-internal write of
// output/result.md — plumbing, not an agent action). Tracked by id so the
// matching tool.completed is suppressed too; the agent's OWN write_file
// calls (WorkspaceToolset) are NOT suppressed and render normally.
const suppressedToolCalls = new Set();
// Tool invocations carry a `round` number so the chat card can show
// `round N` when the LLM goes through several tool-loop iterations.
// Parsed from each backend event's `llm_round_id` (e.g. "llm_2" → 2).
const roundOf = (rid) => {
const m = /llm_(\d+)/.exec(rid || "");
return m ? parseInt(m[1], 10) : null;
};
const writeRun = (mutator) => {
setRuns((prev) => {
const next = [...prev];
const idx = next.length - 1;
next[idx] = mutator(next[idx]);
return next;
});
};
const writeAssistant = (mutator) => {
setMessages((prev) => {
const next = [...prev];
const idx = next.length - 1;
next[idx] = mutator(next[idx]);
return next;
});
};
try {
const trimmedRunContext = trimRunContext(runContext);
const effectiveLocalToolNames = [...new Set([
...sessionLocalTools,
])];
// Send the exact session-selected subset. Non-strict resolution lets the
// backend resolve the selected user and agent skill union consistently.
runHandleRef.current = startAgentRun({
thread_id: threadId,
message: text,
agent_id: agent.id,
skill_ids: sessionSkills,
mcp_ids: sessionMcps,
local_tool_names: effectiveLocalToolNames,
restrict_to_agent_tools: false,
context: trimmedRunContext,
model_config: threadModelOverride,
attachments,
callbacks: {
onQueued: ({ run }) => {
localStorage.setItem("baizhi.active_run_id", run.run_id);
writeRun((r) => ({
...r,
runId: run.run_id,
pendingBackendRunId: false,
events: [...r.events, { type: "RUN_STARTED", threadId, runId: run.run_id, timestamp: Date.now(), metadata: { source: "playground", agentId: agent.id } }],
}));
},
onLlmStarted: ({ round_id }) => {
openLlmRounds.set(round_id, performance.now());
},
onLlmCompleted: ({ round_id, round_index, duration_ms, input_tokens, output_tokens, cost_usd, model }) => {
const started = openLlmRounds.get(round_id) ?? performance.now();
openLlmRounds.delete(round_id);
const dur = duration_ms || (performance.now() - started);
llmRounds += 1;
inputTokens += (input_tokens || 0);
outputTokens += (output_tokens || 0);
writeRun((r) => ({
...r,
steps: [...r.steps, llmTimelineStep(
{ round_index, duration_ms: dur, input_tokens, output_tokens, cost_usd, model },
{ model: model || agent.model },
)],
output: {
...(r.output || {}),
inputTokens,
outputTokens,
costUsd: (r.output?.costUsd ?? 0) + (numericUsageValue(cost_usd) ?? 0),
},
}));
},
onModelIOStarted: (call) => {
writeRun((r) => ({
...r,
modelIO: mergeModelIOCalls(r.modelIO, call),
}));
},
onModelIOCompleted: (call) => {
writeRun((r) => ({
...r,
modelIO: mergeModelIOCalls(r.modelIO, call),
}));
},
onModelIOFailed: (call) => {
writeRun((r) => ({
...r,
modelIO: mergeModelIOCalls(r.modelIO, call),
}));
},
onThinkingStarted: ({ thinking_id, llm_round_id }) => {
writeAssistant((m) => ({
...m,
reasoning: {
active: true,
id: thinking_id,
content: m.reasoning?.content || "",
},
}));
writeRun((r) => ({
...r,
events: [...r.events,
{
type: "REASONING_START",
messageId: thinking_id,
llmRoundId: llm_round_id,
timestamp: Date.now(),
},
{
type: "REASONING_MESSAGE_START",
messageId: thinking_id,
role: "assistant",
llmRoundId: llm_round_id,
timestamp: Date.now(),
},
],
}));
},
onThinkingDelta: ({ thinking_id, delta }) => {
writeAssistant((m) => ({
...m,
reasoning: {
active: true,
id: thinking_id,
content: (m.reasoning?.content || "") + (delta || ""),
},
}));
writeRun((r) => ({
...r,
events: [...r.events, {
type: "REASONING_MESSAGE_CONTENT",
messageId: thinking_id,
delta: delta || "",
timestamp: Date.now(),
}],
}));
},
onThinkingCompleted: ({ thinking_id, text }) => {
writeAssistant((m) => ({
...m,
reasoning: {
active: false,
id: thinking_id,
content: text || m.reasoning?.content || "",
},
}));
writeRun((r) => ({
...r,
events: [...r.events,
{
type: "REASONING_MESSAGE_END",
messageId: thinking_id,
text: text || "",
timestamp: Date.now(),
},
{
type: "REASONING_END",
messageId: thinking_id,
timestamp: Date.now(),
},
],
}));
},
onToolStarted: ({ tool_name, tool_call_id, arguments: args, llm_round_id }) => {
// Suppress ONLY the runtime-internal `write_file` that saves the
// final reply to output/result.md — it's plumbing, not an agent
// action (adk_backend emits it via execute_tool at "llm_final" with
// no arguments). The agent's own write_file calls (WorkspaceToolset,
// e.g. authoring a build script) carry a real llm_round_id + path
// and DO render.
if (tool_name === "write_file" && llm_round_id === "llm_final") {
suppressedToolCalls.add(tool_call_id);
return;
}
const round = roundOf(llm_round_id);
openToolCalls.set(tool_call_id, { name: tool_name, started: performance.now(), args, round });
writeRun((r) => ({
...r,
events: [...r.events,
{ type: "TOOL_CALL_START", toolCallId: tool_call_id, toolCallName: tool_name, timestamp: Date.now() },
{ type: "TOOL_CALL_ARGS", toolCallId: tool_call_id, toolCallName: tool_name, delta: JSON.stringify(args || {}), timestamp: Date.now() },
{ type: "TOOL_CALL_END", toolCallId: tool_call_id, timestamp: Date.now() },
],
}));
writeAssistant((m) => ({
...m,
toolInvocations: [...(m.toolInvocations || []), {
toolId: tool_name,
name: tool_name,
args: args || {},
durMs: 0,
preview: "running…",
running: true,
round,
}],
}));
},
onToolCompleted: ({ tool_name, tool_call_id, duration_ms, is_error, result_bytes, result_text }) => {
if (suppressedToolCalls.has(tool_call_id)) { // mirror onToolStarted filter (by id)
suppressedToolCalls.delete(tool_call_id);
return;
}
const open = openToolCalls.get(tool_call_id);
const dur = duration_ms || (open ? performance.now() - open.started : 0);
openToolCalls.delete(tool_call_id);
toolCalls += 1;
const preview = is_error
? `✗ error (${result_bytes || 0} B)`
: `✓ ${result_bytes || 0} B`;
writeRun((r) => ({
...r,
steps: [...r.steps, { kind: "tool", label: tool_name, durMs: dur }],
events: [...r.events, {
type: "TOOL_CALL_RESULT",
messageId: `tool_${tool_call_id}`,
toolCallId: tool_call_id,
role: "tool",
content: {
result: result_text || "",
is_error: !!is_error,
result_bytes: result_bytes || 0,
duration_ms: Math.round(dur),
},
timestamp: Date.now(),
}],
}));
writeAssistant((m) => {
const inv = [...(m.toolInvocations || [])];
for (let i = inv.length - 1; i >= 0; i--) {
if (inv[i].running && inv[i].name === tool_name) {
// pr-tool-error-visibility-ui: keep the full result_text on the
// invocation so chat-panel can render the actual error message
// (truncated server-side to 1024 chars). Without this the user
// saw "× error (66 B)" with no way to see WHAT the error said.
inv[i] = {
...inv[i], running: false, durMs: dur, preview,
isError: !!is_error,
resultText: result_text || null,
};
break;
}
}
return { ...m, toolInvocations: inv };
});
},
onTextDelta: ({ delta }) => {
writeAssistant((m) => ({ ...m, content: (m.content || "") + (delta || "") }));
writeRun((r) => ({
...r,
events: [...r.events, { type: "TEXT_MESSAGE_CONTENT", delta, timestamp: Date.now() }],
}));
},
onSkillActivated: ({ skill_name }) => {
// Surface in the chat footer so the user can see which skills
// the run actually loaded (especially useful when an agent has
// multiple skills enabled but only some matched the request).
writeAssistant((m) => ({
...m,
activatedSkills: [...(m.activatedSkills || []), skill_name],
}));
},
onAgentEvent: (evt) => {
if (["workspace.prepared", "workspace.files_synced", "workspace.sync_failed", "workspace.snapshot_saved"].includes(evt.type)) {
const p = evt.payload || {};
writeRun((r) => ({
...r,
workspace: {
...(r.workspace || {}),
...(evt.type === "workspace.prepared" ? p : evt.type === "workspace.files_synced" ? {
manifest_object_key: p.manifest_key,
manifest_revision: p.revision || 0,
workspace_sync_error: null,
} : evt.type === "workspace.sync_failed" ? {
workspace_sync_error: p.message || "workspace sync failed",
} : {
snapshot_object_key: p.object_key,
snapshot_asset_id: p.asset_id || null,
snapshot_filename: p.filename || "workspace-snapshot.tar.gz",
snapshot_size: p.size || 0,
snapshot_saved: !!p.saved,
snapshot_error: p.error || null,
}),
},
events: [...r.events, { type: evt.type, payload: p, timestamp: Date.now() }],
}));
}
// Inline alerts for the events that need user attention even
// before the run completes — currently just the round-cap hit.
if (evt.type === "agent.max_rounds_hit") {
writeAssistant((m) => ({
...m,
alerts: [...(m.alerts || []), {
level: "warn",
text: `LLM agent loop hit the ${evt.payload?.max_rounds || "?"}-round cap without a clean stop. Check SKILL.md instructions or raise MAX_AGENT_ROUNDS.`,
}],
}));
}
},
onCompleted: ({ run, events }) => {
localStorage.removeItem("baizhi.active_run_id");
const totalMs = performance.now() - t0;
// Replace assistant content with the final asset text if we
// never received text deltas (deterministic backend skips them).
const finalEvent = events.find((e) => e.type === "text.delta");
const fallbackText = !finalEvent
? events.filter((e) => e.type === "text.delta").map((e) => e.payload.delta).join("")
: null;
writeRun((r) => ({
...r,
runId: run.run_id,
running: false,
totalMs,
workspace: run.workspace || r.workspace || null,
events: [...r.events, { type: "RUN_FINISHED", threadId, runId: run.run_id, timestamp: Date.now(), output: { status: run.status, error: run.error } }],
steps: [...r.steps, { kind: "final", label: `RUN_FINISHED · ${run.status}`, durMs: 1 }],
output: {
runId: run.run_id,
threadId,
llmRounds, toolCalls,
totalDurationMs: Math.round(totalMs),
finishReason: run.status,
inputTokens, outputTokens,
costUsd: run.usage?.cost_usd ?? r.output?.costUsd ?? null,
},
}));
writeAssistant((m) => ({
...m,
streaming: false,
reasoning: m.reasoning ? { ...m.reasoning, active: false } : m.reasoning,
content: fallbackText || m.content || (run.error ? `(run ${run.status}: ${run.error})` : ""),
status: run.status, // "completed" | "failed" | "cancelled" | "timeout"
errorText: run.error || null,
stats: { llmRounds, toolCalls, totalMs, tokensIn: inputTokens, tokensOut: outputTokens },
// PR C: surface output assets (PPT / DOCX / etc.) so the chat
// can render download links. result.md is filtered out — its
// contents already live in `content` above.
outputAssets: (run.output_assets || []).filter((a) => a.filename !== "result.md"),
}));
},
onCancelled: () => {
const totalMs = performance.now() - t0;
finishCancelledUi(totalMs, { llmRounds, toolCalls, totalMs, tokensIn: inputTokens, tokensOut: outputTokens });
},
onError: (err) => {
if (isCancelledRunError(err)) {
const totalMs = performance.now() - t0;
finishCancelledUi(totalMs, { llmRounds, toolCalls, totalMs, tokensIn: inputTokens, tokensOut: outputTokens });
return;
}
localStorage.removeItem("baizhi.active_run_id");
writeRun((r) => ({ ...r, running: false, totalMs: performance.now() - t0 }));
writeAssistant((m) => ({
...m,
streaming: false,
reasoning: m.reasoning ? { ...m.reasoning, active: false } : m.reasoning,
content: `(运行失败:${err.message || err})`,
stats: { llmRounds, toolCalls, totalMs: performance.now() - t0, tokensIn: 0, tokensOut: 0 },
}));
},
// HITL pause: the agent called `ask_human` → the run saved its
// session and exited (docs/HIL-design.md). Render the inline form
// from `run.human_request` and stash the resume fn; the run stays
// paused (no thread held) until the human submits via the form.
onAwaitingInput: ({ run, respond }) => {
humanRespondRef.current = respond;
const hr = run.human_request || {};
const budgetInterrupted = run.status === "interrupted_context_budget";
const maxRoundsInterrupted = run.status === "interrupted_max_rounds";
writeRun((r) => ({ ...r, running: false, awaitingInput: true }));
writeAssistant((m) => ({
...m,
streaming: false,
awaitingInput: {
prompt: budgetInterrupted
? "上下文预算已用尽,进度已保存。继续后将从压缩检查点接着完成。"
: maxRoundsInterrupted
? "已达到本轮执行上限,进度已保存。继续后将从检查点接着完成。"
: (hr.prompt || "需要你补充信息后才能继续。"),
fields: maxRoundsInterrupted
? []
: (hr.fields && hr.fields.length)
? hr.fields
: [{ name: "reply", label: "回复", type: "text" }],
},
}));
},
onToolApprovalRequired: ({ payload, respond }) => {
toolApprovalRespondRef.current = respond;
writeAssistant((m) => ({
...m,
toolApproval: payload,
}));
},
},
});
await runHandleRef.current.promise.catch(() => {});
} catch (err) {
if (isCancelledRunError(err)) {
finishCancelledUi(performance.now() - t0);
return;
}
writeAssistant((m) => ({
...m,
streaming: false,
reasoning: m.reasoning ? { ...m.reasoning, active: false } : m.reasoning,
content: `(运行失败:${err.message || err})`,
}));
writeRun((r) => ({ ...r, running: false, totalMs: performance.now() - t0 }));
} finally {
runHandleRef.current = null;
setBusy(false);
}
};
const stop = () => {
localStorage.removeItem("baizhi.active_run_id");
const handle = runHandleRef.current;
handle?.cancel();
finishCancelledUi();
runHandleRef.current = null;
};
// HITL: the human filled the inline form → resume the paused run. Calling
// the stashed `respond` resolves the driver's segment-loop wait; the driver
// then POSTs /respond and re-attaches, so the SAME assistant message keeps
// streaming the resumed work (onTextDelta / onToolStarted / onCompleted).
// We clear the form + flip back to streaming so the UI reads as "working".
const submitHumanInput = (values) => {
const respond = humanRespondRef.current;
if (!respond) return;
humanRespondRef.current = null;
setMessages((prev) => {
const next = [...prev];
const idx = next.length - 1;
if (idx >= 0) next[idx] = { ...next[idx], awaitingInput: null, streaming: true };
return next;
});
setRuns((prev) => {
const next = [...prev];
const idx = next.length - 1;
if (idx >= 0) next[idx] = { ...next[idx], running: true, awaitingInput: false };
return next;
});
respond(values);
};
const submitToolApproval = async (decision, detail) => {
const respond = toolApprovalRespondRef.current;
if (!respond) return;
toolApprovalRespondRef.current = null;
setMessages((prev) => {
const next = [...prev];
const idx = next.length - 1;
if (idx >= 0 && next[idx].toolApproval) {
const anchorAt = (next[idx].toolInvocations || []).length;
next[idx] = {
...next[idx],
toolApproval: {
...next[idx].toolApproval,
submitted: true,
submittedDecision: decision,
submittedValues: detail?.modified_args || null,
submittedReason: detail?.rejection_reason || null,
},
toolApprovalAt: anchorAt,
};
}
return next;
});
try {
await respond(decision, detail || {});
} catch (err) {
toolApprovalRespondRef.current = respond;
setMessages((prev) => {
const next = [...prev];
const idx = next.length - 1;
if (idx >= 0) {
const restoredApproval = next[idx].toolApproval
? { ...next[idx].toolApproval }
: next[idx].toolApproval;
if (restoredApproval) {
delete restoredApproval.submitted;
delete restoredApproval.submittedDecision;
delete restoredApproval.submittedValues;
delete restoredApproval.submittedReason;
}
next[idx] = {
...next[idx],
toolApproval: restoredApproval,
toolApprovalAt: undefined,
alerts: [
...(next[idx].alerts || []),
{ level: "error", text: `工具审批失败:${err.message || err}` },
],
};
}
return next;
});
}
};
if (!loaded) {
return (
Loading…
);
}
if (loadError) {
return (
Failed to load playground
{loadError}
Check the runtime is up at /healthz and the tenant header (currently
{api.userId}) is acceptable.
);
}
if (!agent) {
return (
No agents yet
Create one to get started.
);
}
const currentWorkspace = [...runs].reverse().find((r) => r.workspace)?.workspace || null;
return (
);
}
const tabLink = {
background: "transparent", border: 0, padding: "6px 10px",
borderRadius: 6, color: "var(--text-muted)", fontSize: 13,
textDecoration: "none", cursor: "pointer",
};
ReactDOM.createRoot(document.getElementById("root")).render();