Explorar el Código

可视化:实现可表达回环的 Journey 画布

新增阶段泳道、任务节点、返工边、验收状态和选中交互,让 Phase1 到 Phase3 的动态循环能在同一画布上被观察;补充投影与布局测试。
SamLee hace 9 horas
padre
commit
217edcc6ac

+ 221 - 0
visualization/frontend/components/JourneyCanvas.tsx

@@ -0,0 +1,221 @@
+import {
+  ArrowDown,
+  Brain,
+  CheckCircle2,
+  CircleDot,
+  Database,
+  GitBranch,
+  Play,
+  RefreshCcw,
+  Route,
+  SearchCheck,
+  Wrench,
+} from "lucide-react";
+
+import { buildTaskCycles, groupParallelSteps, statusLabel } from "@/lib/journey";
+import type { JourneyEdge, JourneyStep, JourneyView, StepType, ZoomLevel } from "@/lib/types";
+
+const STEP_META: Record<StepType, { label: string; icon: typeof Brain }> = {
+  plan: { label: "规划", icon: Route },
+  execute: { label: "执行", icon: Play },
+  validate: { label: "验收", icon: SearchCheck },
+  decide: { label: "决策", icon: GitBranch },
+};
+
+interface JourneyCanvasProps {
+  journey: JourneyView;
+  zoom: ZoomLevel;
+  selectedStepId: string | null;
+  onSelectStep: (stepId: string) => void;
+}
+
+export function JourneyCanvas({ journey, zoom, selectedStepId, onSelectStep }: JourneyCanvasProps) {
+  if (zoom === "journey") {
+    return <JourneyOverview journey={journey} onSelectStep={onSelectStep} />;
+  }
+  const groups = groupParallelSteps(journey.steps);
+  const loops = new Map(
+    journey.edges.filter((edge) => edge.relation === "loop").map((edge) => [edge.source, edge]),
+  );
+  return (
+    <section className="flow-canvas" aria-label="创作步骤">
+      <div className="flow-heading">
+        <div>
+          <span>按真实发生顺序</span>
+          <h2>Planner 如何一步步完成目标</h2>
+        </div>
+        <p>点击任何步骤后切换到“证据”,可查看对应的合同、工具调用和验收记录。</p>
+      </div>
+      <div className="causal-spine">
+        {groups.map((group, index) => (
+          <div className={`flow-unit ${group.kind}`} key={group.id}>
+            {group.kind === "parallel" ? (
+              <div className="parallel-frame">
+                <div className="parallel-heading">
+                  <GitBranch size={16} />
+                  <span>Host 并行派发 {group.steps.length} 个任务</span>
+                </div>
+                <div className="parallel-lanes">
+                  {group.steps.map((step) => (
+                    <StepCard
+                      key={step.step_id}
+                      step={step}
+                      loop={loops.get(step.step_id)}
+                      selected={step.step_id === selectedStepId}
+                      onSelect={() => onSelectStep(step.step_id)}
+                    />
+                  ))}
+                </div>
+              </div>
+            ) : (
+              <StepCard
+                step={group.steps[0]}
+                loop={loops.get(group.steps[0].step_id)}
+                selected={group.steps[0].step_id === selectedStepId}
+                onSelect={() => onSelectStep(group.steps[0].step_id)}
+              />
+            )}
+            {index < groups.length - 1 ? (
+              <span className="flow-connector" aria-hidden="true">
+                <ArrowDown size={15} />
+              </span>
+            ) : null}
+          </div>
+        ))}
+      </div>
+      {journey.warnings.map((warning) => (
+        <p className="projection-note" key={warning}>{warning}</p>
+      ))}
+    </section>
+  );
+}
+
+function JourneyOverview({
+  journey,
+  onSelectStep,
+}: {
+  journey: JourneyView;
+  onSelectStep: (stepId: string) => void;
+}) {
+  const cycles = buildTaskCycles(journey.steps, journey.edges);
+  return (
+    <section className="overview-canvas" aria-label="全局创作旅程">
+      <div className="overview-heading">
+        <h2>这次创作经过了 {cycles.length} 个任务回合</h2>
+        <p>默认只看业务因果。点开一个回合,再看 Planner、Worker 和 Validator 如何协作。</p>
+      </div>
+      <ol className="cycle-line">
+        {cycles.map((cycle, index) => (
+          <li key={cycle.taskId}>
+            <button type="button" onClick={() => onSelectStep(cycle.steps[0].step_id)}>
+              <span className="cycle-index">{String(index + 1).padStart(2, "0")}</span>
+              <div className="cycle-copy">
+                <div className="cycle-title-row">
+                  <h3>{cycle.title}</h3>
+                  <span className={`status-chip status-${cycle.status}`}>{statusLabel(cycle.status)}</span>
+                </div>
+                <p>{cycle.steps[0].reasoning}</p>
+                <div className="cycle-facts">
+                  {cycle.goals.length ? <span>目标 {cycle.goals.join("、")}</span> : null}
+                  <span>{cycle.steps.length} 个真实步骤</span>
+                  {cycle.parallel ? <span><GitBranch size={12} /> 包含并行</span> : null}
+                  {cycle.loopCount ? <span><RefreshCcw size={12} /> 返工 {cycle.loopCount} 次</span> : null}
+                </div>
+                <div className="cycle-lifecycle" aria-label="回合生命周期">
+                  {(["plan", "execute", "validate", "decide"] as StepType[]).map((type) => {
+                    const present = cycle.steps.some((step) => step.step_type === type);
+                    const Icon = STEP_META[type].icon;
+                    return (
+                      <span className={present ? "present" : ""} key={type}>
+                        <Icon size={13} /> {STEP_META[type].label}
+                      </span>
+                    );
+                  })}
+                </div>
+              </div>
+              <ArrowDown className="cycle-open" size={17} />
+            </button>
+          </li>
+        ))}
+      </ol>
+    </section>
+  );
+}
+
+function StepCard({
+  step,
+  loop,
+  selected,
+  onSelect,
+}: {
+  step: JourneyStep;
+  loop?: JourneyEdge;
+  selected: boolean;
+  onSelect: () => void;
+}) {
+  const meta = STEP_META[step.step_type];
+  const Icon = meta.icon;
+  return (
+    <article className={`step-node type-${step.step_type} ${selected ? "selected" : ""}`}>
+      <button type="button" className="step-hit-area" onClick={onSelect} aria-label={`查看${step.title}的原始证据`} />
+      <header className="step-header">
+        <span className="step-type"><Icon size={15} /> {meta.label}</span>
+        <span className={`status-chip status-${step.status}`}>{statusLabel(step.status)}</span>
+      </header>
+      <h3>{step.title}</h3>
+
+      <div className="step-layer reasoning-layer">
+        <span><Brain size={14} /> 思考</span>
+        <p>{step.reasoning}</p>
+        <small>{step.reasoning_source === "observable" ? "Planner / Agent 主动说明" : "由真实冻结记录转述"}</small>
+      </div>
+
+      <div className="step-layer process-layer">
+        <span><Wrench size={14} /> 动作</span>
+        <p>{step.process.label}</p>
+        <small>{step.process.actor}{step.process.tool_name ? ` · ${step.process.tool_name}` : ""}</small>
+      </div>
+
+      <div className="data-flow">
+        <div>
+          <span><Database size={13} /> 输入</span>
+          {step.input_data.map((item, index) => <b key={`${item.label}-${index}`}>{item.label}</b>)}
+        </div>
+        <ArrowDown size={14} aria-hidden="true" />
+        <div>
+          <span><CircleDot size={13} /> 产出</span>
+          {step.output_data.map((item, index) => <b key={`${item.label}-${index}`}>{item.label}</b>)}
+        </div>
+      </div>
+
+      {step.contract ? (
+        <div className="contract-summary">
+          <strong>任务合同</strong>
+          <span>{step.contract.goals.length ? `覆盖 ${step.contract.goals.join("、")}` : "不限定单一 Goal"}</span>
+          <span>产出 {step.contract.output}</span>
+          <span>{step.contract.criteria.length} 项验收标准</span>
+        </div>
+      ) : null}
+
+      {step.parallel_total ? (
+        <span className="parallel-badge">并行 {step.parallel_position}/{step.parallel_total}</span>
+      ) : null}
+      {loop ? (
+        <div className="loop-ribbon"><RefreshCcw size={14} /> {loop.label}·回到前面继续</div>
+      ) : null}
+      <footer className="step-metrics">
+        {step.duration_ms !== null ? <span>{formatDuration(step.duration_ms)}</span> : null}
+        {step.tokens !== null ? <span>{step.tokens.toLocaleString()} tokens</span> : null}
+        {step.cost !== null ? <span>cost {step.cost.toFixed(4)}</span> : null}
+        {step.model ? <span>{step.model}</span> : null}
+        <span className="evidence-cue"><CheckCircle2 size={13} /> 可追溯</span>
+      </footer>
+    </article>
+  );
+}
+
+function formatDuration(value: number): string {
+  if (value < 1000) return `${value}ms`;
+  if (value < 60_000) return `${(value / 1000).toFixed(1)}s`;
+  return `${Math.floor(value / 60_000)}m ${Math.round((value % 60_000) / 1000)}s`;
+}

