MissionControl.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. "use client";
  2. import { useCallback, useEffect, useMemo, useRef, useState } from "react";
  3. import {
  4. ArrowDownToLine,
  5. ArrowUpFromLine,
  6. Braces,
  7. Check,
  8. ChevronRight,
  9. CircleDot,
  10. Database,
  11. FileCode2,
  12. LoaderCircle,
  13. RefreshCw,
  14. Search,
  15. X,
  16. } from "lucide-react";
  17. import { fetchExecution, fetchRuns, fetchTaskDetail } from "@/lib/api";
  18. import { buildTaskForest, statusLabel, taskKindLabel, type TaskBranch } from "@/lib/task-tree";
  19. import type { AttemptDetail, ExecutionView, PlannerTurn, RunSummary, TaskDetail, TaskNode } from "@/lib/types";
  20. type DetailTab = "lifecycle" | "attempt" | "data" | "code";
  21. function shortId(value: string | null) {
  22. return value ? `${value.slice(0, 8)}…` : "—";
  23. }
  24. function formatTime(value: string) {
  25. if (!value) return "—";
  26. const parsed = new Date(value);
  27. return Number.isNaN(parsed.valueOf()) ? value : parsed.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
  28. }
  29. function JsonBlock({ value }: { value: unknown }) {
  30. return <pre className="json-block">{JSON.stringify(value, null, 2)}</pre>;
  31. }
  32. function Empty({ children }: { children: React.ReactNode }) {
  33. return <p className="empty-copy">{children}</p>;
  34. }
  35. function StatusMark({ status }: { status: string }) {
  36. const complete = ["completed", "passed", "accept", "submitted", "succeeded"].includes(status);
  37. return <span className={`status-mark status-${status}`}>{complete ? <Check size={11} /> : <CircleDot size={10} />}{statusLabel(status)}</span>;
  38. }
  39. function Header({ runs, view, selectedId, onSelect, onRefresh, refreshing }: {
  40. runs: RunSummary[];
  41. view: ExecutionView;
  42. selectedId: number;
  43. onSelect: (id: number) => void;
  44. onRefresh: () => void;
  45. refreshing: boolean;
  46. }) {
  47. return <header className="mission-header">
  48. <div className="mission-kicker">
  49. <span>REAL EXECUTION OBSERVER</span>
  50. <span>TaskLedger revision {view.run.revision}</span>
  51. <span>每 15 秒只读刷新</span>
  52. </div>
  53. <div className="mission-title-row">
  54. <div>
  55. <p>创作总目标</p>
  56. <h1>{view.run.title}</h1>
  57. </div>
  58. <div className="run-actions">
  59. <label>
  60. <span>真实 Script Build</span>
  61. <select value={selectedId} onChange={(event) => onSelect(Number(event.target.value))}>
  62. {runs.map((run) => <option key={run.script_build_id} value={run.script_build_id}>#{run.script_build_id} · {run.task_count} Tasks</option>)}
  63. </select>
  64. </label>
  65. <button onClick={onRefresh} aria-label="刷新真实运行" title="刷新真实运行"><RefreshCw className={refreshing ? "spin" : ""} size={17} /></button>
  66. </div>
  67. </div>
  68. <div className="mission-state">
  69. <span><b>{view.run.checkpoint}</b></span>
  70. <span>当前缺口:{view.run.current_gap}</span>
  71. <span>{view.run.task_count} Tasks / {view.run.attempt_count} Attempts / {view.run.planner_turn_count} Planner moves</span>
  72. </div>
  73. </header>;
  74. }
  75. function PlannerAxis({ turns, selectedId, tasks, onSelect }: {
  76. turns: PlannerTurn[];
  77. selectedId: string | null;
  78. tasks: TaskNode[];
  79. onSelect: (id: string) => void;
  80. }) {
  81. const selectedRef = useRef<HTMLButtonElement | null>(null);
  82. const taskById = useMemo(() => new Map(tasks.map((task) => [task.task_id, task])), [tasks]);
  83. useEffect(() => {
  84. selectedRef.current?.scrollIntoView({ block: "nearest" });
  85. }, [selectedId]);
  86. return <section className="planner-panel" aria-label="全局 Planner 主轴">
  87. <div className="panel-heading">
  88. <div><span>01 / GLOBAL PLANNER</span><h2>全局 Planner 在树上移动焦点</h2></div>
  89. <small>选择一步,右侧回看当时已经长出的 Task</small>
  90. </div>
  91. <div className="planner-equation">
  92. <b>PLAN</b><i>→</i><b>EXECUTE</b><i>→</i><b>VALIDATE</b><i>→</i><b>DECIDE</b><i>↻</i>
  93. </div>
  94. <ol className="planner-turns">
  95. {turns.map((turn) => {
  96. const focus = turn.focus_task_id ? taskById.get(turn.focus_task_id) : null;
  97. const active = turn.turn_id === selectedId;
  98. return <li key={turn.turn_id}>
  99. <button ref={active ? selectedRef : undefined} className={active ? "active" : ""} onClick={() => onSelect(turn.turn_id)}>
  100. <span className="turn-index">{String(turn.order).padStart(2, "0")}</span>
  101. <span className="turn-copy">
  102. <small>{turn.phase.toUpperCase()} · {formatTime(turn.created_at)}</small>
  103. <strong>{turn.action_label}</strong>
  104. <em>{focus ? `${focus.display_path} ${taskKindLabel(focus.task_kind)}` : "创作总目标"}</em>
  105. </span>
  106. <ChevronRight size={14} />
  107. </button>
  108. </li>;
  109. })}
  110. </ol>
  111. </section>;
  112. }
  113. function TaskRecord({ task, selected, focused, changed, onSelect }: {
  114. task: TaskNode;
  115. selected: boolean;
  116. focused: boolean;
  117. changed: boolean;
  118. onSelect: (task: TaskNode) => void;
  119. }) {
  120. return <button
  121. className={`task-record ${task.task_kind === "root" ? "root-task" : ""} ${selected ? "selected" : ""} ${focused ? "focused" : ""} ${changed ? "changed" : ""}`}
  122. onClick={() => onSelect(task)}
  123. >
  124. <span className="task-path">{task.display_path}</span>
  125. <span className="task-body">
  126. <small>{taskKindLabel(task.task_kind)}</small>
  127. <strong>{task.objective}</strong>
  128. <em>{task.attempt_count ? `${task.attempt_count} Attempt` : "尚未执行"}{task.validation_count ? ` · ${task.validation_count} Validation` : ""}</em>
  129. </span>
  130. <StatusMark status={task.status} />
  131. </button>;
  132. }
  133. function TaskBranchView({ branch, selectedTaskId, focusedId, changedIds, onSelect }: {
  134. branch: TaskBranch;
  135. selectedTaskId: string | null;
  136. focusedId: string | null;
  137. changedIds: Set<string>;
  138. onSelect: (task: TaskNode) => void;
  139. }) {
  140. return <li className="task-branch">
  141. <TaskRecord
  142. task={branch.task}
  143. selected={selectedTaskId === branch.task.task_id}
  144. focused={focusedId === branch.task.task_id}
  145. changed={changedIds.has(branch.task.task_id)}
  146. onSelect={onSelect}
  147. />
  148. {branch.children.length ? <ol className="task-children">{branch.children.map((child) => <TaskBranchView
  149. key={child.task.task_id}
  150. branch={child}
  151. selectedTaskId={selectedTaskId}
  152. focusedId={focusedId}
  153. changedIds={changedIds}
  154. onSelect={onSelect}
  155. />)}</ol> : null}
  156. </li>;
  157. }
  158. function PlannerTurnNote({ turn, taskById }: { turn: PlannerTurn; taskById: Map<string, TaskNode> }) {
  159. const focus = turn.focus_task_id ? taskById.get(turn.focus_task_id) : null;
  160. return <article className="turn-note">
  161. <div><span>Planner move {turn.order}</span><StatusMark status={turn.status} /></div>
  162. <h3>{turn.action_label}{focus ? `:${focus.display_path} ${taskKindLabel(focus.task_kind)}` : ""}</h3>
  163. <p>{turn.summary}</p>
  164. <details>
  165. <summary>查看这一步的可观察说明与工具输入</summary>
  166. <h4>模型主动输出的说明(不是隐藏思维链)</h4>
  167. <pre>{turn.observable_reasoning || "本步没有输出文字说明。"}</pre>
  168. <h4>{turn.tool_name} arguments</h4>
  169. <JsonBlock value={turn.tool_arguments} />
  170. <h4>工具结果摘要</h4>
  171. <pre>{turn.result_summary}</pre>
  172. </details>
  173. </article>;
  174. }
  175. function TaskTreePanel({ view, turn, query, onQuery, selectedTaskId, onSelect }: {
  176. view: ExecutionView;
  177. turn: PlannerTurn | null;
  178. query: string;
  179. onQuery: (value: string) => void;
  180. selectedTaskId: string | null;
  181. onSelect: (task: TaskNode) => void;
  182. }) {
  183. const allVisible = useMemo(() => new Set(turn?.visible_task_ids ?? view.tasks.map((task) => task.task_id)), [turn, view.tasks]);
  184. const matching = useMemo(() => {
  185. if (!query.trim()) return allVisible;
  186. const needle = query.trim().toLowerCase();
  187. const matches = new Set<string>();
  188. const byId = new Map(view.tasks.map((task) => [task.task_id, task]));
  189. for (const task of view.tasks) {
  190. if (!allVisible.has(task.task_id)) continue;
  191. if (`${task.display_path} ${task.task_kind} ${task.objective}`.toLowerCase().includes(needle)) {
  192. let current: TaskNode | undefined = task;
  193. while (current) {
  194. matches.add(current.task_id);
  195. current = current.parent_task_id ? byId.get(current.parent_task_id) : undefined;
  196. }
  197. }
  198. }
  199. return matches;
  200. }, [allVisible, query, view.tasks]);
  201. const forest = useMemo(() => buildTaskForest(view.tasks, matching), [matching, view.tasks]);
  202. const changedIds = useMemo(() => new Set(turn?.affected_task_ids ?? []), [turn]);
  203. const taskById = useMemo(() => new Map(view.tasks.map((task) => [task.task_id, task])), [view.tasks]);
  204. return <section className="tree-panel" aria-label="动态 Task 树">
  205. <div className="panel-heading tree-heading">
  206. <div><span>02 / DYNAMIC TASK TREE</span><h2>围绕创作总目标动态生长</h2></div>
  207. <label className="tree-search"><Search size={15} /><input value={query} onChange={(event) => onQuery(event.target.value)} placeholder="搜索 Task…" />{query ? <button onClick={() => onQuery("")}><X size={13} /></button> : null}</label>
  208. </div>
  209. {turn ? <PlannerTurnNote turn={turn} taskById={taskById} /> : null}
  210. <div className="tree-key"><span><i className="key-focus" />Planner 当前焦点</span><span><i className="key-change" />本步涉及</span><span><i />当前 ledger 状态</span></div>
  211. <div className="tree-scroll">
  212. {forest.length ? <ol className="task-tree">{forest.map((branch) => <TaskBranchView
  213. key={branch.task.task_id}
  214. branch={branch}
  215. selectedTaskId={selectedTaskId}
  216. focusedId={turn?.focus_task_id ?? null}
  217. changedIds={changedIds}
  218. onSelect={onSelect}
  219. />)}</ol> : <Empty>没有符合条件的 Task。</Empty>}
  220. </div>
  221. </section>;
  222. }
  223. function LifecycleView({ detail }: { detail: TaskDetail }) {
  224. return <div className="detail-section">
  225. <div className="loop-line"><b>Planner</b><i>创建 / 决定</i><b>Worker</b><i>提交</i><b>Validator</b><i>验收</i><b>Planner</b><i>↻</i></div>
  226. <ol className="lifecycle-list">{detail.lifecycle.map((event) => <li key={event.event_id}>
  227. <span className={`event-glyph event-${event.kind}`} />
  228. <div><small>{formatTime(event.created_at)} · {event.kind}</small><strong>{event.title}</strong><p>{event.summary || "无补充说明"}</p></div>
  229. <StatusMark status={event.status} />
  230. </li>)}</ol>
  231. </div>;
  232. }
  233. function AttemptLoop({ attempt }: { attempt: AttemptDetail | null }) {
  234. if (!attempt) return <Empty>这个 Task 还没有 Attempt。</Empty>;
  235. return <div className="detail-section">
  236. <div className="attempt-meta"><span>{attempt.worker_preset}</span><span>{attempt.total_tokens.toLocaleString()} tokens</span><span>¥/cost {attempt.total_cost.toFixed(4)}</span><StatusMark status={attempt.status} /></div>
  237. <ol className="worker-loop">{attempt.steps.map((step, index) => <li key={`${step.sequence}-${step.step_kind}-${index}`} className={`step-${step.step_kind}`}>
  238. <span>{String(index + 1).padStart(2, "0")}</span>
  239. <div><small>{step.role} · seq {step.sequence}</small><strong>{step.title}</strong>{step.text ? <pre>{step.text}</pre> : null}{step.tool_arguments ? <JsonBlock value={step.tool_arguments} /> : null}</div>
  240. </li>)}</ol>
  241. </div>;
  242. }
  243. function DataView({ detail }: { detail: TaskDetail }) {
  244. const groups = [
  245. { icon: ArrowDownToLine, title: "进入 Task 的数据", note: "在冻结输入闭包中,不代表 Worker 一定逐项读取", values: detail.data_usage.inherited_inputs },
  246. { icon: Database, title: "Worker 确认读取", note: "来自真实 worker_trace 的 read/get/search/query 工具调用", values: detail.data_usage.confirmed_reads },
  247. { icon: ArrowUpFromLine, title: "Task 产生的数据", note: "来自 Attempt submission 的 Artifact 引用", values: detail.data_usage.produced_outputs },
  248. { icon: Check, title: "采用与回退", note: "来自 PlannerDecision 持久化记录", values: detail.data_usage.adoption },
  249. ];
  250. return <div className="data-groups">{groups.map((group) => <section key={group.title}>
  251. <header><group.icon size={16} /><div><h3>{group.title}</h3><p>{group.note}</p></div><b>{group.values.length}</b></header>
  252. {group.values.length ? group.values.map((value, index) => <JsonBlock key={index} value={value} />) : <Empty>没有对应记录。</Empty>}
  253. </section>)}</div>;
  254. }
  255. function CodePromptView({ detail, attempt }: { detail: TaskDetail; attempt: AttemptDetail | null }) {
  256. const explanations = attempt?.steps.filter((step) => step.step_kind === "explanation") ?? [];
  257. return <div className="detail-section code-view">
  258. <section><h3><Braces size={16} /> Process / Contract</h3><p>Task 的执行边界与输入输出约束。</p>{detail.contract ? <JsonBlock value={detail.contract} /> : <Empty>Root Task 没有独立 ScriptTaskContract。</Empty>}</section>
  259. <section><h3><FileCode2 size={16} /> Prompt</h3><p>当前 Attempt 实际持久化的 Worker system prompt。</p><pre>{attempt?.prompt || "尚无 Worker Prompt。"}</pre></section>
  260. <section><h3><CircleDot size={16} /> Observable reasoning</h3><p>只展示模型主动输出文字、工具动作和验收理由;不把它命名为隐藏思维链。</p>{explanations.length ? explanations.map((step, index) => <pre key={index}>{step.text}</pre>) : <Empty>这个 Attempt 没有主动输出说明文字。</Empty>}</section>
  261. </div>;
  262. }
  263. function TaskInspector({ detail, loading, tab, onTab, attemptId, onAttempt, onClose }: {
  264. detail: TaskDetail | null;
  265. loading: boolean;
  266. tab: DetailTab;
  267. onTab: (tab: DetailTab) => void;
  268. attemptId: string | null;
  269. onAttempt: (id: string) => void;
  270. onClose: () => void;
  271. }) {
  272. if (loading || !detail) return <aside className="task-inspector"><div className="drawer-loading"><LoaderCircle className="spin" /><span>读取真实 Task Trace…</span><button onClick={onClose}><X /></button></div></aside>;
  273. const attempt = detail.attempts.find((item) => item.attempt_id === attemptId) ?? detail.attempts.at(-1) ?? null;
  274. return <aside className="task-inspector">
  275. <header className="drawer-header">
  276. <div><span>{detail.task.display_path} / {taskKindLabel(detail.task.task_kind)}</span><h2>{detail.task.objective}</h2></div>
  277. <button onClick={onClose} aria-label="关闭 Task 详情"><X size={18} /></button>
  278. </header>
  279. <div className="drawer-facts"><StatusMark status={detail.task.status} /><code>{shortId(detail.task.task_id)}</code><span>返回给 {shortId(detail.task.parent_task_id)}</span></div>
  280. {detail.attempts.length ? <div className="attempt-switch"><span>Attempt</span>{detail.attempts.map((item, index) => <button key={item.attempt_id} className={item.attempt_id === attempt?.attempt_id ? "active" : ""} onClick={() => onAttempt(item.attempt_id)}>{index + 1} · {statusLabel(item.status)}</button>)}</div> : null}
  281. <nav className="drawer-tabs">
  282. {([ ["lifecycle", "Task 生命周期"], ["attempt", "Worker Loop"], ["data", "Data 使用"], ["code", "Code / Prompt"] ] as Array<[DetailTab, string]>).map(([id, label]) => <button key={id} className={tab === id ? "active" : ""} onClick={() => onTab(id)}>{label}</button>)}
  283. </nav>
  284. <div className="drawer-scroll">
  285. {tab === "lifecycle" ? <LifecycleView detail={detail} /> : null}
  286. {tab === "attempt" ? <AttemptLoop attempt={attempt} /> : null}
  287. {tab === "data" ? <DataView detail={detail} /> : null}
  288. {tab === "code" ? <CodePromptView detail={detail} attempt={attempt} /> : null}
  289. </div>
  290. </aside>;
  291. }
  292. function LoadingState() {
  293. return <main className="center-state"><LoaderCircle className="spin" /><h1>正在读取真实 E2E 持久化数据</h1><p>TaskLedger · Planner Trace · Worker Attempt</p></main>;
  294. }
  295. function ErrorState({ message, retry }: { message: string; retry: () => void }) {
  296. return <main className="center-state"><h1>真实运行数据暂时不可读</h1><p>{message}</p><button onClick={retry}><RefreshCw size={15} />重试</button></main>;
  297. }
  298. export function MissionControl() {
  299. const [runs, setRuns] = useState<RunSummary[]>([]);
  300. const [selectedRunId, setSelectedRunId] = useState(900031);
  301. const [view, setView] = useState<ExecutionView | null>(null);
  302. const [selectedTurnId, setSelectedTurnId] = useState<string | null>(null);
  303. const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
  304. const [detail, setDetail] = useState<TaskDetail | null>(null);
  305. const [detailLoading, setDetailLoading] = useState(false);
  306. const [detailTab, setDetailTab] = useState<DetailTab>("lifecycle");
  307. const [attemptId, setAttemptId] = useState<string | null>(null);
  308. const [query, setQuery] = useState("");
  309. const [loading, setLoading] = useState(true);
  310. const [refreshing, setRefreshing] = useState(false);
  311. const [error, setError] = useState<string | null>(null);
  312. const loadExecution = useCallback(async (runId: number, quiet = false) => {
  313. if (!quiet) setLoading(true);
  314. else setRefreshing(true);
  315. try {
  316. const execution = await fetchExecution(runId);
  317. setView(execution);
  318. setSelectedTurnId((current) => current && execution.planner_turns.some((turn) => turn.turn_id === current) ? current : execution.planner_turns.at(-1)?.turn_id ?? null);
  319. setError(null);
  320. } catch (reason) {
  321. setError(reason instanceof Error ? reason.message : String(reason));
  322. } finally {
  323. setLoading(false);
  324. setRefreshing(false);
  325. }
  326. }, []);
  327. useEffect(() => {
  328. const controller = new AbortController();
  329. fetchRuns(controller.signal).then((available) => {
  330. setRuns(available);
  331. const preferred = available.find((run) => run.script_build_id === 900031) ?? available[0];
  332. if (!preferred) throw new Error("没有发现真实持久化 Run");
  333. setSelectedRunId(preferred.script_build_id);
  334. return loadExecution(preferred.script_build_id);
  335. }).catch((reason) => {
  336. if (reason instanceof DOMException && reason.name === "AbortError") return;
  337. setError(reason instanceof Error ? reason.message : String(reason));
  338. setLoading(false);
  339. });
  340. return () => controller.abort();
  341. }, [loadExecution]);
  342. useEffect(() => {
  343. const timer = window.setInterval(() => void loadExecution(selectedRunId, true), 15_000);
  344. return () => window.clearInterval(timer);
  345. }, [loadExecution, selectedRunId]);
  346. useEffect(() => {
  347. if (!selectedTaskId) {
  348. setDetail(null);
  349. return;
  350. }
  351. const controller = new AbortController();
  352. setDetailLoading(true);
  353. fetchTaskDetail(selectedRunId, selectedTaskId, controller.signal).then((value) => {
  354. setDetail(value);
  355. setAttemptId(value.attempts.at(-1)?.attempt_id ?? null);
  356. }).catch((reason) => {
  357. if (!(reason instanceof DOMException && reason.name === "AbortError")) setError(reason instanceof Error ? reason.message : String(reason));
  358. }).finally(() => setDetailLoading(false));
  359. return () => controller.abort();
  360. }, [selectedRunId, selectedTaskId]);
  361. const changeRun = (id: number) => {
  362. setSelectedRunId(id);
  363. setSelectedTaskId(null);
  364. setDetail(null);
  365. setSelectedTurnId(null);
  366. void loadExecution(id);
  367. };
  368. const selectedTurn = view?.planner_turns.find((turn) => turn.turn_id === selectedTurnId) ?? null;
  369. if (loading && !view) return <LoadingState />;
  370. if (error && !view) return <ErrorState message={error} retry={() => void loadExecution(selectedRunId)} />;
  371. if (!view) return null;
  372. return <main className={`execution-observer ${selectedTaskId ? "with-drawer" : ""}`}>
  373. <Header runs={runs} view={view} selectedId={selectedRunId} onSelect={changeRun} onRefresh={() => void loadExecution(selectedRunId, true)} refreshing={refreshing} />
  374. <div className="observer-grid">
  375. <PlannerAxis turns={view.planner_turns} selectedId={selectedTurnId} tasks={view.tasks} onSelect={setSelectedTurnId} />
  376. <TaskTreePanel view={view} turn={selectedTurn} query={query} onQuery={setQuery} selectedTaskId={selectedTaskId} onSelect={(task) => { setSelectedTaskId(task.task_id); setDetailTab("lifecycle"); }} />
  377. </div>
  378. <footer className="observer-foot"><span>{view.snapshot_basis}</span><span>root trace {shortId(view.run.root_trace_id)}</span><span>真实数据 · 只读</span></footer>
  379. {selectedTaskId ? <TaskInspector detail={detail} loading={detailLoading} tab={detailTab} onTab={setDetailTab} attemptId={attemptId} onAttempt={setAttemptId} onClose={() => setSelectedTaskId(null)} /> : null}
  380. </main>;
  381. }