|
@@ -1,130 +1,407 @@
|
|
|
"use client";
|
|
"use client";
|
|
|
|
|
|
|
|
-import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
|
|
|
|
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
import {
|
|
import {
|
|
|
- Background, BackgroundVariant, Controls, Panel, ReactFlow, ReactFlowProvider,
|
|
|
|
|
- useReactFlow, type Node,
|
|
|
|
|
-} from "@xyflow/react";
|
|
|
|
|
-import {
|
|
|
|
|
- AlertTriangle, CheckCircle2, Focus, GitBranch, Layers3, LoaderCircle,
|
|
|
|
|
- RefreshCw, Search, Sparkles, Workflow, X,
|
|
|
|
|
|
|
+ ArrowDownToLine,
|
|
|
|
|
+ ArrowUpFromLine,
|
|
|
|
|
+ Braces,
|
|
|
|
|
+ Check,
|
|
|
|
|
+ ChevronRight,
|
|
|
|
|
+ CircleDot,
|
|
|
|
|
+ Database,
|
|
|
|
|
+ FileCode2,
|
|
|
|
|
+ LoaderCircle,
|
|
|
|
|
+ RefreshCw,
|
|
|
|
|
+ Search,
|
|
|
|
|
+ X,
|
|
|
} from "lucide-react";
|
|
} from "lucide-react";
|
|
|
-import { FlowCard } from "@/components/FlowCard";
|
|
|
|
|
-import { Inspector } from "@/components/Inspector";
|
|
|
|
|
-import { PhaseFrameNode } from "@/components/PhaseFrameNode";
|
|
|
|
|
-import { fetchGraph, fetchRuns } from "@/lib/api";
|
|
|
|
|
-import { buildCanvas, PHASE_LABELS, type CanvasData } from "@/lib/flow";
|
|
|
|
|
-import type { FlowNodeRecord, Phase, RunGraph, RunSummary } from "@/lib/types";
|
|
|
|
|
|
|
+import { fetchExecution, fetchRuns, fetchTaskDetail } from "@/lib/api";
|
|
|
|
|
+import { buildTaskForest, statusLabel, taskKindLabel, type TaskBranch } from "@/lib/task-tree";
|
|
|
|
|
+import type { AttemptDetail, ExecutionView, PlannerTurn, RunSummary, TaskDetail, TaskNode } from "@/lib/types";
|
|
|
|
|
|
|
|
-const nodeTypes = { flowCard: FlowCard, phaseFrame: PhaseFrameNode };
|
|
|
|
|
-const ALL_PHASES: Phase[] = ["phase-1", "phase-2", "phase-3", "delivery"];
|
|
|
|
|
|
|
+type DetailTab = "lifecycle" | "attempt" | "data" | "code";
|
|
|
|
|
|
|
|
-function LoadingState() {
|
|
|
|
|
- return <main className="centerState"><LoaderCircle className="spin" /><h1>正在装载 fake mission graph</h1><p>连接本地 FastAPI :8788</p></main>;
|
|
|
|
|
|
|
+function shortId(value: string | null) {
|
|
|
|
|
+ return value ? `${value.slice(0, 8)}…` : "—";
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function ErrorState({ message, retry }: { message: string; retry: () => void }) {
|
|
|
|
|
- return <main className="centerState error"><AlertTriangle /><h1>可视化后端未就绪</h1><p>{message}</p><button onClick={retry}><RefreshCw size={16} />重新连接</button></main>;
|
|
|
|
|
|
|
+function formatTime(value: string) {
|
|
|
|
|
+ if (!value) return "—";
|
|
|
|
|
+ const parsed = new Date(value);
|
|
|
|
|
+ return Number.isNaN(parsed.valueOf()) ? value : parsed.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function JsonBlock({ value }: { value: unknown }) {
|
|
|
|
|
+ return <pre className="json-block">{JSON.stringify(value, null, 2)}</pre>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function Empty({ children }: { children: React.ReactNode }) {
|
|
|
|
|
+ return <p className="empty-copy">{children}</p>;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function Canvas({ graph, selected, onSelect }: {
|
|
|
|
|
- graph: RunGraph;
|
|
|
|
|
- selected: FlowNodeRecord | null;
|
|
|
|
|
- onSelect: (node: FlowNodeRecord | null) => void;
|
|
|
|
|
|
|
+function StatusMark({ status }: { status: string }) {
|
|
|
|
|
+ const complete = ["completed", "passed", "accept", "submitted", "succeeded"].includes(status);
|
|
|
|
|
+ return <span className={`status-mark status-${status}`}>{complete ? <Check size={11} /> : <CircleDot size={10} />}{statusLabel(status)}</span>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function Header({ runs, view, selectedId, onSelect, onRefresh, refreshing }: {
|
|
|
|
|
+ runs: RunSummary[];
|
|
|
|
|
+ view: ExecutionView;
|
|
|
|
|
+ selectedId: number;
|
|
|
|
|
+ onSelect: (id: number) => void;
|
|
|
|
|
+ onRefresh: () => void;
|
|
|
|
|
+ refreshing: boolean;
|
|
|
}) {
|
|
}) {
|
|
|
- const [query, setQuery] = useState("");
|
|
|
|
|
- const [phases, setPhases] = useState<Set<Phase>>(new Set(ALL_PHASES));
|
|
|
|
|
- const { fitView } = useReactFlow();
|
|
|
|
|
- const canvas = useMemo(() => buildCanvas(graph, phases, query), [graph, phases, query]);
|
|
|
|
|
- const togglePhase = (phase: Phase) => setPhases((current) => {
|
|
|
|
|
- const next = new Set(current);
|
|
|
|
|
- if (next.has(phase)) next.delete(phase); else next.add(phase);
|
|
|
|
|
- return next.size ? next : new Set([phase]);
|
|
|
|
|
- });
|
|
|
|
|
- const selectNode = useCallback((_: React.MouseEvent, node: Node<CanvasData>) => {
|
|
|
|
|
- if (node.data.kind === "card") onSelect(node.data.item);
|
|
|
|
|
- }, [onSelect]);
|
|
|
|
|
- const matched = graph.nodes.filter((item) => {
|
|
|
|
|
- const node = canvas.nodes.find((candidate) => candidate.id === item.id);
|
|
|
|
|
- return node?.data.kind === "card" && !node.data.dimmed && !node.hidden;
|
|
|
|
|
- }).length;
|
|
|
|
|
- return <>
|
|
|
|
|
- <header className={`topShell ${selected ? "withInspector" : ""}`}>
|
|
|
|
|
- <div className="brand"><span><Workflow size={22} /></span><div><strong>Mission Control</strong><small>智能创作构建系统</small></div></div>
|
|
|
|
|
- <div className="runTitle"><span>BUILD #{graph.run.script_build_id}</span><strong>{graph.run.title}</strong></div>
|
|
|
|
|
- <div className="topStats">
|
|
|
|
|
- <span className="fakeBadge"><Sparkles size={13} /> FAKE DATA</span>
|
|
|
|
|
- <span><CheckCircle2 size={14} /> {graph.run.status}</span>
|
|
|
|
|
- <span><GitBranch size={14} /> {graph.run.node_count} nodes</span>
|
|
|
|
|
|
|
+ return <header className="mission-header">
|
|
|
|
|
+ <div className="mission-kicker">
|
|
|
|
|
+ <span>REAL EXECUTION OBSERVER</span>
|
|
|
|
|
+ <span>TaskLedger revision {view.run.revision}</span>
|
|
|
|
|
+ <span>每 15 秒只读刷新</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="mission-title-row">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p>创作总目标</p>
|
|
|
|
|
+ <h1>{view.run.title}</h1>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <div className="run-actions">
|
|
|
|
|
+ <label>
|
|
|
|
|
+ <span>真实 Script Build</span>
|
|
|
|
|
+ <select value={selectedId} onChange={(event) => onSelect(Number(event.target.value))}>
|
|
|
|
|
+ {runs.map((run) => <option key={run.script_build_id} value={run.script_build_id}>#{run.script_build_id} · {run.task_count} Tasks</option>)}
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <button onClick={onRefresh} aria-label="刷新真实运行" title="刷新真实运行"><RefreshCw className={refreshing ? "spin" : ""} size={17} /></button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="mission-state">
|
|
|
|
|
+ <span><b>{view.run.checkpoint}</b></span>
|
|
|
|
|
+ <span>当前缺口:{view.run.current_gap}</span>
|
|
|
|
|
+ <span>{view.run.task_count} Tasks / {view.run.attempt_count} Attempts / {view.run.planner_turn_count} Planner moves</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </header>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function PlannerAxis({ turns, selectedId, tasks, onSelect }: {
|
|
|
|
|
+ turns: PlannerTurn[];
|
|
|
|
|
+ selectedId: string | null;
|
|
|
|
|
+ tasks: TaskNode[];
|
|
|
|
|
+ onSelect: (id: string) => void;
|
|
|
|
|
+}) {
|
|
|
|
|
+ const selectedRef = useRef<HTMLButtonElement | null>(null);
|
|
|
|
|
+ const taskById = useMemo(() => new Map(tasks.map((task) => [task.task_id, task])), [tasks]);
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ selectedRef.current?.scrollIntoView({ block: "nearest" });
|
|
|
|
|
+ }, [selectedId]);
|
|
|
|
|
+ return <section className="planner-panel" aria-label="全局 Planner 主轴">
|
|
|
|
|
+ <div className="panel-heading">
|
|
|
|
|
+ <div><span>01 / GLOBAL PLANNER</span><h2>全局 Planner 在树上移动焦点</h2></div>
|
|
|
|
|
+ <small>选择一步,右侧回看当时已经长出的 Task</small>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="planner-equation">
|
|
|
|
|
+ <b>PLAN</b><i>→</i><b>EXECUTE</b><i>→</i><b>VALIDATE</b><i>→</i><b>DECIDE</b><i>↻</i>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <ol className="planner-turns">
|
|
|
|
|
+ {turns.map((turn) => {
|
|
|
|
|
+ const focus = turn.focus_task_id ? taskById.get(turn.focus_task_id) : null;
|
|
|
|
|
+ const active = turn.turn_id === selectedId;
|
|
|
|
|
+ return <li key={turn.turn_id}>
|
|
|
|
|
+ <button ref={active ? selectedRef : undefined} className={active ? "active" : ""} onClick={() => onSelect(turn.turn_id)}>
|
|
|
|
|
+ <span className="turn-index">{String(turn.order).padStart(2, "0")}</span>
|
|
|
|
|
+ <span className="turn-copy">
|
|
|
|
|
+ <small>{turn.phase.toUpperCase()} · {formatTime(turn.created_at)}</small>
|
|
|
|
|
+ <strong>{turn.action_label}</strong>
|
|
|
|
|
+ <em>{focus ? `${focus.display_path} ${taskKindLabel(focus.task_kind)}` : "创作总目标"}</em>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <ChevronRight size={14} />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </li>;
|
|
|
|
|
+ })}
|
|
|
|
|
+ </ol>
|
|
|
|
|
+ </section>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function TaskRecord({ task, selected, focused, changed, onSelect }: {
|
|
|
|
|
+ task: TaskNode;
|
|
|
|
|
+ selected: boolean;
|
|
|
|
|
+ focused: boolean;
|
|
|
|
|
+ changed: boolean;
|
|
|
|
|
+ onSelect: (task: TaskNode) => void;
|
|
|
|
|
+}) {
|
|
|
|
|
+ return <button
|
|
|
|
|
+ className={`task-record ${task.task_kind === "root" ? "root-task" : ""} ${selected ? "selected" : ""} ${focused ? "focused" : ""} ${changed ? "changed" : ""}`}
|
|
|
|
|
+ onClick={() => onSelect(task)}
|
|
|
|
|
+ >
|
|
|
|
|
+ <span className="task-path">{task.display_path}</span>
|
|
|
|
|
+ <span className="task-body">
|
|
|
|
|
+ <small>{taskKindLabel(task.task_kind)}</small>
|
|
|
|
|
+ <strong>{task.objective}</strong>
|
|
|
|
|
+ <em>{task.attempt_count ? `${task.attempt_count} Attempt` : "尚未执行"}{task.validation_count ? ` · ${task.validation_count} Validation` : ""}</em>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <StatusMark status={task.status} />
|
|
|
|
|
+ </button>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function TaskBranchView({ branch, selectedTaskId, focusedId, changedIds, onSelect }: {
|
|
|
|
|
+ branch: TaskBranch;
|
|
|
|
|
+ selectedTaskId: string | null;
|
|
|
|
|
+ focusedId: string | null;
|
|
|
|
|
+ changedIds: Set<string>;
|
|
|
|
|
+ onSelect: (task: TaskNode) => void;
|
|
|
|
|
+}) {
|
|
|
|
|
+ return <li className="task-branch">
|
|
|
|
|
+ <TaskRecord
|
|
|
|
|
+ task={branch.task}
|
|
|
|
|
+ selected={selectedTaskId === branch.task.task_id}
|
|
|
|
|
+ focused={focusedId === branch.task.task_id}
|
|
|
|
|
+ changed={changedIds.has(branch.task.task_id)}
|
|
|
|
|
+ onSelect={onSelect}
|
|
|
|
|
+ />
|
|
|
|
|
+ {branch.children.length ? <ol className="task-children">{branch.children.map((child) => <TaskBranchView
|
|
|
|
|
+ key={child.task.task_id}
|
|
|
|
|
+ branch={child}
|
|
|
|
|
+ selectedTaskId={selectedTaskId}
|
|
|
|
|
+ focusedId={focusedId}
|
|
|
|
|
+ changedIds={changedIds}
|
|
|
|
|
+ onSelect={onSelect}
|
|
|
|
|
+ />)}</ol> : null}
|
|
|
|
|
+ </li>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function PlannerTurnNote({ turn, taskById }: { turn: PlannerTurn; taskById: Map<string, TaskNode> }) {
|
|
|
|
|
+ const focus = turn.focus_task_id ? taskById.get(turn.focus_task_id) : null;
|
|
|
|
|
+ return <article className="turn-note">
|
|
|
|
|
+ <div><span>Planner move {turn.order}</span><StatusMark status={turn.status} /></div>
|
|
|
|
|
+ <h3>{turn.action_label}{focus ? `:${focus.display_path} ${taskKindLabel(focus.task_kind)}` : ""}</h3>
|
|
|
|
|
+ <p>{turn.summary}</p>
|
|
|
|
|
+ <details>
|
|
|
|
|
+ <summary>查看这一步的可观察说明与工具输入</summary>
|
|
|
|
|
+ <h4>模型主动输出的说明(不是隐藏思维链)</h4>
|
|
|
|
|
+ <pre>{turn.observable_reasoning || "本步没有输出文字说明。"}</pre>
|
|
|
|
|
+ <h4>{turn.tool_name} arguments</h4>
|
|
|
|
|
+ <JsonBlock value={turn.tool_arguments} />
|
|
|
|
|
+ <h4>工具结果摘要</h4>
|
|
|
|
|
+ <pre>{turn.result_summary}</pre>
|
|
|
|
|
+ </details>
|
|
|
|
|
+ </article>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function TaskTreePanel({ view, turn, query, onQuery, selectedTaskId, onSelect }: {
|
|
|
|
|
+ view: ExecutionView;
|
|
|
|
|
+ turn: PlannerTurn | null;
|
|
|
|
|
+ query: string;
|
|
|
|
|
+ onQuery: (value: string) => void;
|
|
|
|
|
+ selectedTaskId: string | null;
|
|
|
|
|
+ onSelect: (task: TaskNode) => void;
|
|
|
|
|
+}) {
|
|
|
|
|
+ const allVisible = useMemo(() => new Set(turn?.visible_task_ids ?? view.tasks.map((task) => task.task_id)), [turn, view.tasks]);
|
|
|
|
|
+ const matching = useMemo(() => {
|
|
|
|
|
+ if (!query.trim()) return allVisible;
|
|
|
|
|
+ const needle = query.trim().toLowerCase();
|
|
|
|
|
+ const matches = new Set<string>();
|
|
|
|
|
+ const byId = new Map(view.tasks.map((task) => [task.task_id, task]));
|
|
|
|
|
+ for (const task of view.tasks) {
|
|
|
|
|
+ if (!allVisible.has(task.task_id)) continue;
|
|
|
|
|
+ if (`${task.display_path} ${task.task_kind} ${task.objective}`.toLowerCase().includes(needle)) {
|
|
|
|
|
+ let current: TaskNode | undefined = task;
|
|
|
|
|
+ while (current) {
|
|
|
|
|
+ matches.add(current.task_id);
|
|
|
|
|
+ current = current.parent_task_id ? byId.get(current.parent_task_id) : undefined;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return matches;
|
|
|
|
|
+ }, [allVisible, query, view.tasks]);
|
|
|
|
|
+ const forest = useMemo(() => buildTaskForest(view.tasks, matching), [matching, view.tasks]);
|
|
|
|
|
+ const changedIds = useMemo(() => new Set(turn?.affected_task_ids ?? []), [turn]);
|
|
|
|
|
+ const taskById = useMemo(() => new Map(view.tasks.map((task) => [task.task_id, task])), [view.tasks]);
|
|
|
|
|
+ return <section className="tree-panel" aria-label="动态 Task 树">
|
|
|
|
|
+ <div className="panel-heading tree-heading">
|
|
|
|
|
+ <div><span>02 / DYNAMIC TASK TREE</span><h2>围绕创作总目标动态生长</h2></div>
|
|
|
|
|
+ <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>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {turn ? <PlannerTurnNote turn={turn} taskById={taskById} /> : null}
|
|
|
|
|
+ <div className="tree-key"><span><i className="key-focus" />Planner 当前焦点</span><span><i className="key-change" />本步涉及</span><span><i />当前 ledger 状态</span></div>
|
|
|
|
|
+ <div className="tree-scroll">
|
|
|
|
|
+ {forest.length ? <ol className="task-tree">{forest.map((branch) => <TaskBranchView
|
|
|
|
|
+ key={branch.task.task_id}
|
|
|
|
|
+ branch={branch}
|
|
|
|
|
+ selectedTaskId={selectedTaskId}
|
|
|
|
|
+ focusedId={turn?.focus_task_id ?? null}
|
|
|
|
|
+ changedIds={changedIds}
|
|
|
|
|
+ onSelect={onSelect}
|
|
|
|
|
+ />)}</ol> : <Empty>没有符合条件的 Task。</Empty>}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function LifecycleView({ detail }: { detail: TaskDetail }) {
|
|
|
|
|
+ return <div className="detail-section">
|
|
|
|
|
+ <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>
|
|
|
|
|
+ <ol className="lifecycle-list">{detail.lifecycle.map((event) => <li key={event.event_id}>
|
|
|
|
|
+ <span className={`event-glyph event-${event.kind}`} />
|
|
|
|
|
+ <div><small>{formatTime(event.created_at)} · {event.kind}</small><strong>{event.title}</strong><p>{event.summary || "无补充说明"}</p></div>
|
|
|
|
|
+ <StatusMark status={event.status} />
|
|
|
|
|
+ </li>)}</ol>
|
|
|
|
|
+ </div>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function AttemptLoop({ attempt }: { attempt: AttemptDetail | null }) {
|
|
|
|
|
+ if (!attempt) return <Empty>这个 Task 还没有 Attempt。</Empty>;
|
|
|
|
|
+ return <div className="detail-section">
|
|
|
|
|
+ <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>
|
|
|
|
|
+ <ol className="worker-loop">{attempt.steps.map((step, index) => <li key={`${step.sequence}-${step.step_kind}-${index}`} className={`step-${step.step_kind}`}>
|
|
|
|
|
+ <span>{String(index + 1).padStart(2, "0")}</span>
|
|
|
|
|
+ <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>
|
|
|
|
|
+ </li>)}</ol>
|
|
|
|
|
+ </div>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function DataView({ detail }: { detail: TaskDetail }) {
|
|
|
|
|
+ const groups = [
|
|
|
|
|
+ { icon: ArrowDownToLine, title: "进入 Task 的数据", note: "在冻结输入闭包中,不代表 Worker 一定逐项读取", values: detail.data_usage.inherited_inputs },
|
|
|
|
|
+ { icon: Database, title: "Worker 确认读取", note: "来自真实 worker_trace 的 read/get/search/query 工具调用", values: detail.data_usage.confirmed_reads },
|
|
|
|
|
+ { icon: ArrowUpFromLine, title: "Task 产生的数据", note: "来自 Attempt submission 的 Artifact 引用", values: detail.data_usage.produced_outputs },
|
|
|
|
|
+ { icon: Check, title: "采用与回退", note: "来自 PlannerDecision 持久化记录", values: detail.data_usage.adoption },
|
|
|
|
|
+ ];
|
|
|
|
|
+ return <div className="data-groups">{groups.map((group) => <section key={group.title}>
|
|
|
|
|
+ <header><group.icon size={16} /><div><h3>{group.title}</h3><p>{group.note}</p></div><b>{group.values.length}</b></header>
|
|
|
|
|
+ {group.values.length ? group.values.map((value, index) => <JsonBlock key={index} value={value} />) : <Empty>没有对应记录。</Empty>}
|
|
|
|
|
+ </section>)}</div>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function CodePromptView({ detail, attempt }: { detail: TaskDetail; attempt: AttemptDetail | null }) {
|
|
|
|
|
+ const explanations = attempt?.steps.filter((step) => step.step_kind === "explanation") ?? [];
|
|
|
|
|
+ return <div className="detail-section code-view">
|
|
|
|
|
+ <section><h3><Braces size={16} /> Process / Contract</h3><p>Task 的执行边界与输入输出约束。</p>{detail.contract ? <JsonBlock value={detail.contract} /> : <Empty>Root Task 没有独立 ScriptTaskContract。</Empty>}</section>
|
|
|
|
|
+ <section><h3><FileCode2 size={16} /> Prompt</h3><p>当前 Attempt 实际持久化的 Worker system prompt。</p><pre>{attempt?.prompt || "尚无 Worker Prompt。"}</pre></section>
|
|
|
|
|
+ <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>
|
|
|
|
|
+ </div>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function TaskInspector({ detail, loading, tab, onTab, attemptId, onAttempt, onClose }: {
|
|
|
|
|
+ detail: TaskDetail | null;
|
|
|
|
|
+ loading: boolean;
|
|
|
|
|
+ tab: DetailTab;
|
|
|
|
|
+ onTab: (tab: DetailTab) => void;
|
|
|
|
|
+ attemptId: string | null;
|
|
|
|
|
+ onAttempt: (id: string) => void;
|
|
|
|
|
+ onClose: () => void;
|
|
|
|
|
+}) {
|
|
|
|
|
+ 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>;
|
|
|
|
|
+ const attempt = detail.attempts.find((item) => item.attempt_id === attemptId) ?? detail.attempts.at(-1) ?? null;
|
|
|
|
|
+ return <aside className="task-inspector">
|
|
|
|
|
+ <header className="drawer-header">
|
|
|
|
|
+ <div><span>{detail.task.display_path} / {taskKindLabel(detail.task.task_kind)}</span><h2>{detail.task.objective}</h2></div>
|
|
|
|
|
+ <button onClick={onClose} aria-label="关闭 Task 详情"><X size={18} /></button>
|
|
|
</header>
|
|
</header>
|
|
|
- <aside className="phaseRail">
|
|
|
|
|
- <div className="railTitle"><Layers3 size={16} /><span>流程阶段</span></div>
|
|
|
|
|
- {ALL_PHASES.map((phase) => <button key={phase} className={phases.has(phase) ? "active" : ""} onClick={() => togglePhase(phase)}>
|
|
|
|
|
- <span className={`phaseDot phase-${phase}`} />
|
|
|
|
|
- <span>{PHASE_LABELS[phase]}</span>
|
|
|
|
|
- <small>{graph.nodes.filter((item) => item.phase === phase).length}</small>
|
|
|
|
|
- </button>)}
|
|
|
|
|
- <div className="railDivider" />
|
|
|
|
|
- <button onClick={() => void fitView({ padding: 0.06, duration: 320 })}><Focus size={15} /><span>适应全图</span></button>
|
|
|
|
|
- <div className="checkpoint"><small>CURRENT CHECKPOINT</small><code>{graph.run.checkpoint}</code></div>
|
|
|
|
|
- </aside>
|
|
|
|
|
- <section className={`canvasShell ${selected ? "withInspector" : ""}`}>
|
|
|
|
|
- <ReactFlow
|
|
|
|
|
- nodes={canvas.nodes.map((node) => ({ ...node, selected: node.id === selected?.id }))}
|
|
|
|
|
- edges={canvas.edges}
|
|
|
|
|
- nodeTypes={nodeTypes}
|
|
|
|
|
- onNodeClick={selectNode}
|
|
|
|
|
- onPaneClick={() => onSelect(null)}
|
|
|
|
|
- onInit={(instance) => void instance.fitView({ padding: 0.06, duration: 0 })}
|
|
|
|
|
- minZoom={0.06}
|
|
|
|
|
- maxZoom={1.4}
|
|
|
|
|
- nodesConnectable={false}
|
|
|
|
|
- zoomOnDoubleClick={false}
|
|
|
|
|
- panOnScroll
|
|
|
|
|
- zoomOnPinch
|
|
|
|
|
- proOptions={{ hideAttribution: true }}
|
|
|
|
|
- >
|
|
|
|
|
- <Background variant={BackgroundVariant.Dots} gap={24} size={1.15} color="#383b44" />
|
|
|
|
|
- <Controls position="bottom-left" showInteractive={false} />
|
|
|
|
|
- <Panel position="top-left" className="canvasToolbar">
|
|
|
|
|
- <label><Search size={16} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索节点、模型、Task kind…" />{query ? <button onClick={() => setQuery("")}><X size={14} /></button> : null}</label>
|
|
|
|
|
- <span>{query ? `${matched} / ${graph.nodes.length}` : `${graph.nodes.length} nodes · ${graph.edges.length} edges`}</span>
|
|
|
|
|
- </Panel>
|
|
|
|
|
- <Panel position="bottom-right" className="legend">
|
|
|
|
|
- <span><i className="legend-control" />控制流</span>
|
|
|
|
|
- <span><i className="legend-artifact" />Artifact</span>
|
|
|
|
|
- <span><i className="legend-validation" />Validation</span>
|
|
|
|
|
- <span><i className="legend-publication" />Publication</span>
|
|
|
|
|
- </Panel>
|
|
|
|
|
- </ReactFlow>
|
|
|
|
|
- </section>
|
|
|
|
|
- {selected ? <Inspector item={selected} graph={graph} onClose={() => onSelect(null)} /> : null}
|
|
|
|
|
- </>;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function MissionControlInner() {
|
|
|
|
|
|
|
+ <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>
|
|
|
|
|
+ {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}
|
|
|
|
|
+ <nav className="drawer-tabs">
|
|
|
|
|
+ {([ ["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>)}
|
|
|
|
|
+ </nav>
|
|
|
|
|
+ <div className="drawer-scroll">
|
|
|
|
|
+ {tab === "lifecycle" ? <LifecycleView detail={detail} /> : null}
|
|
|
|
|
+ {tab === "attempt" ? <AttemptLoop attempt={attempt} /> : null}
|
|
|
|
|
+ {tab === "data" ? <DataView detail={detail} /> : null}
|
|
|
|
|
+ {tab === "code" ? <CodePromptView detail={detail} attempt={attempt} /> : null}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </aside>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function LoadingState() {
|
|
|
|
|
+ return <main className="center-state"><LoaderCircle className="spin" /><h1>正在读取真实 E2E 持久化数据</h1><p>TaskLedger · Planner Trace · Worker Attempt</p></main>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function ErrorState({ message, retry }: { message: string; retry: () => void }) {
|
|
|
|
|
+ return <main className="center-state"><h1>真实运行数据暂时不可读</h1><p>{message}</p><button onClick={retry}><RefreshCw size={15} />重试</button></main>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function MissionControl() {
|
|
|
const [runs, setRuns] = useState<RunSummary[]>([]);
|
|
const [runs, setRuns] = useState<RunSummary[]>([]);
|
|
|
- const [graph, setGraph] = useState<RunGraph | null>(null);
|
|
|
|
|
- const [selected, setSelected] = useState<FlowNodeRecord | null>(null);
|
|
|
|
|
|
|
+ const [selectedRunId, setSelectedRunId] = useState(900031);
|
|
|
|
|
+ const [view, setView] = useState<ExecutionView | null>(null);
|
|
|
|
|
+ const [selectedTurnId, setSelectedTurnId] = useState<string | null>(null);
|
|
|
|
|
+ const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
|
|
|
|
+ const [detail, setDetail] = useState<TaskDetail | null>(null);
|
|
|
|
|
+ const [detailLoading, setDetailLoading] = useState(false);
|
|
|
|
|
+ const [detailTab, setDetailTab] = useState<DetailTab>("lifecycle");
|
|
|
|
|
+ const [attemptId, setAttemptId] = useState<string | null>(null);
|
|
|
|
|
+ const [query, setQuery] = useState("");
|
|
|
|
|
+ const [loading, setLoading] = useState(true);
|
|
|
|
|
+ const [refreshing, setRefreshing] = useState(false);
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
- const [nonce, setNonce] = useState(0);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ const loadExecution = useCallback(async (runId: number, quiet = false) => {
|
|
|
|
|
+ if (!quiet) setLoading(true);
|
|
|
|
|
+ else setRefreshing(true);
|
|
|
|
|
+ try {
|
|
|
|
|
+ const execution = await fetchExecution(runId);
|
|
|
|
|
+ setView(execution);
|
|
|
|
|
+ setSelectedTurnId((current) => current && execution.planner_turns.some((turn) => turn.turn_id === current) ? current : execution.planner_turns.at(-1)?.turn_id ?? null);
|
|
|
|
|
+ setError(null);
|
|
|
|
|
+ } catch (reason) {
|
|
|
|
|
+ setError(reason instanceof Error ? reason.message : String(reason));
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setLoading(false);
|
|
|
|
|
+ setRefreshing(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
const controller = new AbortController();
|
|
const controller = new AbortController();
|
|
|
- setError(null);
|
|
|
|
|
- Promise.all([fetchRuns(controller.signal), fetchGraph(10001, controller.signal)])
|
|
|
|
|
- .then(([nextRuns, nextGraph]) => { setRuns(nextRuns); setGraph(nextGraph); })
|
|
|
|
|
- .catch((reason: unknown) => {
|
|
|
|
|
- if (!controller.signal.aborted) setError(reason instanceof Error ? reason.message : "未知错误");
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ fetchRuns(controller.signal).then((available) => {
|
|
|
|
|
+ setRuns(available);
|
|
|
|
|
+ const preferred = available.find((run) => run.script_build_id === 900031) ?? available[0];
|
|
|
|
|
+ if (!preferred) throw new Error("没有发现真实持久化 Run");
|
|
|
|
|
+ setSelectedRunId(preferred.script_build_id);
|
|
|
|
|
+ return loadExecution(preferred.script_build_id);
|
|
|
|
|
+ }).catch((reason) => {
|
|
|
|
|
+ if (reason instanceof DOMException && reason.name === "AbortError") return;
|
|
|
|
|
+ setError(reason instanceof Error ? reason.message : String(reason));
|
|
|
|
|
+ setLoading(false);
|
|
|
|
|
+ });
|
|
|
return () => controller.abort();
|
|
return () => controller.abort();
|
|
|
- }, [nonce]);
|
|
|
|
|
- if (error) return <ErrorState message={error} retry={() => setNonce((value) => value + 1)} />;
|
|
|
|
|
- if (!graph || !runs.length) return <LoadingState />;
|
|
|
|
|
- return <main className="appFrame"><Canvas graph={graph} selected={selected} onSelect={setSelected} /></main>;
|
|
|
|
|
-}
|
|
|
|
|
|
|
+ }, [loadExecution]);
|
|
|
|
|
|
|
|
-export function MissionControl() {
|
|
|
|
|
- return <ReactFlowProvider><MissionControlInner /></ReactFlowProvider>;
|
|
|
|
|
-}
|
|
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ const timer = window.setInterval(() => void loadExecution(selectedRunId, true), 15_000);
|
|
|
|
|
+ return () => window.clearInterval(timer);
|
|
|
|
|
+ }, [loadExecution, selectedRunId]);
|
|
|
|
|
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!selectedTaskId) {
|
|
|
|
|
+ setDetail(null);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const controller = new AbortController();
|
|
|
|
|
+ setDetailLoading(true);
|
|
|
|
|
+ fetchTaskDetail(selectedRunId, selectedTaskId, controller.signal).then((value) => {
|
|
|
|
|
+ setDetail(value);
|
|
|
|
|
+ setAttemptId(value.attempts.at(-1)?.attempt_id ?? null);
|
|
|
|
|
+ }).catch((reason) => {
|
|
|
|
|
+ if (!(reason instanceof DOMException && reason.name === "AbortError")) setError(reason instanceof Error ? reason.message : String(reason));
|
|
|
|
|
+ }).finally(() => setDetailLoading(false));
|
|
|
|
|
+ return () => controller.abort();
|
|
|
|
|
+ }, [selectedRunId, selectedTaskId]);
|
|
|
|
|
+
|
|
|
|
|
+ const changeRun = (id: number) => {
|
|
|
|
|
+ setSelectedRunId(id);
|
|
|
|
|
+ setSelectedTaskId(null);
|
|
|
|
|
+ setDetail(null);
|
|
|
|
|
+ setSelectedTurnId(null);
|
|
|
|
|
+ void loadExecution(id);
|
|
|
|
|
+ };
|
|
|
|
|
+ const selectedTurn = view?.planner_turns.find((turn) => turn.turn_id === selectedTurnId) ?? null;
|
|
|
|
|
+ if (loading && !view) return <LoadingState />;
|
|
|
|
|
+ if (error && !view) return <ErrorState message={error} retry={() => void loadExecution(selectedRunId)} />;
|
|
|
|
|
+ if (!view) return null;
|
|
|
|
|
+ return <main className={`execution-observer ${selectedTaskId ? "with-drawer" : ""}`}>
|
|
|
|
|
+ <Header runs={runs} view={view} selectedId={selectedRunId} onSelect={changeRun} onRefresh={() => void loadExecution(selectedRunId, true)} refreshing={refreshing} />
|
|
|
|
|
+ <div className="observer-grid">
|
|
|
|
|
+ <PlannerAxis turns={view.planner_turns} selectedId={selectedTurnId} tasks={view.tasks} onSelect={setSelectedTurnId} />
|
|
|
|
|
+ <TaskTreePanel view={view} turn={selectedTurn} query={query} onQuery={setQuery} selectedTaskId={selectedTaskId} onSelect={(task) => { setSelectedTaskId(task.task_id); setDetailTab("lifecycle"); }} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <footer className="observer-foot"><span>{view.snapshot_basis}</span><span>root trace {shortId(view.run.root_trace_id)}</span><span>真实数据 · 只读</span></footer>
|
|
|
|
|
+ {selectedTaskId ? <TaskInspector detail={detail} loading={detailLoading} tab={detailTab} onTab={setDetailTab} attemptId={attemptId} onAttempt={setAttemptId} onClose={() => setSelectedTaskId(null)} /> : null}
|
|
|
|
|
+ </main>;
|
|
|
|
|
+}
|