+ 65 - 0
visualization/frontend/tests/journey.test.ts

@@ -0,0 +1,65 @@
+import { expect, test } from "vitest";
+
+import { buildTaskCycles, groupParallelSteps } from "@/lib/journey";
+import type { JourneyEdge, JourneyStep } from "@/lib/types";
+
+function step(overrides: Partial<JourneyStep>): JourneyStep {
+  return {
+    step_id: "plan:task-1",
+    sequence: 1,
+    step_type: "plan",
+    title: "Planner 规划·整体结构",
+    status: "completed",
+    task_id: "task-1",
+    attempt_id: null,
+    validation_id: null,
+    decision_id: null,
+    reasoning: "当前缺少整体结构",
+    reasoning_source: "contract",
+    process: { label: "创建整体结构 Task", actor: "Planner", tool_name: null, trace_id: null, details: {} },
+    input_data: [],
+    output_data: [],
+    contract: { action: "创建", reasoning: "需要结构", goals: ["goal-1"], inputs: [], output: "整体结构", criteria: [] },
+    parallel_group_id: null,
+    parallel_position: null,
+    parallel_total: null,
+    model: null,
+    tokens: null,
+    cost: null,
+    duration_ms: null,
+    created_at: "2026-07-21T00:00:00Z",
+    evidence: { contract: null, tool_calls: [], artifact_refs: [], validation: null, decision: null },
+    ...overrides,
+  };
+}
+
+test("全局层按 Task 聚合规划、执行、验收和返工", () => {
+  const values = [
+    step({}),
+    step({ step_id: "execute:attempt-1", step_type: "execute", attempt_id: "attempt-1" }),
+    step({ step_id: "decide:decision-1", step_type: "decide", decision_id: "decision-1", status: "retry" }),
+  ];
+  const edges: JourneyEdge[] = [
+    { edge_id: "loop", source: "decide:decision-1", target: "execute:attempt-1", relation: "loop", label: "重试" },
+  ];
+
+  const cycles = buildTaskCycles(values, edges);
+
+  expect(cycles).toHaveLength(1);
+  expect(cycles[0].title).toBe("整体结构");
+  expect(cycles[0].loopCount).toBe(1);
+  expect(cycles[0].steps).toHaveLength(3);
+});
+
+test("步骤层把同一 Operation 的 Worker 表达为并行组", () => {
+  const values = [
+    step({ step_id: "execute:a", step_type: "execute", parallel_group_id: "operation-1" }),
+    step({ step_id: "execute:b", step_type: "execute", task_id: "task-2", parallel_group_id: "operation-1" }),
+  ];
+
+  const groups = groupParallelSteps(values);
+
+  expect(groups).toHaveLength(1);
+  expect(groups[0].kind).toBe("parallel");
+  expect(groups[0].steps).toHaveLength(2);
+});