// 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 (
baizhi / Playground
Manage Evals Traces Marketplace Apps {isAdmin && Users} API Docs
); } 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();