Sfoglia il codice sorgente

feat(visualization): build the n8n-style mission canvas

SamLee 1 giorno fa
parent
commit
6b6a955d61
80 ha cambiato i file con 870 aggiunte e 10351 eliminazioni
  1. 0 57
      visualization/frontend/DESIGN.md
  2. 227 3251
      visualization/frontend/app/globals.css
  3. 3 4
      visualization/frontend/app/layout.tsx
  4. 2 2
      visualization/frontend/app/page.tsx
  5. 29 0
      visualization/frontend/components/FieldTree.tsx
  6. 60 0
      visualization/frontend/components/FlowCard.tsx
  7. 75 0
      visualization/frontend/components/Inspector.tsx
  8. 130 0
      visualization/frontend/components/MissionControl.tsx
  9. 15 0
      visualization/frontend/components/PhaseFrameNode.tsx
  10. 0 119
      visualization/frontend/components/artifact/ScriptArtifactViewer.tsx
  11. 0 26
      visualization/frontend/components/artifact/ScriptElementTable.tsx
  12. 0 210
      visualization/frontend/components/artifact/ScriptTable.tsx
  13. 0 60
      visualization/frontend/components/decision/AgentDecisionCard.tsx
  14. 0 172
      visualization/frontend/components/decision/DecisionDetailRenderer.tsx
  15. 0 31
      visualization/frontend/components/decision/PromptDrawer.tsx
  16. 0 10
      visualization/frontend/components/flow/AgentRegion.tsx
  17. 0 22
      visualization/frontend/components/flow/BranchFrame.tsx
  18. 0 31
      visualization/frontend/components/flow/CandidateCard.tsx
  19. 0 26
      visualization/frontend/components/flow/CardFields.tsx
  20. 0 27
      visualization/frontend/components/flow/DirectToolGroupCard.tsx
  21. 0 13
      visualization/frontend/components/flow/QueryAttemptList.tsx
  22. 0 38
      visualization/frontend/components/flow/RetrievalAgentCard.tsx
  23. 0 209
      visualization/frontend/components/flow/RetrievalHoverPreview.tsx
  24. 0 26
      visualization/frontend/components/flow/RetrievalStageFrame.tsx
  25. 0 26
      visualization/frontend/components/flow/RoundFrame.tsx
  26. 0 43
      visualization/frontend/components/flow/RoundSummaryCard.tsx
  27. 0 40
      visualization/frontend/components/flow/RunResultCard.tsx
  28. 0 23
      visualization/frontend/components/flow/SourceComparisonButton.tsx
  29. 0 37
      visualization/frontend/components/flow/StoryCard.tsx
  30. 0 54
      visualization/frontend/components/inspector/BindingBadge.tsx
  31. 0 163
      visualization/frontend/components/inspector/BusinessValueRenderer.tsx
  32. 0 178
      visualization/frontend/components/inspector/ComparisonRowCells.tsx
  33. 0 234
      visualization/frontend/components/inspector/DataFlowRenderer.tsx
  34. 0 129
      visualization/frontend/components/inspector/Inspector.tsx
  35. 0 94
      visualization/frontend/components/inspector/InspectorComparison.tsx
  36. 0 91
      visualization/frontend/components/inspector/LegacyInspector.tsx
  37. 0 52
      visualization/frontend/components/inspector/LongValueDisclosure.tsx
  38. 0 117
      visualization/frontend/components/inspector/RetrievalDetailRenderer.tsx
  39. 0 30
      visualization/frontend/components/ui/Badges.tsx
  40. 0 8
      visualization/frontend/components/ui/RichText.tsx
  41. 0 23
      visualization/frontend/components/ui/TabList.tsx
  42. 0 5
      visualization/frontend/components/ui/ValueView.tsx
  43. 0 418
      visualization/frontend/components/workbench/ExecutionWorkbench.tsx
  44. 0 116
      visualization/frontend/components/workbench/FlowCanvas.tsx
  45. 0 101
      visualization/frontend/components/workbench/MobileTimeline.tsx
  46. 0 36
      visualization/frontend/components/workbench/TopBar.tsx
  47. 0 100
      visualization/frontend/hooks/useExecutionView.ts
  48. 10 19
      visualization/frontend/lib/api.ts
  49. 0 39
      visualization/frontend/lib/expansion.ts
  50. 92 0
      visualization/frontend/lib/flow.ts
  51. 0 493
      visualization/frontend/lib/layout.ts
  52. 0 26
      visualization/frontend/lib/redact.ts
  53. 0 16
      visualization/frontend/lib/status-copy.ts
  54. 57 759
      visualization/frontend/lib/types.ts
  55. 1 2
      visualization/frontend/next.config.mjs
  56. 118 393
      visualization/frontend/package-lock.json
  57. 2 5
      visualization/frontend/package.json
  58. 5 25
      visualization/frontend/playwright.config.ts
  59. 0 221
      visualization/frontend/tests/cards-v8.test.tsx
  60. 0 131
      visualization/frontend/tests/data-flow.test.tsx
  61. 15 0
      visualization/frontend/tests/e2e/fake-graph.spec.ts
  62. 0 152
      visualization/frontend/tests/e2e/real-artifact.spec.ts
  63. 0 240
      visualization/frontend/tests/e2e/runtime-v8.spec.ts
  64. 0 23
      visualization/frontend/tests/expansion.test.ts
  65. 0 106
      visualization/frontend/tests/fixtures.ts
  66. 22 0
      visualization/frontend/tests/flow.test.ts
  67. 0 191
      visualization/frontend/tests/inspector-comparison.test.tsx
  68. 0 446
      visualization/frontend/tests/inspector.test.tsx
  69. 0 161
      visualization/frontend/tests/layout-v8.test.ts
  70. 0 40
      visualization/frontend/tests/prompt-drawer.test.tsx
  71. 0 9
      visualization/frontend/tests/redact.test.ts
  72. 0 135
      visualization/frontend/tests/retrieval-inspector.test.tsx
  73. 0 115
      visualization/frontend/tests/retrieval-v8.test.tsx
  74. 0 14
      visualization/frontend/tests/rich-text.test.tsx
  75. 0 33
      visualization/frontend/tests/run-selection.test.ts
  76. 0 58
      visualization/frontend/tests/script-table.test.tsx
  77. 0 26
      visualization/frontend/tests/setup.ts
  78. 0 11
      visualization/frontend/tests/status-copy.test.ts
  79. 3 4
      visualization/frontend/tsconfig.json
  80. 4 6
      visualization/frontend/vitest.config.ts

+ 0 - 57
visualization/frontend/DESIGN.md

@@ -1,57 +0,0 @@
-# Design System
-
-## Theme
-
-深色运行画布,保留旧 Demo 的 n8n 式点阵背景与橙红当前态。颜色只服务于选中和“已采用/暂存/未采用”等关键业务状态。
-
-## Palette
-
-- Background `#101114`
-- Canvas `#15171b`
-- Surface `#202228`
-- Raised surface `#292c33`
-- Accent `#ff6d5a`
-- Success `#63c883`
-- Warning `#f4c152`
-- Failure `#ff806e`
-
-## Typography
-
-Inter/system-ui 单字体产品界面;数据、ID 与技术引用使用 ui-monospace。层级依靠字重与间距,不使用装饰性大标题。
-
-## Signature
-
-横向的“先发散、再收敛”是界面的视觉签名:主 Agent 目标与规划在左侧,多个实现 Agent 泳道向中间展开;每条泳道内部展示取数、数据决策和候选产出。所有候选先汇入多方案评审 Agent,再由主 Agent 按真实批次收敛,随后进入主脚本整体评审与本轮产出。
-
-## Layout
-
-画布内悬浮运行工具条 + 顶部轮次导航 + 中央 React Flow 横向画布 + 右侧覆盖式 Inspector。面向非技术人员的业务 Inspector 保持单列阅读;独立的“数据来源”Inspector 不挤压画布,桌面端以“业务详情中的一项|这一项的数据依据|对应的原始字段或原文”逐项并行展示,窄屏则在每个比较项内按三段纵向排列。小屏画布本身仍切换为按轮次排列的 accordion。
-
-初始视口以文字可读为先,不把十轮流程强行压缩到一屏。历史轮默认折叠为摘要,当前轮原位展开;展开时保持视口锚点。候选分支纵向并行,高度随真实内容计算。
-
-## Agent Decision Cards
-
-- Direction、Tradeoff、Evaluation、Creative 共用统一卡片外壳:Agent 身份、决策类型、决定权、一个主结论和最多三个补充摘要。
-- Direction 说明最终方向;Tradeoff 说明取舍结果;Evaluation 明确标成建议;Creative 以“产生候选创作表”展示有真实运行记录的创作处理和候选产物,不重复前置“实现任务”的完整创作任务。
-- Implementation Plan 不复用普通 Direction 的长文本布局:Inspector 逐路展示动作、类型、作用范围、实施方法、路线侧重和规划理由,完整形成依据默认折叠。
-- 主卡不重复显示“系统记录”、默认完整性徽标、数据库来源或“为什么 · 0”。
-- 来源仅在“运行记录关联、记录缺失、旧版本记录”会影响理解时显示。
-- 决策卡的摘要预算是一个主结论、默认两个摘要、最多三个摘要;完整内容进入 Inspector,不硬编码句数。
-- 执行节点只回答“做了什么、得到什么”;结果节点回答“是否达标、形成什么、下一步”。
-- 候选卡默认只显示任务、候选产出和状态徽标;取数、依据与自评通过展开和 Inspector 查看。
-- 多方案评审显示“建议”,主 Agent 多路决策显示“最终决定”,不能用颜色或文案将二者混为一体。
-- 主脚本影响并入本轮产出,不设置独立 Base 节点。
-
-## Inspector
-
-- 业务 Inspector 与“数据来源”Inspector 是两个独立入口;业务 Inspector 继续只服务业务阅读,数据来源 Inspector 专门服务调试对照。
-- 数据来源 Inspector 的第一列直接复用业务 Inspector 的投影、顺序和完整内容。每个可见叶子项占一整行,三列共享稳定的 `moduleId`、`rowId` 和 `bindingId`,不再让用户跨模块寻找来源。
-- `这一项的数据依据` 展示来源类型、转换方式与采用证据。“内容来自哪里”和“Agent 是否采用”必须分开;没有引用不等于明确未采用。
-- `对应的原始字段或原文` 默认直接展示精确字段值或带上下文的原文片段,并显示数据表与记录 ID、Event ID 与名称、状态、耗时和完整性。JSON Pointer 与字符范围进入“定位详情”,完整 Event、数据库记录和 JSON 进入二级折叠。
-- 无法安全定位的内容必须显示带原因的 `unresolved`,禁止静默空绑定,也不用文本相似度补链。
-- 业务模块可整体折叠;超过 800 字的内容默认显示 240 字预览,数据在数据来源 Inspector 打开时一次读取,大段 DOM 仅在展开时挂载。
-- Prompt 抽屉把“本次真实任务”和“当前版本规则”分开,并持续说明当前规则不是历史运行快照。
-
-## Interaction
-
-Mac 双指自由平移,捏合缩放,普通滚轮不缩放;不显示 MiniMap。所有主要按钮和触控目标至少 44px。节点支持 Tab、Enter、Space;数据来源 Inspector 中点击或聚焦来源编号会同步突出当前比较行,Escape 先清除突出状态,再关闭 Inspector,关闭后恢复原卡片按钮焦点。

+ 227 - 3251
visualization/frontend/app/globals.css

@@ -1,3273 +1,249 @@
 :root {
-  --font-ui: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
-  --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
-
-  --type-caption: .75rem;
-  --type-label: .8125rem;
-  --type-ui: .875rem;
-  --type-body: 1rem;
-  --type-title: 1.125rem;
-  --type-heading: 1.25rem;
-  --inspector-title-size: var(--type-heading);
-  --inspector-section-title-size: var(--type-title);
-  --inspector-field-label-size: var(--type-label);
-  --inspector-copy-size: var(--type-body);
-  --weight-regular: 400;
-  --weight-medium: 500;
-  --weight-semibold: 600;
-  --weight-bold: 700;
-
+  --font-ui: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
+  --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
   --bg: #101114;
   --canvas: #15171b;
   --surface: #202228;
-  --surface-raised: #25282f;
-  --surface-control: #2b2e36;
-  --surface-subtle: #1b1d22;
-  --text-primary: #f5f5f6;
-  --text-secondary: #d7d9df;
-  --text-muted: #aeb2bb;
-  --text-subtle: #9298a3;
-  --border-subtle: #30343d;
-  --border: #3c414c;
-  --border-strong: #5b6270;
-  --accent: #ff6d5a;
-  --accent-hover: #ff806e;
-  --accent-soft: rgba(255, 109, 90, .12);
-  --success: #63c883;
-  --warning: #f4c152;
-  --danger: #ff806e;
-  --info: #7bb8ff;
-  --evaluator: #b7a0eb;
-  --retrieval: #e6a86f;
-
-  --space-1: .25rem;
-  --space-2: .5rem;
-  --space-3: .75rem;
-  --space-4: 1rem;
-  --space-5: 1.5rem;
-  --space-6: 2rem;
-  --radius-sm: .5rem;
-  --radius-md: .75rem;
-  --radius-lg: 1rem;
-  --shadow-sm: 0 5px 14px rgba(0, 0, 0, .22);
-  --shadow-md: 0 16px 38px rgba(0, 0, 0, .32), 0 2px 8px rgba(0, 0, 0, .24);
-  --ease-out: cubic-bezier(.16, 1, .3, 1);
-  --z-sticky: 20;
-  --z-popover: 30;
-  --z-drawer: 40;
-  --z-toast: 50;
-  --z-modal: 60;
+  --surface-raised: #272a31;
+  --surface-control: #2d3038;
+  --text: #f5f5f6;
+  --text-2: #d5d7dc;
+  --muted: #9ca1ac;
+  --subtle: #747b87;
+  --border: #383c45;
+  --border-strong: #535966;
+  --coral: #ff6d5a;
+  --coral-soft: rgba(255, 109, 90, .13);
+  --blue: #58a7dc;
+  --blue-soft: rgba(88, 167, 220, .12);
+  --purple: #b68cff;
+  --purple-soft: rgba(182, 140, 255, .12);
+  --green: #62c78b;
+  --green-soft: rgba(98, 199, 139, .12);
+  --yellow: #e7b95b;
+  --shadow: 0 15px 35px rgba(0, 0, 0, .34), 0 2px 7px rgba(0, 0, 0, .3);
 }
 
 * { box-sizing: border-box; }
-
-html,
-body {
-  width: 100%;
-  height: 100%;
-  margin: 0;
-}
-
+html, body { width: 100%; height: 100%; margin: 0; }
 body {
   overflow: hidden;
   background: var(--bg);
-  color: var(--text-primary);
+  color: var(--text);
   font-family: var(--font-ui);
-  font-size: 100%;
-  font-weight: var(--weight-regular);
-  line-height: 1.5;
-  letter-spacing: .008em;
-  font-kerning: normal;
-  font-variant-numeric: tabular-nums;
+  font-size: 14px;
+  line-height: 1.45;
   text-rendering: optimizeLegibility;
 }
+button, input { font: inherit; }
+button { cursor: pointer; }
+button:focus-visible, input:focus-visible { outline: 2px solid var(--coral); outline-offset: 2px; }
+code { font-family: var(--font-mono); }
 
-button,
-input,
-select { font: inherit; }
-
-button,
-select { cursor: pointer; }
-
-button:focus-visible,
-input:focus-visible,
-select:focus-visible,
-[tabindex]:focus-visible {
-  outline: 2px solid var(--accent);
-  outline-offset: 3px;
-}
-
-.srOnly {
-  position: absolute !important;
-  width: 1px;
-  height: 1px;
-  overflow: hidden;
-  clip: rect(0, 0, 0, 0);
-  white-space: nowrap;
-}
-
-/* App shell and floating run controls */
-
-.appFrame {
-  position: relative;
-  height: 100vh;
-  background: var(--bg);
-}
+.appFrame { position: relative; width: 100vw; height: 100vh; background: var(--canvas); }
+.canvasShell { position: absolute; inset: 0; transition: right 180ms ease; }
+.canvasShell.withInspector { right: 430px; }
 
-.runHeader {
+/* n8n-style floating chrome */
+.topShell {
   position: absolute;
-  z-index: var(--z-sticky);
-  top: var(--space-4);
-  left: var(--space-4);
-  right: var(--space-4);
-  pointer-events: none;
-}
-
-.withInspector .runHeader { right: 29.75rem; }
-
-.topBar {
-  min-height: 4rem;
-  display: flex;
-  align-items: center;
-  gap: var(--space-2);
-  padding: var(--space-2);
-  border: 1px solid var(--border);
-  border-radius: var(--radius-md);
-  background: var(--surface);
-  box-shadow: var(--shadow-md);
-  pointer-events: auto;
-}
-
-.productMark {
-  min-width: max-content;
-  display: flex;
-  align-items: center;
-  gap: var(--space-2);
-  padding: 0 var(--space-3) 0 var(--space-1);
-}
-
-.productIcon {
-  width: 2.5rem;
-  height: 2.5rem;
-  display: grid;
-  place-items: center;
-  flex: none;
-  color: var(--accent);
-  border: 1px solid rgba(255, 109, 90, .42);
-  border-radius: var(--radius-sm);
-  background: var(--accent-soft);
-}
-
-.productMark h1 {
-  margin: 0;
-  font-size: var(--type-body);
-  font-weight: var(--weight-semibold);
-  line-height: 1.25;
-}
-
-.buildPicker {
-  min-width: 19.5rem;
-  display: flex;
-  align-items: center;
-  gap: var(--space-2);
-}
-
-.buildPicker select,
-.buildPicker input {
-  height: 2.75rem;
-  min-width: 0;
-  padding: 0 var(--space-3);
-  border: 1px solid var(--border);
-  border-radius: var(--radius-sm);
-  background: var(--canvas);
-  color: var(--text-primary);
-  font-size: var(--type-ui);
-  transition: border-color 160ms var(--ease-out), background 160ms var(--ease-out);
-}
-
-.buildPicker select:hover,
-.buildPicker input:hover { border-color: var(--border-strong); }
-.buildPicker select { width: 12rem; }
-.buildPicker input { width: 7rem; }
-.buildPicker input::placeholder { color: var(--text-muted); opacity: 1; }
-
-.quietButton,
-.iconButton {
-  min-height: 2.75rem;
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  gap: .375rem;
-  border-radius: var(--radius-sm);
-  white-space: nowrap;
-  font-size: var(--type-ui);
-  font-weight: var(--weight-semibold);
-  transition: color 160ms var(--ease-out), border-color 160ms var(--ease-out), background 160ms var(--ease-out), transform 160ms var(--ease-out);
-}
-
-.quietButton:active,
-.iconButton:active { transform: translateY(1px); }
-
-.quietButton,
-.iconButton {
-  border: 1px solid var(--border);
-  background: var(--surface-control);
-  color: var(--text-secondary);
-}
-
-.quietButton { padding: 0 var(--space-3); }
-.quietButton:hover,
-.iconButton:hover { border-color: var(--border-strong); color: var(--text-primary); background: #31353e; }
-.iconButton { width: 2.75rem; padding: 0; }
-
-.roundNav {
-  min-width: 0;
-  display: flex;
-  align-items: center;
-  gap: .375rem;
-  overflow-x: auto;
-  scrollbar-width: none;
-}
-.roundNav::-webkit-scrollbar { display: none; }
-.roundNav button {
-  min-width: max-content;
-  min-height: 2.75rem;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  gap: .375rem;
-  padding: 0 var(--space-3);
-  border: 1px solid var(--border);
-  border-radius: var(--radius-sm);
-  background: var(--canvas);
-  color: var(--text-secondary);
-  font-size: var(--type-label);
-  font-weight: var(--weight-medium);
-}
-.roundNav button:hover { border-color: var(--border-strong); }
-.roundNav button.active {
-  border-color: var(--accent);
-  background: var(--accent-soft);
-  color: var(--text-primary);
-}
-
-.runMeta {
-  display: flex;
-  align-items: center;
-  gap: .375rem;
-  margin-left: auto;
-}
-
-.refreshing { width: 1.75rem; display: grid; place-items: center; color: var(--text-muted); }
-.refreshing svg { animation: spin 1s linear infinite; }
-@keyframes spin { to { transform: rotate(360deg); } }
-
-.dataNotes { position: relative; }
-.dataNotes summary {
-  min-height: 2.75rem;
-  display: flex;
-  align-items: center;
-  gap: .375rem;
-  padding: 0 var(--space-3);
-  border-radius: var(--radius-sm);
-  color: var(--warning);
-  font-size: var(--type-label);
-  font-weight: var(--weight-medium);
-  white-space: nowrap;
-  cursor: pointer;
-  list-style: none;
-}
-.dataNotes summary::-webkit-details-marker { display: none; }
-.dataNotes summary:hover { background: var(--surface-control); }
-.dataNotes summary b {
-  min-width: 1.25rem;
-  height: 1.25rem;
-  display: grid;
-  place-items: center;
-  border-radius: 999px;
-  background: rgba(244, 193, 82, .13);
-  font-size: var(--type-caption);
-}
-.dataNotes > div {
+  z-index: 30;
+  top: 14px;
+  left: 14px;
+  right: 14px;
+  height: 64px;
+  display: flex;
+  align-items: center;
+  gap: 18px;
+  padding: 8px 12px;
+  border: 1px solid var(--border);
+  border-radius: 12px;
+  background: rgba(31, 33, 39, .94);
+  box-shadow: var(--shadow);
+  backdrop-filter: blur(16px);
+  transition: right 180ms ease;
+}
+.topShell.withInspector { right: 444px; }
+.brand { display: flex; align-items: center; gap: 10px; padding-right: 18px; border-right: 1px solid var(--border); }
+.brand > span { width: 40px; height: 40px; display: grid; place-items: center; color: var(--coral); border: 1px solid rgba(255,109,90,.4); border-radius: 9px; background: var(--coral-soft); }
+.brand div, .runTitle { display: flex; flex-direction: column; min-width: 0; }
+.brand strong { font-size: 14px; letter-spacing: .02em; }
+.brand small, .runTitle span { color: var(--muted); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; }
+.runTitle { min-width: 0; flex: 1; }
+.runTitle strong { max-width: 630px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; }
+.topStats { display: flex; align-items: center; gap: 7px; }
+.topStats > span { min-height: 34px; display: inline-flex; align-items: center; gap: 5px; padding: 0 10px; border: 1px solid var(--border); border-radius: 7px; color: var(--text-2); background: #262930; font-size: 11px; font-weight: 650; white-space: nowrap; }
+.topStats .fakeBadge { color: #ffc5bc; border-color: rgba(255,109,90,.38); background: var(--coral-soft); }
+
+.phaseRail {
   position: absolute;
-  top: 3.25rem;
-  right: 0;
-  width: min(28rem, 80vw);
-  padding: var(--space-4);
-  border: 1px solid var(--border);
-  border-radius: var(--radius-md);
-  background: var(--surface-raised);
-  box-shadow: var(--shadow-md);
-}
-.dataNotes ul {
-  margin: 0 0 var(--space-3);
-  padding-left: 1.25rem;
-  color: var(--text-secondary);
-  font-size: var(--type-ui);
-  line-height: 1.6;
-}
-.dataNotes li + li { margin-top: var(--space-2); }
-.dataNotes p,
-.dataNotes small { color: var(--text-muted); font-size: var(--type-label); }
-
-/* Canvas and React Flow */
-
-.workspace {
-  position: relative;
-  height: 100vh;
-  min-height: 0;
-  display: grid;
-  grid-template-columns: minmax(0, 1fr);
-  overflow: hidden;
-}
-.withInspector .workspace { grid-template-columns: minmax(0, 1fr) 28.75rem; }
-
-.flowShell {
-  position: relative;
-  min-width: 0;
-  min-height: 0;
-  overflow: hidden;
-  background: var(--canvas);
-}
-.react-flow { background: var(--canvas); }
-.react-flow__edge-path { stroke: #858b95; stroke-width: 1.5; }
-
-.react-flow__controls {
-  overflow: hidden;
-  border: 1px solid var(--border);
-  border-radius: var(--radius-sm);
-  background: var(--surface);
-  box-shadow: var(--shadow-sm);
-}
-.react-flow__controls-button {
-  width: 2.75rem !important;
-  height: 2.75rem !important;
-  border-bottom-color: var(--border) !important;
-  background: var(--surface) !important;
-  fill: var(--text-secondary) !important;
-}
-.react-flow__controls-button:hover { background: var(--surface-control) !important; }
-.react-flow__node.selected .flowCard {
-  border-color: var(--accent);
-  box-shadow: 0 0 0 1px var(--accent), 0 0 0 4px var(--accent-soft), var(--shadow-md);
-}
-
-/* Business cards */
-
+  z-index: 25;
+  top: 92px;
+  left: 14px;
+  width: 218px;
+  padding: 8px;
+  border: 1px solid var(--border);
+  border-radius: 11px;
+  background: rgba(31, 33, 39, .94);
+  box-shadow: var(--shadow);
+  backdrop-filter: blur(14px);
+}
+.railTitle { display: flex; align-items: center; gap: 7px; padding: 7px 9px 10px; color: var(--muted); font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
+.phaseRail button { width: 100%; height: 39px; display: flex; align-items: center; gap: 9px; padding: 0 9px; border: 0; border-radius: 7px; color: var(--muted); background: transparent; text-align: left; font-size: 12px; }
+.phaseRail button:hover { color: var(--text); background: var(--surface-control); }
+.phaseRail button.active { color: var(--text); background: #2a2d34; }
+.phaseRail button span:nth-child(2) { flex: 1; }
+.phaseRail button small { min-width: 24px; padding: 2px 5px; border-radius: 10px; color: var(--muted); background: #17191d; text-align: center; font-size: 10px; }
+.phaseDot { width: 9px; height: 9px; flex: none; border-radius: 50%; box-shadow: 0 0 0 3px rgba(255,255,255,.04); }
+.phase-phase-1 { background: var(--coral); }
+.phase-phase-2 { background: var(--blue); }
+.phase-phase-3 { background: var(--purple); }
+.phase-delivery { background: var(--green); }
+.railDivider { height: 1px; margin: 8px 4px; background: var(--border); }
+.checkpoint { margin: 10px 4px 3px; padding: 9px; border: 1px solid rgba(231,185,91,.24); border-radius: 7px; background: rgba(231,185,91,.07); }
+.checkpoint small { display: block; margin-bottom: 5px; color: var(--yellow); font-size: 9px; letter-spacing: .08em; }
+.checkpoint code { display: block; overflow-wrap: anywhere; color: var(--text-2); font-size: 9px; line-height: 1.45; }
+
+.canvasToolbar { display: flex !important; align-items: center; gap: 9px; margin: 84px 0 0 234px !important; padding: 6px !important; border: 1px solid var(--border); border-radius: 9px; background: rgba(31,33,39,.94) !important; box-shadow: 0 8px 20px rgba(0,0,0,.25); }
+.canvasToolbar label { width: 310px; height: 36px; display: flex; align-items: center; gap: 7px; padding: 0 9px; border-radius: 6px; color: var(--muted); background: #16181c; }
+.canvasToolbar input { min-width: 0; flex: 1; border: 0; outline: 0; color: var(--text); background: transparent; font-size: 12px; }
+.canvasToolbar input::placeholder { color: #737985; }
+.canvasToolbar button { width: 24px; height: 24px; display: grid; place-items: center; padding: 0; border: 0; color: var(--muted); background: transparent; }
+.canvasToolbar > span { padding-right: 7px; color: var(--muted); font-size: 10px; white-space: nowrap; }
+
+.legend { display: flex !important; gap: 12px; padding: 7px 10px !important; border: 1px solid var(--border); border-radius: 8px; background: rgba(31,33,39,.9) !important; box-shadow: 0 7px 18px rgba(0,0,0,.24); }
+.legend span { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px; }
+.legend i { width: 17px; height: 2px; border-radius: 2px; }
+.legend-control { background: #68707f; }.legend-artifact { background: var(--blue); }.legend-validation { background: var(--purple); }.legend-publication { background: var(--green); }
+
+/* Phase frames */
+.phaseFrame { width: 100%; height: 100%; border: 1px dashed var(--border-strong); border-radius: 18px; background: rgba(25,27,32,.4); pointer-events: none; }
+.phaseFrame::before { content: ""; position: absolute; inset: 0; border-radius: inherit; opacity: .33; }
+.phaseFrame.tone-coral { border-color: rgba(255,109,90,.36); }.phaseFrame.tone-coral::before { background: linear-gradient(135deg, var(--coral-soft), transparent 28%); }
+.phaseFrame.tone-blue { border-color: rgba(88,167,220,.34); }.phaseFrame.tone-blue::before { background: linear-gradient(135deg, var(--blue-soft), transparent 24%); }
+.phaseFrame.tone-purple { border-color: rgba(182,140,255,.34); }.phaseFrame.tone-purple::before { background: linear-gradient(135deg, var(--purple-soft), transparent 28%); }
+.phaseFrame.tone-green { border-color: rgba(98,199,139,.34); }.phaseFrame.tone-green::before { background: linear-gradient(135deg, var(--green-soft), transparent 28%); }
+.phaseLabel { position: absolute; top: 20px; left: 24px; display: flex; flex-direction: column; gap: 3px; }
+.phaseLabel span { color: var(--text-2); font-size: 18px; font-weight: 720; letter-spacing: -.01em; }
+.phaseLabel small { color: var(--muted); font-size: 11px; }
+
+/* Flow cards */
 .flowCard {
-  width: 20rem;
-  min-height: 12.25rem;
-  display: flex;
-  flex-direction: column;
-  gap: var(--space-4);
-  padding: 1.125rem;
-  border: 1px solid var(--border);
-  border-radius: var(--radius-md);
-  background: var(--surface);
-  color: var(--text-primary);
-  box-shadow: var(--shadow-sm);
-  user-select: none;
-  transition: border-color 180ms var(--ease-out), box-shadow 180ms var(--ease-out), background 180ms var(--ease-out);
-}
-.flowCard:hover { border-color: var(--border-strong); background: #22252b; }
-.flowHandle {
-  width: .625rem !important;
-  height: .625rem !important;
-  border: 2px solid var(--canvas) !important;
-  background: #858b95 !important;
-}
-.cardHeader {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: var(--space-2);
-}
-.cardIdentity { min-width: 0; display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
-.actorBadge {
-  display: inline-flex;
-  align-items: center;
-  min-height: 1.5rem;
-  padding: .125rem .5rem;
-  border: 1px solid currentColor;
-  border-radius: 999px;
-  font-size: var(--type-caption);
-  font-weight: var(--weight-semibold);
-  line-height: 1.2;
-  white-space: nowrap;
-}
-.actor-main { color: var(--accent); background: rgba(255, 109, 90, .06); }
-.actor-implementer { color: var(--info); background: rgba(123, 184, 255, .06); }
-.actor-retrieval { color: var(--retrieval); background: rgba(230, 168, 111, .065); }
-.actor-evaluator { color: var(--evaluator); background: rgba(183, 160, 235, .065); }
-.flowCard h3 {
-  margin: 0;
-  color: var(--text-primary);
-  font-size: var(--type-title);
-  font-weight: var(--weight-semibold);
-  line-height: 1.35;
-  letter-spacing: -.01em;
-  text-wrap: balance;
-}
-.storyRole,
-.roundNumber,
-.candidateLabel,
-.finalLabel {
-  display: inline-flex;
-  align-items: center;
-  gap: .375rem;
-  color: var(--text-muted);
-  font-size: var(--type-caption);
-  font-weight: var(--weight-semibold);
-  line-height: 1.35;
-}
-.role-objective,
-.story-decision { border-color: rgba(255, 109, 90, .5); }
-.role-branch-task,
-.role-candidate-output { border-color: rgba(123, 184, 255, .42); }
-.role-data-decision { border-color: rgba(230, 168, 111, .42); }
-.role-multipath-decision { border-color: rgba(255, 109, 90, .52); }
-.role-domain-output { border-color: rgba(99, 200, 131, .44); }
-.role-multipath-review,
-.role-round-evaluation { border-color: rgba(183, 160, 235, .48); }
-
-.story-result,
-.runResultCard { border-color: rgba(99, 200, 131, .48); }
-.story-missing { border-color: rgba(244, 193, 82, .5); border-style: dashed; }
-
-.agentDecisionCard { min-height: 18.5rem; }
-.agentDecisionCard.decision-direction { border-color: rgba(255, 109, 90, .55); }
-.agentDecisionCard.decision-tradeoff { border-color: rgba(230, 168, 111, .52); }
-.agentDecisionCard.decision-evaluation { border-color: rgba(183, 160, 235, .54); }
-.agentDecisionCard.decision-creative { border-color: rgba(123, 184, 255, .5); }
-.authorityBadge {
-  display: inline-flex;
-  align-items: center;
-  min-height: 1.5rem;
-  padding: .125rem .5rem;
-  border: 1px solid var(--border);
-  border-radius: 999px;
-  color: var(--text-muted);
-  background: rgba(255, 255, 255, .025);
-  font-size: var(--type-caption);
-  font-weight: var(--weight-semibold);
-  white-space: nowrap;
-}
-.authority-final { color: var(--accent-hover); border-color: rgba(255, 109, 90, .42); }
-.authority-recommendation { color: var(--evaluator); border-color: rgba(183, 160, 235, .42); }
-.authority-implementation-scope { color: var(--info); border-color: rgba(123, 184, 255, .38); }
-.promptAction { color: var(--text-muted); }
-.inspectorDecisionActions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
-
-.cardFields,
-.candidateSummary { display: grid; gap: var(--space-3); }
-.cardFields > div,
-.candidateSummary > div {
-  display: grid;
-  grid-template-columns: 3.25rem minmax(0, 1fr);
-  gap: var(--space-2);
-  align-items: start;
-}
-.cardFields > div,
-.candidateSummary > div { grid-template-columns: 4.5rem minmax(0, 1fr); }
-.cardFields b,
-.candidateSummary b {
-  padding-top: .125rem;
-  color: var(--text-muted);
-  font-size: var(--type-caption);
-  font-weight: var(--weight-semibold);
-  line-height: 1.5;
-  white-space: nowrap;
-}
-.cardFields p,
-.candidateSummary p {
-  margin: 0;
-  color: var(--text-secondary);
-  font-size: var(--type-ui);
-  font-weight: var(--weight-regular);
-  line-height: 1.58;
-  letter-spacing: .012em;
-  overflow-wrap: anywhere;
-  white-space: pre-wrap;
-  text-wrap: pretty;
-}
-.cardFields .cardFieldPrimary p {
-  color: var(--text-primary);
-  font-size: var(--type-body);
-  font-weight: var(--weight-semibold);
-  line-height: 1.58;
-}
-
-.cardFooter {
-  display: flex;
-  align-items: center;
-  gap: var(--space-2);
-  margin-top: auto;
-  flex-wrap: wrap;
-}
-.nodeAction {
-  min-height: 2.75rem;
-  display: inline-flex;
-  align-items: center;
-  gap: .375rem;
-  padding: 0 var(--space-3);
-  border: 1px solid var(--border);
-  border-radius: var(--radius-sm);
-  background: var(--canvas);
-  color: var(--text-secondary);
-  font-size: var(--type-label);
-  font-weight: var(--weight-semibold);
-  transition: color 160ms var(--ease-out), border-color 160ms var(--ease-out), background 160ms var(--ease-out);
-}
-.nodeAction:hover { border-color: var(--accent); background: var(--accent-soft); color: var(--text-primary); }
-.nodeAction.artifactAction {
-  border-color: rgba(255, 109, 90, .42);
-  color: var(--accent-hover);
-}
-.nodeAction.lineageAction,
-.frameDetail.lineageAction {
-  border-color: rgba(123, 184, 255, .34);
-  color: #a9cfff;
-  background: rgba(123, 184, 255, .045);
-}
-.nodeAction.lineageAction:hover,
-.frameDetail.lineageAction:hover { border-color: var(--info); background: rgba(123, 184, 255, .11); color: #d6e9ff; }
-
-.roundSummaryCard { width: 22.5rem; min-height: 25rem; }
-.roundSummaryContent { display: grid; gap: 1.125rem; }
-.roundGoalSummary {
-  display: grid;
-  gap: .5rem;
-  padding-bottom: 1rem;
-  border-bottom: 1px solid var(--border-subtle);
-}
-.roundGoalSummary > b,
-.roundSummaryBlock > b {
-  color: var(--text-muted);
-  font-size: var(--type-label);
-  font-weight: var(--weight-semibold);
-  line-height: 1.4;
-}
-.roundGoalSummary > p {
-  display: -webkit-box;
-  margin: 0;
-  overflow: hidden;
-  color: var(--text-primary);
-  font-size: var(--type-body);
-  font-weight: var(--weight-semibold);
-  line-height: 1.55;
-  letter-spacing: 0;
-  overflow-wrap: anywhere;
-  text-wrap: pretty;
-  -webkit-box-orient: vertical;
-  -webkit-line-clamp: 3;
-}
-.roundGoalSummary > small {
-  color: var(--text-subtle);
-  font-size: var(--type-caption);
-  line-height: 1.45;
-}
-.roundSummaryFacts { display: grid; gap: .9375rem; }
-.roundSummaryBlock {
-  display: grid;
-  grid-template-columns: 5.75rem minmax(0, 1fr);
-  gap: var(--space-3);
-  align-items: start;
-}
-.roundSummaryBlock > div { min-width: 0; display: grid; gap: .25rem; }
-.roundSummaryBlock p,
-.roundSummaryBlock small {
-  margin: 0;
-  overflow-wrap: anywhere;
-  text-wrap: pretty;
-}
-.roundSummaryBlock p {
-  color: var(--text-secondary);
-  font-size: var(--type-ui);
-  font-weight: var(--weight-medium);
-  line-height: 1.55;
-}
-.roundSummaryBlock small {
-  display: -webkit-box;
-  overflow: hidden;
-  color: var(--text-subtle);
-  font-size: var(--type-caption);
-  line-height: 1.5;
-  -webkit-box-orient: vertical;
-  -webkit-line-clamp: 2;
-}
-.summaryTone-decision p:first-child { color: var(--accent-hover); }
-.summaryTone-success p { color: var(--success); }
-.summaryTone-warning p { color: var(--warning); }
-.candidateCard { width: 22.5rem; min-height: 13rem; border-color: rgba(123, 184, 255, .38); }
-.runResultCard {
-  width: 22.5rem;
-  min-height: 23rem;
-  border-color: rgba(99, 200, 131, .52);
-  background: #1d2422;
-}
-.runResultCard:hover { border-color: rgba(99, 200, 131, .72); background: #202824; }
-.runResultCard .finalLabel { color: var(--success); }
-.finalOutcome {
-  display: flex;
-  align-items: flex-start;
-  gap: var(--space-3);
-}
-.finalOutcome > span {
-  width: 2rem;
-  height: 2rem;
-  flex: 0 0 auto;
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  border-radius: 50%;
-  background: rgba(99, 200, 131, .12);
-  color: var(--success);
-}
-.finalOutcome > div { min-width: 0; display: grid; gap: var(--space-1); }
-.finalOutcome b { color: var(--text-primary); font-size: var(--type-body); font-weight: var(--weight-semibold); }
-.finalOutcome p,
-.finalSummaryPreview p { margin: 0; color: var(--text-secondary); font-size: var(--type-ui); line-height: 1.58; text-wrap: pretty; }
-.finalOutcome p { color: #b8c4bf; font-size: var(--type-label); }
-.finalSummaryPreview {
-  display: grid;
-  gap: var(--space-2);
-  padding-block: var(--space-3);
-  border-top: 1px solid rgba(99, 200, 131, .15);
-  border-bottom: 1px solid rgba(99, 200, 131, .15);
-}
-.finalSummaryPreview > b { color: #a9d8b7; font-size: var(--type-caption); font-weight: var(--weight-semibold); }
-.finalSummaryPreview p {
-  display: -webkit-box;
-  overflow: hidden;
-  -webkit-box-orient: vertical;
-  -webkit-line-clamp: 3;
-}
-.finalRunFacts { display: flex; align-items: center; gap: var(--space-4); color: #aebbb6; font-size: var(--type-caption); }
-.finalRunFacts b { color: var(--text-primary); font-weight: var(--weight-semibold); }
-
-.artifactStats,
-.metricGrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-2); }
-.artifactStats span,
-.metricGrid span {
-  padding: var(--space-3) var(--space-2);
-  border: 1px solid var(--border-subtle);
-  border-radius: var(--radius-sm);
-  background: var(--surface-subtle);
-  color: var(--text-muted);
-  font-size: var(--type-caption);
-  text-align: center;
-}
-.runResultCard .artifactStats span {
-  border-color: rgba(99, 200, 131, .18);
-  background: rgba(99, 200, 131, .055);
-  color: #aebbb6;
-}
-.runResultCard .artifactStats b { color: var(--success); }
-.artifactStats b,
-.metricGrid b {
-  display: block;
-  margin-bottom: var(--space-1);
-  color: var(--text-primary);
-  font-size: var(--type-title);
-  font-weight: var(--weight-semibold);
-}
-
-.sourceBadge,
-.statusBadge {
-  width: max-content;
-  justify-self: start;
-  display: inline-flex;
-  align-items: center;
-  min-height: 1.5rem;
-  padding: .125rem .5rem;
-  border: 1px solid var(--border);
-  border-radius: 999px;
-  background: rgba(255, 255, 255, .025);
-  color: var(--text-secondary);
-  font-size: var(--type-caption);
-  font-weight: var(--weight-semibold);
-  line-height: 1.25;
-  white-space: nowrap;
-}
-.source-runtime-associated,
-.source-process-incomplete,
-.source-record-missing,
-.source-legacy-record { align-self: flex-start; color: var(--warning); }
-.status-merged,
-.status-success,
-.status-completed,
-.status-passed,
-.status-done { color: var(--success); }
-.status-parked,
-.status-partial,
-.status-partially-passed,
-.status-open,
-.status-not-evaluated,
-.status-unknown { color: var(--warning); }
-.status-discarded,
-.status-failed,
-.status-needs-retry,
-.status-cancel,
-.status-cancle,
-.status-cancelled { color: var(--danger); }
-.status-running { color: var(--accent); }
-.status-interrupted { color: var(--warning); }
-.status-legacy { color: var(--warning); border-color: rgba(244, 193, 82, .42); }
-
-/* Round group */
-
-.roundFrame {
-  width: 100%;
-  height: 100%;
-  position: relative;
-  overflow: hidden;
-  border: 1px solid var(--border);
-  border-radius: var(--radius-lg);
-  background: rgba(25, 27, 32, .55);
-}
-.roundFrame > header {
-  height: 5rem;
-  display: flex;
-  align-items: center;
-  gap: var(--space-4);
-  padding: var(--space-3) var(--space-4);
-  border-bottom: 1px solid var(--border-subtle);
-  background: var(--surface);
-}
-.roundFrame > header > div { min-width: 0; display: grid; gap: var(--space-1); }
-.roundFrame > header strong { font-size: var(--type-body); font-weight: var(--weight-semibold); }
-.roundFrame > header div span {
-  max-width: 44rem;
-  color: var(--text-muted);
-  font-size: var(--type-label);
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-.roundCollapse {
-  min-height: 2.75rem;
-  display: inline-flex;
-  align-items: center;
-  gap: .375rem;
-  padding: 0 var(--space-3);
-  border: 1px solid var(--border);
-  border-radius: var(--radius-sm);
-  background: var(--surface-control);
-  color: var(--text-secondary);
-  font-size: var(--type-label);
-  font-weight: var(--weight-semibold);
-}
-.roundCollapse:hover { border-color: var(--border-strong); color: var(--text-primary); }
-.branchCount {
-  display: inline-flex;
-  align-items: center;
-  gap: .375rem;
-  margin-left: auto;
-  color: var(--text-muted);
-  font-size: var(--type-label);
-  white-space: nowrap;
-}
-
-/* Agent ownership and convergence regions */
-
-.agentRegion {
-  width: 100%;
-  height: 100%;
-  position: relative;
-  overflow: hidden;
-  border: 1px solid var(--border-subtle);
-  border-radius: var(--radius-md);
-  background: rgba(20, 22, 26, .34);
-  pointer-events: none;
-}
-.agentRegion > header {
-  min-height: 3.375rem;
-  display: flex;
-  align-items: baseline;
-  gap: var(--space-3);
-  padding: var(--space-3) var(--space-4);
-  border-bottom: 1px solid currentColor;
-  color: var(--text-muted);
-}
-.agentRegion > header strong {
-  color: currentColor;
-  font-size: var(--type-ui);
-  font-weight: var(--weight-semibold);
-  white-space: nowrap;
-}
-.agentRegion > header span {
-  min-width: 0;
-  color: var(--text-subtle);
-  font-size: var(--type-caption);
-  line-height: 1.4;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-.region-main { border-color: rgba(255, 109, 90, .22); background: rgba(255, 109, 90, .025); }
-.region-main > header { color: #d9897e; border-bottom-color: rgba(255, 109, 90, .16); }
-.region-implementer { border-color: rgba(123, 184, 255, .24); background: rgba(123, 184, 255, .025); }
-.region-implementer > header { color: #82b5e9; border-bottom-color: rgba(123, 184, 255, .17); }
-.region-evaluator { border-color: rgba(183, 160, 235, .24); background: rgba(183, 160, 235, .025); }
-.region-evaluator > header { color: #ab99d5; border-bottom-color: rgba(183, 160, 235, .17); }
-.region-outcome { border-color: rgba(99, 200, 131, .24); background: rgba(99, 200, 131, .025); }
-.region-outcome > header { color: #71bc88; border-bottom-color: rgba(99, 200, 131, .17); }
-
-.branchFrame {
-  width: 100%;
-  height: 100%;
-  position: relative;
-  overflow: hidden;
-  border: 1px solid rgba(123, 184, 255, .38);
-  border-radius: var(--radius-md);
-  background: rgba(29, 35, 43, .78);
-  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .025);
-}
-.branchFrame > header {
-  min-height: 4.125rem;
-  display: flex;
-  align-items: center;
-  gap: var(--space-3);
-  padding: var(--space-2) var(--space-3);
-  border-bottom: 1px dashed rgba(123, 184, 255, .26);
-  background: rgba(123, 184, 255, .035);
-}
-.branchFrameTitle { display: flex; align-items: center; gap: var(--space-3); }
-.branchFrameTitle span {
-  display: inline-flex;
-  align-items: center;
-  gap: .375rem;
-  color: var(--info);
-  font-size: var(--type-caption);
-  font-weight: var(--weight-semibold);
-}
-.branchFrameTitle strong { font-size: var(--type-ui); font-weight: var(--weight-semibold); }
-.branchFrameActions { display: flex; gap: var(--space-2); margin-left: auto; }
-.branchFrame .nodeAction { min-height: 2.5rem; }
-
-/* Retrieval is a real nested stage: direct tools and delegated agents share one level. */
-
-.retrievalStageFrame {
-  width: 100%;
-  height: 100%;
-  position: relative;
-  overflow: hidden;
-  border: 1px dashed rgba(230, 168, 111, .45);
-  border-radius: var(--radius-md);
-  background: rgba(230, 168, 111, .035);
-  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .02);
-}
-.retrievalStageFrame > header {
-  min-height: 4.5rem;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: var(--space-3);
-  padding: var(--space-3) var(--space-4);
-  border-bottom: 1px dashed rgba(230, 168, 111, .28);
-  background: rgba(230, 168, 111, .035);
-}
-.retrievalStageFrame > header > div { display: flex; align-items: center; gap: var(--space-2); }
-.retrievalStageFrame > header > .retrievalStageActions { flex: none; }
-.retrievalStageIcon {
-  width: 1.75rem;
-  height: 1.75rem;
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  border-radius: 50%;
-  background: rgba(230, 168, 111, .12);
-  color: var(--retrieval);
-}
-.retrievalStageFrame > header strong { color: var(--retrieval); font-size: var(--type-ui); font-weight: var(--weight-semibold); }
-.retrievalStageFrame > header em {
-  padding: .125rem .5rem;
-  border: 1px solid rgba(230, 168, 111, .28);
-  border-radius: 999px;
-  color: var(--text-muted);
-  font-size: var(--type-caption);
-  font-style: normal;
-  white-space: nowrap;
-}
-.frameDetail {
-  min-height: 2.75rem;
-  display: inline-flex;
-  align-items: center;
-  gap: .375rem;
-  padding: 0 var(--space-3);
-  border: 1px solid var(--border);
-  border-radius: var(--radius-sm);
-  background: var(--canvas);
-  color: var(--text-secondary);
-  font-size: var(--type-caption);
-  font-weight: var(--weight-semibold);
-}
-.retrievalEmpty {
-  height: calc(100% - 4.5rem);
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  gap: var(--space-2);
-  color: var(--text-muted);
-  font-size: var(--type-label);
-}
-
-.retrievalCard {
-  width: 21.25rem;
-  min-height: 14rem;
-  border-color: rgba(230, 168, 111, .34);
-  background: rgba(31, 30, 30, .97);
-}
-.retrievalCard > header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); }
-.retrievalCard h3 { font-size: var(--type-body); }
-.retrievalKind {
-  display: inline-flex;
-  align-items: center;
-  gap: .375rem;
-  color: var(--retrieval);
-  font-size: var(--type-label);
-  font-weight: var(--weight-semibold);
-}
-.retrievalOwner,
-.agentRunStatus { color: var(--text-subtle); font-size: var(--type-caption); white-space: nowrap; }
-.run-completed { color: var(--success); }
-.run-failed { color: var(--danger); }
-.run-running { color: var(--accent); }
-.retrievalStats { display: grid; gap: var(--space-2); margin: 0; }
-.retrievalStats > div { display: grid; grid-template-columns: 4rem minmax(0, 1fr); gap: var(--space-3); }
-.retrievalStats dt,
-.agentTask b,
-.agentQuerySummary b,
-.agentScreening b { color: var(--text-muted); font-size: var(--type-caption); font-weight: var(--weight-semibold); }
-.retrievalStats dd { margin: 0; color: var(--text-secondary); font-size: var(--type-ui); }
-.retrievalAgentCard { min-height: 18.25rem; }
-.agentTask,
-.agentQuerySummary,
-.agentScreening { display: grid; grid-template-columns: 4.5rem minmax(0, 1fr); gap: var(--space-3); align-items: start; }
-.agentTask p,
-.agentQuerySummary p,
-.agentScreening p {
-  display: -webkit-box;
-  margin: 0;
-  overflow: hidden;
-  color: var(--text-secondary);
-  font-size: var(--type-label);
-  line-height: 1.55;
-  overflow-wrap: anywhere;
-  -webkit-box-orient: vertical;
-  -webkit-line-clamp: 2;
-}
-.query-hit { color: var(--success); }
-.query-empty { color: var(--text-muted); }
-.query-failure { color: var(--danger); }
-.query-unknown { color: var(--warning); }
-.query-interrupted { color: var(--warning); }
-.retrievalAgentCard > footer { display: flex; gap: var(--space-2); margin-top: auto; flex-wrap: wrap; }
-.queryAttemptList { display: grid; gap: .25rem; margin: var(--space-1) 0 0; padding: var(--space-2) 0 0; border-top: 1px solid var(--border-subtle); list-style: none; }
-.queryAttemptList button {
-  width: 100%;
-  min-height: 2.75rem;
-  display: grid;
-  grid-template-columns: 1.8rem minmax(0, 1fr) auto;
-  align-items: center;
-  gap: var(--space-2);
-  padding: .25rem .375rem;
-  border: 0;
-  border-radius: .375rem;
-  background: transparent;
-  text-align: left;
-}
-.queryAttemptList button:hover { background: rgba(255, 255, 255, .035); }
-.queryIndex { color: var(--text-subtle); font-size: var(--type-caption); font-variant-numeric: tabular-nums; }
-.queryText { overflow: hidden; color: var(--text-secondary); font-size: var(--type-caption); text-overflow: ellipsis; white-space: nowrap; }
-.queryStatus { font-size: var(--type-caption); font-weight: var(--weight-semibold); white-space: nowrap; }
-.queryStatus.query-hit { color: var(--success); }
-.queryStatus.query-empty,
-.queryStatus.query-unknown { color: var(--text-muted); }
-.queryStatus.query-failure { color: var(--danger); }
-.queryStatus.query-running { color: var(--accent); }
-.queryStatus.query-interrupted { color: var(--warning); }
-
-.retrievalHoverPreview {
-  position: fixed;
-  z-index: var(--z-popover);
-  width: min(23.75rem, calc(100vw - 1.5rem));
-  max-height: min(31rem, calc(100vh - 1.5rem));
-  overflow: auto;
-  display: grid;
-  gap: var(--space-4);
-  padding: var(--space-4);
-  border: 1px solid var(--border-strong);
-  border-radius: var(--radius-md);
-  background: #202228;
-  box-shadow: var(--shadow-md);
-  color: var(--text-secondary);
-  animation: retrievalPreviewIn 160ms var(--ease-out);
-}
-.retrievalPreviewHeader { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); }
-.retrievalPreviewHeader > div { min-width: 0; }
-.retrievalPreviewHeader small { display: block; color: var(--text-muted); font-size: var(--type-caption); }
-.retrievalPreviewHeader h3 { margin: .1rem 0 0; color: var(--text-primary); font-size: var(--type-body); line-height: 1.4; text-wrap: balance; }
-.retrievalPreviewHeader strong { align-self: start; padding: .2rem .5rem; border-radius: 999px; background: rgba(99, 200, 131, .1); color: #91dca8; font-size: var(--type-caption); white-space: nowrap; }
-.retrievalPreviewIcon { width: 2.25rem; height: 2.25rem; display: grid; place-items: center; border-radius: .45rem; }
-.retrievalPreviewIcon.is-agent { background: rgba(230, 168, 111, .12); color: var(--retrieval); }
-.retrievalPreviewIcon.is-tool { background: rgba(123, 184, 255, .11); color: #afd2ff; }
-.retrievalHoverPreview section { display: grid; gap: var(--space-2); }
-.retrievalHoverPreview section + section { padding-top: var(--space-3); border-top: 1px solid var(--border-subtle); }
-.retrievalHoverPreview h4 { margin: 0; color: var(--text-primary); font-size: var(--type-label); font-weight: var(--weight-semibold); }
-.retrievalPreviewSummary,
-.retrievalPreviewText,
-.retrievalPreviewError { margin: 0; font-size: var(--type-ui); line-height: 1.65; overflow-wrap: anywhere; text-wrap: pretty; }
-.retrievalPreviewText { color: var(--text-secondary); }
-.retrievalPreviewError { color: #ffb0a2; }
-.retrievalHoverPreview ul { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
-.retrievalHoverPreview li { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-3); align-items: baseline; padding: .45rem .55rem; border-radius: .4rem; background: rgba(255, 255, 255, .035); }
-.retrievalHoverPreview li span { min-width: 0; color: var(--text-secondary); font-size: var(--type-label); line-height: 1.5; overflow-wrap: anywhere; }
-.retrievalHoverPreview li em { color: var(--success); font-size: var(--type-caption); font-style: normal; white-space: nowrap; }
-.retrievalHoverPreview li small { grid-column: 1 / -1; color: var(--text-muted); font-size: var(--type-caption); line-height: 1.55; }
-.retrievalPreviewCalls { display: grid; gap: var(--space-3); }
-.retrievalPreviewLoading { min-height: 5rem; display: flex; align-items: center; justify-content: center; gap: var(--space-2); color: var(--text-muted); font-size: var(--type-ui); }
-.retrievalPreviewLoading svg { animation: retrievalPreviewSpin 900ms linear infinite; }
-.retrievalPreviewMore { color: var(--text-muted); font-size: var(--type-caption); }
-.retrievalPreviewAction { min-height: 2.75rem; display: inline-flex; align-items: center; justify-content: center; gap: .4rem; padding: 0 var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-control); color: var(--text-primary); font-size: var(--type-label); font-weight: var(--weight-semibold); }
-.retrievalPreviewAction:hover { border-color: var(--info); background: rgba(123, 184, 255, .1); }
-.retrievalPreviewAction:focus-visible { outline: 2px solid var(--info); outline-offset: 2px; }
-@keyframes retrievalPreviewIn { from { opacity: 0; transform: translateY(.35rem); } to { opacity: 1; transform: translateY(0); } }
-@keyframes retrievalPreviewSpin { to { transform: rotate(360deg); } }
-
-/* Semantic zoom: reduce content density instead of shrinking unreadable prose. */
-
-.detail-compact .flowCard {
-  min-height: 8.75rem;
-  gap: var(--space-2);
-  padding: .875rem;
-}
-.detail-compact .flowCard h3 { font-size: var(--type-body); }
-.detail-compact .cardFields > div:nth-child(n + 2),
-.detail-compact .candidateSummary > div:nth-child(n + 2) { display: none; }
-.detail-compact .cardFields > div,
-.detail-compact .candidateSummary > div { grid-template-columns: 3.5rem minmax(0, 1fr); }
-.detail-compact .cardFields .cardField:first-child p { -webkit-line-clamp: 2; }
-.detail-compact .roundGoalSummary > small,
-.detail-compact .roundSummaryBlock small { display: none; }
-.detail-compact .finalSummaryPreview,
-.detail-compact .sourceBadge,
-.detail-compact .agentTask,
-.detail-compact .agentScreening { display: none; }
-.detail-compact .nodeAction {
-  min-height: 2.25rem;
-  padding-inline: .625rem;
-}
-.detail-compact .retrievalAgentCard { min-height: 13.75rem; }
-.detail-compact .queryAttemptList button { min-height: 2.25rem; }
-
-.detail-overview .cardFields,
-.detail-overview .roundSummaryContent,
-.detail-overview .candidateSummary,
-.detail-overview .finalSummaryPreview,
-.detail-overview .artifactStats,
-.detail-overview .finalRunFacts,
-.detail-overview .sourceBadge { display: none; }
-.detail-overview .flowCard {
-  min-height: 6.5rem;
-  justify-content: center;
-  gap: var(--space-2);
-  padding: var(--space-3);
-}
-.detail-overview .roundSummaryCard,
-.detail-overview .candidateCard,
-.detail-overview .runResultCard { min-height: 8.5rem; }
-.detail-overview .finalOutcome p { display: none; }
-.detail-overview .finalOutcome > span { width: 1.75rem; height: 1.75rem; }
-.detail-overview .retrievalCard { min-height: 7.5rem; }
-.detail-overview .retrievalCard .agentTask,
-.detail-overview .retrievalCard .agentQuerySummary,
-.detail-overview .retrievalCard .agentScreening,
-.detail-overview .retrievalCard .retrievalStats { display: none; }
-.detail-overview .queryAttemptList,
-.detail-overview .flowCard > footer,
-.detail-overview .flowCard > .cardFooter { display: none; }
-.detail-overview .roundFrame > header div span,
-.detail-overview .branchCount { display: none; }
-.detail-compact .agentRegion > header span,
-.detail-overview .agentRegion > header span { display: none; }
-.detail-overview .agentRegion > header { padding-inline: var(--space-3); }
-
-.cardFields .cardField p,
-.candidateSummary p {
-  display: -webkit-box;
-  overflow: hidden;
-  -webkit-box-orient: vertical;
-  text-overflow: ellipsis;
-  -webkit-line-clamp: 2;
-}
-.cardFields .cardField:first-child p { -webkit-line-clamp: 4; }
-.cardFields .tone-warning p { color: var(--warning); }
-.cardFields .tone-success p { color: var(--success); }
-.cardFields .tone-failure p { color: var(--danger); }
-
-/* Inspector and long-form reading */
-
-.inspector {
-  min-width: 0;
-  min-height: 0;
-  height: 100%;
-  max-height: 100%;
-  display: grid;
-  grid-template-rows: auto auto minmax(0, 1fr);
-  border-left: 1px solid var(--border);
-  background: var(--surface-subtle);
-  box-shadow: -14px 0 35px rgba(0, 0, 0, .22);
-  z-index: var(--z-drawer);
-}
-.inspectorHeader {
-  min-height: 5.5rem;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: var(--space-4);
-  padding: var(--space-4) var(--space-5);
-  border-bottom: 1px solid var(--border-subtle);
-}
-.inspectorHeader > div { min-width: 0; flex: 1; }
-.inspectorHeader > .inspectorHeaderActions { flex: none; display: flex; align-items: center; justify-content: flex-end; gap: var(--space-2); }
-.inspectorHeader > .iconButton { flex: none; }
-.inspectorBack { color: var(--text-secondary); }
-.inspectorHeader span {
-  color: var(--accent);
-  font-size: var(--type-caption);
-  font-weight: var(--weight-semibold);
-}
-.inspectorHeader h2 {
-  max-width: 22rem;
-  margin: var(--space-1) 0 0;
-  font-size: var(--inspector-title-size);
-  font-weight: var(--weight-semibold);
-  line-height: 1.35;
-  letter-spacing: -.015em;
-  text-wrap: balance;
-}
-.inspectorTabs {
-  display: grid;
-  grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
-  border-bottom: 1px solid var(--border-subtle);
-}
-.changeExactness {
-  margin: 0;
-  padding: var(--space-3) var(--space-4);
-  border: 1px solid var(--border-subtle);
-  border-radius: var(--radius-sm);
-  color: var(--text-muted);
-  font-size: var(--type-caption);
-  line-height: 1.55;
-}
-.inspectorTabs button {
-  min-height: 3rem;
-  padding: 0 var(--space-2);
-  border: 0;
-  border-bottom: 2px solid transparent;
-  background: transparent;
-  color: var(--text-muted);
-  font-size: var(--type-ui);
-  font-weight: var(--weight-medium);
-}
-.inspectorTabs button:hover { color: var(--text-secondary); background: rgba(255, 255, 255, .025); }
-.inspectorTabs button.active { border-bottom-color: var(--accent); color: var(--text-primary); font-weight: var(--weight-semibold); }
-
-.inspectorBody {
-  min-height: 0;
-  display: grid;
-  align-content: start;
-  gap: var(--space-5);
-  padding: var(--space-5);
-  overflow-x: hidden;
-  overflow-y: auto;
-  overscroll-behavior: contain;
-  scrollbar-gutter: stable;
-  touch-action: pan-y;
-}
-.inspectorBadgeRow { display: flex; gap: var(--space-2); flex-wrap: wrap; }
-.decisionMetaRow {
-  display: flex;
-  align-items: center;
-  gap: var(--space-2);
-  flex-wrap: wrap;
-}
-.decisionMetaRow span,
-.decisionMetaRow strong {
-  display: inline-flex;
-  min-height: 1.75rem;
-  align-items: center;
-  padding: .125rem .625rem;
-  border: 1px solid var(--border);
-  border-radius: 999px;
-  color: var(--text-secondary);
-  background: var(--surface);
-  font-size: var(--type-caption);
-  font-weight: var(--weight-semibold);
-}
-.decisionMetaRow strong { color: var(--accent-hover); border-color: rgba(255, 109, 90, .38); }
-.decisionDetailBlocks { display: grid; gap: var(--space-5); }
-.decisionDetailBlocks .inspectorPanel { margin: 0; }
-.decisionSummaryPanel[data-presentation="document"] {
-  gap: var(--space-4);
-  padding: 0 0 var(--space-5);
-  border: 0;
-  border-bottom: 1px solid var(--border-subtle);
-  border-radius: 0;
-  background: transparent;
-}
-.decisionSummaryPanel[data-presentation="document"] > h3 {
-  color: var(--accent-hover);
-}
-.decisionSummaryPanel[data-presentation="document"] > .richText {
-  max-width: 72ch;
-  color: var(--text-secondary);
-  font-size: var(--inspector-copy-size);
-  line-height: 1.75;
-}
-.decisionSummaryPanel[data-presentation="document"] .richText h2 {
-  margin: var(--space-6) 0 var(--space-3);
-  padding-bottom: var(--space-2);
-  border-bottom: 1px solid var(--border-subtle);
-  color: var(--text-primary);
-  font-size: var(--inspector-section-title-size);
-  line-height: 1.4;
-}
-.decisionSummaryPanel[data-presentation="document"] .richText h2:first-child { margin-top: 0; }
-.decisionSummaryPanel[data-presentation="document"] .richText h3 {
-  margin: var(--space-5) 0 var(--space-3);
-  color: var(--accent-hover);
-  font-size: var(--type-body);
-  line-height: 1.45;
-}
-.decisionSummaryPanel[data-presentation="document"] .richText > ul {
-  margin: 0;
-  padding-left: 1.25rem;
-}
-.decisionSummaryPanel[data-presentation="document"] .richText ul ul {
-  margin-top: var(--space-2);
-  padding-left: 1.1rem;
-}
-.decisionItemsPanel[data-presentation="labelled-list"] .inspectorDetailItem {
-  grid-template-columns: minmax(5.75rem, 7rem) minmax(0, 1fr);
-  gap: var(--space-4);
-}
-.decisionItemsPanel[data-presentation="labelled-list"] .inspectorDetailItem dt {
-  min-width: 0;
-  padding: 0;
-  border: 0;
-  border-radius: 0;
-  color: var(--accent-hover);
-  font-size: var(--inspector-field-label-size);
-  text-align: left;
-}
-.decisionItemsPanel[data-presentation="labelled-list"] .inspectorDetailItem .richText {
-  color: var(--text-primary);
-  line-height: 1.75;
-}
-.implementationPlanPanel {
-  gap: var(--space-4);
-  padding: 0 0 var(--space-5);
-  border: 0;
-  border-bottom: 1px solid var(--border-subtle);
-  border-radius: 0;
-  background: transparent;
-}
-.implementationPlanHeader,
-.implementationRouteHeader {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: var(--space-3);
-}
-.implementationPlanHeader > div { min-width: 0; display: grid; gap: .25rem; }
-.implementationPlanHeader h3 { color: var(--accent-hover); }
-.implementationPlanHeader p { margin: 0; color: var(--text-muted); font-size: var(--type-caption); }
-.implementationPlanHeader > span {
-  flex: none;
-  padding: .25rem .625rem;
-  color: var(--accent-hover);
-  border: 1px solid rgba(255, 109, 90, .38);
-  border-radius: 999px;
-  background: rgba(255, 109, 90, .08);
-  font-size: var(--type-caption);
-  font-weight: var(--weight-semibold);
-}
-.implementationRoutes { display: grid; gap: var(--space-3); margin: 0; padding: 0; list-style: none; }
-.implementationRoutes > li {
-  min-width: 0;
-  padding: var(--space-4);
-  border: 1px solid var(--border);
-  border-radius: var(--radius-md);
-  background: var(--surface);
-}
-.implementationRouteHeader { padding-bottom: var(--space-3); border-bottom: 1px solid var(--border-subtle); }
-.implementationRouteHeader > strong { color: var(--text-primary); font-size: var(--type-body); }
-.implementationRouteTags { display: inline-flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); }
-.implementationRouteTags b {
-  padding: .1875rem .5rem;
-  color: var(--text-secondary);
-  border: 1px solid var(--border);
-  border-radius: 999px;
-  background: var(--surface-control);
-  font-size: var(--type-caption);
-  font-weight: var(--weight-semibold);
-}
-.implementationRouteBody {
-  min-width: 0;
-  display: grid;
-  grid-template-columns: repeat(2, minmax(0, 1fr));
-  gap: var(--space-4);
-  padding-top: var(--space-4);
-}
-.implementationPlanField { min-width: 0; display: grid; align-content: start; gap: var(--space-2); }
-.implementationPlanField.is-wide { grid-column: 1 / -1; }
-.implementationPlanField > small { color: var(--text-muted); font-size: var(--inspector-field-label-size); font-weight: var(--weight-semibold); }
-.implementationPlanField .richText { color: var(--text-primary); font-size: var(--type-body); line-height: 1.7; overflow-wrap: break-word; word-break: normal; }
-.implementationReasoning { display: grid; gap: var(--space-2); padding-top: var(--space-4); border-top: 1px solid rgba(255, 109, 90, .2); }
-.implementationReasoning h4 { margin: 0; color: var(--text-muted); font-size: var(--inspector-field-label-size); }
-.implementationReasoning .richText { color: var(--text-primary); line-height: 1.75; }
-.creativeProcessPanel,
-.evaluationBranchesPanel,
-.evaluationMatrixPanel { gap: var(--space-4); }
-.creativeProcessHeader { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); }
-.creativeProcessHeader h3,
-.evaluationBranchesPanel > h3,
-.evaluationMatrixPanel > h3 { margin: 0; color: var(--info); font-size: var(--inspector-section-title-size); }
-.creativeProcessHeader p { margin: var(--space-1) 0 0; color: var(--text-muted); font-size: var(--type-caption); }
-.creativeProcessSteps { margin: 0; padding: 0; list-style: none; }
-.creativeProcessSteps > li { display: grid; gap: var(--space-3); padding: var(--space-4) 0; border-top: 1px solid var(--border-subtle); }
-.creativeProcessSteps > li:first-child { padding-top: 0; border-top: 0; }
-.creativeProcessSteps > li > header { display: grid; grid-template-columns: 4.5rem minmax(0, 1fr); align-items: baseline; gap: var(--space-3); }
-.creativeProcessSteps > li > header span { color: var(--text-muted); font-size: var(--type-caption); font-weight: var(--weight-semibold); }
-.creativeProcessSteps > li > header strong { color: var(--text-primary); font-size: var(--type-body); line-height: 1.5; }
-.creativeStepSummary { margin: 0 0 0 5.25rem; color: var(--text-secondary); font-size: var(--type-ui); line-height: 1.65; }
-.creativeStepPlan { display: grid; grid-template-columns: 4.5rem minmax(0, 1fr); gap: var(--space-3); }
-.creativeStepPlan > small { color: var(--text-muted); font-size: var(--inspector-field-label-size); font-weight: var(--weight-semibold); }
-.creativeStepPlan .decisionReadableText { color: var(--text-primary); font-weight: var(--weight-regular); }
-.creativeStepReasoning { margin-left: 5.25rem; }
-.creativeStepReasoning > summary { width: fit-content; display: inline-flex; align-items: center; gap: var(--space-1); color: var(--text-muted); font-size: var(--type-caption); cursor: pointer; list-style: none; }
-.creativeStepReasoning > summary::-webkit-details-marker { display: none; }
-.creativeStepReasoning > summary svg { transition: transform 180ms var(--ease-out); }
-.creativeStepReasoning[open] > summary svg { transform: rotate(90deg); }
-.creativeStepReasoning > .richText { margin-top: var(--space-3); color: var(--text-secondary); font-size: var(--type-ui); }
-.evaluationBranchList { display: grid; }
-.evaluationBranchList > article { display: grid; gap: var(--space-4); padding: var(--space-5) 0; border-top: 1px solid var(--border-subtle); }
-.evaluationBranchList > article:first-child { padding-top: 0; border-top: 0; }
-.evaluationBranchList > article > header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
-.evaluationBranchList h4 { margin: 0; color: var(--text-primary); font-size: var(--type-title); }
-.evaluationBranchList > article > header span { padding: .25rem .55rem; border: 1px solid var(--border); border-radius: 999px; color: var(--text-secondary); font-size: var(--type-caption); }
-.evaluationList { display: grid; grid-template-columns: 4.5rem minmax(0, 1fr); gap: var(--space-3); }
-.evaluationList > strong,
-.evaluationEvidence > strong { font-size: var(--inspector-field-label-size); font-weight: var(--weight-semibold); }
-.evaluationList.is-success > strong { color: var(--success); }
-.evaluationList.is-warning > strong { color: var(--warning); }
-.evaluationList ul { display: grid; gap: var(--space-3); margin: 0; padding: 0; list-style: none; }
-.evaluationList li { padding-bottom: var(--space-3); border-bottom: 1px solid var(--border-subtle); }
-.evaluationList li:last-child { padding-bottom: 0; border-bottom: 0; }
-.evaluationList .richText { color: var(--text-primary); font-size: var(--inspector-copy-size); line-height: 1.7; }
-.evaluationEvidence { display: grid; grid-template-columns: 4.5rem minmax(0, 1fr); gap: var(--space-3); padding-top: var(--space-3); border-top: 1px solid var(--border-subtle); }
-.evaluationEvidence > strong { color: var(--info); }
-.evaluationEvidence .richText { color: var(--text-secondary); font-size: var(--type-ui); }
-.evaluationMatrixScroll { overflow-x: auto; }
-.evaluationMatrixPanel table { width: 100%; min-width: 46rem; border-collapse: collapse; font-size: var(--type-ui); line-height: 1.55; }
-.evaluationMatrixPanel th,
-.evaluationMatrixPanel td { padding: var(--space-3); border: 1px solid var(--border); text-align: left; vertical-align: top; }
-.evaluationMatrixPanel thead th { color: var(--text-primary); background: var(--surface-control); font-weight: var(--weight-semibold); }
-.evaluationMatrixPanel tbody th { width: 10rem; color: var(--info); background: transparent; font-weight: var(--weight-semibold); }
-.evaluationMatrixPanel td { color: var(--text-secondary); }
-.decisionDisclosurePanel {
-  padding: 0;
-  border: 1px solid var(--border);
-  border-radius: var(--radius-md);
-  background: rgba(255, 255, 255, .018);
-  overflow: hidden;
-}
-.decisionDisclosure > summary {
-  min-height: 3.25rem;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: var(--space-3);
-  padding: var(--space-3) var(--space-4);
-  color: var(--text-secondary);
-  cursor: pointer;
-  list-style: none;
-  transition: color 160ms var(--ease-out), background 160ms var(--ease-out);
-}
-.decisionDisclosure > summary::-webkit-details-marker { display: none; }
-.decisionDisclosure > summary:hover { color: var(--text-primary); background: rgba(255, 255, 255, .025); }
-.decisionDisclosure > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
-.decisionDisclosureLabel { min-width: 0; display: inline-flex; align-items: center; gap: var(--space-2); }
-.decisionDisclosureLabel svg { flex: none; color: var(--text-muted); transition: transform 180ms var(--ease-out); }
-.decisionDisclosure[open] .decisionDisclosureLabel svg { transform: rotate(90deg); }
-.decisionDisclosureLabel strong { color: var(--text-primary); font-size: var(--inspector-section-title-size); font-weight: var(--weight-semibold); line-height: 1.4; }
-.decisionDisclosureCount { flex: none; color: var(--text-muted); font-size: var(--type-caption); }
-.decisionDisclosureBody { padding: var(--space-1) var(--space-4) var(--space-4); border-top: 1px solid var(--border-subtle); }
-.decisionDisclosureBody .inspectorDetailItems { padding-top: var(--space-4); }
-.decisionSummaryPanel {
-  gap: var(--space-3);
-  padding: 0 0 var(--space-5);
-  border: 0;
-  border-bottom: 1px solid var(--border-subtle);
-  border-radius: 0;
-  background: transparent;
-}
-.decisionSummaryPanel.is-role-key-result {
-  border-color: var(--border-subtle);
-  background: transparent;
-}
-.inspectorPanel.is-role-key-result > h3 { color: var(--accent-hover); }
-.inspectorPanel.is-role-section > h3,
-.inspectorPanel.is-role-reason > h3,
-.inspectorPanel.is-role-evidence > h3 { color: var(--info); }
-.decisionItemsPanel.is-role-evidence > h3 {
-  color: var(--accent-hover);
-  font-size: var(--type-title);
-}
-.inspectorPanel.is-role-notice > h3 { color: var(--warning); }
-.decisionSummaryPanel .decisionReadableText {
-  width: 100%;
-  min-width: 0;
-  max-width: 68ch;
-  color: var(--text-primary);
-  font-size: var(--inspector-copy-size);
-  font-weight: var(--weight-medium);
-  line-height: 1.78;
-  letter-spacing: 0;
-  text-wrap: pretty;
-  overflow-wrap: break-word;
-  word-break: normal;
-}
-.decisionReadableText > :first-child { margin-top: 0; }
-.decisionReadableText > :last-child { margin-bottom: 0; }
-.decisionReadableText p { margin-block: 0 var(--space-3); }
-.decisionReadableText ol { padding-left: 1.65rem; }
-.decisionReadableText ol > li { padding-left: .25rem; }
-.decisionReadableText ol > li + li { margin-top: var(--space-3); }
-.decisionNotice {
-  padding: var(--space-4);
-  border: 1px solid var(--border);
-  border-radius: var(--radius-sm);
-  background: var(--surface);
-}
-.decisionNotice.is-warning { border-color: rgba(244, 193, 82, .38); background: rgba(244, 193, 82, .05); }
-.decisionNotice.is-info { border-color: rgba(123, 184, 255, .32); background: rgba(123, 184, 255, .04); }
-.decisionComparison { display: grid; min-width: 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-sm); }
-.comparisonHeader,
-.comparisonRow {
-  display: grid;
-  grid-template-columns: minmax(6.5rem, .75fr) repeat(2, minmax(7.5rem, 1fr));
-}
-.decisionComparison.hasHandling .comparisonHeader,
-.decisionComparison.hasHandling .comparisonRow { grid-template-columns: minmax(6.5rem, .75fr) repeat(3, minmax(7.5rem, 1fr)); }
-.comparisonHeader { background: var(--surface-control); }
-.comparisonHeader b,
-.comparisonRow > * { padding: var(--space-3); border-right: 1px solid var(--border-subtle); }
-.comparisonHeader b:last-child,
-.comparisonRow > *:last-child { border-right: 0; }
-.comparisonHeader b { color: var(--text-muted); font-size: var(--type-caption); }
-.comparisonRow { border-top: 1px solid var(--border-subtle); background: var(--surface); }
-.comparisonRow strong { color: var(--text-primary); font-size: var(--type-label); }
-.comparisonRow span { color: var(--text-secondary); font-size: var(--type-label); line-height: 1.55; }
-.decisionNestedList { margin-top: var(--space-3); }
-.decisionNestedList > strong { display: block; margin-bottom: var(--space-2); color: var(--text-muted); font-size: var(--type-caption); }
-.decisionNestedList ul { margin: 0; padding-left: 1.15rem; color: var(--text-secondary); }
-.decisionNestedList li + li { margin-top: var(--space-2); }
-
-.promptDrawer {
-  position: fixed;
-  top: var(--space-4);
-  right: var(--space-4);
-  bottom: var(--space-4);
-  z-index: var(--z-modal);
-  width: min(40rem, calc(100vw - 2rem));
-  display: grid;
-  grid-template-rows: auto minmax(0, 1fr);
-  overflow: hidden;
-  border: 1px solid var(--border);
-  border-radius: var(--radius-lg);
-  background: var(--surface-subtle);
-  box-shadow: 0 28px 90px rgba(0, 0, 0, .58);
-}
-.promptDrawer > header {
-  min-height: 5rem;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: var(--space-4);
-  padding: var(--space-4) var(--space-5);
-  border-bottom: 1px solid var(--border-subtle);
-}
-.promptDrawer > header span { color: var(--accent); font-size: var(--type-caption); font-weight: var(--weight-semibold); }
-.promptDrawer > header h2 { margin: var(--space-1) 0 0; font-size: var(--type-heading); }
-.promptDrawerBody {
-  min-height: 0;
-  display: grid;
-  align-content: start;
-  gap: var(--space-5);
-  padding: var(--space-5);
-  overflow-y: auto;
-  overscroll-behavior: contain;
-  scrollbar-gutter: stable;
-}
-.promptAccuracy { padding: var(--space-4); border: 1px solid rgba(244, 193, 82, .38); border-radius: var(--radius-sm); background: rgba(244, 193, 82, .05); }
-.promptMeta { color: var(--warning) !important; font-size: var(--type-label) !important; }
-.inspectorPanel {
-  display: grid;
-  gap: var(--space-3);
-  padding: 0 0 var(--space-5);
-  border-bottom: 1px solid var(--border-subtle);
-}
-.inspectorPanel:last-child { border-bottom: 0; }
-.inspectorPanel.is-summary {
-  gap: var(--space-2);
-  padding: 0 0 var(--space-5);
-  border: 0;
-  border-bottom: 1px solid var(--border-subtle);
-  border-radius: 0;
-  background: transparent;
-}
-.inspectorPanel.is-summary h3 { color: var(--accent-hover); }
-.inspectorPanel.is-summary .richText {
-  color: var(--text-primary);
-  font-size: var(--inspector-copy-size);
-  font-weight: var(--weight-semibold);
-  line-height: 1.52;
-  letter-spacing: -.008em;
-}
-.inspectorPanel.is-report { gap: var(--space-4); }
-.inspectorPanel.is-report > .richText { max-width: 68ch; }
-.inspectorPanel.isMissing {
-  padding: var(--space-4);
-  border: 1px solid rgba(244, 193, 82, .38);
-  border-radius: var(--radius-md);
-  background: rgba(244, 193, 82, .055);
-}
-.inspectorPanel h3 {
-  margin: 0;
-  color: var(--info);
-  font-size: var(--inspector-section-title-size);
-  font-weight: var(--weight-semibold);
-  line-height: 1.45;
-  letter-spacing: -.006em;
-}
-.inspectorPanel > p {
-  margin: 0;
-  color: var(--text-secondary);
-  font-size: var(--inspector-copy-size);
-  line-height: 1.7;
-  white-space: pre-wrap;
-}
-.inspectorDetailItems {
-  display: grid;
-  gap: var(--space-4);
-  margin: 0;
-}
-.inspectorDetailItem {
-  display: grid;
-  grid-template-columns: minmax(6.5rem, 8rem) minmax(0, 1fr);
-  gap: var(--space-3);
-  align-items: start;
-}
-.inspectorDetailItem + .inspectorDetailItem {
-  padding-top: var(--space-4);
-  border-top: 1px solid var(--border-subtle);
-}
-.inspectorDetailItem dt {
-  color: var(--text-muted);
-  font-size: var(--inspector-field-label-size);
-  font-weight: var(--weight-semibold);
-  line-height: 1.55;
-}
-.inspectorDetailItem dd {
-  min-width: 0;
-  display: grid;
-  gap: var(--space-2);
-  margin: 0;
-}
-.decisionItemsPanel .inspectorDetailItem,
-.decisionDisclosureBody .inspectorDetailItem {
-  grid-template-columns: 1fr;
-}
-.decisionItemsPanel .inspectorDetailItem dt,
-.decisionDisclosureBody .inspectorDetailItem dt {
-  width: fit-content;
-  min-width: 2rem;
-  padding: .125rem .5rem;
-  border: 1px solid var(--border);
-  border-radius: 999px;
-  font-size: var(--inspector-field-label-size);
-  text-align: center;
-}
-.inspectorDetailItem .richText {
-  color: var(--text-secondary);
-  font-size: var(--inspector-copy-size);
-  line-height: 1.68;
-  overflow-wrap: break-word;
-  word-break: normal;
-}
-.inspectorDetailItem .decisionReadableText { max-width: 68ch; line-height: 1.75; text-wrap: pretty; }
-.inspectorDetailItem dd > small { color: var(--text-muted); font-size: var(--type-caption); line-height: 1.55; }
-.inspectorDetailItem .richText > :first-child { margin-top: 0; }
-.inspectorDetailItem .richText > :last-child { margin-bottom: 0; }
-.inspectorDetailItem .sourceBadge { justify-self: start; }
-
-/* Retrieval details: a compact index first, complete records on demand. */
-
-.retrievalInspector { min-width: 0; display: grid; gap: var(--space-5); }
-.retrievalActivityPanel,
-.retrievalConditionPanel,
-.retrievalResultsPanel { gap: var(--space-4); }
-.retrievalSectionHeader {
-  display: flex;
-  align-items: baseline;
-  justify-content: space-between;
-  gap: var(--space-3);
-}
-.retrievalSectionHeader h3 { margin: 0; color: var(--info); font-size: var(--inspector-section-title-size); }
-.retrievalSectionHeader > span { flex: none; color: var(--text-muted); font-size: var(--type-caption); }
-.retrievalActivityList { margin: 0; padding: 0; border-top: 1px solid var(--border-subtle); list-style: none; }
-.retrievalActivityList > li { border-bottom: 1px solid var(--border-subtle); }
-.retrievalActivityList button {
-  width: 100%;
-  min-height: 4rem;
-  display: grid;
-  grid-template-columns: 2rem minmax(0, 1fr) auto 1rem;
-  align-items: center;
-  gap: var(--space-3);
-  padding: var(--space-3) 0;
-  border: 0;
-  border-radius: 0;
-  background: transparent;
-  color: var(--text-primary);
-  text-align: left;
-}
-.retrievalActivityList button:hover:not(:disabled) { background: rgba(255, 255, 255, .025); }
-.retrievalActivityList button:disabled { cursor: default; opacity: .72; }
-.retrievalActivityList button > svg { color: var(--text-subtle); }
-.retrievalActivityIndex { color: var(--text-subtle); font-size: var(--type-caption); font-variant-numeric: tabular-nums; }
-.retrievalActivityCopy { min-width: 0; display: grid; gap: .25rem; }
-.retrievalActivityCopy strong { overflow: hidden; color: var(--text-primary); font-size: var(--type-ui); text-overflow: ellipsis; white-space: nowrap; }
-.retrievalActivityCopy small { overflow: hidden; color: var(--text-muted); font-size: var(--type-caption); line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
-.retrievalActivityStatus { font-size: var(--type-caption); font-weight: var(--weight-semibold); white-space: nowrap; }
-.retrievalActivityStatus.is-hit,
-.retrievalActivityStatus.is-success { color: var(--success); }
-.retrievalActivityStatus.is-empty,
-.retrievalActivityStatus.is-unknown { color: var(--text-muted); }
-.retrievalActivityStatus.is-failure { color: var(--danger); }
-.retrievalActivityStatus.is-running { color: var(--accent); }
-.retrievalActivityStatus.is-interrupted { color: var(--warning); }
-.retrievalMissingNotice,
-.retrievalCompletenessNotice { margin: 0; color: var(--text-muted); font-size: var(--type-ui); line-height: 1.65; }
-.retrievalCompletenessNotice {
-  padding: var(--space-3) var(--space-4);
-  border: 1px solid rgba(244, 193, 82, .28);
-  border-radius: var(--radius-sm);
-  background: rgba(244, 193, 82, .045);
-  color: var(--warning);
-}
-.retrievalOutcome {
-  display: grid;
-  gap: var(--space-2);
-  padding: var(--space-4);
-  border: 1px solid var(--border);
-  border-radius: var(--radius-md);
-  background: var(--surface);
-}
-.retrievalOutcome > span { color: var(--text-muted); font-size: var(--type-caption); font-weight: var(--weight-semibold); }
-.retrievalOutcome > strong { color: var(--text-primary); font-size: var(--type-body); line-height: 1.55; }
-.retrievalOutcome.is-hit { border-color: rgba(99, 200, 131, .34); }
-.retrievalOutcome.is-empty { border-color: var(--border-strong); }
-.retrievalOutcome.is-failure { border-color: rgba(255, 109, 90, .4); }
-.retrievalOutcome.is-running { border-color: rgba(255, 109, 90, .26); }
-.retrievalOutcome.is-interrupted { border-color: rgba(244, 193, 82, .36); }
-.retrievalOutcome.is-hit > span { color: var(--success); }
-.retrievalOutcome.is-failure > span { color: var(--danger); }
-.retrievalOutcome.is-running > span { color: var(--accent); }
-.retrievalOutcome.is-interrupted > span { color: var(--warning); }
-.retrievalConditions { display: grid; gap: 0; margin: 0; }
-.retrievalConditions > div {
-  min-width: 0;
-  display: grid;
-  grid-template-columns: minmax(6rem, 8rem) minmax(0, 1fr);
-  gap: var(--space-3);
-  padding: var(--space-3) 0;
-  border-top: 1px solid var(--border-subtle);
-}
-.retrievalConditions > div:first-child { padding-top: 0; border-top: 0; }
-.retrievalConditions dt { color: var(--text-muted); font-size: var(--inspector-field-label-size); font-weight: var(--weight-semibold); }
-.retrievalConditions dd { min-width: 0; margin: 0; color: var(--text-primary); font-size: var(--type-ui); line-height: 1.65; overflow-wrap: anywhere; }
-.retrievalResultList { display: grid; gap: var(--space-3); }
-.retrievalResult {
-  border: 1px solid var(--border);
-  border-radius: var(--radius-md);
-  background: var(--surface);
-  overflow: hidden;
-}
-.retrievalResult > summary {
-  min-height: 4.25rem;
-  display: grid;
-  grid-template-columns: 1.25rem minmax(0, 1fr);
-  align-items: center;
-  gap: var(--space-3);
-  padding: var(--space-3) var(--space-4);
-  cursor: pointer;
-  list-style: none;
-}
-.retrievalResult > summary::-webkit-details-marker { display: none; }
-.retrievalResult > summary:hover { background: rgba(255, 255, 255, .025); }
-.retrievalResult > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
-.retrievalResultChevron { grid-row: 1 / span 2; align-self: center; display: inline-flex; color: var(--text-muted); transition: transform 180ms var(--ease-out); }
-.retrievalResult[open] .retrievalResultChevron { transform: rotate(90deg); }
-.retrievalResultHeading { min-width: 0; display: grid; gap: .2rem; }
-.retrievalResultHeading strong { color: var(--text-primary); font-size: var(--type-ui); line-height: 1.45; overflow-wrap: break-word; word-break: normal; text-wrap: pretty; }
-.retrievalResultHeading small { color: var(--text-muted); font-size: var(--type-caption); overflow-wrap: anywhere; }
-.retrievalResultMeta { grid-column: 2; min-width: 0; display: flex; justify-content: flex-start; gap: var(--space-2) var(--space-3); flex-wrap: wrap; }
-.retrievalResultMeta b { color: var(--text-muted); font-size: var(--type-caption); font-weight: var(--weight-medium); line-height: 1.45; overflow-wrap: anywhere; }
-.retrievalResultBody { display: grid; gap: var(--space-4); padding: var(--space-4); border-top: 1px solid var(--border-subtle); }
-.retrievalResultLink { width: fit-content; display: inline-flex; align-items: center; gap: var(--space-1); color: var(--info); font-size: var(--type-ui); }
-.retrievalResultSection { min-width: 0; display: grid; gap: var(--space-2); }
-.retrievalResultSection + .retrievalResultSection { padding-top: var(--space-4); border-top: 1px solid var(--border-subtle); }
-.retrievalResultSection h4 { margin: 0; color: var(--info); font-size: var(--type-ui); }
-.retrievalResultSection .richText { color: var(--text-secondary); font-size: var(--inspector-copy-size); line-height: 1.72; overflow-wrap: anywhere; }
-.retrievalEmptyValue { color: var(--text-muted); font-size: var(--type-ui); }
-
-/* Card data lineage: one readable spine from input to output and UI projection. */
-
-.dataFlow {
-  min-width: 0;
-  display: grid;
-  align-content: start;
-  gap: 0;
-  color: var(--text-secondary);
-}
-.dataFlowOverview {
-  min-width: 0;
-  display: grid;
-  grid-template-columns: 2.25rem minmax(0, 1fr);
-  gap: var(--space-3);
-  padding: var(--space-4);
-  border: 1px solid var(--border);
-  border-radius: var(--radius-md);
-  background: var(--surface);
-}
-.dataFlowOverviewIcon {
-  width: 2.25rem;
-  height: 2.25rem;
-  display: grid;
-  place-items: center;
-  border-radius: 50%;
-  background: rgba(123, 184, 255, .11);
-  color: var(--info);
-}
-.dataFlowOverview > div:nth-child(2) { min-width: 0; }
-.dataFlowOverview > div:nth-child(2) > span { color: var(--text-muted); font-size: var(--type-caption); }
-.dataFlowOverview h3 { margin: .125rem 0 0; color: var(--text-primary); font-size: var(--inspector-section-title-size); font-weight: var(--weight-semibold); }
-.dataFlowOverviewBadges { grid-column: 1 / -1; display: flex; gap: var(--space-2); flex-wrap: wrap; }
-.dataFlowOverviewBadges span,
-.dataCompleteness,
-.runtimeStatus {
-  display: inline-flex;
-  min-height: 1.65rem;
-  align-items: center;
-  padding: .125rem .55rem;
-  border: 1px solid var(--border);
-  border-radius: 999px;
-  color: var(--text-secondary);
-  background: var(--surface-control);
-  font-size: var(--type-caption);
-  font-weight: var(--weight-semibold);
-  line-height: 1.3;
-}
-.dataFlowOverviewBadges .is-complete,
-.dataCompleteness.is-complete { border-color: rgba(99, 200, 131, .36); color: var(--success); background: rgba(99, 200, 131, .07); }
-.dataFlowOverviewBadges .is-partial,
-.dataCompleteness.is-partial { border-color: rgba(244, 193, 82, .36); color: var(--warning); background: rgba(244, 193, 82, .07); }
-.dataFlowOverviewBadges .is-missing,
-.dataCompleteness.is-missing { border-color: rgba(255, 128, 110, .36); color: var(--danger); background: rgba(255, 128, 110, .07); }
-.dataFlowNotices { display: grid; gap: var(--space-2); margin-top: var(--space-3); }
-.dataFlowNotices p {
-  display: grid;
-  grid-template-columns: 1rem minmax(0, 1fr);
-  gap: var(--space-2);
-  margin: 0;
-  padding: var(--space-3);
-  border: 1px solid var(--border-subtle);
-  border-radius: var(--radius-sm);
-  color: var(--text-secondary);
-  background: rgba(123, 184, 255, .045);
-  font-size: var(--type-ui);
-  line-height: 1.6;
-  overflow-wrap: anywhere;
-}
-.dataFlowNotices p.is-warning { border-color: rgba(244, 193, 82, .3); background: rgba(244, 193, 82, .045); color: #efd486; }
-.dataFlowNotices svg { margin-top: .25rem; }
-.dataFlowStage,
-.displayUse { --stage-color: var(--info); min-width: 0; display: grid; gap: var(--space-4); }
-.dataFlowStage[data-stage="runtime"] { --stage-color: var(--warning); }
-.dataFlowStage[data-stage="output"] { --stage-color: var(--success); }
-.dataFlowStage > header,
-.displayUse > header {
-  min-width: 0;
-  display: grid;
-  grid-template-columns: 2rem minmax(0, 1fr);
-  align-items: start;
-  gap: var(--space-3);
-}
-.dataFlowStage > header > div,
-.displayUse > header > div { min-width: 0; }
-.dataFlowStage h3,
-.displayUse h3 { margin: 0; color: var(--text-primary); font-size: .9375rem; font-weight: var(--weight-semibold); line-height: 1.45; }
-.dataFlowStage > header p,
-.displayUse > header p { max-width: 70ch; margin: .2rem 0 0; color: var(--text-muted); font-size: var(--type-label); line-height: 1.6; overflow-wrap: anywhere; text-wrap: pretty; }
-.dataFlowStageIcon {
-  width: 2rem;
-  height: 2rem;
-  display: grid;
-  place-items: center;
-  border: 1px solid color-mix(in srgb, var(--stage-color) 34%, transparent);
-  border-radius: 50%;
-  background: color-mix(in srgb, var(--stage-color) 8%, transparent);
-  color: var(--stage-color);
-}
-.dataFlowConnector {
-  position: relative;
-  width: 2rem;
-  height: 2.5rem;
-  display: grid;
-  place-items: center;
-  margin-left: 0;
-  color: var(--text-subtle);
-}
-.dataFlowConnector::before {
-  position: absolute;
-  width: 1px;
-  height: 2.5rem;
-  background: var(--border-strong);
-  content: "";
-}
-.dataFlowConnector svg { position: relative; padding: 2px; border-radius: 50%; background: var(--surface-subtle); }
-.dataFlowConnector.is-muted { color: var(--text-muted); }
-.dataFieldList,
-.runtimeUnitList { min-width: 0; display: grid; gap: var(--space-3); }
-.dataField {
-  min-width: 0;
-  display: grid;
-  gap: var(--space-3);
-  padding: var(--space-4);
-  border: 1px solid var(--border-subtle);
-  border-radius: var(--radius-sm);
-  background: rgba(32, 34, 40, .58);
-}
-.dataField > header { min-width: 0; display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
-.dataField h4 { min-width: 0; flex: 1 1 10rem; margin: 0; color: var(--text-primary); font-size: var(--type-ui); font-weight: var(--weight-semibold); line-height: 1.45; overflow-wrap: anywhere; }
-.sourceMarker {
-  flex: none;
-  min-width: 2rem;
-  min-height: 1.55rem;
-  display: inline-grid;
-  place-items: center;
-  padding: 0 .35rem;
-  border: 1px solid rgba(123, 184, 255, .3);
-  border-radius: .375rem;
-  background: rgba(123, 184, 255, .07);
-  color: #9fcaff;
-  font: var(--type-caption)/1 var(--font-mono);
-}
-.dataField > footer { min-width: 0; display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; color: var(--text-muted); font-size: var(--type-caption); line-height: 1.5; }
-.dataField > footer code,
-.runtimeEventRef code { min-width: 0; color: var(--text-secondary); font: var(--type-caption)/1.5 var(--font-mono); overflow-wrap: anywhere; }
-.dataSourceBadge {
-  display: inline-flex;
-  min-height: 1.65rem;
-  align-items: center;
-  gap: .3rem;
-  padding: .125rem .5rem;
-  border: 1px solid var(--border);
-  border-radius: 999px;
-  background: var(--surface-control);
-  color: var(--text-secondary);
-  font-size: var(--type-caption);
-  font-weight: var(--weight-semibold);
-  white-space: nowrap;
-}
-.dataSourceBadge.is-runtime-event { border-color: rgba(244, 193, 82, .3); color: #efd486; }
-.dataSourceBadge.is-artifact { border-color: rgba(99, 200, 131, .3); color: #8edca6; }
-.dataSourceBadge.is-calculation { border-color: rgba(183, 160, 235, .34); color: #c7b5ee; }
-.dataRelation { flex-basis: 100%; color: var(--text-muted); }
-.dataFlowText { max-width: 70ch; color: var(--text-secondary); font-size: var(--type-ui); line-height: 1.65; overflow-wrap: anywhere; word-break: normal; text-wrap: pretty; }
-.dataFlowText > :first-child { margin-top: 0; }
-.dataFlowText > :last-child { margin-bottom: 0; }
-.runtimeUnit {
-  min-width: 0;
-  border: 1px solid var(--border-subtle);
-  border-radius: var(--radius-sm);
-  background: rgba(32, 34, 40, .58);
-  overflow: hidden;
-}
-.runtimeUnit > summary {
-  min-height: 3.25rem;
-  display: grid;
-  grid-template-columns: auto auto minmax(0, 1fr) auto auto;
-  align-items: center;
-  gap: var(--space-2);
-  padding: var(--space-3);
-  cursor: pointer;
-  list-style: none;
-}
-.runtimeUnit > summary::-webkit-details-marker,
-.longDataValue > summary::-webkit-details-marker { display: none; }
-.runtimeUnit > summary:hover,
-.longDataValue > summary:hover { background: rgba(255, 255, 255, .025); }
-.runtimeUnit > summary:focus-visible,
-.longDataValue > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
-.runtimeUnit > summary strong { min-width: 0; color: var(--text-primary); font-size: var(--type-ui); line-height: 1.45; overflow-wrap: anywhere; }
-.runtimeUnit > summary small { color: var(--text-muted); font: var(--type-caption)/1.4 var(--font-mono); white-space: nowrap; }
-.dataDisclosureChevron { flex: none; color: var(--text-muted); transition: transform 180ms var(--ease-out); }
-.runtimeUnit[open] > summary .dataDisclosureChevron,
-.longDataValue[open] > summary .dataDisclosureChevron { transform: rotate(90deg); }
-.runtimeStatus.is-success { border-color: rgba(99, 200, 131, .3); color: var(--success); }
-.runtimeStatus.is-failure { border-color: rgba(255, 128, 110, .3); color: var(--danger); }
-.runtimeStatus.is-running { border-color: rgba(244, 193, 82, .3); color: var(--warning); }
-.runtimeUnitBody { min-width: 0; display: grid; gap: var(--space-4); padding: var(--space-4); border-top: 1px solid var(--border-subtle); }
-.runtimeValue { min-width: 0; display: grid; gap: var(--space-2); }
-.runtimeValue h5 { margin: 0; color: var(--warning); font-size: var(--type-label); font-weight: var(--weight-semibold); }
-.runtimeEventRef,
-.runtimeCompleteness { display: flex; align-items: center; gap: var(--space-2); margin: 0; color: var(--text-muted); font-size: var(--type-caption); }
-.runtimeCompleteness { color: var(--success); }
-.dataFlowCalculation { display: grid; gap: var(--space-2); padding: var(--space-3) 0 0; border-top: 1px solid var(--border-subtle); }
-.dataFlowCalculation > strong { color: var(--warning); font-size: var(--type-label); }
-.longDataValue { min-width: 0; display: grid; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); overflow: hidden; }
-.longDataValue > summary { min-height: 2.75rem; display: grid; gap: var(--space-2); padding: var(--space-3); cursor: pointer; list-style: none; color: var(--text-secondary); font-size: var(--type-label); font-weight: var(--weight-semibold); }
-.longDataSummaryLine { min-width: 0; display: flex; align-items: center; gap: var(--space-2); }
-.longDataSummaryLine small { margin-left: auto; color: var(--text-muted); font: var(--type-caption)/1.4 var(--font-mono); }
-.longDataPreview { display: block; color: var(--text-secondary); font-size: var(--type-ui); font-weight: var(--weight-regular); line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; }
-.longDataValue > .richText,
-.longDataValue > .valueView { margin: 0 var(--space-3) var(--space-3); }
-.dataFlowEmpty { margin: 0; padding: var(--space-3); border: 1px dashed var(--border); border-radius: var(--radius-sm); color: var(--text-muted); font-size: var(--type-ui); line-height: 1.6; }
-.displayUse { --stage-color: var(--evaluator); }
-.displayUseGroups { display: grid; gap: var(--space-4); }
-.mappingGroup { min-width: 0; display: grid; gap: var(--space-2); }
-.mappingGroup h4,
-.unusedSources > strong { margin: 0; color: var(--text-primary); font-size: var(--type-label); font-weight: var(--weight-semibold); }
-.mappingGroup ul { display: grid; gap: 0; margin: 0; padding: 0; border-top: 1px solid var(--border-subtle); list-style: none; }
-.mappingGroup li { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); padding: var(--space-3) 0; border-bottom: 1px solid var(--border-subtle); }
-.mappingGroup li > div { min-width: 0; display: grid; gap: .2rem; }
-.mappingGroup li strong { color: var(--text-secondary); font-size: var(--type-ui); line-height: 1.5; overflow-wrap: anywhere; }
-.mappingGroup li small { color: var(--text-muted); font-size: var(--type-caption); line-height: 1.5; overflow-wrap: anywhere; }
-.sourceReferences { display: inline-flex; justify-content: flex-end; gap: .25rem; flex-wrap: wrap; }
-.sourceReferences > span { min-width: 2rem; min-height: 1.45rem; display: inline-grid; place-items: center; padding: 0 .3rem; border-radius: .325rem; background: rgba(123, 184, 255, .08); color: #9fcaff; font: var(--type-caption)/1 var(--font-mono); }
-.sourceReferenceEmpty { color: var(--text-subtle); font-size: var(--type-caption); white-space: nowrap; }
-.unusedSources { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding-top: var(--space-3); border-top: 1px solid var(--border-subtle); }
-.dataFlowState { min-height: 16rem; display: grid; place-content: center; justify-items: center; gap: var(--space-2); padding: var(--space-5); color: var(--text-muted); text-align: center; }
-.dataFlowState svg { color: var(--info); }
-.dataFlowState h3 { margin: var(--space-1) 0 0; color: var(--text-primary); font-size: var(--type-title); }
-.dataFlowState p { max-width: 34rem; margin: 0; font-size: var(--type-ui); line-height: 1.65; }
-.dataFlowSkeleton { display: grid; gap: var(--space-3); }
-.dataFlowSkeleton div { height: 5.5rem; border-radius: var(--radius-md); background: var(--surface); }
-.dataFlowSkeleton span { width: 100%; height: 2.75rem; border-radius: var(--radius-sm); background: var(--surface-control); }
-.dataFlowSkeleton span:nth-child(3),
-.dataFlowSkeleton span:nth-child(6),
-.dataFlowSkeleton span:nth-child(8) { width: 72%; }
-.dataFlowSkeleton > * { animation: dataFlowPulse 1.5s ease-in-out infinite alternate; }
-@keyframes dataFlowPulse { from { opacity: .45; } to { opacity: .9; } }
-
-/* Current final script: a document surface, not another canvas card. */
-
-.scriptViewer {
-  width: min(96vw, 112rem);
-  height: 92dvh;
-  max-width: none;
-  max-height: none;
-  margin: auto;
-  padding: 0;
-  overflow: hidden;
-  border: 1px solid var(--border-strong);
-  border-radius: var(--radius-lg);
-  background: var(--surface-subtle);
-  color: var(--text-primary);
-  box-shadow: 0 28px 90px rgba(0, 0, 0, .58);
-  z-index: var(--z-modal);
-}
-.scriptViewer[open] { display: grid; grid-template-rows: auto auto minmax(0, 1fr); }
-.scriptViewer::backdrop { background: rgba(7, 8, 10, .76); }
-.scriptViewerHeader {
-  min-width: 0;
-  min-height: 5.25rem;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: var(--space-5);
-  padding: var(--space-4) var(--space-5);
-  border-bottom: 1px solid var(--border-subtle);
-  background: var(--surface);
-}
-.scriptViewerHeader > .iconButton { flex: none; }
-.scriptViewerHeaderActions { display: flex; align-items: center; gap: var(--space-2); flex: none; }
-.scriptViewerTitle { min-width: 0; display: grid; gap: .15rem; }
-.scriptViewerTitle > span {
-  display: flex;
-  align-items: center;
-  gap: var(--space-2);
-  color: var(--success);
-  font-size: var(--type-label);
-  font-weight: var(--weight-semibold);
-}
-.scriptViewer.isCandidate .scriptViewerTitle > span { color: var(--accent-hover); }
-.scriptViewerTitle h2 { margin: 0; font-size: var(--type-heading); font-weight: var(--weight-semibold); }
-.scriptViewerTitle p { margin: 0; color: var(--text-muted); font-size: var(--type-label); }
-.scriptViewerToolbar {
-  min-width: 0;
-  min-height: 3.5rem;
-  display: flex;
-  align-items: stretch;
-  justify-content: space-between;
-  gap: var(--space-4);
-  padding: 0 var(--space-5);
-  border-bottom: 1px solid var(--border-subtle);
-  background: var(--surface-subtle);
-}
-.scriptViewerTabs { display: flex; align-items: stretch; }
-.scriptViewerTabs button {
-  min-width: 7.5rem;
-  border: 0;
-  border-bottom: 2px solid transparent;
-  background: transparent;
-  color: var(--text-muted);
-  font-size: var(--type-ui);
-  font-weight: var(--weight-medium);
-}
-.scriptViewerTabs button:hover { color: var(--text-primary); }
-.scriptViewerTabs button.active { border-bottom-color: var(--accent); color: var(--text-primary); font-weight: var(--weight-semibold); }
-.scriptViewerVersion {
-  align-self: center;
-  max-width: 52rem;
-  margin: 0;
-  color: var(--text-secondary);
-  font-size: var(--type-label);
-  line-height: 1.5;
-  text-align: right;
-  text-wrap: pretty;
-}
-.scriptViewerVersion.isHistorical { color: var(--success); }
-.scriptViewerVersion.isCurrent { color: var(--warning); }
-.scriptViewerBody { min-width: 0; min-height: 0; overflow: hidden; padding: var(--space-4); background: #e9ecf2; }
-.scriptViewerState {
-  width: min(34rem, 100%);
-  min-height: 14rem;
-  display: grid;
-  align-content: center;
-  gap: var(--space-3);
-  margin: 10vh auto 0;
-  padding: var(--space-5);
-  border: 1px solid #d5dae3;
-  border-radius: var(--radius-md);
-  background: #fff;
-  color: #323944;
-  text-align: center;
-}
-.scriptViewerState p { margin: 0; color: #667080; }
-.scriptViewerState.isError { border-color: #efb5ad; }
-
-.originalScriptTable {
-  height: 100%;
-  min-height: 0;
-  display: grid;
-  grid-template-rows: auto minmax(0, 1fr);
-  color: #1e293b;
-}
-.scriptTableTitle {
-  display: flex;
-  align-items: center;
-  gap: 16px;
-  min-height: 48px;
-  padding: 12px 16px;
-  border: 1px solid rgba(0, 0, 0, .04);
-  border-bottom: 0;
-  border-radius: 10px 10px 0 0;
-  background: #fff;
-  color: #1e293b;
-  font-size: 14px;
-}
-.script-table-wrapper {
-  min-width: 0;
-  min-height: 0;
-  overflow: auto;
-  overscroll-behavior: contain;
-  scrollbar-gutter: stable;
-  padding: 0 0 16px;
-  background: #fff;
-  border-radius: 0 0 10px 10px;
-  border: 1px solid rgba(0, 0, 0, .04);
-  border-top: none;
-  box-shadow: 0 1px 4px rgba(0, 0, 0, .06);
-  isolation: isolate;
-}
-.script-table-wrapper::-webkit-scrollbar { width: 8px; height: 8px; }
-.script-table-wrapper::-webkit-scrollbar-thumb { background: #c1c9d2; border-radius: 4px; }
-.script-table-wrapper::-webkit-scrollbar-track { background: #f1f5f9; }
-.script-table {
-  border-collapse: separate;
-  border-spacing: 0;
-  table-layout: fixed;
-  overflow: visible;
-  border-radius: 0;
-  box-shadow: none;
-  border: none;
-  color: #1e293b;
-  font-size: 12px;
-  line-height: 1.5;
-}
-.script-table th,
-.script-table td {
-  max-width: 0;
-  padding: 6px 8px;
-  border-right: 1px solid #d0d5dd;
-  border-bottom: 1px solid #d0d5dd;
-  text-align: left;
-  vertical-align: top;
-  word-break: break-word;
-  overflow-wrap: break-word;
-}
-.script-table th:first-child,
-.script-table td:first-child { border-left: 1px solid #d0d5dd; }
-.script-table thead tr:first-child th { border-top: 1px solid #d0d5dd; }
-.script-table thead th {
-  position: sticky;
-  z-index: 5;
-  color: #1e293b;
-  font-size: 11px;
-  font-weight: 600;
-  text-align: center;
-  vertical-align: middle;
-  white-space: nowrap;
-}
-.script-table thead tr.st-header-group th { top: 0; z-index: 12; }
-.script-table thead tr.st-header-subgroup th { top: 34px; z-index: 11; }
-.script-table thead tr.st-header-col th { top: 68px; z-index: 10; }
-.st-header-group th { font-size: 12px; font-weight: 700; }
-.st-header-subgroup th { font-size: 11px; font-weight: 600; }
-.st-header-col th { color: #475569; font-size: 11px; font-weight: 500; }
-.col-paragraph { background: #ffe4e1; }
-.col-range { background: #ffdab9; }
-.col-outline { background: #d6eaf8; }
-.script-table thead th.col-outline-theme { background: #c8e6c9; color: #2e7d32; }
-.script-table thead th.col-outline-form { background: #ffe0b2; color: #e65100; }
-.script-table thead th.col-outline-action { background: #bbdefb; color: #1565c0; }
-.script-table thead th.col-outline-feeling { background: #f8bbd0; color: #c2185b; }
-.col-full-desc { background: #eceff1; color: #37474f; }
-.script-table tbody td { position: relative; z-index: 0; }
-.script-table th.col-paragraph,
-.script-table td.col-paragraph {
-  position: sticky;
-  padding: 8px 14px;
-  background-color: #fff5f3;
-}
-.script-table thead tr.st-header-group th.col-paragraph,
-.script-table thead tr.st-header-subgroup th.col-paragraph,
-.script-table thead tr.st-header-col th.col-paragraph { z-index: 40; background-color: #ffe4e1; }
-.script-table thead tr.st-header-group th.col-paragraph,
-.script-table thead tr.st-header-subgroup th.col-paragraph:first-child { box-shadow: -14px 0 0 #ffe4e1; }
-.script-table tbody td.col-paragraph { z-index: 15; box-shadow: inset 0 -1px #d0d5dd; }
-.script-table th.col-paragraph-last,
-.script-table td.col-paragraph-last { box-shadow: inset 0 -1px #d0d5dd, 3px 0 6px -2px rgba(0, 0, 0, .07); }
-.script-table tbody tr > td.col-paragraph:first-child { box-shadow: -14px 0 0 #fff5f3, inset 0 -1px #d0d5dd; }
-.script-table tbody td.col-paragraph { background: #fff5f3; }
-.script-table tbody td.col-range { background: #fff8f0; }
-.script-table tbody td.col-outline,
-.script-table tbody td.col-outline-theme,
-.script-table tbody td.col-outline-form,
-.script-table tbody td.col-outline-action,
-.script-table tbody td.col-outline-feeling { background: #fff; }
-.script-table tbody td.col-full-desc { background: #f5f5f5; }
-.script-table thead th.col-atom-sub { font-size: 11px; font-weight: 600; }
-.script-table tbody td.col-atom-group { padding: 0; background: #fff; font-size: 12px; vertical-align: top; }
-.script-table tbody td.col-atom-group .atom-rows-table { width: 100%; margin: 0; border-collapse: collapse; table-layout: fixed; }
-.script-table tbody td.col-atom-group .atom-rows-table td {
-  padding: 6px 8px;
-  border-right: 1px solid #d0d5dd;
-  border-bottom: 1px solid #d0d5dd;
-  line-height: 1.35;
-  vertical-align: top;
-  white-space: normal;
-}
-.script-table tbody td.col-atom-group .atom-rows-table tr:last-child td { border-bottom: none; }
-.script-table tbody td.col-atom-group .atom-rows-table td:last-child { border-right: none; }
-.script-table tbody td.col-atom-group .atom-rows-table .col-atom-type { color: #64748b; }
-.script-table tbody td.col-atom-group-theme .atom-rows-table .atom-dim-main { background: #66bb6a; color: #fff; font-weight: 600; }
-.script-table tbody td.col-atom-group-theme .atom-rows-table .atom-dim-sub { background: #c8e6c9; color: #2e7d32; font-weight: 500; }
-.script-table tbody td.col-atom-group-form .atom-rows-table .atom-dim-main { background: #ffb74d; color: #4e342e; font-weight: 600; }
-.script-table tbody td.col-atom-group-form .atom-rows-table .atom-dim-sub { background: #ffe0b2; color: #e65100; font-weight: 500; }
-.script-table tbody td.col-atom-group-action .atom-rows-table .atom-dim-main { background: #64b5f6; color: #fff; font-weight: 600; }
-.script-table tbody td.col-atom-group-action .atom-rows-table .atom-dim-sub { background: #bbdefb; color: #1565c0; font-weight: 500; }
-.script-table tbody td.col-atom-group .atom-val-with-id { cursor: help; border-bottom: 1px dotted #99f6e4; }
-.script-row-l1 td { font-weight: 500; }
-.script-row-l2 td { color: #475569; font-size: 11.5px; }
-.script-row-l3 td,
-tr[class^="script-row-l"]:not(.script-row-l1):not(.script-row-l2):not(.script-row-l3) td { color: #64748b; font-size: 11px; }
-tr[class^="script-row-l"]:hover td:not(.col-paragraph) { background: rgba(91, 141, 239, .04); }
-.collapse-toggle {
-  width: 16px;
-  min-height: 16px;
-  display: inline-block;
-  margin: 0 4px 0 0;
-  padding: 0;
-  border: 0;
-  background: transparent;
-  color: #64748b;
-  cursor: pointer;
-  font-size: 10px;
-  text-align: center;
-  vertical-align: middle;
-}
-.collapse-toggle:hover { color: #1e293b; }
-.collapse-toggle.collapsed { color: #94a3b8; }
-.content-range-tags { display: flex; flex-wrap: wrap; align-items: flex-start; }
-.cr-tag {
-  display: inline-block;
-  margin: 1px 2px;
-  padding: 1px 6px;
-  border: 1px solid #e2e8f0;
-  border-radius: 3px;
-  background: #f1f5f9;
-  color: #475569;
-  font-size: 11px;
-  word-break: break-word;
-}
-.elem-hl-extended {
-  display: inline;
-  margin: 0;
-  padding: 0 1px 0 2px;
-  border: 0;
-  border-radius: 2px;
-  background: #fff;
-  color: #0288d1;
-  box-shadow: 0 0 0 1px #81d4fa;
-  font: inherit;
-  font-weight: 600;
-  line-height: inherit;
-  cursor: pointer;
-}
-span.elem-hl-extended { cursor: default; }
-.cell-expandable {
-  max-height: 100px;
-  overflow: hidden;
+  --node-color: #8c94a2;
   position: relative;
-  color: #4a5568;
-  cursor: pointer;
-  font-size: 11px;
-  line-height: 1.5;
-  white-space: pre-wrap;
-  word-break: break-all;
-}
-.cell-expandable::after {
-  content: '... 点击展开';
-  position: absolute;
-  right: 0;
-  bottom: 0;
-  left: 0;
-  height: 24px;
-  background: linear-gradient(transparent, #f5f5f5 60%);
-  color: #5b8def;
-  font-size: 10px;
-  line-height: 28px;
-  text-align: center;
-}
-.cell-expandable.expanded { max-height: none; }
-.cell-expandable.expanded::after { content: none; }
-.script-legend { display: flex; flex-wrap: wrap; gap: 10px; color: #64748b; font-size: 11px; font-weight: 400; }
-.legend-item { display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; }
-.legend-swatch { width: 12px; height: 12px; display: inline-block; border: 1px solid #81d4fa; border-radius: 2px; background: #fff; }
-.elementParagraphs { margin: 0; padding: 0; list-style: none; }
-.elementTableViewport {
   width: 100%;
-  height: 100%;
-  min-width: 0;
-  min-height: 0;
-  overflow: auto;
-  overscroll-behavior: contain;
-  scrollbar-gutter: stable;
-  border: 1px solid #cfd5df;
-  border-radius: var(--radius-sm);
-  background: #f8f9fc;
-  color: #2e3540;
-  box-shadow: 0 4px 18px rgba(30, 40, 55, .1);
-}
-.elementTable {
-  width: 100%;
-  min-width: 72rem;
-  border-collapse: separate;
-  border-spacing: 0;
-  table-layout: fixed;
-  color: #303744;
-  font-size: .875rem;
-  line-height: 1.55;
-}
-.elementTable th,
-.elementTable td {
-  min-width: 9.5rem;
-  padding: .875rem;
-  border-right: 1px solid #d7dce5;
-  border-bottom: 1px solid #d7dce5;
-  background: #fff;
-  overflow-wrap: anywhere;
-  text-align: left;
-  vertical-align: top;
-}
-.elementTable thead th { top: 0 !important; min-width: 12rem; background: #e8edf4; }
-.elementTable thead th { position: sticky; z-index: 2; color: #374151; font-size: .8125rem; font-weight: 700; }
-.elementTable thead th:first-child { width: 18rem; }
-.elementTable tbody th { background: #f5f7fa; color: #2d3541; font-weight: 700; }
-.elementTable tr.isSelected th,
-.elementTable tr.isSelected td { background: #fff2d7; box-shadow: inset 0 2px #e6a83f, inset 0 -2px #e6a83f; }
-.elementParagraphs { margin: 0; display: grid; gap: .4rem; }
-.elementParagraphs li { display: grid; grid-template-columns: 4rem minmax(0, 1fr); gap: var(--space-2); }
-.elementParagraphs span { color: #788291; font-size: .75rem; font-weight: 600; }
-
-.richText {
-  max-width: 72ch;
-  color: var(--text-secondary);
-  font-size: var(--type-body);
-  line-height: 1.72;
-  letter-spacing: .012em;
-  overflow-wrap: anywhere;
-}
-.richText > :first-child { margin-top: 0; }
-.richText > :last-child { margin-bottom: 0; }
-.richText h1,
-.richText h2,
-.richText h3,
-.richText h4 {
-  margin: 1.5em 0 .55em;
-  color: var(--text-primary);
-  font-weight: var(--weight-semibold);
-  line-height: 1.35;
-  letter-spacing: -.01em;
-  text-wrap: balance;
-}
-.richText h1 { font-size: var(--type-heading); }
-.richText h2 { font-size: var(--type-title); }
-.richText h3,
-.richText h4 { font-size: var(--type-body); }
-.richText p { margin: 0 0 1em; text-wrap: pretty; }
-.richText strong { color: var(--text-primary); font-weight: var(--weight-semibold); }
-.richText ul,
-.richText ol { margin: .5em 0 1em; padding-left: 1.4em; }
-.richText li + li { margin-top: .35em; }
-.richText blockquote {
-  margin: 1em 0;
-  padding: var(--space-3) var(--space-4);
-  border: 1px solid var(--border);
-  border-radius: var(--radius-sm);
-  background: var(--surface);
-  color: var(--text-muted);
-}
-.richText code {
-  padding: .12em .35em;
-  border-radius: .25rem;
-  background: var(--canvas);
-  color: #e4e6ea;
-  font-family: var(--font-mono);
-  font-size: .875em;
-  font-variant-ligatures: none;
-}
-.richText pre {
-  max-width: 100%;
-  padding: var(--space-3);
-  overflow: auto;
-  border: 1px solid var(--border-subtle);
-  border-radius: var(--radius-sm);
-  background: var(--canvas);
-}
-.richText pre code { padding: 0; background: transparent; }
-.richText table {
-  display: block;
-  max-width: 100%;
-  overflow-x: auto;
-  border-collapse: collapse;
-  font-size: var(--type-ui);
-}
-.richText th,
-.richText td { padding: var(--space-2); border: 1px solid var(--border); text-align: left; vertical-align: top; }
-.richText th { color: var(--text-primary); background: var(--surface); font-weight: var(--weight-semibold); }
-.richText hr { height: 1px; margin: var(--space-5) 0; border: 0; background: var(--border-subtle); }
-.richText a { color: var(--info); text-underline-offset: .18em; }
-
-.valueView {
-  max-width: 100%;
-  max-height: 62vh;
-  margin: 0;
-  padding: var(--space-4);
-  overflow: auto;
-  border: 1px solid var(--border-subtle);
-  border-radius: var(--radius-sm);
-  background: var(--canvas);
-  color: #d8dae0;
-  font: var(--type-label)/1.65 var(--font-mono);
-  font-variant-ligatures: none;
-  white-space: pre-wrap;
-  overflow-wrap: anywhere;
-}
-.diffWarning,
-.emptyState { color: var(--warning) !important; }
-.planHistorySummary { color: var(--text-muted) !important; }
-.planHistoryList { display: grid; gap: var(--space-3); margin: var(--space-1) 0 0; padding: 0; list-style: none; }
-.planHistoryItem {
-  padding: var(--space-3);
-  border: 1px solid var(--border);
-  border-radius: var(--radius-sm);
-  background: var(--surface);
-}
-.planHistoryItem.is-success { border-color: rgba(99, 200, 131, .48); }
-.planHistoryItem.is-failure { border-color: rgba(255, 128, 110, .48); }
-.planHistoryItem p { margin-top: var(--space-2); }
-.planCurrentTag {
-  display: inline-flex;
-  margin-left: var(--space-2);
-  padding: .125rem .5rem;
-  border-radius: 999px;
-  background: rgba(99, 200, 131, .14);
-  color: var(--success);
-  font-size: var(--type-caption);
-  font-weight: var(--weight-semibold);
-}
-
-/* Feedback and loading */
-
-.errorBanner {
-  position: fixed;
-  left: 1.125rem;
-  right: 1.125rem;
-  bottom: 1.125rem;
-  z-index: var(--z-toast);
-  display: flex;
-  align-items: center;
-  gap: var(--space-2);
-  padding: var(--space-3) var(--space-4);
-  border: 1px solid rgba(255, 128, 110, .45);
-  border-radius: var(--radius-sm);
-  background: #3a201d;
-  color: #ffb7aa;
-  font-size: var(--type-ui);
-  box-shadow: var(--shadow-md);
-}
-.loadingScreen,
-.fatalState {
-  min-height: 100vh;
-  display: grid;
-  place-content: center;
-  justify-items: center;
-  gap: var(--space-4);
-  padding: var(--space-6);
-  background: var(--canvas);
-  text-align: center;
-}
-.loadingScreen h1,
-.fatalState h1 { margin: 0; font-size: var(--type-heading); font-weight: var(--weight-semibold); }
-.fatalState p { max-width: 65ch; margin: 0; color: var(--text-muted); font-size: var(--type-body); line-height: 1.65; }
-.skeletonWide,
-.skeletonLines { width: min(26rem, 80vw); display: grid; gap: var(--space-2); }
-.skeletonWide i,
-.skeletonLines i { height: .625rem; border-radius: .25rem; background: var(--surface-control); }
-.skeletonWide i:nth-child(2),
-.skeletonLines i:nth-child(2) { width: 82%; }
-.skeletonWide i:nth-child(3),
-.skeletonLines i:nth-child(3) { width: 64%; }
-
-.mobileTimeline { display: none; }
-.exitFullscreen {
-  position: absolute;
-  z-index: var(--z-toast);
-  top: var(--space-3);
-  left: 50%;
-  transform: translateX(-50%);
-  min-height: 2.75rem;
-  padding: 0 var(--space-4);
-  border: 1px solid var(--accent);
-  border-radius: var(--radius-sm);
-  background: var(--surface);
-  color: var(--text-primary);
-  font-size: var(--type-ui);
-  font-weight: var(--weight-semibold);
-}
-
-@media (max-width: 1199px) {
-  .runHeader,
-  .withInspector .runHeader { right: var(--space-3); left: var(--space-3); }
-  .topBar { flex-wrap: wrap; }
-  .runMeta { order: 3; }
-  .roundNav { order: 5; flex: 1 1 20rem; width: auto; }
-  .withInspector .workspace { grid-template-columns: 1fr; }
-  .inspector {
-    position: absolute;
-    top: var(--space-3);
-    right: var(--space-3);
-    bottom: var(--space-3);
-    width: min(28.75rem, calc(100vw - 1.5rem));
-    height: auto;
-    border: 1px solid var(--border);
-    border-radius: var(--radius-md);
-    overflow: hidden;
-  }
-}
-
-@media (max-width: 767px) {
-  body { overflow: auto; }
-  .appFrame { height: auto; min-height: 100vh; }
-  .runHeader,
-  .withInspector .runHeader { position: relative; top: auto; left: auto; right: auto; margin: var(--space-2); }
-  .topBar { gap: var(--space-2); }
-  .productMark { flex: 1; }
-  .buildPicker { order: 3; width: 100%; min-width: 0; }
-  .buildPicker select { flex: 1; width: auto; }
-  .buildPicker input { flex: 1; width: auto; }
-  .roundNav { order: 5; width: 100%; }
-  .runMeta { order: 2; margin-left: auto; }
-  .compactAction span,
-  .dataNotes summary span,
-  .refreshing { display: none; }
-  .dataNotes > div { position: fixed; top: auto; left: .625rem; right: .625rem; bottom: .625rem; width: auto; }
-
-  .workspace { height: auto; min-height: calc(100vh - 11.25rem); display: block; overflow: visible; }
-  .flowShell { display: none; }
-  .mobileTimeline { display: block; padding: var(--space-4); background: var(--canvas); }
-  .mobileTimeline > header { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); margin-bottom: var(--space-4); }
-  .mobileTimeline h2 { margin: 0; font-size: var(--type-title); font-weight: var(--weight-semibold); }
-  .mobileTimeline header p { margin: var(--space-1) 0 0; color: var(--text-muted); font-size: var(--type-label); }
-
-  .mobileStory {
-    width: 100%;
-    display: grid;
-    grid-template-columns: auto 1fr;
-    gap: var(--space-2) var(--space-3);
-    min-height: 6rem;
-    margin-bottom: var(--space-3);
-    padding: var(--space-4);
-    border: 1px solid var(--border);
-    border-radius: var(--radius-md);
-    background: var(--surface);
-    color: var(--text-primary);
-    text-align: left;
-  }
-  .mobileStory > span:first-child { color: var(--accent); font-size: var(--type-label); font-weight: var(--weight-semibold); }
-  .mobileStory strong,
-  .mobileStory p { grid-column: 1 / -1; margin: 0; }
-  .mobileStory strong { font-size: var(--type-title); font-weight: var(--weight-semibold); }
-  .mobileStory p { color: var(--text-secondary); font-size: var(--type-body); line-height: 1.65; }
-  .mobileStory > .cardFields,
-  .mobileSteps .cardFields {
-    grid-column: 1 / -1;
-    width: 100%;
-    gap: var(--space-3);
-  }
-  .mobileStory .cardField,
-  .mobileSteps .cardField {
-    grid-template-columns: minmax(4.75rem, 5.5rem) minmax(0, 1fr);
-    gap: var(--space-3);
-  }
-  .mobileStory .cardField b,
-  .mobileSteps .cardField b {
-    color: var(--text-muted);
-    font-size: var(--type-label);
-    line-height: 1.55;
-  }
-  .mobileStory .cardField p,
-  .mobileSteps .cardField p {
-    grid-column: auto;
-    color: var(--text-secondary);
-    font-size: var(--type-ui);
-    font-weight: var(--weight-regular);
-    line-height: 1.58;
-    -webkit-line-clamp: unset;
-  }
-  .mobileStory .cardFieldPrimary p,
-  .mobileSteps .cardFieldPrimary p {
-    color: var(--text-primary);
-    font-size: var(--type-body);
-    font-weight: var(--weight-semibold);
-  }
-  .mobileFinal { display: block; padding: 0; }
-  .mobileFinalSummary {
-    width: 100%;
-    display: grid;
-    grid-template-columns: auto 1fr;
-    gap: var(--space-2) var(--space-3);
-    padding: var(--space-4);
-    border: 0;
-    background: transparent;
-    color: var(--text-primary);
-    text-align: left;
-  }
-  .mobileFinalSummary > span:first-child { color: var(--accent); font-size: var(--type-label); font-weight: var(--weight-semibold); }
-  .mobileFinalSummary strong,
-  .mobileFinalSummary p { grid-column: 1 / -1; margin: 0; }
-  .mobileFinal > .nodeAction { margin: 0 var(--space-4) var(--space-4); }
-
-  .mobileTimeline > .mobileDecision {
-    margin: 0 0 var(--space-3);
-    padding: 0;
-    list-style: none;
-  }
-  .mobileDecision > button:first-child {
-    width: 100%;
-    min-height: 5rem;
-    display: grid;
-    grid-template-columns: 1fr auto;
-    gap: var(--space-2) var(--space-3);
-    padding: var(--space-4);
-    border: 1px solid rgba(255, 109, 90, .42);
-    border-radius: var(--radius-md);
-    background: var(--surface);
-    color: var(--text-primary);
-    text-align: left;
-  }
-  .mobileDecision > button:first-child > span { color: var(--accent); font-size: var(--type-label); font-weight: var(--weight-semibold); }
-  .mobileDecision > button:first-child > em { color: var(--text-muted); font-size: var(--type-caption); font-style: normal; }
-  .mobileDecision > button:first-child > strong,
-  .mobileDecision > button:first-child > .cardFields { grid-column: 1 / -1; }
-  .mobileDecision > button:first-child > strong { font-size: var(--type-title); }
-  .mobilePromptAction { width: 100%; min-height: 2.75rem; margin-bottom: var(--space-2); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--canvas); color: var(--text-secondary); }
-
-  .roundList,
-  .mobileSteps { margin: 0; padding: 0; list-style: none; }
-  .roundList > li { margin-bottom: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
-  .mobileRoundSummary { display: grid; grid-template-columns: 3.25rem minmax(0, 1fr); }
-  .roundExpand { border: 0; border-right: 1px solid var(--border-subtle); background: var(--surface-control); color: var(--text-primary); }
-  .roundSummaryOpen {
-    min-width: 0;
-    display: grid;
-    grid-template-columns: auto 1fr;
-    gap: var(--space-2) var(--space-3);
-    padding: var(--space-4);
-    border: 0;
-    background: transparent;
-    color: var(--text-primary);
-    text-align: left;
-  }
-  .roundSummaryOpen > span { color: var(--accent); font-size: var(--type-label); font-weight: var(--weight-semibold); }
-  .roundSummaryOpen > strong,
-  .mobileSummarySequence { grid-column: 1 / -1; }
-  .roundSummaryOpen > strong { font-size: var(--type-title); font-weight: var(--weight-semibold); }
-  .mobileSummarySequence { display: grid; gap: var(--space-2); }
-  .mobileSummarySequence p {
-    display: grid;
-    grid-template-columns: 2.75rem 1fr;
-    gap: var(--space-2);
-    margin: 0;
-    color: var(--text-secondary);
-    font-size: var(--type-ui);
-    line-height: 1.55;
-  }
-  .mobileSummarySequence b { color: var(--text-muted); font-size: var(--type-label); font-weight: var(--weight-semibold); }
-
-  .mobileSteps { padding: var(--space-2); border-top: 1px solid var(--border-subtle); background: var(--canvas); }
-  .mobileSteps li > button:first-child {
-    width: 100%;
-    min-height: 5rem;
-    display: grid;
-    grid-template-columns: auto 1fr;
-    gap: var(--space-2) var(--space-3);
-    margin-bottom: var(--space-2);
-    padding: var(--space-3);
-    border: 1px solid var(--border);
-    border-radius: var(--radius-sm);
-    background: var(--surface);
-    color: var(--text-primary);
-    text-align: left;
-  }
-  .mobileSteps span { color: var(--accent); font-size: var(--type-label); font-weight: var(--weight-semibold); }
-  .mobileSteps strong,
-  .mobileSteps p { grid-column: 1 / -1; }
-  .mobileSteps strong { font-size: var(--type-body); font-weight: var(--weight-semibold); }
-  .mobileSteps p { margin: 0; color: var(--text-secondary); font-size: var(--type-ui); line-height: 1.55; }
-  .mobileConvergenceSteps {
-    display: grid;
-    gap: var(--space-2);
-    margin: 0;
-    padding: 0;
-    list-style: none;
-  }
-  .mobileConvergenceSteps > li { margin: 0; padding: 0; }
-  .inspectorDetailItem {
-    grid-template-columns: 1fr;
-    gap: var(--space-2);
-  }
-  .implementationRouteBody { grid-template-columns: 1fr; }
-  .implementationPlanField.is-wide { grid-column: auto; }
-  .mobileCandidate { padding: var(--space-2); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-control); }
-  .mobileCandidate .candidateExpand {
-    width: 100%;
-    min-height: 2.75rem;
-    border: 1px solid var(--border);
-    border-radius: var(--radius-sm);
-    background: var(--canvas);
-    color: var(--accent);
-    font-size: var(--type-ui);
-    font-weight: var(--weight-semibold);
-  }
-  .mobileCandidateActions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
-  .mobileCandidateActions .candidateExpand {
-    width: auto;
-    flex: 1 1 12rem;
-    display: inline-flex;
-    align-items: center;
-    justify-content: center;
-    gap: .375rem;
-    padding: 0 var(--space-3);
-  }
-  .mobileCandidate ol { margin: 0; padding: var(--space-2) 0 0; list-style: none; }
-  .mobileBranchProcess { display: grid; gap: var(--space-2); }
-  .mobileRetrievalStage {
-    padding: var(--space-2);
-    border: 1px dashed rgba(230, 168, 111, .42);
-    border-radius: var(--radius-sm);
-    background: rgba(230, 168, 111, .045);
-  }
-  .mobileRetrievalStage > button:first-child { border-color: rgba(230, 168, 111, .38); background: rgba(37, 35, 31, .96); }
-  .mobileRetrievalStage > button:first-child span,
-  .mobileRetrievalStage > ol > li > button:first-child span { color: var(--retrieval); }
-  .mobileRetrievalStage > ol { display: grid; gap: var(--space-2); padding: 0; }
-  .mobileRetrievalStage > ol > li { padding-left: var(--space-2); border-left: 2px solid rgba(230, 168, 111, .28); }
-  .mobileRetrievalAgent > button:first-child { background: rgba(31, 30, 30, .97); }
-  .mobileRetrievalAgent > ol { display: grid; gap: .25rem; padding-left: var(--space-3); }
-  .mobileRetrievalAgent > ol > li > button:first-child { min-height: 2.75rem; }
-
-  .flowShell.mobileFullscreen { position: fixed; inset: 0; z-index: var(--z-drawer); display: block; }
-  .inspector { position: fixed; inset: 0; width: 100%; height: 100dvh; max-height: none; border: 0; border-radius: 0; }
-  .inspectorHeader { padding: var(--space-4); }
-  .inspectorTabs {
-    grid-template-columns: repeat(2, minmax(0, 1fr));
-    overflow-x: visible;
-  }
-  .inspectorTabs button { min-width: 0; }
-  .inspectorTabs button:last-child:nth-child(odd) { grid-column: 1 / -1; }
-  .inspectorBody { gap: var(--space-4); padding: var(--space-4); }
-  .retrievalActivityList button { grid-template-columns: 1.75rem minmax(0, 1fr) auto; gap: var(--space-2); }
-  .retrievalActivityList button > svg { display: none; }
-  .retrievalConditions > div { grid-template-columns: 1fr; gap: var(--space-1); }
-  .retrievalResult > summary { grid-template-columns: 1.25rem minmax(0, 1fr); }
-  .retrievalResultMeta { grid-column: 2; justify-content: flex-start; }
-  .decisionComparison { overflow-x: auto; }
-  .comparisonHeader,
-  .comparisonRow { min-width: 30rem; }
-  .decisionComparison.hasHandling .comparisonHeader,
-  .decisionComparison.hasHandling .comparisonRow { min-width: 38rem; }
-  .runtimeUnit > summary { grid-template-columns: auto auto minmax(0, 1fr) auto; }
-  .runtimeUnit > summary small { display: none; }
-  .dataField { padding: var(--space-3); }
-  .mappingGroup li { align-items: start; grid-template-columns: 1fr; }
-  .sourceReferences { justify-content: flex-start; }
-  .unusedSources { align-items: flex-start; flex-direction: column; }
-  .promptDrawer { inset: 0; width: 100vw; border: 0; border-radius: 0; }
-  .promptDrawerBody { padding: var(--space-4); }
-  .errorBanner { left: .625rem; right: .625rem; bottom: .625rem; }
-
-  .scriptViewer { inset: 0; width: 100vw; height: 100dvh; border: 0; border-radius: 0; }
-  .scriptViewerHeader { min-height: 4.75rem; padding: var(--space-3) var(--space-4); }
-  .scriptViewerTitle h2 { font-size: var(--type-title); }
-  .scriptViewerHeaderActions .quietButton span { display: none; }
-  .scriptViewerHeaderActions .quietButton { width: 2.75rem; padding: 0; justify-content: center; }
-  .scriptViewerToolbar { min-height: 6.5rem; align-items: center; flex-wrap: wrap; padding: var(--space-2) var(--space-3); }
-  .scriptViewerTabs { height: 3rem; flex: 1 1 100%; }
-  .scriptViewerTabs button { flex: 1; min-width: 0; }
-  .scriptViewerVersion { flex: 1 1 100%; max-width: none; text-align: left; }
-  .scriptViewerBody { padding: var(--space-2); }
-}
-
-@media (prefers-reduced-motion: reduce) {
-  *,
-  *::before,
-  *::after {
-    scroll-behavior: auto !important;
-    animation-duration: .01ms !important;
-    animation-iteration-count: 1 !important;
-    transition-duration: .01ms !important;
-  }
-  .react-flow__edge.animated .react-flow__edge-path { animation: none !important; }
-}
-
-/* Inspector three-column source comparison workbench. */
-
-.withLineageInspector .runHeader { right: var(--space-4); }
-.withLineageInspector .workspace { grid-template-columns: minmax(0, 1fr); }
-
-.inspectorWorkbenchBackdrop {
-  position: fixed;
-  z-index: calc(var(--z-drawer) - 1);
-  inset: 0;
-  background: rgba(7, 8, 10, .48);
-  backdrop-filter: blur(1px);
-}
-
-.inspectorWorkbench {
-  position: fixed;
-  inset: var(--space-3) var(--space-3) var(--space-3) auto;
-  width: min(96vw, 112rem);
-  height: auto;
-  max-height: none;
-  grid-template-rows: auto minmax(0, 1fr);
   overflow: hidden;
-  border: 1px solid var(--border);
-  border-radius: var(--radius-lg);
-  background: #17191e;
-  box-shadow: 0 28px 90px rgba(0, 0, 0, .62);
-}
-.comparisonInspectorHeader {
-  min-height: 5rem;
-  padding: var(--space-3) var(--space-4);
-  background: #1d2026;
-}
-.comparisonInspectorTitle { min-width: 0; flex: 1; }
-.comparisonInspectorTitle h2 { max-width: none; font-size: var(--type-title); }
-.comparisonInspectorMeta { min-width: 0; display: flex; align-items: center; gap: var(--space-2); margin-top: var(--space-2); }
-.comparisonInspectorActions { display: flex; align-items: center; justify-content: flex-end; gap: var(--space-2); flex: none; }
-.comparisonInspectorBody { display: block; padding: 0; overflow-y: auto; }
-
-.inspectorComparison { min-width: 0; color: var(--text-secondary); }
-.inspectorComparison .valueView {
-  max-height: none;
-  overflow-x: auto;
-  overflow-y: visible;
-}
-.comparisonToolbar {
-  position: sticky;
-  z-index: 4;
-  top: 0;
-  min-height: 3.25rem;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: var(--space-3);
-  padding: var(--space-2) var(--space-4);
-  border-bottom: 1px solid var(--border);
-  background: #1b1e23;
-}
-.comparisonToolbar > div { min-width: 0; display: flex; align-items: center; gap: var(--space-2); }
-.comparisonToolbar > div:first-child > svg { color: var(--info); }
-.comparisonToolbar span { color: var(--text-secondary); font-size: var(--type-ui); font-weight: var(--weight-semibold); }
-.comparisonCompleteness,
-.sourceCompleteness {
-  display: inline-flex;
-  align-items: center;
-  min-height: 1.6rem;
-  padding: .125rem .5rem;
-  border: 1px solid var(--border);
-  border-radius: 999px;
-  color: var(--text-muted);
-  font-size: var(--type-caption);
-  font-weight: var(--weight-semibold);
-  white-space: nowrap;
-}
-.comparisonCompleteness.is-complete,
-.sourceCompleteness.is-complete { border-color: rgba(99, 200, 131, .35); color: var(--success); }
-.comparisonCompleteness.is-partial,
-.sourceCompleteness.is-partial { border-color: rgba(244, 193, 82, .36); color: var(--warning); }
-.comparisonCompleteness.is-missing,
-.sourceCompleteness.is-missing { border-color: rgba(255, 128, 110, .36); color: var(--danger); }
-.comparisonTextAction,
-.comparisonInlineAction {
-  min-height: 2.25rem;
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  gap: .35rem;
-  padding: 0 var(--space-3);
-  border: 1px solid var(--border);
-  border-radius: var(--radius-sm);
-  background: var(--surface);
-  color: var(--text-secondary);
-  font-size: var(--type-label);
-  font-weight: var(--weight-semibold);
-}
-.comparisonTextAction:hover,
-.comparisonInlineAction:hover { border-color: var(--border-strong); background: var(--surface-control); color: var(--text-primary); }
-.comparisonNotices { display: grid; gap: var(--space-2); padding: var(--space-3) var(--space-4) 0; }
-.comparisonNotices p { margin: 0; display: flex; align-items: flex-start; gap: var(--space-2); padding: var(--space-3); border: 1px solid rgba(123, 184, 255, .25); border-radius: var(--radius-sm); background: rgba(123, 184, 255, .04); color: var(--text-secondary); font-size: var(--type-ui); line-height: 1.6; }
-.comparisonNotices p.is-warning { border-color: rgba(244, 193, 82, .28); color: #efd486; background: rgba(244, 193, 82, .04); }
-.comparisonNotices p.is-error { border-color: rgba(255, 128, 110, .3); color: #ffb0a2; background: rgba(255, 128, 110, .04); }
-.comparisonNotices svg { flex: none; margin-top: .25rem; }
-
-.comparisonColumnHeaders {
-  position: sticky;
-  z-index: 3;
-  top: 3.25rem;
-  display: grid;
-  grid-template-columns: minmax(0, 42fr) minmax(0, 24fr) minmax(0, 34fr);
-  border-top: 1px solid var(--border-subtle);
-  border-bottom: 1px solid var(--border);
-  background: #20232a;
-}
-.comparisonColumnHeaders span { min-width: 0; padding: var(--space-3) var(--space-4); color: var(--text-primary); font-size: var(--type-ui); font-weight: var(--weight-semibold); }
-.comparisonColumnHeaders span:first-child { color: var(--accent-hover); }
-.comparisonColumnHeaders span:nth-child(2) { color: #9fcaff; border-left: 1px solid var(--border); }
-.comparisonColumnHeaders span:nth-child(3) { color: #79d5c3; border-left: 1px solid var(--border); }
-
-.comparisonModules { padding-bottom: var(--space-4); }
-.comparisonModule { min-width: 0; border-bottom: 1px solid var(--border); }
-.comparisonModuleHeader {
-  width: 100%;
-  min-height: 3.5rem;
-  display: grid;
-  grid-template-columns: auto auto minmax(0, 1fr) auto;
-  align-items: center;
-  gap: var(--space-2);
-  padding: var(--space-2) var(--space-4);
-  border: 0;
-  background: #1b1e23;
-  color: var(--text-secondary);
-  text-align: left;
-}
-.comparisonModuleHeader:hover { background: #22252b; color: var(--text-primary); }
-.comparisonModuleHeader > svg { color: var(--text-muted); }
-.comparisonModuleHeader strong { min-width: 0; color: var(--text-primary); font-size: var(--type-body); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
-.comparisonModuleHeader small { color: var(--text-muted); font-size: var(--type-caption); white-space: nowrap; }
-.moduleMarker {
-  display: inline-grid;
-  place-items: center;
-  min-width: 2.2rem;
-  height: 1.55rem;
-  padding: 0 .4rem;
-  border: 1px solid rgba(255, 109, 90, .34);
-  border-radius: .375rem;
-  background: rgba(255, 109, 90, .07);
-  color: var(--accent-hover);
-  font: var(--type-caption)/1 var(--font-mono);
-}
-.comparisonModuleGrid { min-width: 0; display: grid; grid-template-columns: minmax(0, 46fr) minmax(0, 30fr) minmax(0, 24fr); align-items: start; }
-.comparisonColumnRegion { display: contents; }
-.comparisonCell { min-width: 0; padding: var(--space-4); background: #181a1f; }
-.comparisonCell + .comparisonCell { border-left: 1px solid var(--border); }
-.businessModuleCell { background: rgba(255, 109, 90, .018); }
-.evidenceCell { background: rgba(123, 184, 255, .018); }
-.sourceRecordCell { background: rgba(88, 208, 185, .018); }
-
-.comparisonBusinessValue { min-width: 0; display: grid; gap: var(--space-3); }
-.businessSourceLinks { min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: var(--space-2); }
-.businessSourceLinks > span { color: var(--text-muted); font-size: var(--type-caption); }
-.businessDefinitionList { min-width: 0; display: grid; gap: var(--space-3); margin: 0; }
-.businessDefinitionList > div { min-width: 0; display: grid; grid-template-columns: 7.5rem minmax(0, 1fr); gap: var(--space-3); padding-top: var(--space-3); border-top: 1px solid var(--border-subtle); }
-.businessDefinitionList > div:first-child { padding-top: 0; border-top: 0; }
-.businessDefinitionList dt { color: var(--text-muted); font-size: var(--type-label); font-weight: var(--weight-medium); line-height: 1.65; }
-.businessDefinitionList dd { min-width: 0; max-width: 72ch; margin: 0; color: var(--text-secondary); font-size: var(--type-ui); line-height: 1.72; overflow-wrap: anywhere; text-wrap: pretty; }
-.businessDefinitionList .is-emphasis dd { color: var(--text-primary); font-size: var(--type-body); font-weight: var(--weight-semibold); }
-.businessPlanRoutes { min-width: 0; display: grid; gap: var(--space-3); }
-.businessPlanRoute { min-width: 0; padding: var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: rgba(255, 255, 255, .018); }
-.businessPlanRoute > header { display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-3); }
-.businessPlanRoute > header > span { display: grid; place-items: center; width: 1.8rem; height: 1.8rem; border-radius: 999px; background: rgba(255, 109, 90, .11); color: var(--accent-hover); font: var(--type-caption)/1 var(--font-mono); }
-.businessPlanRoute > header > div { min-width: 0; display: flex; align-items: baseline; gap: var(--space-2); }
-.businessPlanRoute h5,
-.businessNarrative h5,
-.businessRecordItem h5 { margin: 0; color: var(--text-primary); font-size: var(--type-body); font-weight: var(--weight-semibold); }
-.businessPlanRoute header small { color: var(--text-muted); font-size: var(--type-caption); }
-.businessPlanRoute dl { margin: 0; }
-.businessRevisionSummary,
-.businessRecordList { min-width: 0; display: grid; gap: var(--space-4); }
-.businessNarrative { min-width: 0; padding-top: var(--space-3); border-top: 1px solid var(--border-subtle); }
-.businessNarrative p,
-.businessRecordItem p { max-width: 72ch; margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--type-ui); line-height: 1.72; overflow-wrap: anywhere; text-wrap: pretty; }
-.businessRecordItem { min-width: 0; padding-bottom: var(--space-3); border-bottom: 1px solid var(--border-subtle); }
-.businessBulletList { max-width: 72ch; margin: 0; padding-left: 1.25rem; color: var(--text-secondary); font-size: var(--type-ui); line-height: 1.72; }
-.businessBulletList li + li { margin-top: var(--space-2); }
-.comparisonBusinessItems { min-width: 0; display: grid; }
-.comparisonBusinessItem { min-width: 0; display: grid; gap: var(--space-3); padding: var(--space-4) 0; border-top: 1px solid var(--border-subtle); }
-.comparisonBusinessItem:first-child { padding-top: 0; border-top: 0; }
-.comparisonBusinessItem:last-child { padding-bottom: 0; }
-.comparisonBusinessItem > header { min-width: 0; display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); }
-.comparisonBusinessItem > header > div:first-child { min-width: 0; display: flex; align-items: baseline; gap: var(--space-2); }
-.comparisonBusinessItem h4 { min-width: 0; margin: 0; color: var(--text-primary); font-size: var(--type-ui); font-weight: var(--weight-semibold); line-height: 1.5; overflow-wrap: anywhere; }
-.businessItemId { color: var(--text-muted); font: var(--type-caption)/1 var(--font-mono); }
-.comparisonReadableText { max-width: 72ch; color: var(--text-secondary); font-size: var(--type-ui); line-height: 1.72; overflow-wrap: anywhere; text-wrap: pretty; }
-.comparisonReadableText > :first-child { margin-top: 0; }
-.comparisonReadableText > :last-child { margin-bottom: 0; }
-.comparisonReadableText h2,
-.comparisonReadableText h3 { color: var(--text-primary); font-size: var(--type-body); }
-.comparisonEmptyValue { margin: 0; color: var(--text-muted); font-size: var(--type-ui); line-height: 1.65; }
-.comparisonInlineAction { width: fit-content; }
-
-.bindingBadgeRow { min-width: 0; display: flex; justify-content: flex-end; gap: var(--space-1); flex-wrap: wrap; }
-.bindingBadge {
-  min-height: 2.75rem;
-  display: inline-flex;
-  align-items: center;
-  gap: .35rem;
-  padding: .25rem .75rem;
-  border: 1px solid rgba(123, 184, 255, .32);
-  border-radius: 999px;
-  background: rgba(123, 184, 255, .06);
-  color: #afd2ff;
-  font-size: var(--type-label);
-  font-weight: var(--weight-semibold);
-  line-height: 1.25;
-  text-align: left;
-}
-.bindingBadge.is-compact { width: 2.75rem; min-width: 2.75rem; padding: 0; justify-content: center; }
-.bindingBadge.is-compact > svg { display: none; }
-.bindingMarker { color: inherit; font-family: var(--font-mono); }
-.bindingBadge:hover,
-.bindingBadge.is-active { border-color: var(--info); background: rgba(123, 184, 255, .16); color: #d6e9ff; box-shadow: 0 0 0 2px rgba(123, 184, 255, .08); }
-.bindingBadge.is-adopted { border-color: rgba(99, 200, 131, .36); color: #91dca8; background: rgba(99, 200, 131, .07); }
-.bindingBadge.is-rejected { border-color: var(--border-strong); color: var(--text-secondary); background: rgba(174, 178, 187, .06); }
-.bindingBadge.is-available { border-color: rgba(244, 193, 82, .36); color: #efd486; background: rgba(244, 193, 82, .06); }
-.bindingBadge.is-calculated { border-color: rgba(183, 160, 235, .38); color: #cabaf0; background: rgba(183, 160, 235, .07); }
-.bindingBadge.is-missing { border-color: rgba(255, 128, 110, .38); color: #ffb0a2; background: rgba(255, 128, 110, .06); }
-.bindingBadge.is-unsafe { border-color: rgba(244, 193, 82, .38); color: #efd486; background: rgba(244, 193, 82, .06); }
-.bindingBadge.is-partial { border-color: rgba(183, 160, 235, .38); color: #cabaf0; background: rgba(183, 160, 235, .07); }
-.bindingBadge.is-fallback { border-color: rgba(174, 178, 187, .34); color: var(--text-secondary); }
-
-.evidenceList { min-width: 0; display: grid; gap: var(--space-3); margin: 0; padding: 0; list-style: none; }
-.evidenceList > li { min-width: 0; display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr); align-items: start; gap: var(--space-2); padding: var(--space-3); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: rgba(32, 34, 40, .55); transition: border-color 160ms var(--ease-out), background 160ms var(--ease-out); }
-.evidenceList > li.is-active { border-color: rgba(123, 184, 255, .62); background: rgba(123, 184, 255, .08); }
-.evidenceCopy { min-width: 0; display: grid; gap: .3rem; }
-.evidenceCopy strong { color: var(--text-primary); font-size: var(--type-label); line-height: 1.45; overflow-wrap: anywhere; }
-.evidenceCopy code { color: #a9cfff; font: var(--type-caption)/1.55 var(--font-mono); overflow-wrap: anywhere; }
-.evidenceCopy span { color: var(--text-muted); font-size: var(--type-caption); line-height: 1.5; }
-
-.sourceRecordCell { display: grid; align-content: start; gap: var(--space-3); }
-.sourceRecord { min-width: 0; display: grid; gap: var(--space-3); padding: var(--space-3); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: rgba(32, 34, 40, .55); transition: border-color 160ms var(--ease-out), background 160ms var(--ease-out); }
-.sourceRecord.is-active { border-color: rgba(88, 208, 185, .62); background: rgba(88, 208, 185, .07); }
-.sourceRecord > header { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: start; gap: var(--space-2); }
-.sourceRecord > header > svg { margin-top: .2rem; color: #79d5c3; }
-.sourceRecord > header > div { min-width: 0; display: grid; gap: .2rem; }
-.sourceRecord > header strong { color: var(--text-primary); font-size: var(--type-label); line-height: 1.45; overflow-wrap: anywhere; }
-.sourceRecord > header small { color: #8ccfc2; font-size: var(--type-caption); }
-.sourceBindingMarkers { display: flex; gap: var(--space-1); flex-wrap: wrap; }
-.sourceBindingMarkers button { min-width: 2.75rem; height: 2.75rem; padding: 0 .55rem; border: 1px solid rgba(88, 208, 185, .3); border-radius: .45rem; background: rgba(88, 208, 185, .05); color: #93ded0; font: var(--type-label)/1 var(--font-mono); }
-.sourceBindingMarkers button:hover,
-.sourceBindingMarkers button.is-active { border-color: #79d5c3; background: rgba(88, 208, 185, .15); color: #d2fff6; }
-.sourceMeta { min-width: 0; display: grid; gap: .4rem; margin: 0; }
-.sourceMeta > div { min-width: 0; display: grid; grid-template-columns: 4.75rem minmax(0, 1fr); gap: var(--space-2); }
-.sourceMeta dt { color: var(--text-muted); font-size: var(--type-caption); }
-.sourceMeta dd { min-width: 0; margin: 0; color: var(--text-secondary); font-size: var(--type-caption); overflow-wrap: anywhere; }
-.sourceMeta code { color: #a8e2d7; font: var(--type-caption)/1.5 var(--font-mono); overflow-wrap: anywhere; }
-.selectedSourceValues { min-width: 0; padding-top: var(--space-2); border-top: 1px solid var(--border-subtle); }
-.selectedSourceValues > summary { min-height: 2.5rem; display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); color: #79d5c3; font-size: var(--type-label); font-weight: var(--weight-semibold); cursor: pointer; list-style: none; }
-.selectedSourceValues > summary::-webkit-details-marker { display: none; }
-.selectedSourceValues > summary small { color: var(--text-muted); font-size: var(--type-caption); font-weight: var(--weight-regular); }
-.selectedSourceValues > .comparisonLongValue,
-.selectedSourceValues > .valueView { margin-top: var(--space-2); }
-.sourceMissingReason { margin: 0; color: #ffb0a2; font-size: var(--type-ui); line-height: 1.6; overflow-wrap: anywhere; }
-.sourceMissingReason code { font-family: var(--font-mono); }
-.unboundRuntimeRefs { min-width: 0; display: grid; gap: var(--space-2); padding: var(--space-3); border: 1px dashed var(--border); border-radius: var(--radius-sm); }
-.unboundRuntimeRefs strong { color: var(--text-primary); font-size: var(--type-label); }
-.unboundRuntimeRefs code { color: var(--text-muted); font: var(--type-caption)/1.5 var(--font-mono); overflow-wrap: anywhere; }
-
-.comparisonLongValue { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); overflow: hidden; }
-.comparisonLongValue > summary { min-height: 2.75rem; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-2); padding: var(--space-3); color: var(--text-secondary); cursor: pointer; list-style: none; }
-.comparisonLongValue > summary::-webkit-details-marker,
-.rawRecordDisclosure > summary::-webkit-details-marker { display: none; }
-.comparisonLongValue > summary:hover,
-.rawRecordDisclosure > summary:hover { background: rgba(255, 255, 255, .025); }
-.comparisonDisclosureTitle { min-width: 0; display: flex; align-items: center; gap: var(--space-2); }
-.comparisonDisclosureTitle svg { flex: none; color: var(--text-muted); transition: transform 180ms var(--ease-out); }
-.comparisonLongValue[open] .comparisonDisclosureTitle svg { transform: rotate(90deg); }
-.comparisonDisclosureTitle strong { color: var(--text-primary); font-size: var(--type-label); }
-.comparisonLongValue > summary > small { color: var(--text-muted); font: var(--type-caption)/1.4 var(--font-mono); white-space: nowrap; }
-.comparisonDisclosurePreview { grid-column: 1 / -1; color: var(--text-secondary); font-size: var(--type-ui); font-weight: var(--weight-regular); line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; }
-.comparisonDisclosureBody { padding: var(--space-3); border-top: 1px solid var(--border-subtle); }
-.comparisonDisclosureBody .valueView,
-.selectedSourceValues .valueView { max-height: none; margin: 0; }
-.rawRecordDisclosure { min-width: 0; border-top: 1px solid var(--border-subtle); }
-.rawRecordDisclosure > summary { min-height: 2.75rem; display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); color: var(--text-secondary); font-size: var(--type-label); cursor: pointer; list-style: none; }
-.rawRecordDisclosure > summary small { color: var(--text-muted); font-size: var(--type-caption); }
-.rawRecordDisclosure > div { padding-top: var(--space-2); }
-.comparisonLongValue > summary:focus-visible,
-.rawRecordDisclosure > summary:focus-visible,
-.comparisonModuleHeader:focus-visible,
-.bindingBadge:focus-visible,
-.sourceBindingMarkers button:focus-visible {
-  outline: 2px solid var(--info);
-  outline-offset: 2px;
-}
-
-.comparisonState { min-height: 22rem; display: grid; place-content: center; justify-items: center; gap: var(--space-2); padding: var(--space-5); color: var(--warning); text-align: center; }
-.comparisonState h3 { margin: var(--space-1) 0 0; color: var(--text-primary); font-size: var(--type-title); }
-.comparisonState p { max-width: 45rem; margin: 0; color: var(--text-muted); font-size: var(--type-ui); line-height: 1.65; }
-.comparisonSkeleton { min-height: 24rem; display: grid; grid-template-columns: 42fr 24fr 34fr; gap: 1px; background: var(--border); }
-.comparisonSkeleton > * { min-height: 8rem; background: var(--surface); animation: dataFlowPulse 1.5s ease-in-out infinite alternate; }
-.comparisonSkeleton > div { grid-column: 1 / -1; min-height: 3.5rem; }
-
-/* One business item, its evidence and its exact source share one physical row. */
-.comparisonItemRows { min-width: 0; }
-.comparisonItemRow {
-  min-width: 0;
-  display: grid;
-  grid-template-columns: minmax(0, 42fr) minmax(0, 24fr) minmax(0, 34fr);
-  align-items: stretch;
-  border-top: 1px solid var(--border-subtle);
-  background: #181a1f;
-}
-.comparisonItemRow:first-child { border-top: 0; }
-.comparisonItemRow.is-active { background: rgba(123, 184, 255, .035); }
-.comparisonItemBusiness,
-.comparisonItemEvidence,
-.comparisonItemSource {
-  min-width: 0;
-  padding: var(--space-4);
-}
-.comparisonItemEvidence,
-.comparisonItemSource { border-left: 1px solid var(--border); }
-.comparisonItemBusiness { background: rgba(255, 109, 90, .018); }
-.comparisonItemEvidence { display: grid; align-content: start; gap: var(--space-3); background: rgba(123, 184, 255, .018); }
-.comparisonItemSource { display: grid; align-content: start; gap: var(--space-3); background: rgba(88, 208, 185, .018); }
-.comparisonItemBusiness > h4 {
-  max-width: 72ch;
-  margin: 0 0 var(--space-3);
-  color: var(--accent-hover);
-  font-size: var(--type-ui);
-  font-weight: var(--weight-semibold);
-  line-height: 1.5;
-  text-wrap: pretty;
-}
-.comparisonItemBusiness .comparisonReadableText,
-.comparisonItemBusiness .businessDefinitionList dd,
-.comparisonItemBusiness .businessNarrative p,
-.comparisonItemBusiness .businessRecordItem p { font-size: var(--type-body); line-height: 1.72; }
-
-.comparisonEvidenceSummary {
-  min-width: 0;
-  display: grid;
-  grid-template-columns: auto minmax(0, 1fr);
-  align-items: start;
-  gap: var(--space-2);
-  padding-bottom: var(--space-3);
-  border-bottom: 1px solid var(--border-subtle);
-}
-.comparisonEvidenceSummary:last-child { padding-bottom: 0; border-bottom: 0; }
-.comparisonEvidenceSummary.is-active { color: var(--text-primary); }
-.comparisonEvidenceCopy { min-width: 0; display: grid; gap: .3rem; }
-.comparisonEvidenceCopy > strong { color: var(--text-primary); font-size: var(--type-label); line-height: 1.45; overflow-wrap: anywhere; }
-.comparisonEvidenceCopy > span { color: #afd2ff; font-size: var(--type-ui); font-weight: var(--weight-semibold); line-height: 1.5; }
-.comparisonEvidenceCopy > small { color: var(--text-muted); font-size: var(--type-caption); line-height: 1.5; }
-.comparisonLocatorDetails { min-width: 0; margin-top: var(--space-1); }
-.comparisonLocatorDetails > summary {
-  width: fit-content;
-  min-height: 2rem;
-  display: flex;
-  align-items: center;
-  color: var(--text-muted);
-  font-size: var(--type-caption);
-  cursor: pointer;
-}
-.comparisonLocatorDetails[open] > summary { color: #afd2ff; }
-.comparisonLocatorDetails > code,
-.comparisonLocatorDetails > small { display: block; margin-top: .35rem; color: var(--text-muted); font: var(--type-caption)/1.55 var(--font-mono); overflow-wrap: anywhere; }
-
-.comparisonSourceExcerpt {
-  min-width: 0;
-  display: grid;
-  gap: var(--space-3);
-  padding-bottom: var(--space-3);
-  border-bottom: 1px solid var(--border-subtle);
-}
-.comparisonSourceExcerpt:last-child { padding-bottom: 0; border-bottom: 0; }
-.comparisonSourceExcerpt > header {
-  min-width: 0;
-  display: grid;
-  grid-template-columns: auto minmax(0, 1fr) auto;
-  align-items: start;
-  gap: var(--space-2);
-}
-.comparisonSourceExcerpt > header > svg { margin-top: .2rem; color: #79d5c3; }
-.comparisonSourceExcerpt > header > div { min-width: 0; display: grid; gap: .2rem; }
-.comparisonSourceExcerpt > header strong { color: var(--text-primary); font-size: var(--type-label); line-height: 1.45; overflow-wrap: anywhere; }
-.comparisonSourceExcerpt > header small { color: #8ccfc2; font-size: var(--type-caption); }
-.sourceExcerptMarker {
-  display: inline-grid;
-  place-items: center;
-  min-width: 2.75rem;
-  height: 2.75rem;
-  padding: 0 .55rem;
-  border: 1px solid rgba(88, 208, 185, .3);
-  border-radius: .45rem;
-  background: rgba(88, 208, 185, .05);
-  color: #93ded0;
-  font: var(--type-label)/1 var(--font-mono);
-}
-.sourceEmptyResult {
-  display: grid;
-  gap: .25rem;
-  padding: var(--space-3);
-  border: 1px solid rgba(99, 200, 131, .3);
-  border-radius: var(--radius-sm);
-  background: rgba(99, 200, 131, .055);
-}
-.sourceEmptyResult strong { color: var(--success); font-size: var(--type-ui); }
-.sourceEmptyResult span { color: var(--text-secondary); font-size: var(--type-caption); }
-.comparisonTextExcerpt {
-  max-width: 72ch;
-  margin: 0;
-  color: var(--text-muted);
-  font-size: var(--type-ui);
-  line-height: 1.75;
-  white-space: pre-wrap;
-  overflow-wrap: anywhere;
-}
-.comparisonTextExcerpt mark {
-  padding: .08em .2em;
-  border-radius: .2rem;
-  background: rgba(88, 208, 185, .18);
-  color: #d8fff7;
-}
-.sourceMemberList { min-width: 0; display: grid; gap: var(--space-3); }
-.sourceMemberList > section { min-width: 0; display: grid; gap: var(--space-2); padding-top: var(--space-3); border-top: 1px solid var(--border-subtle); }
-.sourceMemberList > section:first-child { padding-top: 0; border-top: 0; }
-.sourceMemberList > section > strong { color: var(--text-primary); font-size: var(--type-label); overflow-wrap: anywhere; }
-
-@media (max-width: 1099px) {
-  .inspectorWorkbench { inset: var(--space-2); width: auto; }
-  .comparisonColumnHeaders { display: none; }
-  .comparisonModuleGrid { grid-template-columns: 1fr; }
-  .comparisonItemRow { grid-template-columns: 1fr; }
-  .comparisonItemEvidence,
-  .comparisonItemSource { border-top: 1px solid var(--border); border-left: 0; }
-  .comparisonItemBusiness::before,
-  .comparisonItemEvidence::before,
-  .comparisonItemSource::before { display: block; margin-bottom: var(--space-3); font-size: var(--type-ui); font-weight: var(--weight-semibold); }
-  .comparisonItemBusiness::before { color: var(--accent-hover); content: "业务详情中的内容"; }
-  .comparisonItemEvidence::before { color: #9fcaff; content: "这一项的数据依据"; }
-  .comparisonItemSource::before { color: #79d5c3; content: "对应的原始字段或原文"; }
-  .comparisonCell + .comparisonCell { border-top: 1px solid var(--border); border-left: 0; }
-  .comparisonCell::before { display: block; margin-bottom: var(--space-3); font-size: var(--type-ui); font-weight: var(--weight-semibold); }
-  .businessModuleCell::before { color: var(--accent-hover); content: "业务详情"; }
-  .evidenceCell::before { color: #9fcaff; content: "数据依据"; }
-  .sourceRecordCell::before { color: #79d5c3; content: "原始记录与运行"; }
-  .comparisonSkeleton { grid-template-columns: 1fr; }
-  .comparisonSkeleton > div { grid-column: 1; }
-}
-
-@media (max-width: 1099px) {
-  .bindingBadge,
-  .bindingBadge.is-compact,
-  .sourceBindingMarkers button { min-width: 2.75rem; width: auto; height: 2.75rem; min-height: 2.75rem; }
-}
-
-@media (max-width: 767px) {
-  .inspectorWorkbench { inset: 0; width: 100%; height: 100dvh; border: 0; border-radius: 0; }
-  .comparisonInspectorHeader { align-items: flex-start; flex-wrap: wrap; }
-  .comparisonInspectorTitle { order: 1; flex: 1 1 calc(100% - 7rem); }
-  .comparisonInspectorActions { order: 2; }
-  .comparisonInspectorActions .quietButton { width: 2.75rem; padding: 0; }
-  .comparisonInspectorActions .quietButton:not(:last-child) { font-size: 0; }
-  .comparisonInspectorActions .quietButton svg { margin: 0; }
-  .inspectorHeaderActions .quietButton { width: 2.75rem; padding: 0; font-size: 0; }
-  .inspectorHeaderActions .quietButton svg { margin: 0; }
-  .comparisonToolbar { align-items: flex-start; }
-  .comparisonToolbar > div:last-child { justify-content: flex-end; flex-wrap: wrap; }
-  .comparisonTextAction { min-height: 2.75rem; }
-  .comparisonModuleHeader { grid-template-columns: auto auto minmax(0, 1fr); }
-  .comparisonModuleHeader small { grid-column: 3; }
-  .comparisonCell { padding: var(--space-3); }
-  .comparisonBusinessItem > header { flex-direction: column; }
-  .businessDefinitionList > div { grid-template-columns: 1fr; gap: .25rem; }
-  .bindingBadgeRow { justify-content: flex-start; }
-  .evidenceList > li { grid-template-columns: 1fr; }
-  .sourceMeta > div { grid-template-columns: 1fr; gap: .15rem; }
-}
-
-@media (prefers-reduced-motion: reduce) {
-  .comparisonDisclosureTitle svg,
-  .sourceRecord,
-  .evidenceList > li { transition: none !important; }
+  border: 1px solid #414650;
+  border-radius: 10px;
+  background: #23262c;
+  box-shadow: 0 8px 18px rgba(0,0,0,.25), 0 1px 3px rgba(0,0,0,.3);
+  transition: border-color 130ms ease, box-shadow 130ms ease, opacity 130ms ease, transform 130ms ease;
+}
+.flowCard::before { content: ""; position: absolute; z-index: 1; top: 0; bottom: 0; left: 0; width: 3px; background: var(--node-color); }
+.flowCard:hover { border-color: #666d7a; box-shadow: 0 12px 24px rgba(0,0,0,.32); transform: translateY(-1px); }
+.flowCard.selected { border-color: var(--node-color); box-shadow: 0 0 0 2px color-mix(in srgb, var(--node-color) 28%, transparent), 0 14px 30px rgba(0,0,0,.4); }
+.flowCard.dimmed { opacity: .16; filter: saturate(.35); }
+.flowCard.type-input { --node-color: #e5b65b; }.flowCard.type-contract { --node-color: #e58a67; }.flowCard.type-operation { --node-color: #7da8e6; }
+.flowCard.type-worker, .flowCard.type-attempt { --node-color: #58a7dc; }.flowCard.type-artifact { --node-color: #48b7c5; }
+.flowCard.type-validator, .flowCard.type-validation { --node-color: #b68cff; }.flowCard.type-decision { --node-color: #ff7f68; }
+.flowCard.type-checkpoint { --node-color: #e7b95b; }.flowCard.type-publication { --node-color: #62c78b; }
+.flowCard.type-transaction { --node-color: #73d69d; }.flowCard.type-readback { --node-color: #78c6a0; }
+.cardHeader { height: 34px; display: flex; align-items: center; gap: 7px; padding: 0 10px 0 12px; border-bottom: 1px solid #373b44; background: #292c33; }
+.nodeIcon { width: 23px; height: 23px; display: grid; place-items: center; flex: none; border-radius: 5px; color: var(--node-color); background: color-mix(in srgb, var(--node-color) 13%, transparent); }
+.cardKind { flex: 1; color: var(--muted); font-size: 9px; font-weight: 720; letter-spacing: .09em; text-transform: uppercase; }
+.sequence { color: #707682; font-family: var(--font-mono); font-size: 9px; }
+.cardBody { min-height: 122px; padding: 12px 12px 10px 15px; }
+.cardBody h3 { margin: 0 0 4px; overflow: hidden; color: var(--text); font-size: 13px; font-weight: 690; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; }
+.cardBody > p { min-height: 31px; margin: 0 0 8px; display: -webkit-box; overflow: hidden; color: var(--muted); font-size: 10.5px; line-height: 1.45; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
+.recordType { display: flex; align-items: center; gap: 5px; overflow: hidden; color: #aeb3bd; font-family: var(--font-mono); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
+.recordType svg { flex: none; color: var(--node-color); }
+.metricGrid { margin: 10px 0 0; display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 5px; }
+.metricGrid div { min-width: 0; padding: 5px 6px; border-radius: 5px; background: #1b1d22; }
+.metricGrid dt { overflow: hidden; color: #7f8590; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
+.metricGrid dd { margin: 1px 0 0; overflow: hidden; color: var(--text-2); font-size: 9.5px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
+.cardFooter { height: 29px; display: flex; align-items: center; gap: 6px; padding: 0 10px 0 15px; border-top: 1px solid #353941; color: #8c929d; font-size: 8.5px; }
+.cardFooter strong { color: var(--text-2); font-size: 9px; font-weight: 650; text-transform: uppercase; }
+.cardFooter > span:not(.statusDot) { padding-left: 6px; border-left: 1px solid #3b3f47; }
+.cardFooter .taskKind { min-width: 0; margin-left: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.statusDot { width: 6px; height: 6px; flex: none; border-radius: 50%; background: #8d94a0; }
+.statusDot.status-completed, .statusDot.status-passed, .statusDot.status-accepted,
+.statusDot.status-published, .statusDot.status-committed, .statusDot.status-matched,
+.statusDot.status-success, .statusDot.status-200, .statusDot.status-bound,
+.statusDot.status-frozen, .statusDot.status-submitted, .statusDot.status-active,
+.statusDot.status-migrated { background: var(--green); }
+.statusDot.status-partial { background: var(--yellow); }
+.nodeHandle { width: 9px !important; height: 9px !important; border: 2px solid #17191d !important; background: var(--node-color) !important; }
+.react-flow__edge.animated path { stroke-dasharray: 7 5; animation-duration: .65s; }
+.react-flow__controls { overflow: hidden; margin: 0 0 12px 12px; border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 20px rgba(0,0,0,.28); }
+.react-flow__controls-button { width: 30px; height: 30px; border-bottom-color: var(--border); color: var(--text-2); background: #25282e; fill: currentColor; }
+.react-flow__controls-button:hover { background: #30343c; }
+
+/* Inspector */
+.inspector { position: absolute; z-index: 40; top: 0; right: 0; bottom: 0; width: 430px; display: flex; flex-direction: column; border-left: 1px solid var(--border); background: #1d1f24; box-shadow: -18px 0 40px rgba(0,0,0,.3); }
+.inspectorHeader { min-height: 79px; display: flex; align-items: center; gap: 11px; padding: 13px 12px 10px 16px; border-bottom: 1px solid var(--border); }
+.inspectorIcon { width: 38px; height: 38px; display: grid; place-items: center; flex: none; border-radius: 8px; color: var(--coral); background: var(--coral-soft); }
+.inspectorHeader > div:nth-child(2) { min-width: 0; flex: 1; }
+.inspectorHeader span { color: var(--muted); font-size: 9px; letter-spacing: .06em; text-transform: uppercase; }
+.inspectorHeader h2 { margin: 2px 0 0; overflow: hidden; font-size: 16px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; }
+.iconButton { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 7px; color: var(--muted); background: var(--surface-control); }
+.iconButton:hover { color: var(--text); border-color: var(--border-strong); }
+.inspectorMeta { min-height: 41px; display: flex; align-items: center; gap: 8px; padding: 0 16px; border-bottom: 1px solid var(--border); }
+.inspectorMeta code { min-width: 0; overflow: hidden; color: var(--muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
+.pill { padding: 3px 7px; border: 1px solid rgba(98,199,139,.28); border-radius: 12px; color: #a8e8c2; background: var(--green-soft); font-size: 9px; font-weight: 700; text-transform: uppercase; }
+.inspectorTabs { height: 44px; display: flex; gap: 4px; padding: 6px 12px 0; border-bottom: 1px solid var(--border); }
+.inspectorTabs button { position: relative; flex: 1; border: 0; color: var(--muted); background: transparent; font-size: 11px; }
+.inspectorTabs button:hover { color: var(--text-2); }
+.inspectorTabs button.active { color: var(--text); }
+.inspectorTabs button.active::after { content: ""; position: absolute; right: 9px; bottom: -1px; left: 9px; height: 2px; border-radius: 2px 2px 0 0; background: var(--coral); }
+.inspectorScroll { min-height: 0; flex: 1; overflow: auto; padding: 14px 14px 32px; scrollbar-color: #4a4f5a transparent; }
+.modelBanner { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; padding: 11px; border: 1px solid rgba(88,167,220,.25); border-radius: 8px; background: var(--blue-soft); }
+.modelBanner > div { min-width: 0; flex: 1; }
+.modelBanner small { display: block; color: var(--blue); font-size: 8px; font-weight: 720; letter-spacing: .1em; }
+.modelBanner strong { display: block; overflow: hidden; margin-top: 3px; color: var(--text); font-family: var(--font-mono); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
+.modelBanner button { display: flex; align-items: center; gap: 4px; padding: 6px 7px; border: 1px solid var(--border); border-radius: 6px; color: var(--muted); background: #272a30; font-size: 9px; }
+.fieldTree { margin: 0; display: grid; grid-template-columns: minmax(112px, 38%) minmax(0, 1fr); border: 1px solid var(--border); border-bottom: 0; border-radius: 7px 7px 0 0; background: #202329; }
+.fieldTree dt, .fieldTree dd { min-width: 0; margin: 0; padding: 7px 8px; border-bottom: 1px solid var(--border); font-size: 9.5px; }
+.fieldTree dt { overflow-wrap: anywhere; color: #9298a4; font-family: var(--font-mono); background: rgba(255,255,255,.012); }
+.fieldTree dd { overflow-wrap: anywhere; color: var(--text-2); border-left: 1px solid var(--border); }
+.fieldTree .fieldTree { grid-template-columns: minmax(82px, 40%) minmax(0,1fr); border-color: #343842; background: #1a1c21; }
+.fieldTree .fieldTree dt, .fieldTree .fieldTree dd { padding: 5px 6px; border-color: #343842; font-size: 8.5px; }
+.nestedValue { display: flex; flex-direction: column; gap: 5px; }
+.arrayItem { position: relative; display: grid; grid-template-columns: 18px minmax(0,1fr); gap: 4px; }
+.indexBadge { width: 17px; height: 17px; display: grid; place-items: center; border-radius: 4px; color: #8f96a2; background: #2d3139; font-family: var(--font-mono); font-size: 8px; }
+.scalar { font-family: var(--font-mono); }
+.scalar-string { color: #d9dbe0; }.scalar-number { color: #86c6ee; }.scalar-boolean { color: #c09cf4; }.scalar-null, .emptyValue { color: #757c88; font-family: var(--font-mono); font-style: italic; }
+.inspectorSection { margin-bottom: 18px; }
+.inspectorSection h3 { display: flex; align-items: center; gap: 6px; margin: 0 0 9px; color: var(--text-2); font-size: 11px; }
+.inspectorSection h3 span { min-width: 20px; padding: 1px 5px; border-radius: 9px; color: var(--muted); background: #2c3037; text-align: center; font-size: 9px; }
+.edgeList { display: flex; flex-direction: column; gap: 6px; }
+.edgeRow { --edge-color: #68707f; min-height: 48px; display: flex; align-items: center; gap: 8px; padding: 7px 8px; border: 1px solid var(--border); border-left: 3px solid var(--edge-color); border-radius: 7px; background: #24272d; }
+.edgeRow.edge-artifact { --edge-color: var(--blue); }.edgeRow.edge-validation { --edge-color: var(--purple); }.edgeRow.edge-publication { --edge-color: var(--green); }
+.edgeRow > svg { flex: none; color: var(--edge-color); }
+.edgeRow > svg:last-child { margin-left: auto; color: var(--subtle); }
+.edgeRow div { min-width: 0; display: flex; flex-direction: column; }
+.edgeRow strong { overflow: hidden; color: var(--text-2); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
+.edgeRow small { color: var(--muted); font-size: 8.5px; }
+.emptyState { display: flex; align-items: center; gap: 6px; color: var(--subtle); font-size: 10px; }
+.contractFields > p { color: var(--muted); font-size: 10px; }
+.contractFields ol { margin: 0; padding: 0; list-style: none; border: 1px solid var(--border); border-radius: 7px; }
+.contractFields li { min-height: 33px; display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-bottom: 1px solid var(--border); }
+.contractFields li:last-child { border: 0; }
+.contractFields code { min-width: 0; flex: 1; overflow-wrap: anywhere; color: var(--text-2); font-size: 9px; }
+.contractFields li span { color: var(--green); font-size: 8px; text-transform: uppercase; }
+
+/* Loading and small screens */
+.centerState { width: 100vw; height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; background: radial-gradient(circle at 50% 44%, #252832, var(--bg) 38%); color: var(--text); }
+.centerState > svg { width: 36px; height: 36px; color: var(--coral); }
+.centerState h1 { margin: 15px 0 4px; font-size: 18px; }
+.centerState p { margin: 0; color: var(--muted); }
+.centerState button { display: flex; align-items: center; gap: 6px; margin-top: 15px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 7px; color: var(--text); background: var(--surface-control); }
+.spin { animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } }
+
+@media (max-width: 980px) {
+  .brand small, .topStats > span:not(.fakeBadge), .phaseRail, .runTitle span { display: none; }
+  .topShell { gap: 10px; }
+  .canvasToolbar { margin-left: 4px !important; }
+  .canvasToolbar label { width: min(58vw, 310px); }
+  .inspector { width: min(92vw, 430px); }
+  .canvasShell.withInspector, .topShell.withInspector { right: 0; }
 }

+ 3 - 4
visualization/frontend/app/layout.tsx

@@ -1,13 +1,12 @@
 import type { Metadata } from "next";
-import type { ReactNode } from "react";
 import "@xyflow/react/dist/style.css";
 import "./globals.css";
 
 export const metadata: Metadata = {
-  title: "脚本构建运行台",
-  description: "脚本构建轮次、分支、决策依据与创作表变更的真实运行可视化"
+  title: "Script Build Mission Control",
+  description: "智能创作构建系统节点画布",
 };
 
-export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
+export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
   return <html lang="zh-CN"><body>{children}</body></html>;
 }

+ 2 - 2
visualization/frontend/app/page.tsx

@@ -1,5 +1,5 @@
-import { ExecutionWorkbench } from "@/components/workbench/ExecutionWorkbench";
+import { MissionControl } from "@/components/MissionControl";
 
 export default function Page() {
-  return <ExecutionWorkbench />;
+  return <MissionControl />;
 }

+ 29 - 0
visualization/frontend/components/FieldTree.tsx

@@ -0,0 +1,29 @@
+import { Fragment } from "react";
+
+function scalar(value: unknown): string {
+  if (value === null) return "null";
+  if (typeof value === "boolean") return value ? "true" : "false";
+  if (typeof value === "string") return value || '""';
+  return String(value);
+}
+
+export function FieldTree({ value, depth = 0 }: { value: unknown; depth?: number }) {
+  if (Array.isArray(value)) {
+    if (!value.length) return <span className="emptyValue">[]</span>;
+    return <div className="nestedValue">{value.map((item, index) => <div className="arrayItem" key={index}>
+      <span className="indexBadge">{index}</span><FieldTree value={item} depth={depth + 1} />
+    </div>)}</div>;
+  }
+  if (value !== null && typeof value === "object") {
+    const entries = Object.entries(value as Record<string, unknown>);
+    if (!entries.length) return <span className="emptyValue">{"{}"}</span>;
+    return <dl className={`fieldTree depth-${Math.min(depth, 3)}`}>
+      {entries.map(([key, item]) => <Fragment key={key}>
+        <dt>{key}</dt>
+        <dd><FieldTree value={item} depth={depth + 1} /></dd>
+      </Fragment>)}
+    </dl>;
+  }
+  return <span className={`scalar scalar-${value === null ? "null" : typeof value}`}>{scalar(value)}</span>;
+}
+

+ 60 - 0
visualization/frontend/components/FlowCard.tsx

@@ -0,0 +1,60 @@
+"use client";
+
+import {
+  BadgeCheck, Bot, Braces, Database, FileCheck2, FileLock2, Flag,
+  GitCommitHorizontal, PackageCheck, RadioTower, RefreshCcw, Rocket,
+  Send, ShieldCheck, Workflow,
+} from "lucide-react";
+import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
+import type { CardData } from "@/lib/flow";
+import type { NodeType } from "@/lib/types";
+
+const ICONS: Record<NodeType, typeof Database> = {
+  input: Database,
+  contract: FileLock2,
+  operation: RadioTower,
+  worker: Bot,
+  attempt: Send,
+  artifact: PackageCheck,
+  validator: ShieldCheck,
+  validation: FileCheck2,
+  decision: BadgeCheck,
+  checkpoint: Flag,
+  publication: Rocket,
+  transaction: GitCommitHorizontal,
+  readback: RefreshCcw,
+};
+
+export function FlowCard({ data, selected }: NodeProps<Node<CardData>>) {
+  const item = data.item;
+  const Icon = ICONS[item.node_type] ?? Braces;
+  return <article
+    className={`flowCard type-${item.node_type} status-${item.status} ${selected ? "selected" : ""} ${data.dimmed ? "dimmed" : ""}`}
+    data-node-id={item.id}
+  >
+    <Handle type="target" position={Position.Left} className="nodeHandle" />
+    <header className="cardHeader">
+      <span className="nodeIcon"><Icon size={17} strokeWidth={1.9} /></span>
+      <span className="cardKind">{item.node_type}</span>
+      <span className="sequence">{String(item.sequence).padStart(2, "0")}</span>
+    </header>
+    <div className="cardBody">
+      <h3>{item.title}</h3>
+      <p>{item.subtitle}</p>
+      <div className="recordType"><Workflow size={12} /> {item.record.model_name}</div>
+      {item.metrics.length ? <dl className="metricGrid">
+        {item.metrics.slice(0, 3).map((metric) => <div key={`${metric.label}:${metric.value}`}>
+          <dt>{metric.label}</dt><dd>{metric.value}</dd>
+        </div>)}
+      </dl> : null}
+    </div>
+    <footer className="cardFooter">
+      <span className={`statusDot status-${item.status}`} />
+      <strong>{item.status}</strong>
+      {item.agent_role ? <span>{item.agent_role}</span> : null}
+      {item.task_kind ? <span className="taskKind">{item.task_kind}</span> : null}
+    </footer>
+    <Handle type="source" position={Position.Right} className="nodeHandle" />
+  </article>;
+}
+

+ 75 - 0
visualization/frontend/components/Inspector.tsx

@@ -0,0 +1,75 @@
+"use client";
+
+import { useState } from "react";
+import { Braces, ChevronRight, CircleOff, Copy, GitFork, X } from "lucide-react";
+import { FieldTree } from "@/components/FieldTree";
+import type { FlowEdgeRecord, FlowNodeRecord, RunGraph } from "@/lib/types";
+
+type Tab = "fields" | "lineage" | "contract";
+
+function EdgeList({ title, edges, graph, direction }: {
+  title: string;
+  edges: FlowEdgeRecord[];
+  graph: RunGraph;
+  direction: "incoming" | "outgoing";
+}) {
+  return <section className="inspectorSection">
+    <h3>{title}<span>{edges.length}</span></h3>
+    {edges.length ? <div className="edgeList">{edges.map((edge) => {
+      const otherId = direction === "incoming" ? edge.source : edge.target;
+      const other = graph.nodes.find((item) => item.id === otherId);
+      return <div key={edge.id} className={`edgeRow edge-${edge.kind}`}>
+        <GitFork size={14} />
+        <div><strong>{other?.title ?? otherId}</strong><small>{edge.label ?? edge.kind}</small></div>
+        <ChevronRight size={14} />
+      </div>;
+    })}</div> : <p className="emptyState"><CircleOff size={15} /> 无连接</p>}
+  </section>;
+}
+
+export function Inspector({ item, graph, onClose }: {
+  item: FlowNodeRecord;
+  graph: RunGraph;
+  onClose: () => void;
+}) {
+  const [tab, setTab] = useState<Tab>("fields");
+  const incoming = graph.edges.filter((edge) => edge.target === item.id);
+  const outgoing = graph.edges.filter((edge) => edge.source === item.id);
+  const expectedFields = graph.schema_catalog[item.record.model_name] ?? [];
+  const copyJson = () => navigator.clipboard?.writeText(JSON.stringify(item.record.payload, null, 2));
+  return <aside className="inspector">
+    <header className="inspectorHeader">
+      <div className={`inspectorIcon type-${item.node_type}`}><Braces size={19} /></div>
+      <div><span>{item.phase} / {item.lane}</span><h2>{item.title}</h2></div>
+      <button type="button" className="iconButton" onClick={onClose} aria-label="关闭详情"><X size={18} /></button>
+    </header>
+    <div className="inspectorMeta">
+      <span className={`pill status-${item.status}`}>{item.status}</span>
+      <code>{item.id}</code>
+    </div>
+    <nav className="inspectorTabs" aria-label="节点详情页签">
+      <button className={tab === "fields" ? "active" : ""} onClick={() => setTab("fields")}>全部字段</button>
+      <button className={tab === "lineage" ? "active" : ""} onClick={() => setTab("lineage")}>上下游</button>
+      <button className={tab === "contract" ? "active" : ""} onClick={() => setTab("contract")}>字段合同</button>
+    </nav>
+    <div className="inspectorScroll">
+      {tab === "fields" ? <>
+        <section className="modelBanner">
+          <div><small>PROJECT MODEL</small><strong>{item.record.model_name}</strong></div>
+          <button type="button" onClick={copyJson}><Copy size={14} /> 复制 JSON</button>
+        </section>
+        <FieldTree value={item.record.payload} />
+      </> : null}
+      {tab === "lineage" ? <>
+        <EdgeList title="输入" edges={incoming} graph={graph} direction="incoming" />
+        <EdgeList title="输出" edges={outgoing} graph={graph} direction="outgoing" />
+      </> : null}
+      {tab === "contract" ? <section className="inspectorSection contractFields">
+        <h3>当前源码字段<span>{expectedFields.length}</span></h3>
+        <p>fake payload 必须与此清单完全同键;后端测试会拒绝遗漏和额外字段。</p>
+        <ol>{expectedFields.map((field) => <li key={field}><code>{field}</code><span>{field in item.record.payload ? "present" : "missing"}</span></li>)}</ol>
+      </section> : null}
+    </div>
+  </aside>;
+}
+

+ 130 - 0
visualization/frontend/components/MissionControl.tsx

@@ -0,0 +1,130 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+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,
+} 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";
+
+const nodeTypes = { flowCard: FlowCard, phaseFrame: PhaseFrameNode };
+const ALL_PHASES: Phase[] = ["phase-1", "phase-2", "phase-3", "delivery"];
+
+function LoadingState() {
+  return <main className="centerState"><LoaderCircle className="spin" /><h1>正在装载 fake mission graph</h1><p>连接本地 FastAPI :8788</p></main>;
+}
+
+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 Canvas({ graph, selected, onSelect }: {
+  graph: RunGraph;
+  selected: FlowNodeRecord | null;
+  onSelect: (node: FlowNodeRecord | null) => void;
+}) {
+  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>
+      </div>
+    </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() {
+  const [runs, setRuns] = useState<RunSummary[]>([]);
+  const [graph, setGraph] = useState<RunGraph | null>(null);
+  const [selected, setSelected] = useState<FlowNodeRecord | null>(null);
+  const [error, setError] = useState<string | null>(null);
+  const [nonce, setNonce] = useState(0);
+  useEffect(() => {
+    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 : "未知错误");
+      });
+    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>;
+}
+
+export function MissionControl() {
+  return <ReactFlowProvider><MissionControlInner /></ReactFlowProvider>;
+}
+

+ 15 - 0
visualization/frontend/components/PhaseFrameNode.tsx

@@ -0,0 +1,15 @@
+"use client";
+
+import type { Node, NodeProps } from "@xyflow/react";
+import type { FrameData } from "@/lib/flow";
+
+export function PhaseFrameNode({ data }: NodeProps<Node<FrameData>>) {
+  const frame = data.frame;
+  return <section className={`phaseFrame tone-${frame.tone}`} aria-label={frame.title}>
+    <div className="phaseLabel">
+      <span>{frame.title}</span>
+      <small>{frame.subtitle}</small>
+    </div>
+  </section>;
+}
+

+ 0 - 119
visualization/frontend/components/artifact/ScriptArtifactViewer.tsx

@@ -1,119 +0,0 @@
-"use client";
-
-import { useEffect, useRef, useState } from "react";
-import { FileText, RefreshCw, X } from "lucide-react";
-import { errorMessage, getArtifactDetail } from "@/lib/api";
-import type { ArtifactDetailProjection } from "@/lib/types";
-import { TabList } from "@/components/ui/TabList";
-import { ScriptTable } from "@/components/artifact/ScriptTable";
-import { ScriptElementTable } from "@/components/artifact/ScriptElementTable";
-import { StatusBadge } from "@/components/ui/Badges";
-
-type ViewerTab = "script" | "elements";
-
-interface Props {
-  buildId: string;
-  snapshotRef: string;
-  onClose: () => void;
-}
-
-export function ScriptArtifactViewer({ buildId, snapshotRef, onClose }: Props) {
-  const dialog = useRef<HTMLDialogElement>(null);
-  const [detail, setDetail] = useState<ArtifactDetailProjection>();
-  const [loading, setLoading] = useState(true);
-  const [error, setError] = useState<string>();
-  const [tab, setTab] = useState<ViewerTab>("script");
-  const [selectedElement, setSelectedElement] = useState<number | string>();
-  const [reloadKey, setReloadKey] = useState(0);
-
-  useEffect(() => {
-    const node = dialog.current;
-    if (node && !node.open && typeof node.showModal === "function") node.showModal();
-    const cancel = (event: Event) => { event.preventDefault(); onClose(); };
-    node?.addEventListener("cancel", cancel);
-    const previousOverflow = document.body.style.overflow;
-    document.body.style.overflow = "hidden";
-    return () => {
-      node?.removeEventListener("cancel", cancel);
-      document.body.style.overflow = previousOverflow;
-    };
-  }, [onClose]);
-
-  useEffect(() => {
-    const controller = new AbortController();
-    setLoading(true);
-    setError(undefined);
-    void getArtifactDetail(buildId, snapshotRef, controller.signal)
-      .then(setDetail)
-      .catch((reason) => {
-        if (!controller.signal.aborted) setError(errorMessage(reason));
-      })
-      .finally(() => {
-        if (!controller.signal.aborted) setLoading(false);
-      });
-    return () => controller.abort();
-  }, [buildId, snapshotRef, reloadKey]);
-
-  const table = detail?.scriptTable;
-  const context = detail?.artifactContext;
-  const candidate = context?.type === "candidate" || snapshotRef.startsWith("artifact:branch:");
-  const roundOutput = context?.type === "round" || snapshotRef.startsWith("artifact:round:");
-  const title = candidate
-    ? context?.roundIndex
-      ? `第 ${context.roundIndex} 轮 · 方案 ${context.branchId ?? "-"}`
-      : `候选方案 ${context?.branchId ?? snapshotRef.split(":").at(-1) ?? "-"}`
-    : roundOutput
-      ? `第 ${context?.roundIndex ?? snapshotRef.split(":").at(-1) ?? "-"} 轮 · 本轮产出`
-    : `Run #${buildId} · 当前保存版本`;
-  const versionNotice = candidate ? context ? candidateVersionNotice(context) : "正在读取候选版本信息" : roundOutput ? context?.note || "正在确认本轮产出版本" : "当前保存的最终主脚本";
-  const canRefresh = candidate && ["open", "parked"].includes(String(context?.branchStatus || "").toLowerCase());
-  const openElement = (id: number | string) => {
-    setSelectedElement(id);
-    setTab("elements");
-  };
-
-  return <dialog ref={dialog} className={`scriptViewer ${candidate ? "isCandidate" : "isFinal"}`} aria-labelledby="script-viewer-title" onWheelCapture={(event) => event.stopPropagation()}>
-    <header className="scriptViewerHeader">
-      <div className="scriptViewerTitle">
-        <span><FileText size={17} />{candidate ? "完整候选脚本" : roundOutput ? "本轮产出脚本表" : "当前最终主脚本"}{candidate && context?.branchStatus ? <StatusBadge status={context.branchStatus} /> : null}</span>
-        <h2 id="script-viewer-title">{title}</h2>
-        {table ? <p>{table.counts.paragraphs} 个段落 · {table.counts.elements} 个元素 · {table.counts.links} 条关联</p> : null}
-      </div>
-      <div className="scriptViewerHeaderActions">{canRefresh ? <button className="quietButton" type="button" aria-label="刷新当前候选脚本" onClick={() => setReloadKey((value) => value + 1)} disabled={loading}><RefreshCw size={15} /><span>刷新当前候选</span></button> : null}<button autoFocus className="iconButton" type="button" onClick={onClose} aria-label={candidate ? "关闭完整候选脚本" : "关闭完整主脚本"}><X size={20} /></button></div>
-    </header>
-    <div className="scriptViewerToolbar">
-      <TabList
-        className="scriptViewerTabs"
-        tabs={[{ id: "script", label: "创作脚本" }, { id: "elements", label: `脚本元素${table ? ` ${table.counts.elements}` : ""}` }]}
-        active={tab}
-        onChange={setTab}
-        ariaLabel={candidate ? "候选脚本内容分类" : "主脚本内容分类"}
-      />
-      <p className={`scriptViewerVersion ${context?.exactness === "historical-snapshot" ? "isHistorical" : "isCurrent"}`}>{versionNotice}</p>
-    </div>
-    <div className="scriptViewerBody">
-      {loading ? <div className="scriptViewerState"><div className="skeletonLines" aria-label="正在加载完整脚本表"><i /><i /><i /></div><p>{candidate ? "正在读取候选脚本…" : "正在读取当前主脚本…"}</p></div> : null}
-      {!loading && error ? <div className="scriptViewerState isError"><strong>{candidate ? "完整候选脚本暂时无法读取" : "完整主脚本暂时无法读取"}</strong><p>{error}</p></div> : null}
-      {!loading && !error && table && tab === "script" ? table.paragraphs.length
-        ? <ScriptTable paragraphs={table.paragraphs} elements={table.elements} onOpenElement={openElement} />
-        : <div className="scriptViewerState"><strong>{candidate ? "当前候选版本还没有段落" : "当前主脚本还没有段落"}</strong><p>可以切换到“脚本元素”查看已经保存的元素。</p></div>
-        : null}
-      {!loading && !error && table && tab === "elements" ? table.elements.length
-        ? <ScriptElementTable elements={table.elements} selectedId={selectedElement} />
-        : <div className="scriptViewerState"><strong>{candidate ? "当前候选版本还没有元素" : "当前主脚本还没有元素"}</strong><p>创作脚本段落仍可在另一个页签查看。</p></div>
-        : null}
-    </div>
-  </dialog>;
-}
-
-export function candidateVersionNotice(context: ArtifactDetailProjection["artifactContext"]) {
-  const status = String(context.branchStatus || "").toLowerCase();
-  if (context.exactness === "historical-snapshot") {
-    if (status === "discarded") return "未采用前冻结保存的候选版本";
-    if (status === "merged") return "采用前冻结保存的候选版本";
-    return "处置前冻结保存的候选版本";
-  }
-  if (status === "parked") return "根据当前主脚本和暂存改动组合;不是暂存当时的冻结历史";
-  if (status === "open") return "显示截至本次读取已写入的内容;方案仍可能继续更新";
-  return context.note || "根据当前主脚本和分支改动组合的候选版本";
-}

+ 0 - 26
visualization/frontend/components/artifact/ScriptElementTable.tsx

@@ -1,26 +0,0 @@
-"use client";
-
-import { useEffect, useRef } from "react";
-import type { ScriptElementRow } from "@/lib/types";
-
-export function ScriptElementTable({ elements, selectedId }: { elements: ScriptElementRow[]; selectedId?: number | string }) {
-  const selected = useRef<HTMLTableRowElement>(null);
-  useEffect(() => {
-    if (typeof selected.current?.scrollIntoView === "function") selected.current.scrollIntoView({ block: "center" });
-  }, [selectedId]);
-  return <div className="elementTableViewport" tabIndex={0} aria-label="可滚动的脚本元素表">
-    <table className="elementTable">
-      <caption className="srOnly">完整脚本元素表</caption>
-      <thead><tr><th>元素名称</th><th>一级维度</th><th>二级维度</th><th>使用段落</th></tr></thead>
-      <tbody>{elements.map((element) => {
-        const active = String(element.id) === String(selectedId);
-        return <tr key={String(element.rowId)} ref={active ? selected : undefined} className={active ? "isSelected" : ""}>
-          <th scope="row">{element.name}</th>
-          <td>{element.primaryDimension || "—"}</td>
-          <td>{element.secondaryDimension || "—"}</td>
-          <td>{element.paragraphs.length ? <ul className="elementParagraphs">{element.paragraphs.map((paragraph) => <li key={String(paragraph.id)}><span>{paragraph.index ? `第 ${paragraph.index} 段` : "段落"}</span>{paragraph.name}</li>)}</ul> : <span className="emptyValue">未关联段落</span>}</td>
-        </tr>;
-      })}</tbody>
-    </table>
-  </div>;
-}

+ 0 - 210
visualization/frontend/components/artifact/ScriptTable.tsx

@@ -1,210 +0,0 @@
-"use client";
-
-import { Fragment, useMemo, useState } from "react";
-import type {
-  ScriptDescriptionPart,
-  ScriptDimension,
-  ScriptElementRow,
-  ScriptFacet,
-  ScriptParagraphRow,
-} from "@/lib/types";
-
-const FACETS = [
-  ["theme", "主题", "theme", "主题描述"],
-  ["form", "形式", "form", "形式描述"],
-  ["function", "作用", "action", "作用描述"],
-  ["feeling", "感受", "feeling", "感受描述"],
-] as const;
-
-const PARA_COL_WIDTH = 100;
-const ATOM_TYPE_COL_WIDTH = 72;
-const ATOM_DIM_COL_WIDTH = 108;
-const ATOM_VAL_COL_WIDTH = 108;
-const OUTLINE_DESC_WIDTH = 155;
-const FIXED_COLS_WIDTH = 150 + (OUTLINE_DESC_WIDTH + ATOM_TYPE_COL_WIDTH + ATOM_DIM_COL_WIDTH + ATOM_VAL_COL_WIDTH) * 4 + 400;
-
-interface Props {
-  paragraphs: ScriptParagraphRow[];
-  elements: ScriptElementRow[];
-  onOpenElement: (id: number | string) => void;
-}
-
-export function ScriptTable({ paragraphs, elements, onOpenElement }: Props) {
-  const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
-  const maxDepth = Math.max(1, ...paragraphs.map((row) => Number(row.depth || 0) + 1));
-  const elementLookup = useMemo(() => buildElementLookup(elements), [elements]);
-  const hiddenIds = useMemo(() => descendantIds(paragraphs, collapsed), [paragraphs, collapsed]);
-  const rowsWithChildren = useMemo(() => {
-    const parents = new Set(paragraphs.map((row) => row.parentId).filter((value) => value != null).map(String));
-    return new Set(paragraphs.filter((row) => parents.has(String(row.id)) || parents.has(String(row.rowId))).map((row) => String(row.id)));
-  }, [paragraphs]);
-
-  const toggle = (id: number | string) => setCollapsed((current) => {
-    const next = new Set(current);
-    const key = String(id);
-    if (next.has(key)) next.delete(key);
-    else next.add(key);
-    return next;
-  });
-
-  return <section className="originalScriptTable" aria-label="完整脚本创作表">
-    <div className="scriptTableTitle">
-      <strong>创作脚本</strong>
-      <span className="script-legend"><span className="legend-item"><i className="legend-swatch" />脚本元素</span></span>
-    </div>
-    <div className="script-table-wrapper" tabIndex={0} aria-label="可横向和纵向滚动的脚本创作表">
-      <table className="script-table" style={{ width: maxDepth * PARA_COL_WIDTH + FIXED_COLS_WIDTH }}>
-        <caption className="srOnly">完整脚本创作表</caption>
-        <colgroup>
-          {Array.from({ length: maxDepth }, (_, index) => <col key={`paragraph-${index}`} style={{ width: PARA_COL_WIDTH }} />)}
-          <col style={{ width: 150 }} />
-          {FACETS.flatMap(([key]) => [
-            <col key={`${key}-description`} style={{ width: OUTLINE_DESC_WIDTH }} />,
-            <col key={`${key}-type`} style={{ width: ATOM_TYPE_COL_WIDTH }} />,
-            <col key={`${key}-name`} style={{ width: ATOM_DIM_COL_WIDTH }} />,
-            <col key={`${key}-value`} style={{ width: ATOM_VAL_COL_WIDTH }} />,
-          ])}
-          <col style={{ width: 400 }} />
-        </colgroup>
-        <TableHeader maxDepth={maxDepth} />
-        <tbody>{paragraphs.map((paragraph) => hiddenIds.has(String(paragraph.id)) ? null : <tr key={String(paragraph.rowId)} className={`script-row-l${Number(paragraph.depth || 0) + 1}`}>
-          {Array.from({ length: maxDepth }, (_, columnIndex) => {
-            const isOwnLevel = columnIndex === Number(paragraph.depth || 0);
-            const isLast = columnIndex === maxDepth - 1;
-            return <td key={columnIndex} className={`col-paragraph${isLast ? " col-paragraph-last" : ""}`} style={{ left: columnIndex * PARA_COL_WIDTH }}>
-              {isOwnLevel ? <>
-                {rowsWithChildren.has(String(paragraph.id)) ? <button type="button" className={`collapse-toggle${collapsed.has(String(paragraph.id)) ? " collapsed" : ""}`} aria-label={`${collapsed.has(String(paragraph.id)) ? "展开" : "折叠"}${paragraph.name}`} aria-expanded={!collapsed.has(String(paragraph.id))} onClick={() => toggle(paragraph.id)}>{collapsed.has(String(paragraph.id)) ? "▶" : "▼"}</button> : null}
-                {paragraph.name || "未命名"}
-              </> : null}
-            </td>;
-          })}
-          <td className="col-range"><div className="content-range-tags">{paragraph.contentRange.map((tag, index) => <span className="cr-tag" key={`${tag}-${index}`}>{tag}</span>)}</div></td>
-          {FACETS.map(([key, , section]) => <FacetCells key={key} section={section} facet={paragraph.sections[key]} elementLookup={elementLookup} onOpenElement={onOpenElement} />)}
-          <td className="col-full-desc"><ExpandableDescription parts={paragraph.fullDescriptionParts} fallback={paragraph.fullDescription} elementLookup={elementLookup} onOpenElement={onOpenElement} /></td>
-        </tr>)}</tbody>
-      </table>
-    </div>
-  </section>;
-}
-
-function TableHeader({ maxDepth }: { maxDepth: number }) {
-  return <thead>
-    <tr className="st-header-group">
-      <th colSpan={maxDepth} className="col-paragraph" style={{ left: 0 }}>段落</th>
-      <th rowSpan={3} className="col-range">段落范围</th>
-      <th colSpan={16} className="col-outline">创作目录大纲(段本身)</th>
-      <th rowSpan={3} className="col-full-desc">完整描述</th>
-    </tr>
-    <tr className="st-header-subgroup">
-      {Array.from({ length: maxDepth }, (_, index) => <th key={index} rowSpan={2} className={`col-paragraph${index === maxDepth - 1 ? " col-paragraph-last" : ""}`} style={{ left: index * PARA_COL_WIDTH }}>L{index + 1}</th>)}
-      {FACETS.map(([, label, section]) => <th key={section} colSpan={4} className={`col-outline col-outline-${section}`}>{label}</th>)}
-    </tr>
-    <tr className="st-header-col">
-      {FACETS.map(([, , section, description]) => <Fragment key={section}>
-        <th className={`col-outline col-outline-${section}`}>{description}</th>
-        <th className={`col-outline col-outline-${section} col-atom-sub col-atom-type`}>维度类型</th>
-        <th className={`col-outline col-outline-${section} col-atom-sub col-atom-dim`}>维度名</th>
-        <th className={`col-outline col-outline-${section} col-atom-sub col-atom-val`}>维度值</th>
-      </Fragment>)}
-    </tr>
-  </thead>;
-}
-
-function FacetCells({ section, facet, elementLookup, onOpenElement }: { section: string; facet: ScriptFacet; elementLookup: ElementLookup; onOpenElement: Props["onOpenElement"] }) {
-  const dimensions = sortDimensions(facet.dimensions, section === "feeling");
-  return <>
-    <td className={`col-outline col-outline-${section}`}><ElementText parts={facet.descriptionParts} fallback={facet.description} elementLookup={elementLookup} onOpenElement={onOpenElement} /></td>
-    {dimensions.length ? <td colSpan={3} className={`col-outline col-outline-${section} col-atom-group col-atom-group-${section}`}>
-      <table className="atom-rows-table">
-        <colgroup><col style={{ width: ATOM_TYPE_COL_WIDTH }} /><col style={{ width: ATOM_DIM_COL_WIDTH }} /><col style={{ width: ATOM_VAL_COL_WIDTH }} /></colgroup>
-        <tbody>{dimensions.map((dimension, index) => {
-          const dimClass = dimensionClass(dimension.type, section === "feeling");
-          return <tr key={`${dimension.id ?? "dimension"}-${index}`}>
-            <td className={`col-atom-type${dimClass ? ` ${dimClass}` : ""}`}>{section === "feeling" ? "—" : dimensionTypeLabel(dimension.type)}</td>
-            <td className={`col-atom-dim${dimClass ? ` ${dimClass}` : ""}`}>{dimension.name || "—"}</td>
-            <td className="col-atom-val"><span className={dimension.id != null ? "atom-val-with-id" : undefined} title={dimension.id != null ? `id: ${dimension.id}` : undefined}>{dimension.value || "—"}</span></td>
-          </tr>;
-        })}</tbody>
-      </table>
-    </td> : <td colSpan={3} className={`col-outline col-outline-${section} col-atom-group col-atom-group-${section}`} />}
-  </>;
-}
-
-function ExpandableDescription({ parts, fallback, elementLookup, onOpenElement }: { parts: ScriptDescriptionPart[]; fallback?: string | null; elementLookup: ElementLookup; onOpenElement: Props["onOpenElement"] }) {
-  const [expanded, setExpanded] = useState(false);
-  return <div className={`cell-expandable${expanded ? " expanded" : ""}`} role="button" tabIndex={0} aria-expanded={expanded} onClick={() => setExpanded((value) => !value)} onKeyDown={(event) => {
-    if (event.key === "Enter" || event.key === " ") {
-      event.preventDefault();
-      setExpanded((value) => !value);
-    }
-  }}><ElementText parts={parts} fallback={fallback} elementLookup={elementLookup} onOpenElement={onOpenElement} /></div>;
-}
-
-function ElementText({ parts, fallback, elementLookup, onOpenElement }: { parts: ScriptDescriptionPart[]; fallback?: string | null; elementLookup: ElementLookup; onOpenElement: Props["onOpenElement"] }) {
-  if (!parts.length) return <>{highlightPlainText(fallback || "", elementLookup.names)}</>;
-  return <>{parts.map((part, index) => part.type === "text"
-    ? <Fragment key={index}>{highlightPlainText(part.text, elementLookup.names)}</Fragment>
-    : part.resolved
-      ? <button key={index} type="button" className="elem-hl-extended" title="在脚本元素中查看" onClick={(event) => { event.stopPropagation(); onOpenElement(part.id); }}>{part.name}</button>
-      : <span key={index}>{part.text}</span>)}</>;
-}
-
-type ElementLookup = { names: string[] };
-
-function buildElementLookup(elements: ScriptElementRow[]): ElementLookup {
-  return { names: [...new Set(elements.map((element) => String(element.name || "").trim()).filter(Boolean))].sort((a, b) => b.length - a.length) };
-}
-
-function highlightPlainText(text: string, names: string[]) {
-  if (!text || !names.length) return text || "";
-  const pattern = names.map(escapeRegExp).join("|");
-  if (!pattern) return text;
-  const matcher = new RegExp(`(${pattern})`, "g");
-  return text.split(matcher).map((part, index) => names.includes(part) ? <span key={index} className="elem-hl-extended">{part}</span> : <Fragment key={index}>{part}</Fragment>);
-}
-
-function escapeRegExp(value: string) {
-  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
-}
-
-function sortDimensions(dimensions: ScriptDimension[], isFeeling: boolean) {
-  if (isFeeling) return [...dimensions];
-  const rank: Record<string, number> = { "主维度": 0, "从维度": 1, "子元素": 2 };
-  return [...dimensions].sort((left, right) => {
-    const typeDiff = (rank[String(left.type || "")] ?? 99) - (rank[String(right.type || "")] ?? 99);
-    if (typeDiff) return typeDiff;
-    const nameDiff = String(left.name || "").localeCompare(String(right.name || ""), "zh-CN");
-    return nameDiff || String(left.value || "").localeCompare(String(right.value || ""), "zh-CN");
-  });
-}
-
-function dimensionTypeLabel(value?: string | null) {
-  if (value === "主维度") return "主";
-  if (value === "从维度") return "从";
-  return value || "—";
-}
-
-function dimensionClass(value: string | null | undefined, isFeeling: boolean) {
-  if (isFeeling) return "";
-  if (value === "主维度") return "atom-dim-main";
-  if (value === "从维度") return "atom-dim-sub";
-  return "";
-}
-
-function descendantIds(paragraphs: ScriptParagraphRow[], collapsed: Set<string>) {
-  const hidden = new Set<string>();
-  let changed = true;
-  while (changed) {
-    changed = false;
-    for (const row of paragraphs) {
-      const parent = row.parentId == null ? null : String(row.parentId);
-      if (!parent || (!collapsed.has(parent) && !hidden.has(parent))) continue;
-      const key = String(row.id);
-      if (!hidden.has(key)) {
-        hidden.add(key);
-        changed = true;
-      }
-    }
-  }
-  return hidden;
-}

+ 0 - 60
visualization/frontend/components/decision/AgentDecisionCard.tsx

@@ -1,60 +0,0 @@
-import { ArrowRight, FileText } from "lucide-react";
-import { Handle, Position, type NodeProps } from "@xyflow/react";
-import type { CanvasNodeData } from "@/lib/layout";
-import type { AgentDecisionProjection, CanvasItem } from "@/lib/types";
-import { CardFields } from "@/components/flow/CardFields";
-import { SourceComparisonButton } from "@/components/flow/SourceComparisonButton";
-
-export function AgentDecisionCard({ data }: NodeProps) {
-  const node = data as unknown as CanvasNodeData;
-  const item = node.item;
-  if (!item) return null;
-  const decision = decisionFor(item);
-  if (!decision) return null;
-  return <article
-    className={`flowCard agentDecisionCard decision-${decision.decisionType}`}
-    data-focus-return={`${decision.id}:card`}
-    tabIndex={0}
-    aria-label={`${decision.actor.label}:${item.title}`}
-    onKeyDown={(event) => {
-      if (event.target !== event.currentTarget) return;
-      if (event.key === "Enter" || event.key === " ") {
-        event.preventDefault();
-        node.onOpen(item, "business");
-      }
-    }}
-  >
-    <Handle type="target" position={Position.Left} className="flowHandle" />
-    <Handle type="source" position={Position.Right} className="flowHandle" />
-    <header className="cardHeader">
-      <div className="cardIdentity">
-        <span className={`actorBadge actor-${actorTone(decision)}`}>{decision.actor.label}</span>
-        <span className="storyRole">{decisionTypeLabel(decision.decisionType)}</span>
-      </div>
-      <span className={`authorityBadge authority-${decision.authority}`}>{authorityLabel(decision.authority)}</span>
-    </header>
-    <h3>{item.title}</h3>
-    <CardFields card={decision.card} />
-    <footer className="cardFooter">
-      <button type="button" className="nodeAction nodrag nopan" onClick={(event) => { event.stopPropagation(); node.onOpen(item, "business"); }}>{item.itemType === "story" && item.role === "objective" ? "查看详情" : "查看决策"}<ArrowRight size={14} /></button>
-      <SourceComparisonButton item={item} onOpen={node.onOpen} />
-      {item.itemType === "story" && item.artifactRef ? <button type="button" className="nodeAction nodrag nopan artifactAction" data-focus-return={`${item.id}:artifact`} onClick={(event) => { event.stopPropagation(); node.onOpen(item, "artifact"); }}><FileText size={14} />查看完整候选脚本</button> : null}
-    </footer>
-  </article>;
-}
-
-export function decisionFor(item: CanvasItem): AgentDecisionProjection | undefined {
-  if (item.itemType === "story") return item.decision;
-  if (item.itemType === "multipath-review") return item.review.decisionProjection;
-  if (item.itemType === "multipath-decision") return item.decision.decisionProjection;
-  return undefined;
-}
-
-function actorTone(decision: AgentDecisionProjection) {
-  if (decision.actor.role === "main") return "main";
-  if (decision.actor.role === "implementer") return "implementer";
-  return "evaluator";
-}
-
-export const authorityLabel = (authority: AgentDecisionProjection["authority"]) => ({ final: "最终决定", recommendation: "评审建议", "implementation-scope": "本方案内有效" } as const)[authority];
-export const decisionTypeLabel = (type: AgentDecisionProjection["decisionType"]) => ({ direction: "方向决定", tradeoff: "取舍决定", evaluation: "评审判断", creative: "创作处理" } as const)[type];

+ 0 - 172
visualization/frontend/components/decision/DecisionDetailRenderer.tsx

@@ -1,172 +0,0 @@
-import type { DecisionDetailBlock, InspectorPresentation, InspectorSemanticMeta, InspectorVisualRole } from "@/lib/types";
-import { RichText } from "@/components/ui/RichText";
-import { ChevronRight } from "lucide-react";
-
-export function DecisionDetailRenderer({ blocks }: { blocks: DecisionDetailBlock[] }) {
-  return <div className="decisionDetailBlocks">
-    {blocks.map((block, index) => {
-      if (block.type === "implementation-plan") return <ImplementationPlan key={`${block.title}-${index}`} block={block} />;
-      if (block.type === "creative-process") return <CreativeProcess key={`${block.title}-${index}`} block={block} />;
-      if (block.type === "evaluation-branches") return <EvaluationBranches key={`${block.title}-${index}`} block={block} />;
-      if (block.type === "evaluation-matrix") return <EvaluationMatrix key={`${block.title}-${index}`} block={block} />;
-      if (block.type === "comparison") return <section {...panelProps(block, "comparisonPanel", "section", "comparison")} key={`${block.title}-${index}`}><h3>{block.title}</h3><Comparison rows={block.rows} /></section>;
-      if (block.type === "items" && block.collapsible) return <CollapsibleItems key={`${block.title}-${index}`} block={block} />;
-      if (block.type === "items") return <section {...panelProps(block, "decisionItemsPanel", "section", "list")} key={`${block.title}-${index}`}><h3>{block.title}</h3><Items items={block.items} /></section>;
-      if (block.type === "notice") return <section {...panelProps(block, `decisionNotice is-${block.level}`, "notice", "prose")} key={`${block.title || "notice"}-${index}`}><h3>{block.title || "记录说明"}</h3><RichText value={block.value} /></section>;
-      if (block.type === "artifact") return <section {...panelProps(block, "", "section", "artifact")} key={`${block.title}-${index}`}><h3>{block.title}</h3><ul>{block.refs.map((ref) => <li key={ref.artifactRef}>{ref.label}</li>)}</ul></section>;
-      return <SummaryBlock key={`${block.title}-${index}`} block={block} />;
-    })}
-  </div>;
-}
-
-type CreativeProcessBlock = Extract<DecisionDetailBlock, { type: "creative-process" }>;
-
-function CreativeProcess({ block }: { block: CreativeProcessBlock }) {
-  return <section {...panelProps(block, "creativeProcessPanel", "section", "process")}>
-    <header className="creativeProcessHeader"><div><h3>{block.title}</h3><p>{block.steps.length} 个真实执行步骤</p></div></header>
-    <ol className="creativeProcessSteps">
-      {block.steps.map((step) => <li key={`${step.eventRef || "step"}-${step.stepIndex}`}>
-        <header><span>步骤 {String(step.stepIndex).padStart(2, "0")}</span><strong>{step.action}</strong></header>
-        {step.summary && step.summary !== step.action ? <p className="creativeStepSummary">{step.summary}</p> : null}
-        {step.plan ? <div className="creativeStepPlan"><small>执行计划</small><ReadableDecisionText value={step.plan} /></div> : null}
-        {step.reasoning ? <details className="creativeStepReasoning"><summary><ChevronRight aria-hidden="true" size={15} />查看本步思考依据</summary><ReadableDecisionText value={step.reasoning} /></details> : null}
-      </li>)}
-    </ol>
-  </section>;
-}
-
-type EvaluationBranchesBlock = Extract<DecisionDetailBlock, { type: "evaluation-branches" }>;
-
-function EvaluationBranches({ block }: { block: EvaluationBranchesBlock }) {
-  return <section {...panelProps(block, "evaluationBranchesPanel", "section", "grouped-review")}>
-    <h3>{block.title}</h3>
-    <div className="evaluationBranchList">{block.branches.map((branch) => <article key={branch.subject}>
-      <header><h4>{branch.subject}</h4>{branch.conclusion ? <span>{branch.conclusion}</span> : null}</header>
-      {branch.achievements?.length ? <EvaluationList title="已达成" items={branch.achievements} tone="success" /> : null}
-      {branch.problems?.length ? <EvaluationList title="需关注" items={branch.problems} tone="warning" /> : null}
-      {branch.evidence ? <div className="evaluationEvidence"><strong>有据扎实度</strong><ReadableDecisionText value={branch.evidence} /></div> : null}
-    </article>)}</div>
-  </section>;
-}
-
-function EvaluationList({ title, items, tone }: { title: string; items: string[]; tone: "success" | "warning" }) {
-  return <div className={`evaluationList is-${tone}`}><strong>{title}</strong><ul>{items.map((item, index) => <li key={`${title}-${index}`}><ReadableDecisionText value={item} /></li>)}</ul></div>;
-}
-
-type EvaluationMatrixBlock = Extract<DecisionDetailBlock, { type: "evaluation-matrix" }>;
-
-function EvaluationMatrix({ block }: { block: EvaluationMatrixBlock }) {
-  const subjects = block.rows[0]?.candidates.map((item) => item.subject) || [];
-  return <section {...panelProps(block, "evaluationMatrixPanel", "section", "matrix")}>
-    <h3>{block.title}</h3>
-    <div className="evaluationMatrixScroll"><table><thead><tr><th scope="col">评审标准</th>{subjects.map((subject) => <th scope="col" key={subject}>{subject}</th>)}<th scope="col">差异依据</th></tr></thead><tbody>{block.rows.map((row) => <tr key={row.criterion}><th scope="row">{row.criterion}</th>{subjects.map((subject) => <td key={subject}>{row.candidates.find((item) => item.subject === subject)?.value || "未记录"}</td>)}<td>{row.basis || "未记录"}</td></tr>)}</tbody></table></div>
-  </section>;
-}
-
-type ImplementationPlanBlock = Extract<DecisionDetailBlock, { type: "implementation-plan" }>;
-
-function ImplementationPlan({ block }: { block: ImplementationPlanBlock }) {
-  return <section {...panelProps(block, "implementationPlanPanel", "section", "plan")}>
-    <header className="implementationPlanHeader">
-      <div><h3>{block.title}</h3><p>{block.routes.length} 路实现方案</p></div>
-      {block.mode ? <span>{block.mode}</span> : null}
-    </header>
-    <ol className="implementationRoutes">
-      {block.routes.map((route) => <li key={route.pathIndex}>
-        <header className="implementationRouteHeader">
-          <strong>路线 {route.pathIndex}</strong>
-          <span className="implementationRouteTags">{route.action ? <b>{route.action}</b> : null}{route.pathType ? <b>{route.pathType}</b> : null}</span>
-        </header>
-        <div className="implementationRouteBody">
-          {route.target ? <PlanField label="作用范围" value={route.target} wide /> : null}
-          {route.method ? <PlanField label="实施方法" value={route.method} /> : null}
-          {route.emphasis ? <PlanField label="路线侧重" value={route.emphasis} /> : null}
-        </div>
-      </li>)}
-    </ol>
-    {block.reasoning ? <div className="implementationReasoning"><h4>为什么这样规划</h4><ReadableDecisionText value={block.reasoning} /></div> : null}
-  </section>;
-}
-
-function PlanField({ label, value, wide = false }: { label: string; value: string; wide?: boolean }) {
-  return <div className={`implementationPlanField ${wide ? "is-wide" : ""}`}><small>{label}</small><ReadableDecisionText value={value} /></div>;
-}
-
-type ItemsBlock = Extract<DecisionDetailBlock, { type: "items" }>;
-
-function CollapsibleItems({ block }: { block: ItemsBlock }) {
-  return <section {...panelProps(block, "decisionDisclosurePanel", "evidence", "list")}>
-    <details className="decisionDisclosure" open={block.defaultOpen || undefined}>
-      <summary>
-        <span className="decisionDisclosureLabel"><ChevronRight aria-hidden="true" size={16} strokeWidth={2.25} /><strong>{block.title}</strong></span>
-        <span className="decisionDisclosureCount">共 {block.items.length} 项</span>
-      </summary>
-      <div className="decisionDisclosureBody"><Items items={block.items} /></div>
-    </details>
-  </section>;
-}
-
-type SummaryDetailBlock = Extract<DecisionDetailBlock, { type: "summary" }>;
-
-function SummaryBlock({ block }: { block: SummaryDetailBlock }) {
-  const value = block.presentation === "document" ? normalizeDocumentMarkdown(block.value) : block.value;
-  return <section {...panelProps(block, "decisionSummaryPanel", "section", "prose")}>
-    <h3>{block.title}</h3>
-    <ReadableDecisionText value={value} />
-  </section>;
-}
-
-function normalizeDocumentMarkdown(value: string) {
-  return value.replace(/\*\*(["“])([^"”\n]+)(["”])\*\*/g, "$1**$2**$3");
-}
-
-function panelProps(meta: InspectorSemanticMeta, extraClass: string, fallbackRole: InspectorVisualRole, fallbackPresentation: InspectorPresentation) {
-  const role = meta.visualRole || fallbackRole;
-  return {
-    className: ["inspectorPanel", extraClass, `is-role-${role}`].filter(Boolean).join(" "),
-    "data-visual-role": role,
-    "data-presentation": meta.presentation || fallbackPresentation,
-  };
-}
-
-function Items({ items }: { items: Array<string | Record<string, unknown>> }) {
-  return <dl className="inspectorDetailItems">{items.map((item, index) => {
-    if (typeof item === "string") return <div className="inspectorDetailItem" key={`${item}-${index}`}><dt>{String(index + 1).padStart(2, "0")}</dt><dd><ReadableDecisionText value={item} /></dd></div>;
-    const severity = item.severity ? String(item.severity) : undefined;
-    const label = item.label ? String(item.label)
-      : item.subject ? String(item.subject)
-        : severity ? `问题 · ${severity}`
-          : `项目 ${index + 1}`;
-    const value = String(item.value || item.conclusion || item.summary || "");
-    const note = item.note ? String(item.note) : undefined;
-    const achievements = stringList(item.achievements);
-    const problems = stringList(item.problems);
-    return <div className="inspectorDetailItem" key={`${label}-${index}`}><dt>{label}</dt><dd>{value ? <ReadableDecisionText value={value} /> : null}{note ? <small>{note}</small> : null}{achievements.length ? <NestedList title="已达成" items={achievements} /> : null}{problems.length ? <NestedList title="需关注" items={problems} /> : null}</dd></div>;
-  })}</dl>;
-}
-
-function ReadableDecisionText({ value }: { value: string }) {
-  return <RichText value={insertNumberedLineBreaks(value)} className="decisionReadableText" />;
-}
-
-function insertNumberedLineBreaks(value: string) {
-  const markers = [...value.matchAll(/[ \t\u3000]+(\d+)\.(?=[ \t\u3000]+\S)/g)];
-  if (markers.length < 2) return value;
-  const numbers = markers.map((match) => Number(match[1]));
-  if (numbers[0] !== 1 || numbers.some((number, index) => index > 0 && number !== numbers[index - 1] + 1)) return value;
-  let markerIndex = 0;
-  return value.replace(/[ \t\u3000]+(\d+\.(?=[ \t\u3000]+\S))/g, (_match, marker: string) => `${markerIndex++ === 0 ? "\n\n" : "\n"}${marker}`);
-}
-
-function Comparison({ rows }: { rows: Array<Record<string, unknown>> }) {
-  const showHandling = rows.some((row) => Boolean(row.handling));
-  return <div className={`decisionComparison ${showHandling ? "hasHandling" : ""}`}><div className="comparisonHeader"><b>候选方案</b><b>评审建议</b><b>最终决定</b>{showHandling ? <b>对建议的处理</b> : null}</div>{rows.map((row, index) => <div className="comparisonRow" key={`${row.candidate || index}`}><strong>{String(row.candidate || `方案 ${index + 1}`)}</strong><span>{String(row.recommendation || "未记录")}</span><span>{String(row.finalDecision || "未记录")}</span>{showHandling ? <span>{String(row.handling || "未明确说明")}</span> : null}</div>)}</div>;
-}
-
-function stringList(value: unknown): string[] {
-  return Array.isArray(value) ? value.map((item) => typeof item === "string" ? item : String((item as Record<string, unknown>)?.summary || "")).filter(Boolean) : [];
-}
-
-function NestedList({ title, items }: { title: string; items: string[] }) {
-  return <div className="decisionNestedList"><strong>{title}</strong><ul>{items.map((item, index) => <li key={`${item}-${index}`}><RichText value={item} /></li>)}</ul></div>;
-}

+ 0 - 31
visualization/frontend/components/decision/PromptDrawer.tsx

@@ -1,31 +0,0 @@
-"use client";
-
-import { useEffect, useRef } from "react";
-import { X } from "lucide-react";
-import type { PromptProjection } from "@/lib/types";
-import { RichText } from "@/components/ui/RichText";
-
-export function PromptDrawer({ prompt, loading, error, onClose }: { prompt?: PromptProjection; loading: boolean; error?: string; onClose: () => void }) {
-  const close = useRef<HTMLButtonElement>(null);
-  const dialog = useRef<HTMLElement>(null);
-  useEffect(() => { close.current?.focus(); }, []);
-  useEffect(() => { const handler = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [onClose]);
-  const trapFocus = (event: React.KeyboardEvent<HTMLElement>) => {
-    if (event.key !== "Tab") return;
-    const focusable = [...(dialog.current?.querySelectorAll<HTMLElement>('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])') || [])];
-    if (!focusable.length) return;
-    const first = focusable[0]; const last = focusable[focusable.length - 1];
-    if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
-    else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
-  };
-  return <aside ref={dialog} className="promptDrawer" role="dialog" aria-modal="true" aria-label="Agent 任务与提示词" onKeyDown={trapFocus} onWheelCapture={(event) => event.stopPropagation()}>
-    <header><div><span>Agent 提示词</span><h2>{prompt?.actor.label || "Agent"}</h2></div><button ref={close} className="iconButton" type="button" aria-label="关闭提示词" onClick={onClose}><X size={18} /></button></header>
-    <div className="promptDrawerBody">
-      {loading ? <div className="skeletonLines"><i /><i /><i /></div> : null}
-      {error ? <section className="inspectorPanel isMissing"><h3>暂时无法读取</h3><p>{error}</p></section> : null}
-      {prompt?.notices.map((notice) => <section className="inspectorPanel promptAccuracy" key={notice.code}><h3>准确性说明</h3><p>{notice.message}</p></section>)}
-      {prompt?.exactTask ? <section className="inspectorPanel"><h3>本次真实任务</h3><RichText value={prompt.exactTask.content} /></section> : null}
-      {prompt?.systemPrompt ? <section className="inspectorPanel currentPrompt"><h3>当前提示词</h3><p className="promptMeta">{prompt.systemPrompt.source === "current-db" ? "当前数据库版本" : "当前代码文件"}{prompt.systemPrompt.version ? ` · v${prompt.systemPrompt.version}` : ""}</p><RichText value={prompt.systemPrompt.content} /></section> : null}
-    </div>
-  </aside>;
-}

+ 0 - 10
visualization/frontend/components/flow/AgentRegion.tsx

@@ -1,10 +0,0 @@
-import type { NodeProps } from "@xyflow/react";
-import type { CanvasNodeData } from "@/lib/layout";
-
-export function AgentRegion({ data }: NodeProps) {
-  const region = (data as unknown as CanvasNodeData).region;
-  if (!region) return null;
-  return <section className={`agentRegion region-${region.tone}`} aria-hidden="true">
-    <header><strong>{region.label}</strong><span>{region.description}</span></header>
-  </section>;
-}

+ 0 - 22
visualization/frontend/components/flow/BranchFrame.tsx

@@ -1,22 +0,0 @@
-import { ArrowRight, ChevronUp, GitBranch } from "lucide-react";
-import type { NodeProps } from "@xyflow/react";
-import type { CanvasNodeData } from "@/lib/layout";
-import { StatusBadge } from "@/components/ui/Badges";
-import { SourceComparisonButton } from "@/components/flow/SourceComparisonButton";
-
-export function BranchFrame({ data }: NodeProps) {
-  const node = data as unknown as CanvasNodeData;
-  const item = node.item;
-  if (!item || item.itemType !== "branch-summary") return null;
-  return <section className="branchFrame" aria-label={`方案 ${item.branchId} 实现 Agent 展开流程`}>
-    <header>
-      <div className="branchFrameTitle"><span><GitBranch size={14} />实现 Agent</span><strong>方案 {item.branchId}</strong></div>
-      <StatusBadge status={item.branch.status} />
-      <div className="branchFrameActions">
-        <button type="button" className="nodeAction nodrag nopan" onClick={(event) => { event.stopPropagation(); node.onToggleBranch(item.roundIndex, item.branchId); }}><ChevronUp size={14} />收起方案过程</button>
-        {item.detailRef ? <button type="button" className="nodeAction nodrag nopan" data-focus-return={`${item.id}:business`} onClick={(event) => { event.stopPropagation(); node.onOpen(item, "business"); }}>查看方案汇总<ArrowRight size={14} /></button> : null}
-        <SourceComparisonButton item={item} onOpen={node.onOpen} />
-      </div>
-    </header>
-  </section>;
-}

+ 0 - 31
visualization/frontend/components/flow/CandidateCard.tsx

@@ -1,31 +0,0 @@
-import { ArrowRight, BookOpenCheck, ChevronDown, ChevronUp, FileText, GitBranch } from "lucide-react";
-import { Handle, Position, type NodeProps } from "@xyflow/react";
-import type { CanvasNodeData } from "@/lib/layout";
-import { SourceNoticeBadge, StatusBadge } from "@/components/ui/Badges";
-import { SourceComparisonButton } from "@/components/flow/SourceComparisonButton";
-
-export function CandidateCard({ data }: NodeProps) {
-  const { item, onOpen, onToggleBranch } = data as unknown as CanvasNodeData;
-  if (!item || item.itemType !== "branch-summary") return null;
-  const summary = item.branch.summary;
-  return <article className="flowCard candidateCard" data-focus-return={`${item.id}:card`} aria-label={`候选方案:${item.branch.title}`} tabIndex={0} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onOpen(item, "business"); } }}>
-    <Handle type="target" position={Position.Left} className="flowHandle" /><Handle type="source" position={Position.Right} className="flowHandle" />
-    <header className="cardHeader"><span className="candidateLabel">{item.branch.pathType === "领域信息" ? <BookOpenCheck size={14} /> : <GitBranch size={14} />}实现 Agent · 方案 {item.branchId} · {item.branch.pathType === "unknown" ? "类型未记录" : item.branch.pathType}</span><StatusBadge status={item.branch.status} /></header>
-    {item.branch.title !== `方案 ${item.branchId}` ? <h3>{item.branch.title}</h3> : null}
-    <div className="candidateSummary">
-      <SummaryRow label="任务" value={summary.task} />
-      <SummaryRow label={item.branch.pathType === "领域信息" ? "领域事实" : "候选产出"} value={summary.output} />
-    </div>
-    <footer className="cardFooter">
-      <button type="button" className="nodeAction nodrag nopan" onClick={(event) => { event.stopPropagation(); onToggleBranch(item.roundIndex, item.branchId); }}>{data.expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}{data.expanded ? "收起方案过程" : "展开方案过程"}</button>
-      {item.detailRef ? <button type="button" className="nodeAction nodrag nopan" data-focus-return={`${item.id}:business`} onClick={(event) => { event.stopPropagation(); onOpen(item, "business"); }}>查看方案汇总<ArrowRight size={14} /></button> : null}
-      <SourceComparisonButton item={item} onOpen={onOpen} />
-      {item.branch.output.type === "script-artifact" && item.branch.output.snapshotRef ? <button type="button" className="nodeAction nodrag nopan artifactAction" data-focus-return={`${item.id}:artifact`} onClick={(event) => { event.stopPropagation(); onOpen(item, "artifact"); }}><FileText size={14} />查看完整候选脚本</button> : null}
-    </footer>
-    <SourceNoticeBadge notice={item.branch.outcome.sourceNotice || item.branch.task.sourceNotice || item.branch.output.node.sourceNotice} />
-  </article>;
-}
-
-function SummaryRow({ label, value }: { label: string; value?: string | null }) {
-  return <div><b>{label}</b><p>{value || "未记录"}</p></div>;
-}

+ 0 - 26
visualization/frontend/components/flow/CardFields.tsx

@@ -1,26 +0,0 @@
-import type { CardProjection } from "@/lib/types";
-
-interface Props {
-  card: CardProjection;
-  className?: string;
-}
-
-/**
- * Shared business-card field hierarchy for both the canvas and mobile timeline.
- * The backend owns field selection and ordering; this component only renders it.
- */
-export function CardFields({ card, className = "" }: Props) {
-  if (!card.primary && !card.secondary.length) return null;
-  const classes = ["cardFields", className].filter(Boolean).join(" ");
-  return <div className={classes}>
-    {card.primary ? <CardField label={card.primary.label} value={card.primary.value} primary /> : null}
-    {card.secondary.map((field) => <CardField key={field.key} label={field.label} value={field.value} tone={field.tone} />)}
-  </div>;
-}
-
-function CardField({ label, value, primary = false, tone = "normal" }: { label?: string; value: string; primary?: boolean; tone?: "normal" | "success" | "warning" | "failure" }) {
-  return <div className={`cardField ${primary ? "cardFieldPrimary" : "cardFieldSecondary"} tone-${tone}`}>
-    {label ? <b>{label}</b> : null}
-    <p>{value}</p>
-  </div>;
-}

+ 0 - 27
visualization/frontend/components/flow/DirectToolGroupCard.tsx

@@ -1,27 +0,0 @@
-import { useRef } from "react";
-import { ArrowRight, Database } from "lucide-react";
-import { Handle, Position, type NodeProps } from "@xyflow/react";
-import type { CanvasNodeData } from "@/lib/layout";
-import { SourceComparisonButton } from "@/components/flow/SourceComparisonButton";
-import { useRetrievalHoverPreview } from "@/components/flow/RetrievalHoverPreview";
-
-export function DirectToolGroupCard({ data }: NodeProps) {
-  const node = data as unknown as CanvasNodeData;
-  const item = node.item;
-  if (!item || item.itemType !== "retrieval-direct") return null;
-  return <DirectToolGroupCardContent node={node} item={item} />;
-}
-
-function DirectToolGroupCardContent({ node, item }: { node: CanvasNodeData; item: Extract<NonNullable<CanvasNodeData["item"]>, { itemType: "retrieval-direct" }> }) {
-  const cardRef = useRef<HTMLElement>(null);
-  const hoverPreview = useRetrievalHoverPreview({ anchorRef: cardRef, buildId: node.buildId, item, onOpenDetail: () => node.onOpen(item, "business") });
-  const sourceText = item.group.sources.map((source) => source.businessLabel).join("、") || "相关信息";
-  return <><article ref={cardRef} className="flowCard retrievalCard directToolCard" tabIndex={0} data-focus-return={`${item.id}:card`} {...hoverPreview.anchorProps} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); node.onOpen(item, "business"); } }}>
-    <Handle type="target" position={Position.Left} className="flowHandle" />
-    <Handle type="source" position={Position.Right} className="flowHandle" />
-    <header><span className="retrievalKind"><Database size={14} />工具取数</span><span className="retrievalOwner">实现 Agent 直接读取</span></header>
-    <h3>{sourceText}</h3>
-    <dl className="retrievalStats"><div><dt>读取</dt><dd>{item.group.callCount} 次</dd></div><div><dt>结果</dt><dd>{item.group.failureCount ? `${item.group.failureCount} 次失败` : item.group.interruptedCount ? `${item.group.interruptedCount} 次中断` : item.group.runningCount ? "进行中" : "已完成"}</dd></div></dl>
-    <footer><button type="button" className="nodeAction nodrag nopan" data-focus-return={`${item.id}:business`} onClick={(event) => { event.stopPropagation(); node.onOpen(item, "business"); }}>查看读取内容<ArrowRight size={14} /></button><SourceComparisonButton item={item} onOpen={node.onOpen} /></footer>
-  </article>{hoverPreview.preview}</>;
-}

+ 0 - 13
visualization/frontend/components/flow/QueryAttemptList.tsx

@@ -1,13 +0,0 @@
-import type { CanvasItem, QueryAttempt } from "@/lib/types";
-import { queryAttemptItem } from "@/lib/layout";
-
-const statusText = { hit: "命中", empty: "无结果", failure: "失败", running: "进行中", interrupted: "构建中断", unknown: "状态未知" } as const;
-
-export function QueryAttemptList({ id, attempts, roundIndex, branchId, onOpen }: { id?: string; attempts: QueryAttempt[]; roundIndex: number; branchId: number; onOpen: (item: CanvasItem, target: "business") => void }) {
-  return <ol id={id} className="queryAttemptList">
-    {attempts.map((attempt, index) => {
-      const item = queryAttemptItem(attempt, roundIndex, branchId);
-      return <li key={attempt.id}><button type="button" className="nodrag nopan" data-focus-return={`${item.id}:query`} onClick={(event) => { event.stopPropagation(); onOpen(item, "business"); }}><span className="queryIndex">{String(index + 1).padStart(2, "0")}</span><span className="queryText">{attempt.queryLabel}</span><span className={`queryStatus query-${attempt.status}`}>{attempt.resultCount && attempt.resultCount > 0 ? `${attempt.resultCount} 条` : statusText[attempt.status]}</span></button></li>;
-    })}
-  </ol>;
-}

+ 0 - 38
visualization/frontend/components/flow/RetrievalAgentCard.tsx

@@ -1,38 +0,0 @@
-import { useRef } from "react";
-import { ArrowRight, Bot, ChevronDown, ChevronUp } from "lucide-react";
-import { Handle, Position, type NodeProps } from "@xyflow/react";
-import type { CanvasNodeData } from "@/lib/layout";
-import { QueryAttemptList } from "@/components/flow/QueryAttemptList";
-import { SourceComparisonButton } from "@/components/flow/SourceComparisonButton";
-import { useRetrievalHoverPreview } from "@/components/flow/RetrievalHoverPreview";
-
-export function RetrievalAgentCard({ data }: NodeProps) {
-  const node = data as unknown as CanvasNodeData;
-  const item = node.item;
-  if (!item || item.itemType !== "retrieval-agent") return null;
-  return <RetrievalAgentCardContent node={node} item={item} />;
-}
-
-function RetrievalAgentCardContent({ node, item }: { node: CanvasNodeData; item: Extract<NonNullable<CanvasNodeData["item"]>, { itemType: "retrieval-agent" }> }) {
-  const cardRef = useRef<HTMLElement>(null);
-  const hoverPreview = useRetrievalHoverPreview({ anchorRef: cardRef, buildId: node.buildId, item, onOpenDetail: () => node.onOpen(item, "business") });
-  const run = item.run;
-  const expanded = Boolean(node.expanded);
-  const query = run.querySummary;
-  const queryListId = `query-list-${run.eventId}`;
-  return <><article ref={cardRef} className={`flowCard retrievalCard retrievalAgentCard status-${run.status}${expanded ? " isQueryExpanded" : ""}`} tabIndex={0} data-focus-return={`${item.id}:card`} {...hoverPreview.anchorProps} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); node.onOpen(item, "business"); } }}>
-    <Handle type="target" position={Position.Left} className="flowHandle" />
-    <Handle type="source" position={Position.Right} className="flowHandle" />
-    <header><span className="retrievalKind"><Bot size={14} />Agent 取数</span><span className={`agentRunStatus run-${run.status}`}>{run.status === "completed" ? "已完成" : run.status === "failed" ? "失败" : run.status === "interrupted" ? "构建中断" : "进行中"}</span></header>
-    <h3>{run.businessLabel}</h3>
-    <div className="agentTask"><b>取数目标</b><p>{run.taskPreview || "任务未记录"}</p></div>
-    <div className="agentQuerySummary"><b>查询过程</b><p>{query.total} 次 · <span className="query-hit">{query.hit} 次命中</span> · <span className="query-empty">{query.empty} 次无结果</span>{query.failure ? <> · <span className="query-failure">{query.failure} 次失败</span></> : null}{query.interrupted ? <> · <span className="query-interrupted">{query.interrupted} 次中断</span></> : null}{query.unknown ? <> · <span className="query-unknown">{query.unknown} 次结果待确认</span></> : null}</p></div>
-    <div className="agentScreening"><b>初筛整理</b><p>{run.screening.preview || (run.screening.state === "running" ? "正在整理查询结果" : "初筛整理未记录")}</p></div>
-    {expanded ? <QueryAttemptList id={queryListId} attempts={run.attempts} roundIndex={item.roundIndex} branchId={item.branchId} onOpen={node.onOpen} /> : null}
-    <footer>
-      {run.attempts.length ? <button type="button" className="nodeAction nodrag nopan" aria-expanded={expanded} aria-controls={queryListId} onClick={(event) => { event.stopPropagation(); node.onToggleRetrievalAgent(item.id); }}>{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}{expanded ? "收起查询" : `展开 ${run.attempts.length} 次查询`}</button> : null}
-      <button type="button" className="nodeAction nodrag nopan" data-focus-return={`${item.id}:business`} onClick={(event) => { event.stopPropagation(); node.onOpen(item, "business"); }}>查看 Agent 详情<ArrowRight size={14} /></button>
-      <SourceComparisonButton item={item} onOpen={node.onOpen} />
-    </footer>
-  </article>{hoverPreview.preview}</>;
-}

+ 0 - 209
visualization/frontend/components/flow/RetrievalHoverPreview.tsx

@@ -1,209 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
-import { createPortal } from "react-dom";
-import { ArrowRight, Bot, Database, LoaderCircle } from "lucide-react";
-import { errorMessage, getActivityDetail } from "@/lib/api";
-import type { CanvasItem, InspectorDetailProjection } from "@/lib/types";
-
-type RetrievalItem = Extract<CanvasItem, { itemType: "retrieval-direct" | "retrieval-agent" }>;
-
-interface Options {
-  anchorRef: RefObject<HTMLElement | null>;
-  buildId?: string;
-  item: RetrievalItem;
-  onOpenDetail: () => void;
-}
-
-const detailCache = new Map<string, InspectorDetailProjection>();
-const OPEN_DELAY = 300;
-const CLOSE_DELAY = 160;
-const PREVIEW_WIDTH = 380;
-
-export function useRetrievalHoverPreview({ anchorRef, buildId, item, onOpenDetail }: Options) {
-  const previewRef = useRef<HTMLDivElement>(null);
-  const openTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
-  const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
-  const [open, setOpen] = useState(false);
-  const [position, setPosition] = useState({ left: 0, top: 0 });
-
-  const clearTimers = useCallback(() => {
-    if (openTimer.current) clearTimeout(openTimer.current);
-    if (closeTimer.current) clearTimeout(closeTimer.current);
-  }, []);
-  const place = useCallback(() => {
-    const anchor = anchorRef.current;
-    if (!anchor) return;
-    const rect = anchor.getBoundingClientRect();
-    const gap = 12;
-    const left = rect.right + PREVIEW_WIDTH + gap <= window.innerWidth
-      ? rect.right + gap
-      : Math.max(gap, rect.left - PREVIEW_WIDTH - gap);
-    const estimatedHeight = Math.min(460, Math.max(260, window.innerHeight * .58));
-    const top = Math.min(Math.max(gap, rect.top), Math.max(gap, window.innerHeight - estimatedHeight - gap));
-    setPosition({ left, top });
-  }, [anchorRef]);
-  const showSoon = useCallback(() => {
-    if (closeTimer.current) clearTimeout(closeTimer.current);
-    if (open || openTimer.current) return;
-    openTimer.current = setTimeout(() => {
-      openTimer.current = undefined;
-      place();
-      setOpen(true);
-    }, OPEN_DELAY);
-  }, [open, place]);
-  const showNow = useCallback(() => {
-    clearTimers();
-    place();
-    setOpen(true);
-  }, [clearTimers, place]);
-  const hideSoon = useCallback(() => {
-    if (openTimer.current) { clearTimeout(openTimer.current); openTimer.current = undefined; }
-    if (closeTimer.current) clearTimeout(closeTimer.current);
-    closeTimer.current = setTimeout(() => setOpen(false), CLOSE_DELAY);
-  }, []);
-  const keepOpen = useCallback(() => {
-    if (closeTimer.current) clearTimeout(closeTimer.current);
-  }, []);
-
-  useEffect(() => clearTimers, [clearTimers]);
-  useEffect(() => {
-    if (!open) return;
-    const update = () => place();
-    window.addEventListener("resize", update);
-    window.addEventListener("scroll", update, true);
-    return () => { window.removeEventListener("resize", update); window.removeEventListener("scroll", update, true); };
-  }, [open, place]);
-
-  const preview = open && typeof document !== "undefined" ? createPortal(
-    <RetrievalPreviewPanel
-      ref={previewRef}
-      buildId={buildId}
-      item={item}
-      position={position}
-      onPointerEnter={keepOpen}
-      onPointerLeave={hideSoon}
-      onOpenDetail={onOpenDetail}
-    />,
-    document.body,
-  ) : null;
-
-  return {
-    preview,
-    anchorProps: {
-      onPointerEnter: showSoon,
-      onPointerLeave: hideSoon,
-      onFocusCapture: showNow,
-      onBlurCapture: (event: React.FocusEvent<HTMLElement>) => {
-        const next = event.relatedTarget as Node | null;
-        if (next && (anchorRef.current?.contains(next) || previewRef.current?.contains(next))) return;
-        hideSoon();
-      },
-    },
-  };
-}
-
-const RetrievalPreviewPanel = function RetrievalPreviewPanel({
-  buildId,
-  item,
-  position,
-  onPointerEnter,
-  onPointerLeave,
-  onOpenDetail,
-  ref,
-}: {
-  buildId?: string;
-  item: RetrievalItem;
-  position: { left: number; top: number };
-  onPointerEnter: () => void;
-  onPointerLeave: () => void;
-  onOpenDetail: () => void;
-  ref: RefObject<HTMLDivElement | null>;
-}) {
-  const isAgent = item.itemType === "retrieval-agent";
-  return <div
-    ref={ref}
-    className="retrievalHoverPreview nodrag nopan"
-    role="dialog"
-    aria-label={isAgent ? `${item.run.businessLabel}取数预览` : "工具取数预览"}
-    style={{ left: position.left, top: position.top }}
-    onPointerEnter={onPointerEnter}
-    onPointerLeave={onPointerLeave}
-  >
-    {isAgent ? <AgentPreview item={item} /> : <DirectToolPreview buildId={buildId} item={item} />}
-    <button type="button" className="retrievalPreviewAction" onClick={onOpenDetail}>查看完整详情<ArrowRight size={14} /></button>
-  </div>;
-};
-
-function AgentPreview({ item }: { item: Extract<RetrievalItem, { itemType: "retrieval-agent" }> }) {
-  const run = item.run;
-  const hits = run.attempts.filter((attempt) => attempt.status === "hit");
-  const returnedCount = hits.reduce((sum, attempt) => sum + (attempt.resultCount || 0), 0);
-  const screening = cleanPreview(run.screening.preview || "");
-  return <>
-    <header className="retrievalPreviewHeader"><span className="retrievalPreviewIcon is-agent"><Bot size={16} /></span><div><small>Agent 取数</small><h3>{run.businessLabel}</h3></div><strong>{run.querySummary.hit} 次命中</strong></header>
-    <p className="retrievalPreviewSummary">{run.querySummary.total} 次查询{returnedCount ? `,共返回 ${returnedCount} 条结果` : ""}{run.querySummary.empty ? `,${run.querySummary.empty} 次无结果` : ""}{run.querySummary.failure ? `,${run.querySummary.failure} 次失败` : ""}。</p>
-    {hits.length ? <section><h4>命中了什么</h4><ul>{hits.slice(0, 3).map((attempt) => <li key={attempt.id}><span>{attempt.queryLabel}</span><em>{attempt.resultCount ?? "?"} 条</em></li>)}</ul>{hits.length > 3 ? <small className="retrievalPreviewMore">另有 {hits.length - 3} 次命中</small> : null}</section> : null}
-    <section><h4>初筛得到</h4><p className="retrievalPreviewText">{screening || (run.screening.state === "running" ? "正在整理查询结果。" : "本次运行没有保存初筛结论。")}</p></section>
-  </>;
-}
-
-function DirectToolPreview({ buildId, item }: { buildId?: string; item: Extract<RetrievalItem, { itemType: "retrieval-direct" }> }) {
-  const calls = item.group.calls;
-  const [details, setDetails] = useState<Array<{ callId: string; detail?: InspectorDetailProjection; error?: string }>>([]);
-  const [loading, setLoading] = useState(Boolean(buildId && calls.length));
-  useEffect(() => {
-    let active = true;
-    if (!buildId || !calls.length) { setLoading(false); return; }
-    setLoading(true);
-    void Promise.all(calls.slice(0, 4).map(async (call) => {
-      const key = `${buildId}:${call.detailRef}`;
-      const cached = detailCache.get(key);
-      if (cached) return { callId: call.id, detail: cached };
-      try {
-        const detail = await getActivityDetail(buildId, call.detailRef);
-        detailCache.set(key, detail);
-        return { callId: call.id, detail };
-      } catch (error) {
-        return { callId: call.id, error: errorMessage(error) };
-      }
-    })).then((result) => { if (active) { setDetails(result); setLoading(false); } });
-    return () => { active = false; };
-  }, [buildId, calls]);
-
-  const summaries = useMemo(() => calls.slice(0, 4).map((call) => {
-    const detail = details.find((entry) => entry.callId === call.id);
-    return {
-      call,
-      outcome: detail?.detail?.outcome?.message,
-      items: (detail?.detail?.items || []).slice(0, 3),
-      error: detail?.error,
-    };
-  }), [calls, details]);
-
-  return <>
-    <header className="retrievalPreviewHeader"><span className="retrievalPreviewIcon is-tool"><Database size={16} /></span><div><small>工具取数</small><h3>{item.group.sources.map((source) => source.businessLabel).join("、") || "读取内容"}</h3></div><strong>{item.group.successCount}/{item.group.callCount} 完成</strong></header>
-    {loading ? <div className="retrievalPreviewLoading"><LoaderCircle size={16} />正在读取真实返回内容…</div> : null}
-    {!loading ? <div className="retrievalPreviewCalls">{summaries.map(({ call, outcome, items, error }) => <section key={call.id}>
-      <h4>{call.businessLabel}</h4>
-      {error ? <p className="retrievalPreviewError">完整返回暂时不可用:{error}</p> : <>
-        <p className="retrievalPreviewSummary">{outcome || call.resultSummary}</p>
-        {items.length ? <ul>{items.map((result) => <li key={result.id}><span>{result.title}</span>{result.subtitle ? <small>{cleanPreview(result.subtitle)}</small> : null}</li>)}</ul> : null}
-      </>}
-    </section>)}</div> : null}
-    {calls.length > 4 ? <small className="retrievalPreviewMore">完整详情中还有 {calls.length - 4} 次工具调用</small> : null}
-  </>;
-}
-
-function cleanPreview(value: string) {
-  if (!value || value.startsWith("functioncall:")) return "";
-  const clean = value
-    .replace(/```[\s\S]*?```/g, " ")
-    .replace(/^#{1,6}\s+/gm, "")
-    .replace(/^[-*+]\s+/gm, "")
-    .replace(/\|/g, " ")
-    .replace(/[*_`>#]/g, "")
-    .replace(/\s+/g, " ")
-    .trim();
-  return clean.length > 360 ? `${clean.slice(0, 360).trim()}…` : clean;
-}

+ 0 - 26
visualization/frontend/components/flow/RetrievalStageFrame.tsx

@@ -1,26 +0,0 @@
-import { ArrowRight, Database, GitFork } from "lucide-react";
-import type { NodeProps } from "@xyflow/react";
-import type { CanvasNodeData } from "@/lib/layout";
-import { SourceComparisonButton } from "@/components/flow/SourceComparisonButton";
-
-const modeText = {
-  single: "单项取数",
-  sequential: "依次取数",
-  parallel: "并行取数",
-  mixed: "混合执行",
-  unknown: "先后未确认",
-} as const;
-
-export function RetrievalStageFrame({ data }: NodeProps) {
-  const node = data as unknown as CanvasNodeData;
-  const item = node.item;
-  if (!item || item.itemType !== "retrieval-stage") return null;
-  const operationCount = item.stage.directToolGroups.length + item.stage.agentRuns.length;
-  return <section className={`retrievalStageFrame retrieval-${item.stage.status}`} aria-label={`取数阶段,${modeText[item.stage.observedMode]}`}>
-    <header>
-      <div><span className="retrievalStageIcon"><GitFork size={14} /></span><strong>取数阶段</strong><em>{modeText[item.stage.observedMode]}</em></div>
-      <div className="retrievalStageActions"><button type="button" className="frameDetail nodrag nopan" data-focus-return={`${item.id}:business`} onClick={(event) => { event.stopPropagation(); node.onOpen(item, "business"); }}>查看取数概况<ArrowRight size={13} /></button><SourceComparisonButton item={item} onOpen={node.onOpen} className="frameDetail nodrag nopan" /></div>
-    </header>
-    {operationCount === 0 ? <div className="retrievalEmpty"><Database size={18} /><span>{item.stage.status === "running" ? "正在等待取数记录" : "未记录可确认的取数活动"}</span></div> : null}
-  </section>;
-}

+ 0 - 26
visualization/frontend/components/flow/RoundFrame.tsx

@@ -1,26 +0,0 @@
-import { ChevronLeft, GitBranch } from "lucide-react";
-import type { NodeProps } from "@xyflow/react";
-import type { CanvasNodeData } from "@/lib/layout";
-import { StatusBadge } from "@/components/ui/Badges";
-
-export function RoundFrame({ data }: NodeProps) {
-  const node = data as unknown as CanvasNodeData;
-  const round = node.round;
-  if (!round) return null;
-  const defaultTitle = `第 ${round.roundIndex} 轮`;
-  return <section className="roundFrame" aria-label={`第 ${round.roundIndex} 轮展开流程`}>
-    <header>
-      <button
-        type="button"
-        className="roundCollapse nodrag nopan"
-        onClick={(event) => {
-          event.stopPropagation();
-          node.onToggleRound(round.roundIndex);
-        }}
-      ><ChevronLeft size={17} />折叠本轮</button>
-      <div><strong>{round.summary.title && round.summary.title !== defaultTitle ? `${defaultTitle} · ${round.summary.title}` : defaultTitle}</strong><span>{round.summary.goal.headline || "本轮目标未记录"}</span></div>
-      <span className="branchCount"><GitBranch size={13} />{round.branches.length} 个候选 · {round.convergenceBatches.length} 个收敛批次</span>
-      <StatusBadge status={round.state} />
-    </header>
-  </section>;
-}

+ 0 - 43
visualization/frontend/components/flow/RoundSummaryCard.tsx

@@ -1,43 +0,0 @@
-import { ArrowRight, ChevronDown, FileText } from "lucide-react";
-import { Handle, Position, type NodeProps } from "@xyflow/react";
-import type { CanvasNodeData } from "@/lib/layout";
-import { StatusBadge } from "@/components/ui/Badges";
-import { SourceComparisonButton } from "@/components/flow/SourceComparisonButton";
-
-export function RoundSummaryCard({ data }: NodeProps) {
-  const { item, onOpen, onToggleRound } = data as unknown as CanvasNodeData;
-  if (!item || item.itemType !== "round-summary") return null;
-  const summary = item.round.summary;
-  const approach = `${summary.approach.mode || "方式未记录"} · ${summary.approach.candidateCount} 个实现方案`;
-  const decisionMeta = summary.decision.batchCount
-    ? `${summary.decision.batchCount} 个决策批次${summary.decision.branchIds.length ? ` · 涉及方案 ${summary.decision.branchIds.join("、")}` : ""}`
-    : summary.decision.summary;
-  const outputs = [
-    summary.output.mergedContentCount ? summary.output.scriptSummary : null,
-    summary.output.domainFactCount ? summary.output.domainSummary : null,
-  ].filter((value): value is string => Boolean(value));
-  if (!outputs.length) outputs.push(summary.output.summary);
-
-  return <article className="flowCard roundSummaryCard" data-focus-return={`${item.id}:card`} aria-label={`第 ${item.roundIndex} 轮摘要`} tabIndex={0} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onOpen(item, "business"); } }}>
-    <Handle type="target" position={Position.Left} className="flowHandle" /><Handle type="source" position={Position.Right} className="flowHandle" />
-    <header className="cardHeader"><span className="roundNumber">第 {item.roundIndex} 轮</span><StatusBadge status={item.round.state} /></header>
-    <div className="roundSummaryContent">
-      <section className="roundGoalSummary">
-        <b>本轮目标</b>
-        <p>{summary.goal.headline}</p>
-        {summary.goal.itemCount ? <small>{summary.goal.itemCount} 项具体任务 · 完整内容见详情</small> : summary.goal.truncated ? <small>完整内容见详情</small> : null}
-      </section>
-      <div className="roundSummaryFacts">
-        <SummaryBlock label="尝试方案" value={approach} />
-        <SummaryBlock label="主 Agent 决策" value={decisionMeta} detail={summary.decision.batchCount ? summary.decision.summary : undefined} tone={summary.decision.batchCount ? "decision" : "warning"} />
-        <SummaryBlock label="本轮产出" values={outputs} tone={summary.output.mergedContentCount || summary.output.domainFactCount ? "success" : "normal"} />
-      </div>
-    </div>
-    <footer className="cardFooter"><button type="button" className="nodeAction nodrag nopan" onClick={(event) => { event.stopPropagation(); onToggleRound(item.roundIndex); }}><ChevronDown size={14} />展开本轮</button>{item.detailRef ? <button type="button" className="nodeAction nodrag nopan" data-focus-return={`${item.id}:business`} onClick={(event) => { event.stopPropagation(); onOpen(item, "business"); }}>查看详情<ArrowRight size={14} /></button> : null}<SourceComparisonButton item={item} onOpen={onOpen} /><button type="button" className="nodeAction nodrag nopan artifactAction" data-focus-return={`${item.id}:artifact`} onClick={(event) => { event.stopPropagation(); onOpen(item, "artifact"); }}><FileText size={14} />查看本轮产出脚本表</button></footer>
-  </article>;
-}
-
-function SummaryBlock({ label, value, values, detail, tone = "normal" }: { label: string; value?: string; values?: string[]; detail?: string; tone?: "normal" | "decision" | "success" | "warning" }) {
-  const lines = values || (value ? [value] : []);
-  return <section className={`roundSummaryBlock summaryTone-${tone}`}><b>{label}</b><div>{lines.map((line) => <p key={line}>{line}</p>)}{detail && detail !== value ? <small>{detail}</small> : null}</div></section>;
-}

+ 0 - 40
visualization/frontend/components/flow/RunResultCard.tsx

@@ -1,40 +0,0 @@
-import { ArrowRight, CheckCircle2, FileCheck2 } from "lucide-react";
-import { Handle, Position, type NodeProps } from "@xyflow/react";
-import type { CanvasNodeData } from "@/lib/layout";
-import { runResultFallback } from "@/lib/status-copy";
-import { SourceNoticeBadge, StatusBadge } from "@/components/ui/Badges";
-import { SourceComparisonButton } from "@/components/flow/SourceComparisonButton";
-
-export function RunResultCard({ data }: NodeProps) {
-  const { item, onOpen } = data as unknown as CanvasNodeData;
-  if (!item || item.itemType !== "final-result") return null;
-  const result = item.result;
-  const artifact = result.artifact;
-  const summary = result.summary || result.errorMessage || runResultFallback(result.status, result.terminal);
-  const summaryPreview = resultSummaryPreview(summary);
-  const adoptedCount = result.branchCounts.merged ?? 0;
-  return <article className="flowCard runResultCard" data-focus-return={`${item.id}:card`} aria-label="最终结果" tabIndex={0} onKeyDown={(event) => { if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); onOpen(item, "business"); } }}>
-    <Handle type="target" position={Position.Left} className="flowHandle" />
-    <header className="cardHeader"><span className="finalLabel"><FileCheck2 size={14} />最终结果</span><StatusBadge status={result.status} /></header>
-    {result.title !== "最终结果" ? <h3>{result.title}</h3> : null}
-    <div className="finalOutcome"><span><CheckCircle2 size={18} /></span><div><b>{result.terminal ? "当前主脚本已经形成" : "脚本构建仍在推进"}</b><p>{result.roundCount} 轮构建 · {result.branchCounts.total} 个候选方案</p></div></div>
-    {summaryPreview ? <section className="finalSummaryPreview"><b>构建摘要</b><p>{summaryPreview}</p></section> : null}
-    <div className="artifactStats"><span><b>{artifact.paragraphCount ?? 0}</b>段落</span><span><b>{artifact.elementCount ?? 0}</b>元素</span><span><b>{artifact.linkCount ?? 0}</b>关联</span></div>
-    <div className="finalRunFacts"><span><b>{adoptedCount}</b> 个方案已采用</span><span><b>{result.branchCounts.discarded ?? 0}</b> 个未采用</span></div>
-    <footer className="cardFooter"><button type="button" className="nodeAction nodrag nopan" data-focus-return={`${item.id}:business`} onClick={(event) => { event.stopPropagation(); onOpen(item, "business"); }}>查看结果详情</button><SourceComparisonButton item={item} onOpen={onOpen} /><button type="button" className="nodeAction nodrag nopan artifactAction" data-focus-return={`${item.id}:artifact`} onClick={(event) => { event.stopPropagation(); onOpen(item, "artifact"); }}>查看完整主脚本<ArrowRight size={14} /></button></footer>
-    <SourceNoticeBadge notice={result.sourceNotice} />
-  </article>;
-}
-
-function resultSummaryPreview(value: string) {
-  const lines = value
-    .replace(/\\n/g, "\n")
-    .split(/\n+/)
-    .map((line) => line.replace(/^\s*(?:#{1,6}\s*|[-*+]\s+|\d+[.、)]\s*)/, "").replace(/[`*_]/g, "").trim())
-    .filter(Boolean);
-  const text = lines.find((line) => line.length >= 18) || lines[0] || "";
-  if (text.length <= 150) return text;
-  const candidate = text.slice(0, 151);
-  const punctuation = Math.max(...["。", "!", "?", ";"].map((mark) => candidate.lastIndexOf(mark)));
-  return punctuation >= 72 ? candidate.slice(0, punctuation + 1) : `${text.slice(0, 149).trim()}…`;
-}

+ 0 - 23
visualization/frontend/components/flow/SourceComparisonButton.tsx

@@ -1,23 +0,0 @@
-import { GitCompareArrows } from "lucide-react";
-import type { CanvasItem, OpenTarget } from "@/lib/types";
-
-interface Props {
-  item: CanvasItem;
-  onOpen: (item: CanvasItem, target: OpenTarget) => void;
-  className?: string;
-}
-
-export function SourceComparisonButton({ item, onOpen, className = "nodeAction nodrag nopan" }: Props) {
-  if (!("detailRef" in item) || !item.detailRef) return null;
-  return <button
-    type="button"
-    className={`${className} lineageAction`}
-    data-focus-return={`${item.id}:lineage`}
-    onClick={(event) => {
-      event.stopPropagation();
-      onOpen(item, "lineage");
-    }}
-  >
-    <GitCompareArrows aria-hidden="true" size={14} />数据来源
-  </button>;
-}

+ 0 - 37
visualization/frontend/components/flow/StoryCard.tsx

@@ -1,37 +0,0 @@
-import { ArrowRight, FileText } from "lucide-react";
-import { Handle, Position, type NodeProps } from "@xyflow/react";
-import type { CanvasNodeData } from "@/lib/layout";
-import { SourceNoticeBadge, StatusBadge } from "@/components/ui/Badges";
-import { CardFields } from "@/components/flow/CardFields";
-import { SourceComparisonButton } from "@/components/flow/SourceComparisonButton";
-
-export function StoryCard({ data }: NodeProps) {
-  const { item, onOpen } = data as unknown as CanvasNodeData;
-  if (!item || item.itemType !== "story") return null;
-  const role = roleLabel(item.role);
-  const actor = actorLabel(item.role);
-  const hasFields = Boolean(item.card.primary || item.card.secondary.length);
-  return <article className={`flowCard storyCard story-${item.kind} role-${item.role}`} data-focus-return={`${item.id}:card`} aria-label={`${item.title}`} tabIndex={0} onKeyDown={(event) => { if (event.target !== event.currentTarget) return; if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onOpen(item, "business"); } }}>
-    <Handle type="target" position={Position.Left} className="flowHandle" />
-    <Handle type="source" position={Position.Right} className="flowHandle" />
-    <header className="cardHeader"><div className="cardIdentity"><span className={`actorBadge actor-${actor.tone}`}>{actor.label}</span>{role !== item.title ? <span className="storyRole">{role}</span> : null}</div>{item.status ? <StatusBadge status={item.status} /> : null}</header>
-    <h3>{item.title}</h3>
-    <CardFields card={item.card} />
-    <footer className="cardFooter">
-      {item.detailRef || hasFields ? <button type="button" className="nodeAction nodrag nopan" data-focus-return={`${item.id}:business`} onClick={(event) => { event.stopPropagation(); onOpen(item, "business"); }}>查看详情<ArrowRight size={14} /></button> : null}
-      <SourceComparisonButton item={item} onOpen={onOpen} />
-      {item.artifactRef ? <button type="button" className="nodeAction nodrag nopan artifactAction" data-focus-return={`${item.id}:artifact`} onClick={(event) => { event.stopPropagation(); onOpen(item, "artifact"); }}><FileText size={14} />{item.role === "round-result" ? "查看本轮产出脚本表" : "查看完整候选脚本"}</button> : null}
-    </footer>
-    <SourceNoticeBadge notice={item.sourceNotice} />
-  </article>;
-}
-
-function roleLabel(role: string) {
-  return ({ "planning-analysis": "规划解析", objective: "创作目标", goal: "本轮目标", plan: "实现规划", "branch-task": "实现任务", "data-decision": "数据取舍", "candidate-output": "候选脚本", "domain-output": "领域事实", "multipath-review": "多方案评审", "multipath-decision": "多路决策", "round-evaluation": "主脚本整体评审", "round-result": "本轮产出", "final-result": "最终结果" } as Record<string, string>)[role] || "运行步骤";
-}
-
-function actorLabel(role: string) {
-  if (role === "multipath-review" || role === "round-evaluation") return { label: role === "multipath-review" ? "多方案评审 Agent" : "整体评审 Agent", tone: "evaluator" };
-  if (["branch-task", "data-decision", "candidate-output", "domain-output"].includes(role)) return { label: "实现 Agent", tone: "implementer" };
-  return { label: "主 Agent", tone: "main" };
-}

+ 0 - 54
visualization/frontend/components/inspector/BindingBadge.tsx

@@ -1,54 +0,0 @@
-"use client";
-
-import { AlertTriangle, Ban, Calculator, CheckCircle2, CircleHelp, Eye, History, PackageCheck } from "lucide-react";
-import type { SourceBinding } from "@/lib/types";
-
-interface Props {
-  binding: SourceBinding;
-  marker: string;
-  active: boolean;
-  onActivate: (bindingId: string, toggle?: boolean) => void;
-  compact?: boolean;
-}
-
-export function BindingBadge({ binding, marker, active, onActivate, compact = false }: Props) {
-  const evidence = evidencePresentation(binding);
-  const Icon = evidence.icon;
-  return <button
-    type="button"
-    className={`bindingBadge is-${evidence.tone} ${active ? "is-active" : ""} ${compact ? "is-compact" : ""}`}
-    data-binding-id={binding.id}
-    aria-pressed={active}
-    title={selectorDescription(binding)}
-    onClick={() => onActivate(binding.id, false)}
-    onFocus={() => onActivate(binding.id, false)}
-  >
-    <span className="bindingMarker">{marker}</span>
-    <Icon aria-hidden="true" size={13} />
-    {!compact ? <span>{evidence.label}</span> : null}
-  </button>;
-}
-
-export function evidencePresentation(binding: SourceBinding) {
-  if (binding.resolution === "unsafe") return { label: "无法安全关联", tone: "unsafe", icon: CircleHelp };
-  if (binding.resolution === "partial" || (binding.selector.kind === "unresolved" && binding.resolution !== "missing")) return { label: "部分可定位", tone: "partial", icon: AlertTriangle };
-  if (binding.resolution === "missing") return { label: "记录缺失", tone: "missing", icon: AlertTriangle };
-  if (binding.transform.kind === "fallback") return { label: "历史回退", tone: "fallback", icon: History };
-  if (binding.evidence.adoption === "explicitly-adopted") return { label: "明确采用", tone: "adopted", icon: CheckCircle2 };
-  if (binding.evidence.adoption === "explicitly-rejected") return { label: "明确未采用", tone: "rejected", icon: Ban };
-  if (binding.evidence.availability === "direct-read") return { label: "已读取,是否采用未记录", tone: "read", icon: Eye };
-  if (binding.evidence.availability === "returned-to-agent") return { label: "已返回给 Agent,是否采用未记录", tone: "read", icon: PackageCheck };
-  if (binding.evidence.availability === "available-upstream") return { label: "当时可获得,是否读取和采用未记录", tone: "available", icon: CircleHelp };
-  if (binding.evidence.availability === "produced-by-run") return { label: "运行产出", tone: "produced", icon: PackageCheck };
-  if (binding.evidence.availability === "visualization-derived") return { label: "可视化计算", tone: "calculated", icon: Calculator };
-  return { label: "是否采用未记录", tone: "unknown", icon: CircleHelp };
-}
-
-export function selectorDescription(binding: SourceBinding) {
-  const selector = binding.selector;
-  if (selector.kind === "json-pointer") return selector.path;
-  if (selector.kind === "text-span") return `${selector.path} · 字符 ${selector.startCodePoint}–${selector.endCodePoint}`;
-  if (selector.kind === "members") return `${selector.members.length} 项聚合 · ${selector.reducer}`;
-  if (selector.kind === "whole-record") return "完整记录";
-  return selector.reason;
-}

+ 0 - 163
visualization/frontend/components/inspector/BusinessValueRenderer.tsx

@@ -1,163 +0,0 @@
-"use client";
-
-import type { InspectorModule } from "@/lib/types";
-import { LongValueDisclosure } from "@/components/inspector/LongValueDisclosure";
-
-interface Props {
-  module: InspectorModule;
-  value: unknown;
-  label?: string;
-}
-
-/** Business-only renderer. Raw JSON belongs to the source column, never here. */
-export function BusinessValueRenderer({ module, value, label }: Props) {
-  if (module.id === "conditions" && Array.isArray(value) && value.length === 0) {
-    return <p className="comparisonEmptyValue">本次调用没有保存额外输入条件。</p>;
-  }
-  if (module.id === "activities" && Array.isArray(value) && value.length === 0) {
-    return <p className="comparisonEmptyValue">本次运行没有保存可下钻的结构化查询记录。</p>;
-  }
-  if (module.id === "completeness") {
-    if (label === "完整返回" && value === false) {
-      return <p className="comparisonEmptyValue">仅保存了摘要,完整返回不可用。</p>;
-    }
-    if (label === "截断说明") {
-      return <p className="comparisonEmptyValue">返回内容超过安全展示上限,已省略 {typeof value === "number" ? value : "部分"} 个字符。</p>;
-    }
-  }
-  if (typeof value === "string" || value === null || value === undefined) {
-    return <LongValueDisclosure value={value} label={label || "完整内容"} />;
-  }
-
-  if (module.id === "mode" && isStringPair(value)) {
-    return <dl className="businessDefinitionList">
-      <BusinessDefinition label="规划方式" value={value[0]} emphasis />
-      <BusinessDefinition label="为什么这样规划" value={value[1]} />
-    </dl>;
-  }
-
-  if (Array.isArray(value)) {
-    if (value.every((item) => typeof item === "string")) {
-      return <ul className="businessBulletList">{value.map((item, index) => <li key={index}>{item}</li>)}</ul>;
-    }
-    if (value.every(isPlanRoute)) return <PlanRoutes routes={value} />;
-    return <BusinessRecordList values={value} />;
-  }
-
-  if (isRecord(value)) {
-    if (Array.isArray(value.paths)) {
-      return <div className="businessRevisionSummary">
-        <dl className="businessDefinitionList">
-          {typeof value.mode === "string" ? <BusinessDefinition label="保存的规划方式" value={value.mode} emphasis /> : null}
-          <BusinessDefinition label="保存结果" value={`已保存 ${value.paths.length} 条实现路线`} />
-        </dl>
-        {typeof value.note === "string" ? <section className="businessNarrative"><h5>保存时的说明</h5><p>{value.note}</p></section> : null}
-      </div>;
-    }
-    return <BusinessRecord value={value} rowLabel={label} />;
-  }
-
-  return <p className="comparisonReadableText">{String(value)}</p>;
-}
-
-function PlanRoutes({ routes }: { routes: PlanRoute[] }) {
-  if (!routes.length) return <p className="comparisonEmptyValue">没有保存具体路线。</p>;
-  return <div className="businessPlanRoutes">{routes.map((route, index) => <article key={`${route.path_index || index}-${route.target || "route"}`} className="businessPlanRoute">
-    <header><span>{route.path_index || index + 1}</span><div><h5>路线 {route.path_index || index + 1}</h5>{route.path_type ? <small>{friendlyValue(route.path_type)}</small> : null}</div></header>
-    <dl>
-      {route.target ? <BusinessDefinition label="要实现什么" value={route.target} /> : null}
-      {route.method ? <BusinessDefinition label="怎么实现" value={friendlyValue(route.method)} /> : null}
-      {route.action ? <BusinessDefinition label="具体动作" value={friendlyValue(route.action)} /> : null}
-      {route.emphasis ? <BusinessDefinition label="这条路线的重点" value={route.emphasis} /> : null}
-    </dl>
-  </article>)}</div>;
-}
-
-function BusinessRecordList({ values }: { values: unknown[] }) {
-  return <div className="businessRecordList">{values.map((value, index) => <section key={index} className="businessRecordItem">
-    <h5>第 {index + 1} 项</h5>
-    {isRecord(value) ? <BusinessRecord value={value} /> : <p>{printSimple(value)}</p>}
-  </section>)}</div>;
-}
-
-function BusinessRecord({ value, rowLabel }: { value: Record<string, unknown>; rowLabel?: string }) {
-  const primary = value.value;
-  const entries = Object.entries(value).filter(([key, item]) => {
-    if (TECHNICAL_KEYS.has(key) || key === "value" || item === undefined || item === null || item === "") return false;
-    if (rowLabel && ["label", "title", "subject", "summary"].includes(key) && String(item) === rowLabel) return false;
-    return true;
-  });
-  if (!entries.length && (primary === undefined || primary === null || primary === "")) return <p className="comparisonEmptyValue">这条记录没有可展示的业务内容。</p>;
-  return <>{primary !== undefined && primary !== null && primary !== "" ? <LongValueDisclosure value={primary} label="完整内容" /> : null}<dl className="businessDefinitionList">{entries.map(([key, item]) => <BusinessDefinition key={key} label={fieldLabel(key)} value={printSimple(item)} />)}</dl></>;
-}
-
-function BusinessDefinition({ label, value, emphasis = false }: { label: string; value: string; emphasis?: boolean }) {
-  return <div className={emphasis ? "is-emphasis" : ""}><dt>{label}</dt><dd>{value}</dd></div>;
-}
-
-type PlanRoute = {
-  action?: string;
-  method?: string;
-  target?: string;
-  emphasis?: string;
-  path_type?: string;
-  path_index?: string | number;
-};
-
-const TECHNICAL_KEYS = new Set(["id", "path_index", "detailRef", "event_id", "eventId", "sourceNotice"]);
-const FIELD_LABELS: Record<string, string> = {
-  mode: "规划方式",
-  note: "说明",
-  goal: "目标",
-  target: "要实现什么",
-  method: "怎么实现",
-  action: "具体动作",
-  emphasis: "重点",
-  path_type: "路线类型",
-  status: "状态",
-  summary: "结果摘要",
-  reason: "原因",
-  reasoning: "判断理由",
-  decision: "最终取舍",
-  result: "结果",
-  count: "数量",
-  task: "任务",
-  scope: "范围",
-  content: "内容",
-  label: "名称",
-  title: "标题",
-  subtitle: "补充说明",
-  kind: "类型",
-  resultCount: "返回数量",
-  meta: "关键信息",
-  sections: "内容详情",
-  query: "查询内容",
-  keyword: "关键词",
-  keywords: "关键词",
-  account: "账号",
-  platform: "平台",
-  score: "匹配分数",
-  url: "链接",
-  input: "输入",
-  output: "输出",
-  error: "失败原因",
-  criteria: "评审标准",
-  suggestion: "建议",
-  conclusion: "结论",
-  issues: "发现的问题",
-  strengths: "已经达成",
-  next_step: "下一步",
-};
-
-function isRecord(value: unknown): value is Record<string, unknown> { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }
-function isStringPair(value: unknown): value is [string, string] { return Array.isArray(value) && value.length === 2 && value.every((item) => typeof item === "string"); }
-function isPlanRoute(value: unknown): value is PlanRoute { return isRecord(value) && ("target" in value || "method" in value || "emphasis" in value) && ("path_index" in value || "path_type" in value); }
-function fieldLabel(key: string) { return FIELD_LABELS[key] || "补充信息"; }
-function friendlyValue(value: string) { return ({ "增": "新增", "改": "修改", "删": "删除", "内容": "内容路线" } as Record<string, string>)[value] || value; }
-function printSimple(value: unknown): string {
-  if (typeof value === "string") return value;
-  if (typeof value === "number" || typeof value === "boolean") return String(value);
-  if (Array.isArray(value)) return value.map(printSimple).filter(Boolean).join(";");
-  if (isRecord(value)) return Object.entries(value).filter(([key]) => !TECHNICAL_KEYS.has(key)).map(([key, item]) => `${fieldLabel(key)}:${printSimple(item)}`).join(";");
-  return String(value ?? "");
-}

+ 0 - 178
visualization/frontend/components/inspector/ComparisonRowCells.tsx

@@ -1,178 +0,0 @@
-"use client";
-
-import { useMemo, useState } from "react";
-import { Activity, Calculator, Database, FileText, History, ScrollText } from "lucide-react";
-import type { InspectorComparisonRow, InspectorModule, SourceBinding, SourceRecord, SourceSelector } from "@/lib/types";
-import { BindingBadge, selectorDescription } from "@/components/inspector/BindingBadge";
-import { BusinessValueRenderer } from "@/components/inspector/BusinessValueRenderer";
-import { LongValueDisclosure } from "@/components/inspector/LongValueDisclosure";
-
-interface RowProps {
-  row: InspectorComparisonRow;
-  module: InspectorModule;
-  businessProjection: unknown;
-  sources: Record<string, SourceRecord>;
-  markers: Map<string, string>;
-  activeBindingId?: string;
-  onActivate: (bindingId: string, toggle?: boolean) => void;
-}
-
-export function ComparisonRowCells(props: RowProps) {
-  const bindings = props.row.bindingIds
-    .map((id) => props.module.bindings.find((binding) => binding.id === id))
-    .filter((binding): binding is SourceBinding => Boolean(binding));
-  const businessResult = pointerValue(props.businessProjection, props.row.businessSelector);
-  const active = bindings.some((binding) => binding.id === props.activeBindingId);
-  return <article className={`comparisonItemRow ${active ? "is-active" : ""}`} data-row-id={props.row.id}>
-    <section className="comparisonItemBusiness" aria-label="业务详情中的这一项">
-      {props.row.label ? <h4>{props.row.label}</h4> : null}
-      {businessResult.found
-        ? <BusinessValueRenderer module={props.module} value={businessResult.value} label={props.row.label || props.module.title} />
-        : <p className="comparisonEmptyValue">业务详情中的这一项无法定位。</p>}
-    </section>
-    <section className="comparisonItemEvidence" aria-label="这一项的数据依据">
-      {bindings.length ? bindings.map((binding) => <EvidenceSummary
-        key={binding.id}
-        binding={binding}
-        source={props.sources[binding.sourceId]}
-        marker={props.markers.get(binding.id) || "S?"}
-        active={props.activeBindingId === binding.id}
-        onActivate={props.onActivate}
-      />) : <p className="comparisonEmptyValue">没有返回来源绑定。</p>}
-    </section>
-    <section className="comparisonItemSource" aria-label="对应的原始字段或原文">
-      {bindings.length ? bindings.map((binding) => <SourceExcerpt
-        key={binding.id}
-        binding={binding}
-        source={props.sources[binding.sourceId]}
-        sources={props.sources}
-        marker={props.markers.get(binding.id) || "S?"}
-      />) : <p className="comparisonEmptyValue">没有可展示的原始记录。</p>}
-    </section>
-  </article>;
-}
-
-function EvidenceSummary({ binding, source, marker, active, onActivate }: { binding: SourceBinding; source?: SourceRecord; marker: string; active: boolean; onActivate: RowProps["onActivate"] }) {
-  return <div className={`comparisonEvidenceSummary ${active ? "is-active" : ""}`} data-binding-id={binding.id}>
-    <BindingBadge binding={binding} marker={marker} active={active} onActivate={onActivate} />
-    <div className="comparisonEvidenceCopy">
-      <strong>{source?.label || (binding.selector.kind === "unresolved" ? "无法定位来源" : binding.sourceId)}</strong>
-      <small>{roleLabel(binding.role)} · {transformLabel(binding)}</small>
-      <details className="comparisonLocatorDetails">
-        <summary>定位详情</summary>
-        <code>{selectorDescription(binding)}</code>
-        <small>{confidenceLabel(binding.evidence.confidence)}{binding.resolution !== "resolved" ? ` · ${resolutionLabel(binding.resolution)}` : ""}</small>
-      </details>
-    </div>
-  </div>;
-}
-
-function SourceExcerpt({ binding, source, sources, marker }: { binding: SourceBinding; source?: SourceRecord; sources: Record<string, SourceRecord>; marker: string }) {
-  const [rawOpen, setRawOpen] = useState(false);
-  const excerpt = useMemo(() => resolveExcerpt(binding.selector, source, sources), [binding.selector, source, sources]);
-  const Icon = sourceIcon(source?.kind);
-  return <div className={`comparisonSourceExcerpt is-${source?.resultState || "present"}`} data-binding-id={binding.id}>
-    <header>
-      <Icon aria-hidden="true" size={15} />
-      <div><strong>{source?.label || "原始记录不可用"}</strong><small>{sourceKindLabel(source?.kind)}</small></div>
-      <span className="sourceExcerptMarker">{marker}</span>
-    </header>
-    {source ? <SourceMeta source={source} /> : null}
-    {source?.resultState === "empty" ? <div className="sourceEmptyResult"><strong>查询完成,返回 0 条</strong><span>这不是记录缺失。</span></div> : null}
-    {excerpt.kind === "span" ? <TextSpanExcerpt before={excerpt.before} exact={excerpt.exact} after={excerpt.after} />
-      : excerpt.kind === "members" ? <div className="sourceMemberList">{excerpt.items.map((item, index) => <section key={`${item.label}-${index}`}><strong>{item.label}</strong><LongValueDisclosure value={item.value} label="完整成员值" /></section>)}</div>
-        : excerpt.kind === "missing" ? <p className="sourceMissingReason">{excerpt.reason}</p>
-          : <LongValueDisclosure value={excerpt.value} label="完整字段值" />}
-    {source?.rawRecord !== undefined && source?.rawRecord !== null ? <details className="rawRecordDisclosure" onToggle={(event) => setRawOpen(event.currentTarget.open)}>
-      <summary><span>完整原始记录</span><small>{source.truncated ? `已省略 ${source.omittedCharacters || 0} 字符` : "按需展开"}</small></summary>
-      {rawOpen ? <div><LongValueDisclosure value={source.rawRecord} label="完整原始记录" threshold={Number.MAX_SAFE_INTEGER} /></div> : null}
-    </details> : null}
-  </div>;
-}
-
-type Excerpt =
-  | { kind: "value"; value: unknown }
-  | { kind: "span"; before: string; exact: string; after: string }
-  | { kind: "members"; items: Array<{ label: string; value: unknown }> }
-  | { kind: "missing"; reason: string };
-
-function resolveExcerpt(selector: SourceSelector, source: SourceRecord | undefined, sources: Record<string, SourceRecord>): Excerpt {
-  if (selector.kind === "unresolved") return { kind: "missing", reason: selector.reason };
-  if (!source) return { kind: "missing", reason: "来源记录没有随接口响应返回。" };
-  if (selector.kind === "whole-record") return { kind: "value", value: source.rawRecord };
-  if (selector.kind === "json-pointer") {
-    const selected = pointerValue(source.rawRecord, selector.path);
-    return selected.found ? { kind: "value", value: selected.value } : { kind: "missing", reason: `原始记录中不存在字段 ${selector.path}` };
-  }
-  if (selector.kind === "text-span") {
-    const selected = pointerValue(source.rawRecord, selector.path);
-    if (!selected.found || typeof selected.value !== "string") return { kind: "missing", reason: `原始记录中不存在文本字段 ${selector.path}` };
-    const characters = Array.from(selected.value);
-    const start = selector.startCodePoint;
-    const end = selector.endCodePoint;
-    return {
-      kind: "span",
-      before: characters.slice(Math.max(0, start - 80), start).join(""),
-      exact: characters.slice(start, end).join("") || selector.exactText,
-      after: characters.slice(end, end + 80).join(""),
-    };
-  }
-  const items = selector.members.map((member, index) => {
-    const memberSource = sources[member.sourceId];
-    const resolved = resolveExcerpt(member.selector, memberSource, sources);
-    return {
-      label: memberSource?.label || `参与记录 ${index + 1}`,
-      value: resolved.kind === "value" ? resolved.value
-        : resolved.kind === "span" ? `${resolved.before}${resolved.exact}${resolved.after}`
-          : resolved.kind === "missing" ? resolved.reason
-            : resolved.items.map((item) => item.value),
-    };
-  });
-  return { kind: "members", items };
-}
-
-function TextSpanExcerpt({ before, exact, after }: { before: string; exact: string; after: string }) {
-  return <p className="comparisonTextExcerpt">{before ? <span>{before}</span> : null}<mark>{exact}</mark>{after ? <span>{after}</span> : null}</p>;
-}
-
-function pointerValue(value: unknown, pointer: string): { found: boolean; value: unknown } {
-  if (!pointer) return { found: true, value };
-  let current = value;
-  for (const rawPart of pointer.replace(/^\//, "").split("/")) {
-    const part = rawPart.replaceAll("~1", "/").replaceAll("~0", "~");
-    if (Array.isArray(current)) {
-      const index = Number(part);
-      if (!Number.isInteger(index) || index < 0 || index >= current.length) return { found: false, value: undefined };
-      current = current[index];
-    } else if (current && typeof current === "object" && Object.prototype.hasOwnProperty.call(current, part)) {
-      current = (current as Record<string, unknown>)[part];
-    } else return { found: false, value: undefined };
-  }
-  return { found: true, value: current };
-}
-
-function SourceMeta({ source }: { source: SourceRecord }) {
-  const locator = typeof source.locator === "string" ? undefined : source.locator;
-  const fields = locator ? Object.entries(locator).filter(([, value]) => value !== undefined && value !== null && value !== "") : [];
-  return fields.length || source.status || typeof source.durationMs === "number" ? <dl className="sourceMeta">
-    {fields.map(([key, value]) => <div key={key}><dt>{locatorLabel(key)}</dt><dd><code>{printMeta(value)}</code></dd></div>)}
-    {source.status ? <div><dt>状态</dt><dd>{source.status}</dd></div> : null}
-    {typeof source.durationMs === "number" ? <div><dt>耗时</dt><dd>{source.durationMs >= 1000 ? `${(source.durationMs / 1000).toFixed(1)} 秒` : `${source.durationMs} ms`}</dd></div> : null}
-  </dl> : null;
-}
-
-function sourceIcon(kind?: string) {
-  if (kind === "database") return Database;
-  if (kind === "runtime-event") return Activity;
-  if (kind === "artifact") return FileText;
-  if (kind === "log-anchor") return ScrollText;
-  if (kind === "calculation") return Calculator;
-  return History;
-}
-function sourceKindLabel(kind?: string) { return ({ database: "数据库记录", "runtime-event": "运行事件 / Event Body", artifact: "脚本或候选产物", "log-anchor": "日志锚点", calculation: "可视化确定性计算", "historical-fallback": "历史回退", missing: "原始记录缺失" } as Record<string, string>)[kind || "missing"] || kind || "原始记录缺失"; }
-function roleLabel(role: SourceBinding["role"]) { return ({ input: "输入", basis: "判断依据", constraint: "约束", process: "运行过程", output: "产出", status: "状态" } as const)[role]; }
-function transformLabel(binding: SourceBinding) { const value = binding.transform; return value.kind === "direct" ? "直接展示" : value.kind === "parsed" ? "确定性解析" : value.kind === "combined" ? "多来源组合" : value.kind === "calculated" ? "确定性计算" : "历史回退"; }
-function confidenceLabel(value: SourceBinding["evidence"]["confidence"]) { return ({ exact: "精确对应", "safe-association": "可安全关联", deterministic: "确定性生成", unconfirmed: "关联未确认" } as const)[value]; }
-function resolutionLabel(value: SourceBinding["resolution"]) { return ({ partial: "部分定位", missing: "记录缺失", unsafe: "无法安全关联", resolved: "已定位" } as const)[value]; }
-function locatorLabel(key: string) { return ({ table: "数据库表", recordId: "记录 ID", eventId: "Event ID", eventName: "Event 名称", eventType: "Event 类型", artifactRef: "产物", logAnchor: "日志锚点", fieldPath: "字段路径", filters: "查询条件", resultCount: "结果数量", reason: "缺失原因" } as Record<string, string>)[key] || key; }
-function printMeta(value: unknown) { if (typeof value === "string") return value; try { return JSON.stringify(value); } catch { return String(value); } }

+ 0 - 234
visualization/frontend/components/inspector/DataFlowRenderer.tsx

@@ -1,234 +0,0 @@
-"use client";
-
-import { useMemo, useState } from "react";
-import { AlertTriangle, ArrowDown, Check, ChevronRight, CircleDashed, Database, FileOutput, GitBranch, Link2, ScrollText } from "lucide-react";
-import { RichText } from "@/components/ui/RichText";
-import { ValueView } from "@/components/ui/ValueView";
-import type { CardBusinessData, CardDataCompleteness, CardDataField, CardDataSourceKind, RuntimeUnit } from "@/lib/types";
-
-interface Props {
-  data?: CardBusinessData;
-  loading?: boolean;
-  error?: string;
-  missingRef?: boolean;
-}
-
-const sourceLabels: Record<CardDataSourceKind, string> = {
-  database: "数据库",
-  "runtime-event": "运行事件",
-  artifact: "产物",
-  calculation: "计算生成",
-  "log-anchor": "日志锚点",
-};
-
-const relationLabels: Record<CardBusinessData["relationKind"], string> = {
-  "single-event": "单次运行事件",
-  "event-sequence": "事件序列",
-  "business-record": "业务记录",
-  aggregate: "多条记录聚合",
-  calculated: "计算生成",
-  missing: "运行关系缺失",
-};
-
-const completenessLabels: Record<CardDataCompleteness, string> = {
-  complete: "记录完整",
-  partial: "部分记录",
-  missing: "记录缺失",
-};
-
-export function DataFlowRenderer({ data, loading, error, missingRef }: Props) {
-  if (loading) return <DataFlowSkeleton />;
-  if (error) return <DataFlowState title="数据流暂时不可用" description={error} />;
-  if (missingRef) return <DataFlowState title="没有稳定的数据定位" description="这张摘要卡没有独立业务详情,不会推断或拼接它与运行记录的关系。" />;
-  if (!data) return <DataFlowState title="还没有数据流记录" description="打开其他卡片,查看其业务输入、运行过程和业务输出。" />;
-
-  const sourceIndex = new Map<string, number>();
-  [...data.businessInputs, ...data.businessOutputs].forEach((field) => {
-    if (!sourceIndex.has(field.id)) sourceIndex.set(field.id, sourceIndex.size + 1);
-  });
-  data.runtime.units.forEach((unit) => {
-    if (!sourceIndex.has(unit.id)) sourceIndex.set(unit.id, sourceIndex.size + 1);
-  });
-
-  return <div className="dataFlow" aria-label="卡片数据流">
-    <header className="dataFlowOverview">
-      <div className="dataFlowOverviewIcon" aria-hidden="true"><GitBranch size={18} /></div>
-      <div>
-        <span>{data.cardKind || "业务卡片"}</span>
-        <h3>数据关系与完整性</h3>
-      </div>
-      <div className="dataFlowOverviewBadges">
-        <span>{relationLabels[data.relationKind] || data.relationKind}</span>
-        <span className={`is-${data.completeness}`}>{completenessLabels[data.completeness]}</span>
-      </div>
-    </header>
-
-    {data.notices.length ? <div className="dataFlowNotices" aria-label="数据说明">
-      {data.notices.map((notice, index) => <p key={`${notice.message}-${index}`} className={`is-${notice.level}`}><AlertTriangle size={14} aria-hidden="true" />{notice.message}</p>)}
-    </div> : null}
-
-    <FlowSection stage="input" title="业务输入" description="这一步开始前,可确认获得的信息。">
-      <FieldList fields={data.businessInputs} sourceIndex={sourceIndex} empty="没有记录到可确认的业务输入。" />
-    </FlowSection>
-    <FlowConnector />
-    <FlowSection stage="runtime" title="运行过程 / I/O" description={data.runtime.summary || "本次运行没有留下过程概要。"}>
-      {data.runtime.units.length ? <div className="runtimeUnitList">
-        {data.runtime.units.map((unit, index) => <RuntimeUnitDisclosure key={unit.id || index} unit={unit} index={index + 1} sourceNumber={sourceIndex.get(unit.id)} />)}
-      </div> : <EmptyLine>没有保存可下钻的运行单元。</EmptyLine>}
-      {data.runtime.calculation ? <div className="dataFlowCalculation"><strong>聚合方式</strong><LongValue value={data.runtime.calculation} /></div> : null}
-    </FlowSection>
-    <FlowConnector />
-    <FlowSection stage="output" title="业务输出" description="运行完成后实际形成或保存的结果。">
-      <FieldList fields={data.businessOutputs} sourceIndex={sourceIndex} empty="没有记录到可确认的业务输出。" />
-    </FlowSection>
-    <FlowConnector muted />
-    <DisplayUse data={data} sourceIndex={sourceIndex} />
-  </div>;
-}
-
-function FlowSection({ stage, title, description, children }: { stage: "input" | "runtime" | "output"; title: string; description: string; children: React.ReactNode }) {
-  return <section className="dataFlowStage" data-stage={stage} aria-labelledby={`data-flow-${stage}`}>
-    <header>
-      <span className="dataFlowStageIcon" aria-hidden="true">{stage === "input" ? <Database size={16} /> : stage === "runtime" ? <CircleDashed size={16} /> : <FileOutput size={16} />}</span>
-      <div><h3 id={`data-flow-${stage}`}>{title}</h3><p>{description}</p></div>
-    </header>
-    {children}
-  </section>;
-}
-
-function FlowConnector({ muted = false }: { muted?: boolean }) {
-  return <div className={`dataFlowConnector ${muted ? "is-muted" : ""}`} aria-hidden="true"><ArrowDown size={14} /></div>;
-}
-
-function FieldList({ fields, sourceIndex, empty }: { fields: CardDataField[]; sourceIndex: Map<string, number>; empty: string }) {
-  if (!fields.length) return <EmptyLine>{empty}</EmptyLine>;
-  return <div className="dataFieldList">
-    {fields.map((field, index) => <article className="dataField" key={`${field.id}-${index}`}>
-      <header>
-        <SourceMarker number={sourceIndex.get(field.id)} />
-        <h4>{field.label}</h4>
-        <span className={`dataCompleteness is-${field.completeness}`}>{completenessLabels[field.completeness]}</span>
-      </header>
-      <LongValue value={field.value} />
-      <footer>
-        <SourceBadge kind={field.source.kind} />
-        <span>{field.source.label}</span>
-        {field.source.ref ? <code>{field.source.ref}</code> : null}
-        {field.source.fieldPath ? <code>{field.source.fieldPath}</code> : null}
-        <span className="dataRelation">{fieldRelationLabel(field)}</span>
-      </footer>
-    </article>)}
-  </div>;
-}
-
-function RuntimeUnitDisclosure({ unit, index, sourceNumber }: { unit: RuntimeUnit; index: number; sourceNumber?: number }) {
-  const [open, setOpen] = useState(false);
-  const title = unit.label || `运行单元 ${index}`;
-  return <details className="runtimeUnit" open={open}>
-    <summary aria-expanded={open} onClick={(event) => { event.preventDefault(); setOpen((current) => !current); }}>
-      <ChevronRight className="dataDisclosureChevron" size={15} aria-hidden="true" />
-      <SourceMarker number={sourceNumber} />
-      <strong>{title}</strong>
-      {unit.status ? <span className={`runtimeStatus is-${statusClass(unit.status)}`}>{unit.status}</span> : null}
-      {typeof unit.durationMs === "number" ? <small>{formatDuration(unit.durationMs)}</small> : null}
-    </summary>
-    <div className="runtimeUnitBody">
-      {unit.summary ? <LongValue value={unit.summary} /> : null}
-      {unit.input !== undefined ? <RuntimeValue label="输入" value={unit.input} /> : null}
-      {unit.output !== undefined ? <RuntimeValue label="输出" value={unit.output} /> : null}
-      {unit.eventRef ? <p className="runtimeEventRef"><span>运行事件</span><code>{unit.eventRef}</code></p> : null}
-      {unit.completeness ? <p className="runtimeCompleteness"><Check size={13} aria-hidden="true" />{completenessLabels[unit.completeness]}</p> : null}
-    </div>
-  </details>;
-}
-
-function RuntimeValue({ label, value }: { label: string; value: unknown }) {
-  return <section className="runtimeValue"><h5>{label}</h5><LongValue value={value} /></section>;
-}
-
-function DisplayUse({ data, sourceIndex }: { data: CardBusinessData; sourceIndex: Map<string, number> }) {
-  const { businessModules, cardFields, unusedSourceIds } = data.displayUse;
-  return <section className="displayUse" aria-labelledby="data-flow-display-use">
-    <header><span className="dataFlowStageIcon" aria-hidden="true"><Link2 size={16} /></span><div><h3 id="data-flow-display-use">页面如何使用这些数据</h3><p>同一编号把原始字段、业务详情模块与卡片摘要对应起来。</p></div></header>
-    <div className="displayUseGroups">
-      <MappingGroup title="业务详情" items={businessModules.map((module) => ({ key: module.id, title: module.title, sourceIds: module.sourceIds }))} sourceIndex={sourceIndex} />
-      <MappingGroup title="卡片摘要" items={cardFields.map((field) => ({ key: field.key, title: field.label, sourceIds: field.sourceIds, note: field.transform }))} sourceIndex={sourceIndex} />
-    </div>
-    {unusedSourceIds.length ? <div className="unusedSources"><strong>未用于页面展示</strong><SourceReferences ids={unusedSourceIds} sourceIndex={sourceIndex} /></div> : null}
-  </section>;
-}
-
-function MappingGroup({ title, items, sourceIndex }: { title: string; items: Array<{ key: string; title: string; sourceIds: string[]; note?: string }>; sourceIndex: Map<string, number> }) {
-  return <section className="mappingGroup"><h4>{title}</h4>{items.length ? <ul>{items.map((item) => <li key={item.key}><div><strong>{item.title}</strong>{item.note ? <small>{item.note}</small> : null}</div><SourceReferences ids={item.sourceIds} sourceIndex={sourceIndex} /></li>)}</ul> : <EmptyLine>没有配置展示映射。</EmptyLine>}</section>;
-}
-
-function SourceReferences({ ids, sourceIndex }: { ids: string[]; sourceIndex: Map<string, number> }) {
-  if (!ids.length) return <span className="sourceReferenceEmpty">无直接来源</span>;
-  return <span className="sourceReferences">{ids.map((id) => <span key={id} title={id}>F{sourceIndex.get(id) || "?"}</span>)}</span>;
-}
-
-function SourceMarker({ number }: { number?: number }) {
-  return <span className="sourceMarker" title={number ? `数据字段 F${number}` : "运行单元"}>{number ? `F${number}` : "I/O"}</span>;
-}
-
-function SourceBadge({ kind }: { kind: CardDataSourceKind }) {
-  const Icon = kind === "database" ? Database : kind === "artifact" ? FileOutput : kind === "log-anchor" ? ScrollText : kind === "runtime-event" ? CircleDashed : GitBranch;
-  return <span className={`dataSourceBadge is-${kind}`}><Icon size={12} aria-hidden="true" />{sourceLabels[kind]}</span>;
-}
-
-function LongValue({ value }: { value: unknown }) {
-  const { text, structured } = useMemo(() => displayValue(value), [value]);
-  const [open, setOpen] = useState(false);
-  if (text.length <= 800) return structured ? <ValueView value={value} /> : <RichText className="dataFlowText" value={text || "(空)"} />;
-  const preview = `${text.slice(0, 240).trimEnd()}…`;
-  return <details className="longDataValue" open={open}>
-    <summary aria-expanded={open} onClick={(event) => { event.preventDefault(); setOpen((current) => !current); }}>
-      <span className="longDataSummaryLine"><ChevronRight className="dataDisclosureChevron" size={15} aria-hidden="true" /><span>{open ? "收起完整内容" : "展开完整内容"}</span><small>{text.length.toLocaleString("zh-CN")} 字符</small></span>
-      {!open ? <span className="longDataPreview">{preview}</span> : null}
-    </summary>
-    {open ? structured ? <ValueView value={value} /> : <RichText className="dataFlowText" value={text} /> : null}
-  </details>;
-}
-
-function EmptyLine({ children }: { children: React.ReactNode }) { return <p className="dataFlowEmpty">{children}</p>; }
-
-function DataFlowState({ title, description }: { title: string; description: string }) {
-  return <div className="dataFlowState" role="status"><GitBranch size={20} aria-hidden="true" /><h3>{title}</h3><p>{description}</p></div>;
-}
-
-function DataFlowSkeleton() {
-  return <div className="dataFlowSkeleton" aria-label="正在加载数据流"><div /><span /><span /><div /><span /><span /><div /><span /></div>;
-}
-
-function displayValue(value: unknown) {
-  if (typeof value === "string") return { text: value, structured: false };
-  if (value === undefined) return { text: "(未记录)", structured: false };
-  if (value === null) return { text: "(空)", structured: false };
-  try { return { text: JSON.stringify(value, null, 2), structured: true }; }
-  catch { return { text: String(value), structured: false }; }
-}
-
-function fieldRelationLabel(field: CardDataField) {
-  if (field.relation === "available-upstream") return "可确认是上游信息,但无法确认是否被本次决策采用";
-  return ({
-    "direct-input": "本次运行的直接输入",
-    "previous-result": "上一步形成的结果",
-    "standing-constraint": "持续生效的约束",
-    "explicit-basis": "记录明确采用的依据",
-    "persisted-output": "已保存的业务输出",
-  } as Record<string, string>)[field.relation] || field.relation;
-}
-
-function statusClass(value: string) {
-  const normalized = value.toLowerCase();
-  if (["success", "completed", "hit", "passed"].includes(normalized)) return "success";
-  if (["failed", "failure", "error"].includes(normalized)) return "failure";
-  if (["running", "processing"].includes(normalized)) return "running";
-  return "neutral";
-}
-
-function formatDuration(value: number) {
-  if (value < 1000) return `${value}ms`;
-  if (value < 60_000) return `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)}s`;
-  return `${Math.floor(value / 60_000)}m ${Math.round((value % 60_000) / 1000)}s`;
-}

+ 0 - 129
visualization/frontend/components/inspector/Inspector.tsx

@@ -1,129 +0,0 @@
-"use client";
-
-import { useEffect, useRef, useState } from "react";
-import { ArrowLeft, BookOpen, FileText, GitCompareArrows, X } from "lucide-react";
-import type { CanvasItem, CardBusinessData, InspectorDetailProjection, InspectorTab, InspectorWorkbenchProjection, RetrievalActivity } from "@/lib/types";
-import { StatusBadge } from "@/components/ui/Badges";
-import { InspectorComparison } from "@/components/inspector/InspectorComparison";
-import { LegacyInspector } from "@/components/inspector/LegacyInspector";
-
-interface Props {
-  item: CanvasItem;
-  mode?: "business" | "lineage";
-  onModeChange?: (mode: "business" | "lineage") => void;
-  onClose: () => void;
-  onBack?: () => void;
-  backLabel?: string;
-  onNavigate?: (activity: RetrievalActivity) => void;
-  onPrompt?: (promptRef: string) => void;
-  onArtifact?: () => void;
-  workbench?: InspectorWorkbenchProjection;
-  workbenchLoading?: boolean;
-  workbenchError?: string;
-  topOverlayOpen?: boolean;
-  // Kept only so old isolated callers still compile while the formal path uses workbench.
-  tab?: InspectorTab;
-  onTab?: (tab: InspectorTab) => void;
-  detail?: unknown;
-  detailLoading?: boolean;
-  detailError?: string;
-  cardData?: CardBusinessData;
-  cardDataLoading?: boolean;
-  cardDataError?: string;
-  cardDataMissingRef?: boolean;
-}
-
-export function Inspector(props: Props) {
-  const [internalTab, setInternalTab] = useState<InspectorTab>("business");
-  useEffect(() => setInternalTab("business"), [props.item.id]);
-  const tab = props.tab || internalTab;
-  const onTab = props.onTab || setInternalTab;
-  const mode = props.mode || (props.tab && props.onTab ? "business" : "lineage");
-  if (mode === "business") return <LegacyInspector {...props} tab={tab} onTab={onTab} onLineage={props.onModeChange ? () => props.onModeChange?.("lineage") : undefined} />;
-  return <ComparisonInspector {...props} onBusiness={props.onModeChange ? () => props.onModeChange?.("business") : undefined} />;
-}
-
-function ComparisonInspector({ item, onClose, onBack, backLabel, onNavigate, onPrompt, onArtifact, onBusiness, workbench, workbenchLoading, workbenchError, detailLoading, detailError, topOverlayOpen = false }: Props & { onBusiness?: () => void }) {
-  const dialog = useRef<HTMLElement>(null);
-  const topOverlayOpenRef = useRef(topOverlayOpen);
-  topOverlayOpenRef.current = topOverlayOpen;
-  const [activeBindingId, setActiveBindingId] = useState<string>();
-  const [clearActiveSignal, setClearActiveSignal] = useState(0);
-  const promptRef = promptRefFor(item);
-
-  useEffect(() => setActiveBindingId(undefined), [item.id]);
-  useEffect(() => {
-    const keepFocusInside = (event: FocusEvent) => {
-      const panel = dialog.current;
-      if (topOverlayOpenRef.current || !panel || panel.contains(event.target as Node)) return;
-      panel.querySelector<HTMLElement>('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), summary, [tabindex]:not([tabindex="-1"])')?.focus();
-    };
-    document.addEventListener("focusin", keepFocusInside);
-    return () => document.removeEventListener("focusin", keepFocusInside);
-  }, []);
-
-  const trapFocus = (event: React.KeyboardEvent<HTMLElement>) => {
-    if (topOverlayOpen) return;
-    if (event.key === "Escape") {
-      if (activeBindingId) { event.preventDefault(); event.stopPropagation(); setActiveBindingId(undefined); setClearActiveSignal((value) => value + 1); return; }
-      event.preventDefault(); event.stopPropagation(); onClose(); return;
-    }
-    if (event.key !== "Tab") return;
-    const focusable = [...(dialog.current?.querySelectorAll<HTMLElement>('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), summary, [tabindex]:not([tabindex="-1"])') || [])];
-    if (!focusable.length) return;
-    const first = focusable[0]; const last = focusable[focusable.length - 1];
-    if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
-    else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
-  };
-
-  const effectiveError = workbenchError || (!workbench && !workbenchLoading && detailError ? detailError : undefined);
-  const effectiveLoading = workbenchLoading || (!workbench && !workbenchError && detailLoading);
-  return <><div className="inspectorWorkbenchBackdrop" aria-hidden="true" /><aside ref={dialog} className="inspector inspectorWorkbench" role="dialog" aria-modal="true" aria-label={`${item.title}三列来源详情`} onKeyDown={trapFocus} onWheelCapture={(event) => event.stopPropagation()}>
-    <header className="inspectorHeader comparisonInspectorHeader">
-      {onBack ? <button className="iconButton inspectorBack" type="button" onClick={onBack} aria-label={backLabel || "返回上一级详情"}><ArrowLeft size={18} /></button> : null}
-      <div className="comparisonInspectorTitle"><span>{itemLabel(item)}</span><h2>{item.title}</h2><div className="comparisonInspectorMeta"><StoryStatus item={item} /></div></div>
-      <div className="comparisonInspectorActions">
-        {onBusiness ? <button type="button" className="quietButton" onClick={onBusiness}><GitCompareArrows size={14} />返回业务详情</button> : null}
-        {promptRef && onPrompt ? <button type="button" className="quietButton" data-focus-return={`prompt:${item.id}:inspector`} onClick={() => onPrompt(promptRef)}><BookOpen size={14} />{promptActionLabel(item)}</button> : null}
-        {item.itemType === "final-result" && onArtifact ? <button type="button" className="quietButton" data-focus-return={`artifact:${item.id}:inspector`} onClick={onArtifact}><FileText size={14} />查看当前主脚本</button> : null}
-        <button autoFocus className="iconButton" type="button" onClick={onClose} aria-label="关闭详情"><X size={18} /></button>
-      </div>
-    </header>
-    <div className="inspectorBody comparisonInspectorBody">
-      <InspectorComparison data={workbench} loading={effectiveLoading} error={effectiveError} onNavigate={onNavigate} onActiveBindingChange={setActiveBindingId} clearActiveSignal={clearActiveSignal} />
-    </div>
-  </aside></>;
-}
-
-function promptRefFor(item: CanvasItem) {
-  if (item.itemType === "story") return item.decision?.promptRef || undefined;
-  if (item.itemType === "multipath-review") return item.review.promptRef;
-  if (item.itemType === "multipath-decision") return item.decision.decisionProjection?.promptRef || undefined;
-  return undefined;
-}
-
-function promptActionLabel(item: CanvasItem) {
-  if (item.itemType === "story" && item.role === "data-decision") return "查看取数任务与提示词";
-  const actorRole = item.itemType === "story" ? item.decision?.actor.role : item.itemType === "multipath-review" ? "multipath-evaluator" : item.itemType === "multipath-decision" ? item.decision.decisionProjection?.actor.role : undefined;
-  if (actorRole === "main") return "查看主 Agent 提示词";
-  if (actorRole === "multipath-evaluator") return "查看评审任务与提示词";
-  if (actorRole === "overall-evaluator") return "查看整体评审提示词";
-  return "查看实现任务与提示词";
-}
-
-function StoryStatus({ item }: { item: CanvasItem }) {
-  const status = item.itemType === "story" ? item.status
-    : item.itemType === "branch-summary" ? item.branch.status
-      : item.itemType === "round-summary" ? item.round.state
-        : item.itemType === "final-result" ? item.result.status
-          : item.itemType === "retrieval-stage" ? item.stage.status
-            : item.itemType === "retrieval-agent" ? item.run.status
-              : item.itemType === "retrieval-direct" ? item.group.failureCount || item.group.interruptedCount ? "partial" : item.group.runningCount ? "running" : "completed"
-                : item.itemType === "query-attempt" ? item.attempt.status
-                  : item.itemType === "retrieval-activity" ? item.activity.status
-                    : item.itemType === "multipath-review" ? item.review.status
-                      : undefined;
-  return status ? <StatusBadge status={status} /> : null;
-}
-
-function itemLabel(item: CanvasItem) { return item.itemType === "story" ? item.role === "planning-analysis" ? "构建开始前的规划" : item.kind === "decision" ? "这一步的决定" : item.kind === "result" ? "这一步的结果" : "这一步做了什么" : item.itemType === "branch-summary" ? "候选方案" : item.itemType === "round-summary" ? "轮次摘要" : item.itemType === "retrieval-stage" ? "取数阶段" : item.itemType === "retrieval-direct" ? "工具取数" : item.itemType === "retrieval-agent" ? "Agent 取数" : item.itemType === "query-attempt" ? "单次查询" : item.itemType === "retrieval-activity" ? item.activity.kind === "tool" ? "单次工具调用" : "单次查询" : item.itemType === "multipath-review" ? "候选方案评审" : item.itemType === "multipath-decision" ? "主 Agent 决策" : "最终结果"; }

+ 0 - 94
visualization/frontend/components/inspector/InspectorComparison.tsx

@@ -1,94 +0,0 @@
-"use client";
-
-import { useEffect, useMemo, useState } from "react";
-import { AlertTriangle, ChevronDown, ChevronRight, Columns3, UnfoldVertical } from "lucide-react";
-import type { InspectorModule, InspectorWorkbenchProjection, RetrievalActivity, SourceBinding } from "@/lib/types";
-import { ComparisonRowCells } from "@/components/inspector/ComparisonRowCells";
-
-interface Props {
-  data?: InspectorWorkbenchProjection;
-  loading?: boolean;
-  error?: string;
-  onNavigate?: (activity: RetrievalActivity) => void;
-  onActiveBindingChange?: (bindingId?: string) => void;
-  clearActiveSignal?: number;
-}
-
-export function InspectorComparison({ data, loading, error, onActiveBindingChange, clearActiveSignal }: Props) {
-  const [openModules, setOpenModules] = useState<Set<string>>(new Set());
-  const [activeBindingId, setActiveBindingId] = useState<string>();
-  const markers = useMemo(() => bindingMarkers(data), [data]);
-  const moduleSignature = (data?.modules || []).map((module) => `${module.id}:${module.defaultOpen}`).join("|");
-
-  useEffect(() => {
-    setOpenModules(new Set((data?.modules || []).filter((module) => module.defaultOpen).map((module) => module.id)));
-    setActiveBindingId(undefined);
-    onActiveBindingChange?.(undefined);
-  }, [data?.detailRef, moduleSignature, onActiveBindingChange]);
-  useEffect(() => {
-    if (clearActiveSignal === undefined) return;
-    setActiveBindingId(undefined);
-  }, [clearActiveSignal]);
-
-  if (loading) return <ComparisonSkeleton />;
-  if (error) return <ComparisonState title="数据来源读取失败" message={error} />;
-  if (!data) return <ComparisonState title="没有可对照的数据" message="当前卡片没有统一来源记录。" />;
-
-  const allOpen = data.modules.length > 0 && data.modules.every((module) => openModules.has(module.id));
-  const activate = (bindingId: string, toggle = true) => {
-    const next = toggle && activeBindingId === bindingId ? undefined : bindingId;
-    setActiveBindingId(next);
-    onActiveBindingChange?.(next);
-    if (!next) return;
-    requestAnimationFrame(() => document.querySelector<HTMLElement>(`.inspectorComparison [data-binding-id="${cssEscape(next)}"]`)?.scrollIntoView({ block: "nearest", behavior: "smooth" }));
-  };
-  const clearActive = () => { setActiveBindingId(undefined); onActiveBindingChange?.(undefined); };
-
-  return <div className="inspectorComparison" data-completeness={data.completeness} onKeyDown={(event) => { if (event.key === "Escape" && activeBindingId) { event.preventDefault(); event.stopPropagation(); clearActive(); } }}>
-    <div className="comparisonToolbar">
-      <div><Columns3 aria-hidden="true" size={17} /><span>{data.modules.length} 个业务模块</span><b className={`comparisonCompleteness is-${data.completeness}`}>{completenessLabel(data.completeness)}</b></div>
-      <div>{activeBindingId ? <button type="button" className="comparisonTextAction" onClick={clearActive}>清除突出</button> : null}<button type="button" className="comparisonTextAction" onClick={() => setOpenModules(allOpen ? new Set() : new Set(data.modules.map((module) => module.id)))}><UnfoldVertical aria-hidden="true" size={14} />{allOpen ? "折叠全部" : "展开全部"}</button></div>
-    </div>
-    {data.notices.length ? <div className="comparisonNotices">{data.notices.map((notice, index) => <p key={`${notice.code || "notice"}-${index}`} className={`is-${notice.level || "info"}`}><AlertTriangle aria-hidden="true" size={14} />{notice.message}</p>)}</div> : null}
-    <div className="comparisonColumnHeaders" role="row"><span role="columnheader">业务详情中的内容</span><span role="columnheader">这一项的数据依据</span><span role="columnheader">对应的原始字段或原文</span></div>
-    <div className="comparisonModules">{data.modules.map((module, index) => {
-      const open = openModules.has(module.id);
-      return <section key={module.id} className={`comparisonModule ${open ? "is-open" : ""}`} data-module-id={module.id}>
-        <button type="button" className="comparisonModuleHeader" aria-expanded={open} onClick={() => setOpenModules((current) => { const next = new Set(current); next.has(module.id) ? next.delete(module.id) : next.add(module.id); return next; })}>
-          {open ? <ChevronDown aria-hidden="true" size={16} /> : <ChevronRight aria-hidden="true" size={16} />}
-          <span className="moduleMarker">M{index + 1}</span><strong>{module.title}</strong><small>{module.rows?.length || 0} 项业务内容</small>
-        </button>
-        {open ? <div className="comparisonItemRows">{(module.rows || []).map((row) => <ComparisonRowCells
-          key={row.id}
-          row={row}
-          module={module}
-          businessProjection={data.businessProjection}
-          sources={data.sources}
-          markers={markers}
-          activeBindingId={activeBindingId}
-          onActivate={activate}
-        />)}</div> : null}
-      </section>;
-    })}</div>
-  </div>;
-}
-
-export function allBindings(module: InspectorModule): SourceBinding[] {
-  const seen = new Set<string>();
-  return (module.bindings || []).filter((binding) => {
-    if (seen.has(binding.id)) return false;
-    seen.add(binding.id);
-    return true;
-  });
-}
-
-function bindingMarkers(data?: InspectorWorkbenchProjection) {
-  const result = new Map<string, string>();
-  let index = 1;
-  for (const module of data?.modules || []) for (const binding of allBindings(module)) if (!result.has(binding.id)) result.set(binding.id, `S${index++}`);
-  return result;
-}
-function completenessLabel(value: InspectorWorkbenchProjection["completeness"]) { return value === "complete" ? "来源完整" : value === "partial" ? "部分来源缺失" : "来源记录缺失"; }
-function cssEscape(value: string) { return typeof CSS !== "undefined" && CSS.escape ? CSS.escape(value) : value.replace(/[^a-zA-Z0-9_-]/g, "\\$&"); }
-function ComparisonState({ title, message }: { title: string; message: string }) { return <div className="comparisonState"><AlertTriangle size={22} /><h3>{title}</h3><p>{message}</p></div>; }
-function ComparisonSkeleton() { return <div className="comparisonSkeleton" aria-label="正在加载业务详情、数据依据和原始记录"><div /><span /><span /><span /><div /><span /><span /><span /></div>; }

+ 0 - 91
visualization/frontend/components/inspector/LegacyInspector.tsx

@@ -1,91 +0,0 @@
-"use client";
-
-import { useEffect, useMemo, useRef, useState } from "react";
-import { ArrowLeft, BookOpen, ChevronRight, FileText, GitCompareArrows, X } from "lucide-react";
-import type { CanvasItem, DetailItem, DetailSection, InspectorDetailProjection, InspectorSemanticMeta, InspectorTab, RetrievalActivity } from "@/lib/types";
-import { SourceNoticeBadge, StatusBadge } from "@/components/ui/Badges";
-import { RichText } from "@/components/ui/RichText";
-import { ValueView } from "@/components/ui/ValueView";
-import { TabList } from "@/components/ui/TabList";
-import { DecisionDetailRenderer } from "@/components/decision/DecisionDetailRenderer";
-import { authorityLabel, decisionFor, decisionTypeLabel } from "@/components/decision/AgentDecisionCard";
-import { RetrievalDetailRenderer } from "@/components/inspector/RetrievalDetailRenderer";
-
-interface Props { item: CanvasItem; tab: InspectorTab; onTab: (tab: InspectorTab) => void; onClose: () => void; onBack?: () => void; backLabel?: string; onNavigate?: (activity: RetrievalActivity) => void; onPrompt?: (promptRef: string) => void; onArtifact?: () => void; onLineage?: () => void; detail?: unknown; detailLoading?: boolean; detailError?: string; }
-
-export function LegacyInspector({ item, tab, onTab, onClose, onBack, backLabel, onNavigate, onPrompt, onArtifact, onLineage, detail, detailLoading, detailError }: Props) {
-  const dialog = useRef<HTMLElement>(null);
-  const [modal, setModal] = useState(false);
-  const projection = useMemo(() => normalizeProjection(item, detail), [item, detail]);
-  const tabs = useMemo<Array<{ id: InspectorTab; label: string }>>(() => [
-    { id: "business", label: "业务详情" },
-    ...(projection.changes ? [{ id: "changes" as const, label: "产出与改动" }] : []),
-    { id: "technical", label: "技术详情" },
-  ], [projection.changes]);
-
-  useEffect(() => {
-    const query = window.matchMedia("(max-width: 1199px)");
-    const update = () => setModal(query.matches);
-    update(); query.addEventListener("change", update);
-    return () => query.removeEventListener("change", update);
-  }, []);
-  useEffect(() => { if (!tabs.some((entry) => entry.id === tab)) onTab("business"); }, [onTab, tab, tabs]);
-
-  const trapFocus = (event: React.KeyboardEvent<HTMLElement>) => {
-    if (event.key !== "Tab" || !modal) return;
-    const focusable = [...(dialog.current?.querySelectorAll<HTMLElement>('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])') || [])];
-    if (!focusable.length) return;
-    const first = focusable[0]; const last = focusable[focusable.length - 1];
-    if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
-    else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
-  };
-  return <aside ref={dialog} className="inspector" role="dialog" aria-modal={modal || undefined} aria-label="运行详情" onKeyDown={trapFocus} onWheelCapture={(event) => event.stopPropagation()}>
-    <header className="inspectorHeader">{onBack ? <button className="iconButton inspectorBack" type="button" onClick={onBack} aria-label={backLabel || "返回上一级详情"}><ArrowLeft size={18} /></button> : null}<div><span>{itemLabel(item)}</span><h2>{item.title}</h2></div><div className="inspectorHeaderActions">{onLineage ? <button type="button" className="quietButton" aria-label="打开数据来源" onClick={onLineage}><GitCompareArrows size={14} />数据来源</button> : null}<button autoFocus className="iconButton" type="button" onClick={onClose} aria-label="关闭详情"><X size={18} /></button></div></header>
-    <TabList className="inspectorTabs" tabs={tabs} active={tab} onChange={onTab} ariaLabel="详情分类" />
-    <div className="inspectorBody">
-      {detailLoading && tab === "business" ? <div className="skeletonLines" aria-label="正在加载完整业务记录"><i /><i /><i /></div> : null}
-      {detailError && tab === "business" ? <Panel title="完整记录暂时不可用" missing><BusinessText value={detailError} /></Panel> : null}
-      {tab === "business" ? <BusinessDetails item={item} projection={projection} onPrompt={onPrompt} onNavigate={onNavigate} onArtifact={onArtifact} /> : null}
-      {tab === "changes" && projection.changes ? <ChangesDetails changes={projection.changes} /> : null}
-      {tab === "technical" ? detailLoading ? <div className="skeletonLines" aria-label="正在加载技术详情"><i /><i /><i /></div> : detailError ? <Panel title="技术详情读取失败"><BusinessText className="diffWarning" value={detailError} /></Panel> : <TechnicalDetails value={projection.technical} /> : null}
-    </div>
-  </aside>;
-}
-
-function BusinessDetails({ item, projection, onPrompt, onNavigate, onArtifact }: { item: CanvasItem; projection: InspectorDetailProjection; onPrompt?: Props["onPrompt"]; onNavigate?: Props["onNavigate"]; onArtifact?: Props["onArtifact"] }) {
-  const decision = decisionFor(item);
-  const promptRef = projection.promptRef || decision?.promptRef;
-  const actorRole = projection.actor?.role || decision?.actor.role;
-  return <>
-    <StoryBadges item={item} />
-    {promptRef && onPrompt || item.itemType === "final-result" && onArtifact ? <div className="inspectorDecisionActions">
-      {promptRef && onPrompt ? <button type="button" className="nodeAction promptAction" data-focus-return={`prompt:${item.id}:inspector`} onClick={() => onPrompt(promptRef)}><BookOpen size={14} />{promptActionLabel(item, actorRole)}</button> : null}
-      {item.itemType === "final-result" && onArtifact ? <button type="button" className="nodeAction promptAction" data-focus-return={`artifact:${item.id}:inspector`} onClick={onArtifact}><FileText size={14} />查看当前主脚本</button> : null}
-    </div> : null}
-    {projection.detailKind?.startsWith("retrieval-") ? <RetrievalDetailRenderer projection={projection} onNavigate={onNavigate} /> : projection.detailKind === "agent-decision" ? <>
-      <div className="decisionMetaRow">{projection.actor ? <span>{projection.actor.label}</span> : null}{projection.decisionType ? <span>{decisionTypeLabel(projection.decisionType)}</span> : null}{projection.authority ? <strong>{authorityLabel(projection.authority)}</strong> : null}</div>
-      {projection.question ? <Panel title="这一步要回答什么" variant="summary" visualRole="key-result"><BusinessText value={projection.question} /></Panel> : null}
-      <DecisionDetailRenderer blocks={projection.blocks || []} />
-    </> : projection.businessSections?.length ? projection.businessSections.map((section) => <SectionPanel key={section.id} section={section} />) : <Panel title="业务记录" missing><BusinessText value="本次运行没有记录更多业务说明。" /></Panel>}
-  </>;
-}
-
-function promptActionLabel(item: CanvasItem, actorRole?: string) {
-  if (item.itemType === "story" && item.role === "data-decision") return "查看取数任务与提示词";
-  if (actorRole === "main") return "查看主 Agent 提示词";
-  if (actorRole === "multipath-evaluator") return "查看评审任务与提示词";
-  if (actorRole === "overall-evaluator") return "查看整体评审提示词";
-  return "查看实现任务与提示词";
-}
-function ChangesDetails({ changes }: { changes: NonNullable<InspectorDetailProjection["changes"]> }) { return <>{changes.summary ? <Panel title="改动摘要"><BusinessText value={changes.summary} /></Panel> : null}{changes.sections.map((section) => <SectionPanel key={section.id} section={section} />)}<p className="changeExactness">{exactnessLabel(changes.exactness)}</p></>; }
-function SectionPanel({ section }: { section: DetailSection }) { const content = section.id === "plan" && section.content ? insertExecutionPlanBreaks(section.content) : section.content; return <Panel title={section.title} variant={section.variant} visualRole={section.visualRole} presentation={section.presentation} collapsible={section.collapsible} defaultOpen={section.defaultOpen}>{section.items?.length ? <DetailItems items={section.items} /> : content ? <BusinessText value={content} /> : <BusinessText value="本次运行没有记录更多业务说明。" />}</Panel>; }
-function insertExecutionPlanBreaks(value: string) { const pattern = /(^|[ \t\u3000]+)(\d+)([.)、])(?=[ \t\u3000]*\S)/gm; const markers = [...value.matchAll(pattern)]; if (markers.length < 2) return value; const numbers = markers.map((match) => Number(match[2])); if (numbers[0] !== 1 || numbers.some((number, index) => index > 0 && number !== numbers[index - 1] + 1)) return value; let markerIndex = 0; return value.replace(pattern, (match, prefix: string, number: string, punctuation: string, offset: number) => { const first = markerIndex++ === 0; if (first && offset === 0) return `${number}${punctuation}`; return `${first ? "\n\n" : "  \n"}${number}${punctuation}`; }); }
-function DetailItems({ items }: { items: DetailItem[] }) { return <dl className="inspectorDetailItems">{items.map((item, index) => <div key={`${item.label}-${index}`} className="inspectorDetailItem" data-detail-ref={item.detailRef || undefined}><dt>{item.label}</dt><dd><BusinessText value={item.value} />{item.sourceNotice && item.sourceNotice !== "runtime-associated" ? <SourceNoticeBadge notice={item.sourceNotice} /> : null}</dd></div>)}</dl>; }
-function StoryBadges({ item }: { item: CanvasItem }) { const status = item.itemType === "story" ? item.status : item.itemType === "branch-summary" ? item.branch.status : item.itemType === "round-summary" ? item.round.state : item.itemType === "final-result" ? item.result.status : item.itemType === "retrieval-stage" ? item.stage.status : item.itemType === "retrieval-agent" ? item.run.status : item.itemType === "retrieval-direct" ? item.group.failureCount || item.group.interruptedCount ? "partial" : item.group.runningCount ? "running" : "completed" : item.itemType === "query-attempt" ? item.attempt.status : item.itemType === "retrieval-activity" ? item.activity.status : item.itemType === "multipath-review" ? item.review.status : undefined; return status ? <div className="inspectorBadgeRow"><StatusBadge status={status} /></div> : null; }
-function normalizeProjection(item: CanvasItem, detail: unknown): InspectorDetailProjection { const record = asRecord(detail); if (record && Object.prototype.hasOwnProperty.call(record, "technical") && (Array.isArray(record.businessSections) || typeof record.detailKind === "string")) return record as unknown as InspectorDetailProjection; return { detailKind: "activity", businessSections: [], technical: detail ?? item }; }
-function TechnicalDetails({ value }: { value: unknown }) { const record = asRecord(value); if (!record) return <Panel title="原始技术数据"><ValueView value={value} /></Panel>; const groups = [["source", "数据来源与定位"], ["association", "关联方式与完整性"], ["input", "原始输入"], ["output", "原始输出"], ["cost", "耗时与成本"]] as const; const shown = new Set<string>(); return <>{groups.map(([key, title]) => { if (!Object.prototype.hasOwnProperty.call(record, key)) return null; shown.add(key); return <Panel key={key} title={title}><ValueView value={record[key]} /></Panel>; })}<Panel title="原始记录"><ValueView value={Object.fromEntries(Object.entries(record).filter(([key]) => !shown.has(key)))} /></Panel></>; }
-function exactnessLabel(value: NonNullable<InspectorDetailProjection["changes"]>["exactness"]) { return ({ exact: "这是可确认的精确记录。", "current-only": "这里只能确认当前主脚本,无法还原每轮结束时的完整版本。", "historical-snapshot": "这里展示的是处置前保存的候选快照。", unknown: "当前记录不足以还原精确的前后差异。" } as const)[value]; }
-function Panel({ title, children, missing = false, variant = "default", visualRole, presentation, collapsible = false, defaultOpen = false }: { title: string; children: React.ReactNode; missing?: boolean; variant?: DetailSection["variant"] } & InspectorSemanticMeta) { const role = visualRole || (variant === "summary" ? "key-result" : "section"); const panelProps = { className: `inspectorPanel ${missing ? "isMissing" : ""} is-${variant} is-role-${role} ${collapsible ? "decisionDisclosurePanel" : ""}`, "data-visual-role": role, "data-presentation": presentation || "prose" }; if (collapsible) return <section {...panelProps}><details className="decisionDisclosure" open={defaultOpen || undefined}><summary><span className="decisionDisclosureLabel"><ChevronRight aria-hidden="true" size={16} strokeWidth={2.25} /><h3>{title}</h3></span></summary><div className="decisionDisclosureBody">{children}</div></details></section>; return <section {...panelProps}><h3>{title}</h3>{children}</section>; }
-function BusinessText({ value, className = "" }: { value: string; className?: string }) { return <RichText value={value} className={className} />; }
-function asRecord(value: unknown): Record<string, unknown> | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined; }
-function itemLabel(item: CanvasItem) { return item.itemType === "story" ? item.role === "planning-analysis" ? "构建开始前的规划" : item.kind === "decision" ? "这一步的决定" : item.kind === "result" ? "这一步的结果" : "这一步做了什么" : item.itemType === "branch-summary" ? "候选方案" : item.itemType === "round-summary" ? "轮次摘要" : item.itemType === "retrieval-stage" ? "取数阶段" : item.itemType === "retrieval-direct" ? "工具取数" : item.itemType === "retrieval-agent" ? "Agent 取数" : item.itemType === "query-attempt" ? "单次查询" : item.itemType === "retrieval-activity" ? item.activity.kind === "tool" ? "单次工具调用" : "单次查询" : item.itemType === "multipath-review" ? "候选方案评审" : item.itemType === "multipath-decision" ? "主 Agent 决策" : "最终结果"; }

+ 0 - 52
visualization/frontend/components/inspector/LongValueDisclosure.tsx

@@ -1,52 +0,0 @@
-"use client";
-
-import { useMemo, useState } from "react";
-import { ChevronRight } from "lucide-react";
-import { RichText } from "@/components/ui/RichText";
-import { ValueView } from "@/components/ui/ValueView";
-
-interface Props {
-  value: unknown;
-  label?: string;
-  threshold?: number;
-  previewLength?: number;
-  className?: string;
-}
-
-export function LongValueDisclosure({ value, label = "完整内容", threshold = 800, previewLength = 240, className = "" }: Props) {
-  const text = useMemo(() => printable(value), [value]);
-  const characters = useMemo(() => Array.from(text), [text]);
-  const isLong = characters.length > threshold || (Array.isArray(value) && value.length > 5);
-  const [open, setOpen] = useState(false);
-
-  if (!isLong) return <ReadableValue value={value} />;
-  return <details className={`comparisonLongValue ${className}`} onToggle={(event) => setOpen(event.currentTarget.open)}>
-    <summary aria-label={`${open ? "收起" : "展开"}${label}`}>
-      <span className="comparisonDisclosureTitle"><ChevronRight aria-hidden="true" size={15} /><strong>{label}</strong></span>
-      <small>{characters.length.toLocaleString()} 字符</small>
-      {!open ? <span className="comparisonDisclosurePreview">{preview(value, text, previewLength)}</span> : null}
-    </summary>
-    {open ? <div className="comparisonDisclosureBody"><ReadableValue value={value} /></div> : null}
-  </details>;
-}
-
-export function ReadableValue({ value }: { value: unknown }) {
-  if (typeof value === "string") return <RichText value={value || "(空)"} className="comparisonReadableText" />;
-  if (value === null || value === undefined || value === "") return <p className="comparisonEmptyValue">(空)</p>;
-  return <ValueView value={value} />;
-}
-
-function printable(value: unknown) {
-  if (typeof value === "string") return value;
-  if (value === undefined) return "";
-  try { return JSON.stringify(value, null, 2) || ""; } catch { return String(value); }
-}
-
-function preview(value: unknown, text: string, previewLength: number) {
-  if (Array.isArray(value) && value.length > 5) {
-    const first = printable(value.slice(0, 3));
-    return `${first}\n…还有 ${value.length - 3} 项`;
-  }
-  const characters = Array.from(text);
-  return `${characters.slice(0, previewLength).join("")}${characters.length > previewLength ? "…" : ""}`;
-}

+ 0 - 117
visualization/frontend/components/inspector/RetrievalDetailRenderer.tsx

@@ -1,117 +0,0 @@
-import { ChevronRight, ExternalLink } from "lucide-react";
-import type {
-  InspectorDetailProjection,
-  RetrievalActivity,
-  RetrievalOutcome,
-  RetrievalResultItem,
-} from "@/lib/types";
-import { RichText } from "@/components/ui/RichText";
-import { ValueView } from "@/components/ui/ValueView";
-
-
-const STATE_LABELS: Record<string, string> = {
-  hit: "查询命中",
-  empty: "查询成功 · 无匹配",
-  failure: "查询失败",
-  running: "查询中",
-  interrupted: "构建中断",
-  unknown: "状态未知",
-  success: "已完成",
-};
-
-export function RetrievalDetailRenderer({
-  projection,
-  onNavigate,
-}: {
-  projection: InspectorDetailProjection;
-  onNavigate?: (activity: RetrievalActivity) => void;
-}) {
-  if (projection.detailKind === "retrieval-event") {
-    return <RetrievalEvent projection={projection} />;
-  }
-  return <RetrievalActivityGroup projection={projection} onNavigate={onNavigate} />;
-}
-
-function RetrievalActivityGroup({
-  projection,
-  onNavigate,
-}: {
-  projection: InspectorDetailProjection;
-  onNavigate?: (activity: RetrievalActivity) => void;
-}) {
-  const activities = projection.activities || [];
-  return <div className="retrievalInspector">
-    {(projection.businessSections || []).map((section) => <section className={`inspectorPanel is-${section.variant || "default"}`} key={section.id}>
-      <h3>{section.title}</h3>
-      {section.content ? <RichText value={section.content} /> : null}
-    </section>)}
-    <section className="inspectorPanel retrievalActivityPanel">
-      <div className="retrievalSectionHeader"><h3>{projection.retrievalKind === "direct-tools" ? "每次工具调用" : "查询记录"}</h3><span>{activities.length} 次</span></div>
-      {activities.length ? <ol className="retrievalActivityList">
-        {activities.map((activity, index) => <li key={`${activity.id}-${index}`}>
-          <button type="button" onClick={() => onNavigate?.(activity)} disabled={!onNavigate || !activity.detailRef}>
-            <span className="retrievalActivityIndex">{String(index + 1).padStart(2, "0")}</span>
-            <span className="retrievalActivityCopy"><strong>{activity.label}</strong>{activity.summary ? <small>{activity.summary}</small> : null}</span>
-            <span className={`retrievalActivityStatus is-${activity.status}`}>{activity.resultCount != null && activity.resultCount > 0 ? `${activity.resultCount} 条` : STATE_LABELS[activity.status] || "查看详情"}</span>
-            <ChevronRight aria-hidden="true" size={16} />
-          </button>
-        </li>)}
-      </ol> : <p className="retrievalMissingNotice">{projection.missingActivityNotice || "本次运行没有保存可下钻的结构化查询记录。"}</p>}
-    </section>
-  </div>;
-}
-
-function RetrievalEvent({ projection }: { projection: InspectorDetailProjection }) {
-  const outcome = projection.outcome || { state: "unknown", message: "现有记录不足以可靠判断查询结果。" };
-  const conditions = projection.queryConditions || [];
-  const items = projection.items || [];
-  const completeness = projection.completeness;
-  return <div className="retrievalInspector retrievalEventDetail">
-    <OutcomePanel outcome={outcome} />
-    <section className="inspectorPanel retrievalConditionPanel">
-      <h3>{projection.sourceKind === "direct-tool" ? "调用输入" : "查询条件"}</h3>
-      {conditions.length ? <dl className="retrievalConditions">
-        {conditions.map((condition) => <div key={condition.key}><dt>{condition.label}</dt><dd><StructuredValue value={condition.value} /></dd></div>)}
-      </dl> : <p className="retrievalMissingNotice">本次调用没有保存额外输入条件。</p>}
-    </section>
-    {items.length ? <section className="inspectorPanel retrievalResultsPanel">
-      <div className="retrievalSectionHeader"><h3>{projection.sourceKind === "direct-tool" ? "读取结果" : "返回结果"}</h3><span>{items.length} 项</span></div>
-      <div className="retrievalResultList">{items.map((item, index) => <ResultDisclosure key={`${item.id}-${index}`} item={item} defaultOpen={items.length === 1} />)}</div>
-    </section> : null}
-    {!completeness?.bodyAvailable ? <p className="retrievalCompletenessNotice is-warning">仅保存了摘要,完整返回不可用。</p> : null}
-    {completeness?.truncated ? <p className="retrievalCompletenessNotice is-warning">返回内容超过安全展示上限,已省略 {completeness.omittedCharacters || "部分"} 个字符。</p> : null}
-  </div>;
-}
-
-function OutcomePanel({ outcome }: { outcome: RetrievalOutcome }) {
-  return <section className={`retrievalOutcome is-${outcome.state}`} aria-live="polite">
-    <span>{STATE_LABELS[outcome.state] || "查询状态"}</span>
-    <strong>{outcome.message}</strong>
-  </section>;
-}
-
-function ResultDisclosure({ item, defaultOpen }: { item: RetrievalResultItem; defaultOpen: boolean }) {
-  const safeHref = typeof item.href === "string" && /^https?:\/\//i.test(item.href) ? item.href : undefined;
-  return <details className="retrievalResult" open={defaultOpen || undefined}>
-    <summary>
-      <span className="retrievalResultChevron"><ChevronRight aria-hidden="true" size={17} /></span>
-      <span className="retrievalResultHeading"><strong>{item.title}</strong>{item.subtitle ? <small>{item.subtitle}</small> : null}</span>
-      {item.meta?.length ? <span className="retrievalResultMeta">{item.meta.map((entry) => <b key={entry.label}>{entry.label}:{scalarText(entry.value)}</b>)}</span> : null}
-    </summary>
-    <div className="retrievalResultBody">
-      {safeHref ? <a className="retrievalResultLink" href={safeHref} target="_blank" rel="noreferrer">查看原始内容<ExternalLink aria-hidden="true" size={14} /></a> : null}
-      {item.sections.map((section, index) => <section className="retrievalResultSection" key={`${section.title}-${index}`}><h4>{section.title}</h4><StructuredValue value={section.value} /></section>)}
-    </div>
-  </details>;
-}
-
-function StructuredValue({ value }: { value: unknown }) {
-  if (typeof value === "string") return <RichText value={value} />;
-  if (value == null) return <span className="retrievalEmptyValue">未记录</span>;
-  return <ValueView value={value} />;
-}
-
-function scalarText(value: unknown) {
-  if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
-  return "已记录";
-}

+ 0 - 30
visualization/frontend/components/ui/Badges.tsx

@@ -1,30 +0,0 @@
-import type { SourceNotice } from "@/lib/types";
-
-const statusLabels: Record<string, string> = {
-  merged: "已采用", parked: "暂存", discarded: "未采用", open: "尚未处置",
-  running: "运行中", success: "已完成", completed: "已完成", partial: "部分完成",
-  failed: "失败", stopped: "已停止", cancel: "已取消", cancle: "已取消", cancelled: "已取消", done: "已结束", passed: "通过",
-  interrupted: "构建中断",
-  hit: "查询命中", empty: "查询成功 · 无匹配", failure: "查询失败",
-  "partially-passed": "部分通过", "needs-retry": "需要继续", "not-evaluated": "未整体评审", unknown: "尚无结论",
-  legacy: "旧结构",
-};
-
-export function statusLabel(status?: string) {
-  const value = (status || "unknown").toLowerCase();
-  return statusLabels[value] || status || "尚无结论";
-}
-
-export function StatusBadge({ status }: { status?: string }) {
-  const value = (status || "unknown").toLowerCase();
-  return <span className={`statusBadge status-${value}`}>{statusLabel(status)}</span>;
-}
-
-export function SourceNoticeBadge({ notice }: { notice?: SourceNotice }) {
-  // Runtime association is provenance, not a business status. Keep it in the
-  // Inspector's technical record; only surface notices that change how a
-  // business user should interpret the visible step.
-  if (!notice || notice === "runtime-associated") return null;
-  const label = notice === "process-incomplete" ? "流程未完成" : notice === "legacy-record" ? "旧版本记录" : "记录缺失";
-  return <span className={`sourceBadge source-${notice}`}>{label}</span>;
-}

+ 0 - 8
visualization/frontend/components/ui/RichText.tsx

@@ -1,8 +0,0 @@
-import ReactMarkdown from "react-markdown";
-import remarkGfm from "remark-gfm";
-
-export function RichText({ value, className = "" }: { value: string; className?: string }) {
-  return <div className={`richText ${className}`.trim()}>
-    <ReactMarkdown remarkPlugins={[remarkGfm]} skipHtml>{value}</ReactMarkdown>
-  </div>;
-}

+ 0 - 23
visualization/frontend/components/ui/TabList.tsx

@@ -1,23 +0,0 @@
-interface TabOption<T extends string> { id: T; label: string }
-
-interface Props<T extends string> {
-  tabs: Array<TabOption<T>>;
-  active: T;
-  onChange: (tab: T) => void;
-  ariaLabel: string;
-  className?: string;
-}
-
-export function TabList<T extends string>({ tabs, active, onChange, ariaLabel, className = "" }: Props<T>) {
-  const move = (event: React.KeyboardEvent<HTMLDivElement>) => {
-    if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
-    event.preventDefault();
-    const index = tabs.findIndex((entry) => entry.id === active);
-    const next = (index + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
-    onChange(tabs[next].id);
-    event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]')[next]?.focus();
-  };
-  return <div className={className} role="tablist" aria-label={ariaLabel} onKeyDown={move}>
-    {tabs.map((entry) => <button key={entry.id} type="button" role="tab" tabIndex={active === entry.id ? 0 : -1} aria-selected={active === entry.id} className={active === entry.id ? "active" : ""} onClick={() => onChange(entry.id)}>{entry.label}</button>)}
-  </div>;
-}

+ 0 - 5
visualization/frontend/components/ui/ValueView.tsx

@@ -1,5 +0,0 @@
-import { pretty } from "@/lib/redact";
-
-export function ValueView({ value, className = "" }: { value: unknown; className?: string }) {
-  return <pre className={`valueView ${className}`}>{pretty(value)}</pre>;
-}

+ 0 - 418
visualization/frontend/components/workbench/ExecutionWorkbench.tsx

@@ -1,418 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useRef, useState } from "react";
-import { AlertTriangle } from "lucide-react";
-import { ReactFlowProvider } from "@xyflow/react";
-import { errorMessage, getActivityDetail, getDecisionPrompt, getInspectorView, getRoundDetail } from "@/lib/api";
-import type { CanvasItem, ExecutionViewV8, InspectorWorkbenchProjection, OpenTarget, PromptProjection, RetrievalActivity, StoryNode } from "@/lib/types";
-import { branchFlow, branchSummaryItem, finalResultItem, multipathDecisionItem, multipathReviewItem, queryAttemptItem, retrievalActivityItem, retrievalAgentItem, retrievalDirectItem, retrievalStageItem, roundSummaryItem, storyItem } from "@/lib/layout";
-import { useExecutionView } from "@/hooks/useExecutionView";
-import { TopBar } from "@/components/workbench/TopBar";
-import { FlowCanvas } from "@/components/workbench/FlowCanvas";
-import { MobileTimeline } from "@/components/workbench/MobileTimeline";
-import { Inspector } from "@/components/inspector/Inspector";
-import { ScriptArtifactViewer } from "@/components/artifact/ScriptArtifactViewer";
-import { PromptDrawer } from "@/components/decision/PromptDrawer";
-import { expandedStateFor, isFullyExpanded } from "@/lib/expansion";
-
-interface ArtifactTarget { snapshotRef: string }
-
-export function ExecutionWorkbench() {
-  const state = useExecutionView();
-  const [expandedRounds, setExpandedRounds] = useState<Set<number>>(new Set());
-  const [expandedBranches, setExpandedBranches] = useState<Set<string>>(new Set());
-  const [expandedRetrievalAgents, setExpandedRetrievalAgents] = useState<Set<string>>(new Set());
-  const [selected, setSelected] = useState<CanvasItem | null>(null);
-  const [inspectorHistory, setInspectorHistory] = useState<CanvasItem[]>([]);
-  const [artifactTarget, setArtifactTarget] = useState<ArtifactTarget | null>(null);
-  const [promptRef, setPromptRef] = useState<string>();
-  const [prompt, setPrompt] = useState<PromptProjection>();
-  const [promptLoading, setPromptLoading] = useState(false);
-  const [promptError, setPromptError] = useState<string>();
-  const [inspectorMode, setInspectorMode] = useState<"business" | "lineage">("business");
-  const [rawDetail, setRawDetail] = useState<unknown>();
-  const [rawLoading, setRawLoading] = useState(false);
-  const [detailError, setDetailError] = useState<string>();
-  const [inspectorView, setInspectorView] = useState<InspectorWorkbenchProjection>();
-  const [inspectorLoading, setInspectorLoading] = useState(false);
-  const [inspectorError, setInspectorError] = useState<string>();
-  const [mobileCanvas, setMobileCanvas] = useState(false);
-  const [focusRound, setFocusRound] = useState<{ roundIndex: number; token: number }>();
-  const selectedDetailRef = selected ? inspectorDetailRefFor(selected) : undefined;
-  const inspectorTrigger = useRef<HTMLElement | null>(null);
-  const inspectorTriggerKey = useRef<string | null>(null);
-  const pendingFocusRestore = useRef<{ key: string | null; element: HTMLElement | null } | null>(null);
-  const promptTrigger = useRef<HTMLElement | null>(null);
-  const promptTriggerKey = useRef<string | null>(null);
-  const pendingPromptFocusRestore = useRef<{ key: string | null; element: HTMLElement | null } | null>(null);
-  const expansionPreference = useRef<"expanded" | "custom">("expanded");
-  const detailCache = useRef(new Map<string, unknown>());
-  const inspectorCache = useRef(new Map<string, InspectorWorkbenchProjection>());
-
-  useEffect(() => {
-    if (!state.view) return;
-    expansionPreference.current = "expanded";
-    const expanded = expandedStateFor(state.view);
-    setExpandedRounds(expanded.rounds);
-    setExpandedBranches(expanded.branches);
-    setExpandedRetrievalAgents(expanded.retrievalAgents);
-    setSelected(null);
-    setInspectorHistory([]);
-    setInspectorMode("business");
-    detailCache.current.clear();
-    inspectorCache.current.clear();
-    setRawDetail(undefined);
-    setDetailError(undefined);
-    setInspectorView(undefined);
-    setInspectorError(undefined);
-    setArtifactTarget(null);
-    setPromptRef(undefined);
-  }, [state.view?.header.id]);
-
-  useEffect(() => {
-    if (!state.view || expansionPreference.current !== "expanded") return;
-    const expanded = expandedStateFor(state.view);
-    setExpandedRounds(expanded.rounds);
-    setExpandedBranches(expanded.branches);
-    setExpandedRetrievalAgents(expanded.retrievalAgents);
-  }, [state.view?.capturedAt]);
-
-  useEffect(() => {
-    if (inspectorMode !== "business" || !selected || !state.view) return;
-    const detailRef = selectedDetailRef;
-    if (!detailRef) {
-      setRawDetail(selected);
-      setRawLoading(false);
-      setDetailError(undefined);
-      return;
-    }
-    const cacheKey = `${state.view.header.id}:${detailRef}:${state.view.capturedAt}`;
-    const cached = detailCache.current.get(cacheKey);
-    if (cached !== undefined && !isTransientDetail(cached)) {
-      setRawDetail(cached);
-      setRawLoading(false);
-      setDetailError(undefined);
-      return;
-    }
-    const controller = new AbortController();
-    setRawDetail(undefined);
-    setRawLoading(true);
-    setDetailError(undefined);
-    const request = selected.itemType === "round-summary"
-      ? getRoundDetail(String(state.view.header.id), selected.roundIndex, controller.signal)
-      : selected.itemType === "story" && selected.roundIndex && detailRef === `round:${selected.roundIndex}`
-        ? getRoundDetail(String(state.view.header.id), selected.roundIndex, controller.signal)
-        : getActivityDetail(String(state.view.header.id), detailRef, controller.signal);
-    void request
-      .then((detail) => {
-        if (!isTransientDetail(detail)) detailCache.current.set(cacheKey, detail);
-        setRawDetail(detail);
-      })
-      .catch((error) => {
-        if (!controller.signal.aborted) {
-          setRawDetail(undefined);
-          setDetailError(errorMessage(error));
-        }
-      })
-      .finally(() => {
-        if (!controller.signal.aborted) setRawLoading(false);
-      });
-    return () => controller.abort();
-  }, [inspectorMode, selected?.id, selectedDetailRef, state.view?.header.id, state.view?.capturedAt]);
-
-  useEffect(() => {
-    if (!selected || !state.view) return;
-    const updated = findItem(state.view, selected.id);
-    if (updated) setSelected(updated);
-  }, [state.view?.capturedAt]);
-
-  useEffect(() => {
-    if (inspectorMode !== "lineage") return;
-    if (!selected || !state.view) return;
-    const detailRef = selectedDetailRef;
-    if (!detailRef) {
-      setInspectorView(undefined);
-      setInspectorLoading(false);
-      setInspectorError("当前卡片没有可用的详情引用。");
-      return;
-    }
-    const cacheKey = `${state.view.header.id}:${detailRef}:${state.view.capturedAt}`;
-    const cached = inspectorCache.current.get(cacheKey);
-    if (cached) {
-      setInspectorView(cached);
-      setInspectorLoading(false);
-      setInspectorError(undefined);
-      return;
-    }
-    const controller = new AbortController();
-    setInspectorView(undefined);
-    setInspectorLoading(true);
-    setInspectorError(undefined);
-    void getInspectorView(String(state.view.header.id), detailRef, controller.signal)
-      .then((payload) => {
-        const prefix = `${state.view!.header.id}:${detailRef}:`;
-        for (const key of inspectorCache.current.keys()) {
-          if (key.startsWith(prefix)) inspectorCache.current.delete(key);
-        }
-        inspectorCache.current.set(cacheKey, payload);
-        while (inspectorCache.current.size > 32) {
-          const oldest = inspectorCache.current.keys().next().value;
-          if (!oldest) break;
-          inspectorCache.current.delete(oldest);
-        }
-        setInspectorView(payload);
-      })
-      .catch((error) => {
-        if (!controller.signal.aborted) {
-          setInspectorView(undefined);
-          setInspectorError(errorMessage(error));
-        }
-      })
-      .finally(() => {
-        if (!controller.signal.aborted) setInspectorLoading(false);
-      });
-    return () => controller.abort();
-  }, [inspectorMode, selected?.id, selectedDetailRef, state.view?.header.id, state.view?.capturedAt]);
-
-  useEffect(() => {
-    if (!promptRef || !state.view) return;
-    const controller = new AbortController();
-    setPrompt(undefined); setPromptError(undefined); setPromptLoading(true);
-    void getDecisionPrompt(String(state.view.header.id), promptRef, controller.signal)
-      .then(setPrompt)
-      .catch((error) => {
-        if (!controller.signal.aborted) setPromptError(errorMessage(error));
-      })
-      .finally(() => {
-        if (!controller.signal.aborted) setPromptLoading(false);
-      });
-    return () => controller.abort();
-  }, [promptRef, state.view?.header.id]);
-
-  const closeInspector = useCallback(() => {
-    pendingFocusRestore.current = { key: inspectorTriggerKey.current, element: inspectorTrigger.current };
-    setSelected(null);
-    setInspectorHistory([]);
-    setInspectorMode("business");
-  }, []);
-  const closeArtifact = useCallback(() => {
-    pendingFocusRestore.current = { key: inspectorTriggerKey.current, element: inspectorTrigger.current };
-    setArtifactTarget(null);
-  }, []);
-  const openPrompt = useCallback((nextPromptRef: string) => {
-    promptTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
-    promptTriggerKey.current = promptTrigger.current?.dataset.focusReturn || null;
-    setPromptRef(nextPromptRef);
-  }, []);
-  const openArtifactFromInspector = useCallback(() => {
-    if (!selected) return;
-    const snapshotRef = artifactRefFor(selected);
-    if (!snapshotRef) return;
-    inspectorTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
-    inspectorTriggerKey.current = inspectorTrigger.current?.dataset.focusReturn || null;
-    setArtifactTarget({ snapshotRef });
-  }, [selected]);
-  const closePrompt = useCallback(() => {
-    pendingPromptFocusRestore.current = {
-      key: promptTriggerKey.current,
-      element: promptTrigger.current,
-    };
-    setPromptRef(undefined);
-  }, []);
-
-  useEffect(() => {
-    const pending = pendingFocusRestore.current;
-    if (artifactTarget || !pending) return;
-    pendingFocusRestore.current = null;
-    let secondFrame = 0;
-    const firstFrame = requestAnimationFrame(() => {
-      secondFrame = requestAnimationFrame(() => {
-        const replacement = pending.key
-          ? [...document.querySelectorAll<HTMLElement>("[data-focus-return]")]
-            .find((element) => element.dataset.focusReturn === pending.key)
-          : null;
-        const target = replacement || (pending.element?.isConnected ? pending.element : null);
-        target?.focus({ preventScroll: true });
-      });
-    });
-    return () => {
-      cancelAnimationFrame(firstFrame);
-      if (secondFrame) cancelAnimationFrame(secondFrame);
-    };
-  }, [selected, artifactTarget]);
-
-  useEffect(() => {
-    const pending = pendingPromptFocusRestore.current;
-    if (promptRef || !pending) return;
-    pendingPromptFocusRestore.current = null;
-    let secondFrame = 0;
-    const firstFrame = requestAnimationFrame(() => {
-      secondFrame = requestAnimationFrame(() => {
-        const replacement = pending.key
-          ? [...document.querySelectorAll<HTMLElement>("[data-focus-return]")]
-            .find((element) => element.dataset.focusReturn === pending.key)
-          : null;
-        const target = replacement || (pending.element?.isConnected ? pending.element : null);
-        target?.focus({ preventScroll: true });
-      });
-    });
-    return () => {
-      cancelAnimationFrame(firstFrame);
-      if (secondFrame) cancelAnimationFrame(secondFrame);
-    };
-  }, [promptRef]);
-
-  useEffect(() => { const close = (event: KeyboardEvent) => { if (event.key === "Escape" && !artifactTarget && !promptRef) { closeInspector(); setMobileCanvas(false); } }; window.addEventListener("keydown", close); return () => window.removeEventListener("keydown", close); }, [artifactTarget, closeInspector, promptRef]);
-
-  const open = useCallback((item: CanvasItem, target: OpenTarget) => {
-    inspectorTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
-    inspectorTriggerKey.current = inspectorTrigger.current?.dataset.focusReturn || null;
-    if (target === "artifact") {
-      const snapshotRef = artifactRefFor(item);
-      if (!snapshotRef) return;
-      setSelected(null);
-      setInspectorHistory([]);
-      setArtifactTarget({ snapshotRef });
-      return;
-    }
-    setInspectorHistory([]);
-    setInspectorMode(target === "lineage" ? "lineage" : "business");
-    setSelected(item);
-    setRawDetail(undefined);
-    setDetailError(undefined);
-    setInspectorView(undefined);
-    setInspectorError(undefined);
-  }, []);
-  const navigateInspector = useCallback((activity: RetrievalActivity) => {
-    if (!selected || !activity.detailRef) return;
-    const roundIndex = "roundIndex" in selected ? selected.roundIndex : 0;
-    const branchId = "branchId" in selected ? selected.branchId : 0;
-    setInspectorHistory((current) => [...current, selected]);
-    setSelected(retrievalActivityItem(activity, roundIndex || 0, branchId || 0));
-    setInspectorView(undefined);
-    setInspectorError(undefined);
-  }, [selected]);
-  const navigateInspectorBack = useCallback(() => {
-    setInspectorHistory((current) => {
-      const parent = current[current.length - 1];
-      if (!parent) return current;
-      const refreshed = state.view ? findItem(state.view, parent.id) : undefined;
-      setSelected(refreshed || parent);
-      setInspectorView(undefined);
-      setInspectorError(undefined);
-      return current.slice(0, -1);
-    });
-  }, [state.view]);
-  const toggleRound = useCallback((roundIndex: number) => {
-    expansionPreference.current = "custom";
-    setExpandedRounds((current) => { const next = new Set(current); next.has(roundIndex) ? next.delete(roundIndex) : next.add(roundIndex); return next; });
-  }, []);
-  const toggleBranch = useCallback((roundIndex: number, branchId: number) => setExpandedBranches((current) => {
-    expansionPreference.current = "custom";
-    const next = new Set(current);
-    const key = `${roundIndex}:${branchId}`;
-    next.has(key) ? next.delete(key) : next.add(key);
-    return next;
-  }), []);
-  const toggleRetrievalAgent = useCallback((agentId: string) => setExpandedRetrievalAgents((current) => {
-    expansionPreference.current = "custom";
-    const next = new Set(current);
-    next.has(agentId) ? next.delete(agentId) : next.add(agentId);
-    return next;
-  }), []);
-  const navigateRound = useCallback((roundIndex: number) => {
-    expansionPreference.current = "custom";
-    setExpandedRounds((current) => new Set(current).add(roundIndex));
-    setFocusRound((current) => ({ roundIndex, token: (current?.token || 0) + 1 }));
-  }, []);
-  const allExpanded = state.view
-    ? isFullyExpanded(state.view, expandedRounds, expandedBranches, expandedRetrievalAgents)
-    : false;
-  const toggleAll = useCallback(() => {
-    if (!state.view) return;
-    if (isFullyExpanded(state.view, expandedRounds, expandedBranches, expandedRetrievalAgents)) {
-      expansionPreference.current = "custom";
-      setExpandedRounds(new Set());
-      setExpandedBranches(new Set());
-      setExpandedRetrievalAgents(new Set());
-      return;
-    }
-    expansionPreference.current = "expanded";
-    const expanded = expandedStateFor(state.view);
-    setExpandedRounds(expanded.rounds);
-    setExpandedBranches(expanded.branches);
-    setExpandedRetrievalAgents(expanded.retrievalAgents);
-  }, [state.view, expandedRounds, expandedBranches, expandedRetrievalAgents]);
-
-  if (state.loading && !state.view) return <LoadingScreen />;
-  if (!state.view) return <FatalState message={state.error || "无法读取真实构建数据"} />;
-  const view = state.view;
-  return <main className={`appFrame ${selected ? "withInspector" : ""} ${selected && inspectorMode === "lineage" ? "withLineageInspector" : ""}`}>
-    <TopBar builds={state.builds} selectedId={state.selectedId} view={view} refreshing={state.refreshing} onSelect={state.selectBuild} expandedRounds={expandedRounds} allExpanded={allExpanded} onRound={navigateRound} onToggleAll={toggleAll} />
-    {state.error ? <div className="errorBanner"><AlertTriangle size={15} /><span>{state.error}</span></div> : null}
-    <div className="workspace">
-      <MobileTimeline view={view} expandedRounds={expandedRounds} expandedBranches={expandedBranches} expandedRetrievalAgents={expandedRetrievalAgents} onToggleRound={toggleRound} onToggleBranch={toggleBranch} onToggleRetrievalAgent={toggleRetrievalAgent} onOpen={open} onCanvas={() => setMobileCanvas(true)} />
-      <ReactFlowProvider><FlowCanvas view={view} expandedRounds={expandedRounds} expandedBranches={expandedBranches} expandedRetrievalAgents={expandedRetrievalAgents} onToggleRound={toggleRound} onToggleBranch={toggleBranch} onToggleRetrievalAgent={toggleRetrievalAgent} onOpen={open} onPrompt={openPrompt} selectedId={selected?.id} focusRound={focusRound} canvasFullscreen={mobileCanvas} onExitFullscreen={() => setMobileCanvas(false)} /></ReactFlowProvider>
-      {selected ? <Inspector item={selected} mode={inspectorMode} onModeChange={setInspectorMode} onClose={closeInspector} onBack={inspectorHistory.length ? navigateInspectorBack : undefined} backLabel={inspectorHistory.length ? `返回${inspectorHistory[inspectorHistory.length - 1].title}` : undefined} onNavigate={navigateInspector} onPrompt={openPrompt} onArtifact={selected.itemType === "final-result" ? openArtifactFromInspector : undefined} detail={rawDetail} detailLoading={rawLoading} detailError={detailError} workbench={inspectorView} workbenchLoading={inspectorLoading} workbenchError={inspectorError} topOverlayOpen={Boolean(artifactTarget || promptRef)} /> : null}
-    </div>
-    {artifactTarget ? <ScriptArtifactViewer buildId={String(view.header.id)} snapshotRef={artifactTarget.snapshotRef} onClose={closeArtifact} /> : null}
-    {promptRef ? <PromptDrawer prompt={prompt} loading={promptLoading} error={promptError} onClose={closePrompt} /> : null}
-  </main>;
-}
-
-function artifactRefFor(item: CanvasItem): string | undefined {
-  if (item.itemType === "final-result") return item.result.artifact.snapshotRef || item.detailRef || "artifact:base:current";
-  if (item.itemType === "round-summary") return item.round.result.artifactRef || `artifact:round:${item.roundIndex}`;
-  if (item.itemType === "branch-summary" && item.branch.output.type === "script-artifact") return item.branch.output.snapshotRef;
-  if (item.itemType === "story") return item.artifactRef;
-  return undefined;
-}
-
-function inspectorDetailRefFor(item: CanvasItem): string | undefined {
-  if (item.itemType === "final-result") return "run:final-result";
-  return "detailRef" in item ? item.detailRef : undefined;
-}
-
-function findItem(view: ExecutionViewV8, id: string): CanvasItem | undefined {
-  if (id === view.planning?.id) return storyItem(view.planning);
-  if (id === view.objective.id) return storyItem(view.objective);
-  if (id === view.finalResult.id) return finalResultItem(view);
-  for (const round of view.rounds) {
-    if (id === `round:${round.roundIndex}:summary`) return roundSummaryItem(round);
-    const main: StoryNode[] = [round.goal, round.plan.node, ...round.convergenceBatches.flatMap((batch) => [batch.missingReview, batch.missingDecision].filter((node): node is StoryNode => Boolean(node))), round.overallEvaluation, round.result];
-    const mainStory = main.find((story) => story.id === id);
-    if (mainStory) return storyItem(mainStory, round.roundIndex);
-    for (const batch of round.convergenceBatches) {
-      if (batch.review?.id === id) return multipathReviewItem(batch.review, round.roundIndex);
-      if (batch.decision?.id === id) return multipathDecisionItem(batch.decision, round.roundIndex);
-    }
-    for (const branch of round.branches) {
-      if (id === `round:${round.roundIndex}:branch:${branch.branchId}:summary`) return branchSummaryItem(branch, round.roundIndex);
-      const branchStory = branchFlow(branch).find((story) => story.id === id);
-      if (branchStory) return storyItem(branchStory, round.roundIndex, branch.branchId);
-      if (branch.retrievalStage.id === id) return retrievalStageItem(branch.retrievalStage, round.roundIndex, branch.branchId);
-      const direct = branch.retrievalStage.directToolGroups.find((group) => group.id === id);
-      if (direct) return retrievalDirectItem(direct, round.roundIndex, branch.branchId);
-      for (const group of branch.retrievalStage.directToolGroups) {
-        const call = group.calls.find((item) => item.id === id || item.detailRef === id);
-        if (call) return retrievalActivityItem({ id: call.id, kind: "tool", label: call.businessLabel, status: call.status, summary: call.resultSummary, detailRef: call.detailRef }, round.roundIndex, branch.branchId);
-      }
-      const agent = branch.retrievalStage.agentRuns.find((run) => run.id === id);
-      if (agent) return retrievalAgentItem(agent, round.roundIndex, branch.branchId);
-      for (const run of branch.retrievalStage.agentRuns) {
-        const attempt = run.attempts.find((item) => item.id === id);
-        if (attempt) return queryAttemptItem(attempt, round.roundIndex, branch.branchId);
-      }
-    }
-  }
-  return undefined;
-}
-
-function isTransientDetail(detail: unknown) {
-  if (!detail || typeof detail !== "object" || Array.isArray(detail)) return false;
-  const record = detail as { outcome?: { state?: string }; activities?: Array<{ status?: string }> };
-  return record.outcome?.state === "running" || record.activities?.some((item) => item.status === "running") === true;
-}
-
-function LoadingScreen() { return <main className="loadingScreen"><h1>正在读取真实构建记录…</h1><div className="skeletonWide"><i /><i /><i /></div></main>; }
-function FatalState({ message }: { message: string }) { return <main className="fatalState"><AlertTriangle size={26} /><h1>真实运行数据暂时不可用</h1><p>{message}</p><p>请确认只读数据库连接和可视化后端已经启动。</p></main>; }

+ 0 - 116
visualization/frontend/components/workbench/FlowCanvas.tsx

@@ -1,116 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { Background, BackgroundVariant, Controls, PanOnScrollMode, ReactFlow, useNodesInitialized, useNodesState, useReactFlow, type Node, type OnMove, type Rect } from "@xyflow/react";
-import type { CanvasItem, ExecutionViewV8, OpenTarget } from "@/lib/types";
-import { buildHorizontalLayout, type CanvasNodeData } from "@/lib/layout";
-import { StoryCard } from "@/components/flow/StoryCard";
-import { RoundSummaryCard } from "@/components/flow/RoundSummaryCard";
-import { RunResultCard } from "@/components/flow/RunResultCard";
-import { RoundFrame } from "@/components/flow/RoundFrame";
-import { CandidateCard } from "@/components/flow/CandidateCard";
-import { AgentRegion } from "@/components/flow/AgentRegion";
-import { BranchFrame } from "@/components/flow/BranchFrame";
-import { RetrievalStageFrame } from "@/components/flow/RetrievalStageFrame";
-import { DirectToolGroupCard } from "@/components/flow/DirectToolGroupCard";
-import { RetrievalAgentCard } from "@/components/flow/RetrievalAgentCard";
-import { AgentDecisionCard } from "@/components/decision/AgentDecisionCard";
-
-const nodeTypes = { storyCard: StoryCard, agentDecision: AgentDecisionCard, candidateCard: CandidateCard, roundSummary: RoundSummaryCard, finalResult: RunResultCard, roundFrame: RoundFrame, agentRegion: AgentRegion, branchFrame: BranchFrame, retrievalStageFrame: RetrievalStageFrame, directToolGroup: DirectToolGroupCard, retrievalAgent: RetrievalAgentCard };
-const MIN_CANVAS_ZOOM = .2;
-const INITIAL_VIEWPORT = { x: 18, y: 94, zoom: MIN_CANVAS_ZOOM };
-
-export function centeredOverviewViewport(bounds: Pick<Rect, "x" | "width">, canvasWidth: number) {
-  return {
-    x: canvasWidth / 2 - (bounds.x + bounds.width / 2) * MIN_CANVAS_ZOOM,
-    y: INITIAL_VIEWPORT.y,
-    zoom: MIN_CANVAS_ZOOM,
-  };
-}
-
-interface Props {
-  view: ExecutionViewV8;
-  expandedRounds: Set<number>;
-  expandedBranches: Set<string>;
-  expandedRetrievalAgents: Set<string>;
-  onToggleRound: (round: number) => void;
-  onToggleBranch: (round: number, branchId: number) => void;
-  onToggleRetrievalAgent: (agentId: string) => void;
-  onOpen: (item: CanvasItem, target: OpenTarget) => void;
-  onPrompt: (promptRef: string) => void;
-  selectedId?: string;
-  focusRound?: { roundIndex: number; token: number };
-  canvasFullscreen?: boolean;
-  onExitFullscreen?: () => void;
-}
-
-export function FlowCanvas(props: Props) {
-  const { getNode, getNodesBounds, setCenter, setViewport } = useReactFlow();
-  const nodesInitialized = useNodesInitialized();
-  const shellRef = useRef<HTMLElement>(null);
-  const previousBuild = useRef<string | undefined>(undefined);
-  const positionBuild = useRef<string | undefined>(undefined);
-  const manualPositions = useRef(new Map<string, { position: { x: number; y: number }; parentId?: string }>());
-  const [detailLevel, setDetailLevel] = useState<"full" | "compact" | "overview">("overview");
-  const layout = useMemo(() => buildHorizontalLayout(props.view, { buildId: String(props.view.header.id), expandedRounds: props.expandedRounds, expandedBranches: props.expandedBranches, expandedRetrievalAgents: props.expandedRetrievalAgents, detailLevel, onOpen: props.onOpen, onPrompt: props.onPrompt, onToggleRound: props.onToggleRound, onToggleBranch: props.onToggleBranch, onToggleRetrievalAgent: props.onToggleRetrievalAgent }), [props.view, props.expandedRounds, props.expandedBranches, props.expandedRetrievalAgents, detailLevel, props.onOpen, props.onPrompt, props.onToggleRound, props.onToggleBranch, props.onToggleRetrievalAgent]);
-  const [nodes, setNodes, onNodesChange] = useNodesState<Node<CanvasNodeData>>([]);
-  const handleMove: OnMove = useCallback((_, viewport) => {
-    setDetailLevel((current) => {
-      if (current === "full") return viewport.zoom < .74 ? "compact" : "full";
-      if (current === "compact") {
-        if (viewport.zoom >= .82) return "full";
-        return viewport.zoom < .36 ? "overview" : "compact";
-      }
-      return viewport.zoom > .42 ? "compact" : "overview";
-    });
-  }, []);
-  useEffect(() => {
-    const buildId = String(props.view.header.id);
-    if (positionBuild.current !== buildId) {
-      positionBuild.current = buildId;
-      manualPositions.current.clear();
-    }
-    setNodes(layout.nodes.map((node) => {
-      const saved = manualPositions.current.get(node.id);
-      return saved && saved.parentId === node.parentId ? { ...node, position: saved.position } : node;
-    }));
-  }, [layout.nodes, props.view.header.id, setNodes]);
-  const rememberNodePosition = useCallback((_: unknown, node: Node<CanvasNodeData>) => {
-    if (!node.draggable) return;
-    manualPositions.current.set(node.id, { position: node.position, parentId: node.parentId });
-  }, []);
-  useEffect(() => {
-    const buildId = String(props.view.header.id);
-    if (!nodesInitialized || previousBuild.current === buildId) return;
-    previousBuild.current = buildId;
-    const frame = requestAnimationFrame(() => {
-      const topLevelNodes = layout.nodes.filter((node) => !node.parentId);
-      const canvasWidth = shellRef.current?.clientWidth || 0;
-      const viewport = topLevelNodes.length && canvasWidth
-        ? centeredOverviewViewport(getNodesBounds(topLevelNodes), canvasWidth)
-        : INITIAL_VIEWPORT;
-      void setViewport(viewport, { duration: 0 });
-    });
-    return () => cancelAnimationFrame(frame);
-  }, [props.view.header.id, getNodesBounds, layout.nodes, nodesInitialized, setViewport]);
-  useEffect(() => {
-    if (!props.focusRound) return;
-    const frame = requestAnimationFrame(() => {
-      const target = getNode(`frame:round:${props.focusRound!.roundIndex}`) || getNode(`round:${props.focusRound!.roundIndex}:summary`);
-      if (target) {
-        const isFrame = target.id.startsWith("frame:");
-        const width = isFrame ? Number(target.style?.width || 1980) : 320;
-        const height = isFrame ? Number(target.style?.height || 720) : 360;
-        void setCenter(target.position.x + width / 2, target.position.y + height / 2, { zoom: 1, duration: 220 });
-      }
-    });
-    return () => cancelAnimationFrame(frame);
-  }, [props.focusRound?.token, getNode, setCenter]);
-  return <section ref={shellRef} className={`flowShell detail-${detailLevel} ${props.canvasFullscreen ? "mobileFullscreen" : ""}`} data-detail-level={detailLevel} aria-label="脚本构建横向运行画布">
-    {props.canvasFullscreen ? <button type="button" className="exitFullscreen" onClick={props.onExitFullscreen}>返回轮次列表</button> : null}
-    <ReactFlow nodes={nodes.map((node) => ({ ...node, selected: node.id === props.selectedId }))} edges={layout.edges} nodeTypes={nodeTypes} onNodesChange={onNodesChange} onNodeDrag={rememberNodePosition} onNodeDragStop={rememberNodePosition} defaultViewport={INITIAL_VIEWPORT} minZoom={MIN_CANVAS_ZOOM} maxZoom={1.5} onMove={handleMove} panOnScroll panOnScrollMode={PanOnScrollMode.Free} panOnScrollSpeed={.7} zoomOnScroll={false} zoomOnPinch nodesConnectable={false} elementsSelectable proOptions={{ hideAttribution: true }}>
-      <Background variant={BackgroundVariant.Dots} gap={22} size={1.2} color="#343740" />
-      <Controls position="bottom-left" showInteractive={false} />
-    </ReactFlow>
-  </section>;
-}

+ 0 - 101
visualization/frontend/components/workbench/MobileTimeline.tsx

@@ -1,101 +0,0 @@
-import { ChevronDown, ChevronRight, FileText, Maximize2 } from "lucide-react";
-import { branchFlow, branchSummaryItem, finalResultItem, multipathDecisionItem, multipathReviewItem, queryAttemptItem, retrievalAgentItem, retrievalDirectItem, retrievalStageItem, roundSummaryItem, storyItem } from "@/lib/layout";
-import type { AgentDecisionProjection, BranchStory, CanvasItem, ExecutionViewV8, OpenTarget, RoundStoryV8, StoryNode } from "@/lib/types";
-import { runResultFallback } from "@/lib/status-copy";
-import { StatusBadge } from "@/components/ui/Badges";
-import { CardFields } from "@/components/flow/CardFields";
-import { authorityLabel, decisionTypeLabel } from "@/components/decision/AgentDecisionCard";
-
-interface Props { view: ExecutionViewV8; expandedRounds: Set<number>; expandedBranches: Set<string>; expandedRetrievalAgents: Set<string>; onToggleRound: (round: number) => void; onToggleBranch: (round: number, branchId: number) => void; onToggleRetrievalAgent: (agentId: string) => void; onOpen: (item: CanvasItem, target: OpenTarget) => void; onCanvas: () => void; }
-
-export function MobileTimeline({ view, expandedRounds, expandedBranches, expandedRetrievalAgents, onToggleRound, onToggleBranch, onToggleRetrievalAgent, onOpen, onCanvas }: Props) {
-  const objective = storyItem(view.objective);
-  const final = finalResultItem(view);
-  const objectiveLabel = "创作目标";
-  return <section className="mobileTimeline">
-    <header><div><h2>这次构建经历了什么</h2><p>按时间从上到下阅读</p></div><button className="quietButton" type="button" onClick={onCanvas}><Maximize2 size={15} />横向画布</button></header>
-    {view.planning ? <ol className="mobileSteps mobilePrelude"><StoryItem story={view.planning} onOpen={onOpen} /></ol> : null}
-    <MobileDecision item={objective} decision={view.objective.decision} fallbackLabel={objectiveLabel} fallbackCard={view.objective.card} onOpen={onOpen} />
-    <ol className="roundList">{view.rounds.map((round) => <RoundItem key={round.id} round={round} expanded={expandedRounds.has(round.roundIndex)} expandedBranches={expandedBranches} expandedRetrievalAgents={expandedRetrievalAgents} onToggle={() => onToggleRound(round.roundIndex)} onToggleBranch={onToggleBranch} onToggleRetrievalAgent={onToggleRetrievalAgent} onOpen={onOpen} />)}</ol>
-    <article className="mobileStory mobileFinal"><button className="mobileFinalSummary" data-focus-return={`${final.id}:mobile`} type="button" onClick={() => onOpen(final, "business")}><span>最终结果</span><StatusBadge status={view.finalResult.status} /><strong>{view.finalResult.title}</strong><p>{view.finalResult.summary || view.finalResult.errorMessage || runResultFallback(view.finalResult.status, view.finalResult.terminal)}</p></button><button type="button" className="nodeAction" data-focus-return={`${final.id}:artifact-mobile`} onClick={() => onOpen(final, "artifact")}>查看完整主脚本</button></article>
-  </section>;
-}
-
-function RoundItem({ round, expanded, expandedBranches, expandedRetrievalAgents, onToggle, onToggleBranch, onToggleRetrievalAgent, onOpen }: { round: RoundStoryV8; expanded: boolean; expandedBranches: Set<string>; expandedRetrievalAgents: Set<string>; onToggle: () => void; onToggleBranch: Props["onToggleBranch"]; onToggleRetrievalAgent: Props["onToggleRetrievalAgent"]; onOpen: Props["onOpen"] }) {
-  const summary = roundSummaryItem(round);
-  return <li>
-    <div className="mobileRoundSummary">
-      <button type="button" className="roundExpand" aria-label={`${expanded ? "折叠" : "展开"}第 ${round.roundIndex} 轮`} onClick={onToggle}>{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}</button>
-      <button type="button" className="roundSummaryOpen" data-focus-return={`${summary.id}:mobile`} onClick={() => onOpen(summary, "business")}><span>第 {round.roundIndex} 轮</span><StatusBadge status={round.state} /><strong>{round.summary.title}</strong><SummarySequence round={round} /></button>
-      <button type="button" className="candidateExpand artifactAction" data-focus-return={`${summary.id}:artifact-mobile`} onClick={() => onOpen(summary, "artifact")}><FileText size={14} />查看本轮产出脚本表</button>
-    </div>
-    {expanded ? <ol className="mobileSteps">
-      <StoryItem story={round.goal} roundIndex={round.roundIndex} onOpen={onOpen} />
-      <StoryItem story={round.plan.node} roundIndex={round.roundIndex} onOpen={onOpen} />
-      {round.branches.map((branch) => {
-        const key = `${round.roundIndex}:${branch.branchId}`;
-        const item = branchSummaryItem(branch, round.roundIndex);
-        return <li className="mobileCandidate" key={branch.id}><button type="button" data-focus-return={`${item.id}:mobile`} onClick={() => onOpen(item, "business")}><span>候选方案 {branch.branchId}</span><StatusBadge status={branch.status} /><strong>{branch.title}</strong><p>{branch.summary.task || branch.summary.output || "未记录方案摘要"}</p></button><div className="mobileCandidateActions"><button type="button" className="candidateExpand" onClick={() => onToggleBranch(round.roundIndex, branch.branchId)}>{expandedBranches.has(key) ? "收起方案过程" : "展开方案过程"}</button>{branch.output.type === "script-artifact" && branch.output.snapshotRef ? <button type="button" className="candidateExpand artifactAction" data-focus-return={`${item.id}:artifact-mobile`} onClick={() => onOpen(item, "artifact")}><FileText size={14} />查看完整候选脚本</button> : null}</div>{expandedBranches.has(key) ? <BranchProcess branch={branch} roundIndex={round.roundIndex} expandedRetrievalAgents={expandedRetrievalAgents} onToggleRetrievalAgent={onToggleRetrievalAgent} onOpen={onOpen} /> : null}</li>;
-      })}
-      {round.convergenceBatches.map((batch) => <li className="mobileConvergence" key={batch.id}>
-        <ol className="mobileConvergenceSteps">
-          {batch.review ? <li><MobileDecision item={multipathReviewItem(batch.review, round.roundIndex)} decision={batch.review.decisionProjection} fallbackLabel="多方案评审" fallbackCard={{ primary: { value: batch.review.conclusion || "查看评审结论" }, secondary: [] }} onOpen={onOpen} /></li> : batch.missingReview ? <StoryItem story={batch.missingReview} roundIndex={round.roundIndex} onOpen={onOpen} /> : null}
-          {batch.decision ? <li><MobileDecision item={multipathDecisionItem(batch.decision, round.roundIndex)} decision={batch.decision.decisionProjection} fallbackLabel="主 Agent 多路决策" fallbackCard={{ primary: { value: batch.decision.decision }, secondary: [] }} onOpen={onOpen} /></li> : batch.missingDecision ? <StoryItem story={batch.missingDecision} roundIndex={round.roundIndex} onOpen={onOpen} /> : null}
-        </ol>
-      </li>)}
-      <StoryItem story={round.overallEvaluation} roundIndex={round.roundIndex} onOpen={onOpen} />
-      <StoryItem story={round.result} roundIndex={round.roundIndex} onOpen={onOpen} />
-    </ol> : null}
-  </li>;
-}
-
-function SummarySequence({ round }: { round: RoundStoryV8 }) {
-  const fields = [
-    ["目标", round.summary.goal.headline],
-    ["尝试", `${round.summary.approach.mode || "方式未记录"} · ${round.summary.approach.candidateCount} 个实现方案`],
-    ["决策", round.summary.decision.batchCount ? `${round.summary.decision.batchCount} 个决策批次` : round.summary.decision.summary],
-    ["产出", round.summary.output.summary],
-  ] as const;
-  return <div className="mobileSummarySequence">{fields.map(([label, value]) => <p key={label}><b>{label}</b>{value || "未记录"}</p>)}</div>;
-}
-
-function BranchProcess({ branch, roundIndex, expandedRetrievalAgents, onToggleRetrievalAgent, onOpen }: { branch: BranchStory; roundIndex: number; expandedRetrievalAgents: Set<string>; onToggleRetrievalAgent: Props["onToggleRetrievalAgent"]; onOpen: Props["onOpen"] }) {
-  const stage = retrievalStageItem(branch.retrievalStage, roundIndex, branch.branchId);
-  const [task, ...tail] = branchFlow(branch);
-  return <ol className="mobileBranchProcess">
-    <StoryItem story={task} roundIndex={roundIndex} branchId={branch.branchId} onOpen={onOpen} />
-    <li className="mobileRetrievalStage">
-      <button type="button" data-focus-return={`${stage.id}:mobile`} onClick={() => onOpen(stage, "business")}><span>取数阶段</span><strong>{modeLabel(branch.retrievalStage.observedMode)}</strong><p>工具取数和 Agent 取数属于同一阶段</p></button>
-      <ol>
-        {branch.retrievalStage.directToolGroups.map((group) => { const item = retrievalDirectItem(group, roundIndex, branch.branchId); return <li key={item.id}><button type="button" onClick={() => onOpen(item, "business")}><span>工具取数</span><strong>{group.sources.map((source) => source.businessLabel).join("、") || "相关信息"}</strong><p>{group.callCount} 次读取</p></button></li>; })}
-        {branch.retrievalStage.agentRuns.map((run) => {
-          const item = retrievalAgentItem(run, roundIndex, branch.branchId);
-          const expanded = expandedRetrievalAgents.has(run.id);
-          const queryListId = `mobile-query-list-${run.eventId}`;
-          return <li key={run.id} className="mobileRetrievalAgent"><button type="button" onClick={() => onOpen(item, "business")}><span>Agent 取数</span><strong>{run.businessLabel}</strong><p>{run.querySummary.total} 次查询 · {run.querySummary.hit} 次命中 · {run.querySummary.empty} 次无结果{run.querySummary.failure ? ` · ${run.querySummary.failure} 次失败` : ""}{run.querySummary.interrupted ? ` · ${run.querySummary.interrupted} 次中断` : ""}{run.querySummary.unknown ? ` · ${run.querySummary.unknown} 次结果待确认` : ""}</p><p>{run.screening.preview || "初筛整理未记录"}</p></button>{run.attempts.length ? <button type="button" className="candidateExpand" aria-expanded={expanded} aria-controls={queryListId} onClick={() => onToggleRetrievalAgent(run.id)}>{expanded ? "收起查询" : `展开 ${run.attempts.length} 次查询`}</button> : null}{expanded ? <ol id={queryListId}>{run.attempts.map((attempt, index) => { const query = queryAttemptItem(attempt, roundIndex, branch.branchId); return <li key={attempt.id}><button type="button" onClick={() => onOpen(query, "business")}><span>{String(index + 1).padStart(2, "0")}</span><strong>{attempt.queryLabel}</strong><p>{attempt.resultCount && attempt.resultCount > 0 ? `${attempt.resultCount} 条` : attempt.status === "empty" ? "无结果" : attempt.status === "failure" ? "失败" : attempt.status === "interrupted" ? "构建中断" : "查看结果"}</p></button></li>; })}</ol> : null}</li>;
-        })}
-      </ol>
-    </li>
-    {tail.map((story) => <StoryItem key={story.id} story={story} roundIndex={roundIndex} branchId={branch.branchId} onOpen={onOpen} />)}
-  </ol>;
-}
-
-function StoryItem({ story, roundIndex, branchId, onOpen }: { story: StoryNode; roundIndex?: number; branchId?: number; onOpen: Props["onOpen"] }) {
-  const item = storyItem(story, roundIndex, branchId);
-  const card = story.card.primary || story.card.secondary.length ? story.card : { primary: { value: "查看详情" }, secondary: [] };
-  const label = roleLabel(story.role);
-  if (story.decision) return <li className="mobileDecisionItem"><MobileDecision item={item} decision={story.decision} fallbackLabel={label} fallbackCard={card} onOpen={onOpen} /></li>;
-  return <li><button type="button" data-focus-return={`${story.id}:mobile`} onClick={() => onOpen(item, "business")}><span>{label}</span>{story.title !== label ? <strong>{story.title}</strong> : null}<CardFields card={card} /></button>{story.artifactRef ? <button type="button" className="candidateExpand artifactAction" data-focus-return={`${story.id}:artifact-mobile`} onClick={() => onOpen(item, "artifact")}><FileText size={14} />{story.role === "round-result" ? "查看本轮产出脚本表" : "查看完整候选脚本"}</button> : null}</li>;
-}
-
-function MobileDecision({ item, decision, fallbackLabel, fallbackCard, onOpen }: { item: CanvasItem; decision?: AgentDecisionProjection; fallbackLabel: string; fallbackCard: StoryNode["card"]; onOpen: Props["onOpen"] }) {
-  const card = decision?.card || fallbackCard;
-  const artifactRef = item.itemType === "story" ? item.artifactRef : undefined;
-  return <div className="mobileDecision"><button type="button" data-focus-return={`${item.id}:mobile`} onClick={() => onOpen(item, "business")}><span>{decision ? `${decision.actor.label} · ${decisionTypeLabel(decision.decisionType)}` : fallbackLabel}</span>{decision ? <em>{authorityLabel(decision.authority)}</em> : null}<strong>{item.title}</strong><CardFields card={card} /></button>{artifactRef ? <button type="button" className="candidateExpand artifactAction" data-focus-return={`${item.id}:artifact-mobile`} onClick={() => onOpen(item, "artifact")}><FileText size={14} />查看完整候选脚本</button> : null}</div>;
-}
-
-function roleLabel(role: StoryNode["role"]) {
-  return ({ "planning-analysis": "创作规划解析", goal: "本轮目标", plan: "实现规划", "multipath-review": "多方案评审", "multipath-decision": "主 Agent 多路决策", "round-evaluation": "主脚本整体评审", "round-result": "本轮产出", "branch-task": "实现任务", "data-decision": "数据取舍", "candidate-output": "候选脚本", "domain-output": "领域事实" } as Partial<Record<StoryNode["role"], string>>)[role] || "运行步骤";
-}
-
-function modeLabel(mode: BranchStory["retrievalStage"]["observedMode"]) { return ({ single: "单项取数", sequential: "依次取数", parallel: "并行取数", mixed: "混合执行", unknown: "执行先后未确认" } as const)[mode]; }

+ 0 - 36
visualization/frontend/components/workbench/TopBar.tsx

@@ -1,36 +0,0 @@
-import { Activity, AlertTriangle, ChevronsDown, ChevronsUp, Columns3, RefreshCw } from "lucide-react";
-import { type FormEvent, useEffect, useState } from "react";
-import type { BuildSummary, ExecutionViewV8 } from "@/lib/types";
-import { warningText } from "@/lib/types";
-import { statusLabel, StatusBadge } from "@/components/ui/Badges";
-import { moduleAuditUrl } from "@/lib/api";
-
-interface Props { builds: BuildSummary[]; selectedId: string; view: ExecutionViewV8; refreshing: boolean; onSelect: (id: string) => void; expandedRounds: Set<number>; allExpanded: boolean; onRound: (round: number) => void; onToggleAll: () => void; }
-
-export function TopBar({ builds, selectedId, view, refreshing, onSelect, expandedRounds, allExpanded, onRound, onToggleAll }: Props) {
-  const [input, setInput] = useState(selectedId);
-  useEffect(() => setInput(selectedId), [selectedId]);
-  const submit = (event: FormEvent) => { event.preventDefault(); onSelect(input); };
-  const noteCount = view.warnings.length;
-  return <header className="runHeader" aria-label="运行控制">
-    <div className="topBar">
-      <div className="productMark"><span className="productIcon"><Activity size={17} /></span><h1>脚本构建</h1></div>
-      <form className="buildPicker" onSubmit={submit}>
-        <label className="srOnly" htmlFor="recent-build">最近构建</label>
-        <select id="recent-build" aria-label="最近构建" value={builds.some((build) => build.id === selectedId) ? selectedId : ""} onChange={(event) => onSelect(event.target.value)}><option value="" disabled>{builds.length ? "选择构建" : "暂无列表"}</option>{builds.map((build) => <option key={build.id} value={build.id}>#{build.id} · {statusLabel(build.status)}</option>)}</select>
-        <label className="srOnly" htmlFor="build-id">脚本构建 ID</label>
-        <input id="build-id" value={input} onChange={(event) => setInput(event.target.value)} placeholder="Run ID" title="输入 Run ID 后按 Enter 打开" enterKeyHint="go" />
-      </form>
-      <nav className="roundNav" aria-label="轮次导航">{view.rounds.map((round) => <button type="button" key={round.id} className={expandedRounds.has(round.roundIndex) ? "active" : ""} onClick={() => onRound(round.roundIndex)}><span>第 {round.roundIndex} 轮</span><StatusBadge status={round.state} /></button>)}</nav>
-      <div className="runMeta">
-        {refreshing ? <span className="refreshing" aria-label="正在刷新"><RefreshCw size={13} /></span> : null}
-        {view.dataShape === "legacy-incomplete" ? <StatusBadge status="legacy" /> : null}
-        <a className="quietButton compactAction" href={moduleAuditUrl(selectedId)} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }} title="对照这个 Run 的模块实际使用、读取与原始记录"><Columns3 size={15} /><span>数据比对</span></a>
-        <button className="quietButton compactAction" type="button" onClick={onToggleAll} aria-expanded={allExpanded} title={allExpanded ? "折叠全部内容" : "展开全部内容"}>{allExpanded ? <ChevronsUp size={15} /> : <ChevronsDown size={15} />}<span>{allExpanded ? "折叠" : "展开全部"}</span></button>
-        {noteCount ? <details className="dataNotes"><summary aria-label={`数据说明 ${noteCount} 条`}><AlertTriangle size={14} /><span>数据说明</span><b>{noteCount}</b></summary><div><ul>{view.warnings.map((warning, index) => <li key={typeof warning === "string" ? warning : warning.id || index}>{warningText(warning)}</li>)}</ul><small>数据时间:{formatTime(view.capturedAt)}</small></div></details> : null}
-      </div>
-    </div>
-  </header>;
-}
-
-const formatTime = (value: string) => { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString("zh-CN", { hour12: false }); };

+ 0 - 100
visualization/frontend/hooks/useExecutionView.ts

@@ -1,100 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useRef, useState } from "react";
-import { errorMessage, getExecutionView, listScriptBuilds } from "@/lib/api";
-import { isTerminalStatus, type BuildSummary, type ExecutionViewV8 } from "@/lib/types";
-
-const RUN_QUERY_KEY = "run";
-const LAST_RUN_STORAGE_KEY = "script-build:last-run";
-
-export function initialBuildId(builds: BuildSummary[], search: string, storedId?: string | null) {
-  const queryId = new URLSearchParams(search).get(RUN_QUERY_KEY)?.trim();
-  if (queryId) return queryId;
-  const remembered = storedId?.trim();
-  if (remembered && builds.some((build) => build.id === remembered)) return remembered;
-  return builds[0]?.id || "";
-}
-
-export function useExecutionView() {
-  const [builds, setBuilds] = useState<BuildSummary[]>([]);
-  const [selectedId, setSelectedId] = useState("");
-  const [view, setView] = useState<ExecutionViewV8 | null>(null);
-  const [loading, setLoading] = useState(true);
-  const [refreshing, setRefreshing] = useState(false);
-  const [error, setError] = useState<string | null>(null);
-  const generation = useRef(0);
-
-  const loadView = useCallback(async (id: string, signal?: AbortSignal, quiet = false) => {
-    const requestId = ++generation.current;
-    quiet ? setRefreshing(true) : setLoading(true);
-    try {
-      if (!id) throw new Error("请输入真实 Run ID");
-      const data = await getExecutionView(id, signal);
-      if (requestId !== generation.current) return;
-      setView(data);
-      setError(null);
-      const loadedId = String(data.header.id);
-      setSelectedId(loadedId);
-      rememberBuildId(loadedId);
-    } catch (requestError) {
-      if (signal?.aborted || requestId !== generation.current) return;
-      if (quiet && view) {
-        setError(`刷新失败,保留 ${view.capturedAt} 的快照:${errorMessage(requestError)}`);
-      } else {
-        setView(null);
-        setError(`无法读取构建 #${id}:${errorMessage(requestError)}`);
-      }
-    } finally {
-      if (requestId === generation.current) { setLoading(false); setRefreshing(false); }
-    }
-  }, [view]);
-
-  useEffect(() => {
-    const controller = new AbortController();
-    void listScriptBuilds(controller.signal)
-      .then((items) => {
-        setBuilds(items);
-        const preferred = initialBuildId(items, window.location.search, readRememberedBuildId());
-        setSelectedId(preferred);
-        if (!preferred) throw new Error("当前没有可读取的真实构建记录");
-        return loadView(preferred, controller.signal);
-      })
-      .catch((listError) => { if (!controller.signal.aborted) { setView(null); setLoading(false); setError(`无法读取真实构建列表:${errorMessage(listError)}`); } });
-    return () => controller.abort();
-  }, []);
-
-  useEffect(() => {
-    if (!view || isTerminalStatus(view.header.status)) return;
-    const timer = window.setTimeout(() => void loadView(String(view.header.id), undefined, true), 3000);
-    return () => window.clearTimeout(timer);
-  }, [view?.capturedAt, view?.header.id, view?.header.status, loadView]);
-
-  const selectBuild = useCallback((id: string) => {
-    const normalized = id.trim();
-    if (!normalized) return;
-    setSelectedId(normalized); setView(null); setError(null);
-    void loadView(normalized);
-  }, [loadView]);
-
-  return { builds, selectedId, view, loading, refreshing, error, selectBuild };
-}
-
-function readRememberedBuildId() {
-  try {
-    return window.localStorage.getItem(LAST_RUN_STORAGE_KEY);
-  } catch {
-    return null;
-  }
-}
-
-function rememberBuildId(id: string) {
-  try {
-    window.localStorage.setItem(LAST_RUN_STORAGE_KEY, id);
-  } catch {
-    // URL persistence still works when storage is unavailable.
-  }
-  const url = new URL(window.location.href);
-  if (url.searchParams.get(RUN_QUERY_KEY) === id) return;
-  url.searchParams.set(RUN_QUERY_KEY, id);
-  window.history.replaceState(window.history.state, "", url);
-}

+ 10 - 19
visualization/frontend/lib/api.ts

@@ -1,26 +1,17 @@
-import type { ArtifactDetailProjection, BuildSummary, CardBusinessData, ExecutionViewV8, InspectorDetailProjection, InspectorWorkbenchProjection, PromptProjection } from "@/lib/types";
+import type { RunGraph, RunSummary } from "@/lib/types";
 
-const API_BASE = (process.env.NEXT_PUBLIC_API_BASE || process.env.NEXT_PUBLIC_VISUALIZATION_API || "http://127.0.0.1:8788").replace(/\/$/, "");
+const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://127.0.0.1:8788";
 
-export const moduleAuditUrl = (buildId: string) => `${API_BASE}/audit/${encodeURIComponent(buildId)}`;
-
-async function request<T>(path: string, signal?: AbortSignal): Promise<T> {
-  const response = await fetch(`${API_BASE}${path}`, { signal, cache: "no-store", headers: { Accept: "application/json" } });
-  if (!response.ok) throw new Error(`API ${response.status}: ${response.statusText}`);
+async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
+  const response = await fetch(`${API_BASE}${path}`, { signal, cache: "no-store" });
+  if (!response.ok) throw new Error(`可视化后端返回 ${response.status}`);
   return response.json() as Promise<T>;
 }
 
-export async function listScriptBuilds(signal?: AbortSignal): Promise<BuildSummary[]> {
-  const payload = await request<BuildSummary[] | { items?: Array<BuildSummary & { script_build_id?: string | number; buildId?: string | number }> }>("/api/script-builds?limit=30", signal);
-  const items = Array.isArray(payload) ? payload : payload.items || [];
-  return items.map((item) => { const raw = item as typeof item & { script_build_id?: string | number; buildId?: string | number }; return { ...item, id: String(item.id ?? raw.script_build_id ?? raw.buildId ?? "") }; });
+export function fetchRuns(signal?: AbortSignal) {
+  return getJson<RunSummary[]>("/api/runs", signal);
 }
 
-export const getExecutionView = (id: string, signal?: AbortSignal) => request<ExecutionViewV8>(`/api/script-builds/${encodeURIComponent(id)}/execution-view`, signal);
-export const getRoundDetail = (buildId: string, roundIndex: number, signal?: AbortSignal) => request<InspectorDetailProjection>(`/api/script-builds/${encodeURIComponent(buildId)}/rounds/${roundIndex}`, signal);
-export const getActivityDetail = (buildId: string, detailRef: string, signal?: AbortSignal) => request<InspectorDetailProjection>(`/api/script-builds/${encodeURIComponent(buildId)}/activities/${encodeURIComponent(detailRef)}`, signal);
-export const getArtifactDetail = (buildId: string, snapshotRef: string, signal?: AbortSignal) => request<ArtifactDetailProjection>(`/api/script-builds/${encodeURIComponent(buildId)}/artifacts/${encodeURIComponent(snapshotRef)}`, signal);
-export const getDecisionPrompt = (buildId: string, promptRef: string, signal?: AbortSignal) => request<PromptProjection>(`/api/script-builds/${encodeURIComponent(buildId)}/decision-prompts/${encodeURIComponent(promptRef)}`, signal);
-export const getCardData = (buildId: string, detailRef: string, signal?: AbortSignal) => request<CardBusinessData>(`/api/script-builds/${encodeURIComponent(buildId)}/card-data/${encodeURIComponent(detailRef)}`, signal);
-export const getInspectorView = (buildId: string, detailRef: string, signal?: AbortSignal) => request<InspectorWorkbenchProjection>(`/api/script-builds/${encodeURIComponent(buildId)}/inspector-view/${encodeURIComponent(detailRef)}`, signal);
-export const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error);
+export function fetchGraph(scriptBuildId: number, signal?: AbortSignal) {
+  return getJson<RunGraph>(`/api/runs/${scriptBuildId}/graph`, signal);
+}

+ 0 - 39
visualization/frontend/lib/expansion.ts

@@ -1,39 +0,0 @@
-import type { ExecutionViewV8 } from "@/lib/types";
-
-export interface ExpansionState {
-  rounds: Set<number>;
-  branches: Set<string>;
-  retrievalAgents: Set<string>;
-}
-
-export function expandedStateFor(view: ExecutionViewV8): ExpansionState {
-  return {
-    rounds: new Set(view.rounds.map((round) => round.roundIndex)),
-    branches: new Set(
-      view.rounds.flatMap((round) =>
-        round.branches.map((branch) => `${round.roundIndex}:${branch.branchId}`),
-      ),
-    ),
-    retrievalAgents: new Set(
-      view.rounds.flatMap((round) =>
-        round.branches.flatMap((branch) =>
-          branch.retrievalStage.agentRuns.map((agent) => agent.id),
-        ),
-      ),
-    ),
-  };
-}
-
-export function isFullyExpanded(
-  view: ExecutionViewV8,
-  expandedRounds: Set<number>,
-  expandedBranches: Set<string>,
-  expandedRetrievalAgents: Set<string>,
-): boolean {
-  const expected = expandedStateFor(view);
-  return (
-    [...expected.rounds].every((id) => expandedRounds.has(id))
-    && [...expected.branches].every((id) => expandedBranches.has(id))
-    && [...expected.retrievalAgents].every((id) => expandedRetrievalAgents.has(id))
-  );
-}

+ 92 - 0
visualization/frontend/lib/flow.ts

@@ -0,0 +1,92 @@
+import { MarkerType, Position, type Edge, type Node } from "@xyflow/react";
+import type { FlowNodeRecord, Phase, PhaseFrame, RunGraph } from "@/lib/types";
+
+export type CardData = {
+  kind: "card";
+  item: FlowNodeRecord;
+  dimmed: boolean;
+} & Record<string, unknown>;
+
+export type FrameData = {
+  kind: "frame";
+  frame: PhaseFrame;
+} & Record<string, unknown>;
+
+export type CanvasData = CardData | FrameData;
+
+export const PHASE_LABELS: Record<Phase, string> = {
+  "phase-1": "Phase 1",
+  "phase-2": "Phase 2",
+  "phase-3": "Phase 3",
+  delivery: "Final Delivery",
+};
+
+const EDGE_COLOR = {
+  control: "#68707f",
+  artifact: "#4ca6d8",
+  validation: "#b68cff",
+  publication: "#5ec48d",
+};
+
+export function matchesNode(item: FlowNodeRecord, query: string): boolean {
+  const value = query.trim().toLocaleLowerCase();
+  if (!value) return true;
+  return [
+    item.title, item.subtitle, item.id, item.lane, item.status,
+    item.task_kind ?? "", item.agent_role ?? "", item.record.model_name,
+    ...item.tags,
+  ].some((part) => part.toLocaleLowerCase().includes(value));
+}
+
+export function buildCanvas(
+  graph: RunGraph,
+  enabledPhases: Set<Phase>,
+  query: string,
+): { nodes: Node<CanvasData>[]; edges: Edge[] } {
+  const visibleIds = new Set(
+    graph.nodes.filter((item) => enabledPhases.has(item.phase)).map((item) => item.id),
+  );
+  const frameNodes: Node<CanvasData>[] = graph.frames.map((frame) => {
+    const phase = frame.id.replace("frame-", "") as Phase;
+    return {
+      id: frame.id,
+      type: "phaseFrame",
+      position: { x: frame.x, y: frame.y },
+      data: { kind: "frame", frame },
+      style: { width: frame.width, height: frame.height },
+      selectable: false,
+      draggable: false,
+      connectable: false,
+      hidden: !enabledPhases.has(phase),
+      zIndex: -10,
+    };
+  });
+  const cardNodes: Node<CanvasData>[] = graph.nodes.map((item) => ({
+    id: item.id,
+    type: "flowCard",
+    position: { x: item.x, y: item.y },
+    data: { kind: "card", item, dimmed: !matchesNode(item, query) },
+    sourcePosition: Position.Right,
+    targetPosition: Position.Left,
+    style: { width: item.width },
+    hidden: !visibleIds.has(item.id),
+    zIndex: 2,
+  }));
+  const edges: Edge[] = graph.edges.map((edge) => ({
+    id: edge.id,
+    source: edge.source,
+    target: edge.target,
+    label: edge.label ?? undefined,
+    type: "smoothstep",
+    animated: edge.animated,
+    hidden: !visibleIds.has(edge.source) || !visibleIds.has(edge.target),
+    markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: EDGE_COLOR[edge.kind] },
+    style: { stroke: EDGE_COLOR[edge.kind], strokeWidth: edge.kind === "publication" ? 2.2 : 1.45 },
+    labelStyle: { fill: "#9aa0ab", fontSize: 10, fontWeight: 600 },
+    labelBgStyle: { fill: "#15171b", fillOpacity: 0.92 },
+    labelBgPadding: [5, 3] as [number, number],
+    labelBgBorderRadius: 5,
+  }));
+  return { nodes: [...frameNodes, ...cardNodes], edges };
+}
+

+ 0 - 493
visualization/frontend/lib/layout.ts

@@ -1,493 +0,0 @@
-import { MarkerType, type Edge, type Node } from "@xyflow/react";
-import type {
-  BranchStory,
-  CanvasItem,
-  DirectToolGroup,
-  ExecutionViewV8,
-  OpenTarget,
-  RetrievalAgentRun,
-  RetrievalStage,
-  RoundStoryV8,
-  StoryNode,
-} from "@/lib/types";
-
-const STORY_W = 320;
-const RETRIEVAL_W = 340;
-const SUMMARY_W = 360;
-const ROUND_GAP = 104;
-const ROUND_HEADER = 88;
-const REGION_TOP = ROUND_HEADER + 16;
-const REGION_HEADER = 54;
-const REGION_PAD = 24;
-const REGION_GAP = 32;
-const CARD_GAP = 32;
-const BRANCH_HEADER = 66;
-const RETRIEVAL_HEADER = 72;
-const RETRIEVAL_ITEM_GAP = 18;
-const FLOW_CENTER_Y = 350;
-const FINAL_RESULT_H = 368;
-const PLANNING_W = STORY_W * 2 + CARD_GAP + REGION_PAD * 2;
-const IMPLEMENT_COMPACT_W = SUMMARY_W + REGION_PAD * 2;
-const SINGLE_REGION_W = STORY_W + REGION_PAD * 2;
-
-export type AgentRegionTone = "main" | "implementer" | "evaluator" | "outcome";
-export type CanvasDetailLevel = "full" | "compact" | "overview";
-export interface AgentRegionProjection { label: string; description: string; tone: AgentRegionTone }
-export interface CanvasNodeData extends Record<string, unknown> {
-  buildId?: string;
-  item?: CanvasItem;
-  round?: RoundStoryV8;
-  region?: AgentRegionProjection;
-  expanded?: boolean;
-  onOpen: (item: CanvasItem, target: OpenTarget) => void;
-  onPrompt?: (promptRef: string) => void;
-  onToggleRound: (roundIndex: number) => void;
-  onToggleBranch: (roundIndex: number, branchId: number) => void;
-  onToggleRetrievalAgent: (agentId: string) => void;
-}
-export interface LayoutOptions {
-  buildId?: string;
-  expandedRounds: Set<number>;
-  expandedBranches: Set<string>;
-  expandedRetrievalAgents: Set<string>;
-  detailLevel?: CanvasDetailLevel;
-  onOpen: CanvasNodeData["onOpen"];
-  onPrompt?: CanvasNodeData["onPrompt"];
-  onToggleRound: CanvasNodeData["onToggleRound"];
-  onToggleBranch: CanvasNodeData["onToggleBranch"];
-  onToggleRetrievalAgent: CanvasNodeData["onToggleRetrievalAgent"];
-}
-export interface LayoutResult { nodes: Array<Node<CanvasNodeData>>; edges: Edge[] }
-
-interface RoundMetrics {
-  width: number;
-  height: number;
-  regionHeight: number;
-  laneTops: number[];
-  laneHeights: number[];
-  contentCenterY: number;
-  planningX: number;
-  implementationX: number;
-  implementationWidth: number;
-  reviewX: number;
-  convergenceX: number;
-  evaluatorX: number;
-  outcomeX: number;
-  batchTops: number[];
-  batchHeights: Array<{ review: number; decision: number; group: number }>;
-}
-
-export function buildHorizontalLayout(view: ExecutionViewV8, options: LayoutOptions): LayoutResult {
-  const nodes: Array<Node<CanvasNodeData>> = [];
-  const edges: Edge[] = [];
-  const planning = view.planning ? storyItem(view.planning) : undefined;
-  const objective = storyItem(view.objective);
-  if (planning) nodes.push(canvasNode(planning, "storyCard", { x: 60, y: 210 }, options));
-  const objectiveX = planning ? 60 + STORY_W + ROUND_GAP : 60;
-  nodes.push(canvasNode(objective, "storyCard", { x: objectiveX, y: 210 }, options));
-  if (planning) edges.push(flowEdge(planning.id, objective.id));
-  const roundX = objectiveX + STORY_W + ROUND_GAP;
-  let previousRowBottom = 0;
-  let previous = objective.id;
-  let finalX = roundX;
-  let finalY = 170;
-
-  for (const [roundPosition, round] of [...view.rounds].sort((a, b) => a.roundIndex - b.roundIndex).entries()) {
-    if (!options.expandedRounds.has(round.roundIndex)) {
-      const item = roundSummaryItem(round);
-      const rowHeight = collapsedRoundHeight(options.detailLevel ?? "full");
-      const rowY = roundPosition === 0 ? 148 : previousRowBottom + ROUND_GAP;
-      nodes.push(canvasNode(item, "roundSummary", { x: roundX, y: rowY }, options));
-      edges.push(flowEdge(previous, item.id, { status: round.state }));
-      previous = item.id;
-      previousRowBottom = rowY + rowHeight;
-      finalX = roundX + SUMMARY_W + ROUND_GAP;
-      finalY = rowY + (rowHeight - FINAL_RESULT_H) / 2;
-      continue;
-    }
-
-    const groupId = `frame:round:${round.roundIndex}`;
-    const metrics = expandedRoundMetrics(round, options);
-    const rowY = roundPosition === 0
-      ? Math.min(42, FLOW_CENTER_Y - metrics.contentCenterY)
-      : previousRowBottom + ROUND_GAP;
-    nodes.push({
-      id: groupId,
-      type: "roundFrame",
-      position: { x: roundX, y: rowY },
-      data: baseData(options, { round, expanded: true }),
-      style: { width: metrics.width, height: metrics.height },
-      draggable: false,
-      selectable: true,
-      focusable: false,
-      zIndex: -3,
-    });
-    addAgentRegions(nodes, round, groupId, options, metrics);
-    const result = addExpandedRound(nodes, edges, round, groupId, options, metrics);
-    edges.push(flowEdge(previous, result.entry));
-    previous = result.exit;
-    previousRowBottom = rowY + metrics.height;
-    finalX = roundX + metrics.width + ROUND_GAP;
-    finalY = rowY + metrics.contentCenterY - FINAL_RESULT_H / 2;
-  }
-
-  const finalItem = finalResultItem(view);
-  nodes.push(canvasNode(finalItem, "finalResult", { x: finalX, y: finalY }, options));
-  edges.push(flowEdge(previous, finalItem.id, { status: view.finalResult.status }));
-  return { nodes, edges };
-}
-
-function expandedRoundMetrics(round: RoundStoryV8, options: LayoutOptions): RoundMetrics {
-  const detailLevel = options.detailLevel ?? "full";
-  const density = densityFor(detailLevel);
-  const expandedWidths = round.branches
-    .filter((branch) => options.expandedBranches.has(branchKey(round.roundIndex, branch.branchId)))
-    .map((branch) => branchFrameWidth(branch));
-  const implementationWidth = expandedWidths.length
-    ? Math.max(...expandedWidths) + REGION_PAD * 2
-    : IMPLEMENT_COMPACT_W;
-  const planningX = 32;
-  const implementationX = planningX + PLANNING_W + REGION_GAP;
-  const reviewX = implementationX + implementationWidth + REGION_GAP;
-  const convergenceX = reviewX + SINGLE_REGION_W + REGION_GAP;
-  const evaluatorX = convergenceX + SINGLE_REGION_W + REGION_GAP;
-  const outcomeX = evaluatorX + SINGLE_REGION_W + REGION_GAP;
-  const width = outcomeX + SINGLE_REGION_W + 32;
-  const branches: Array<BranchStory | undefined> = round.branches.length ? round.branches : [undefined];
-  const laneHeights = branches.map((branch) => {
-    if (!branch) return 240;
-    return options.expandedBranches.has(branchKey(round.roundIndex, branch.branchId))
-      ? branchFrameHeight(branch, detailLevel, options.expandedRetrievalAgents)
-      : estimateBranchHeight(branch, detailLevel);
-  });
-  const laneTops: number[] = [];
-  const laneStart = REGION_TOP + REGION_HEADER + density.contentInset;
-  laneHeights.reduce((top, height) => { laneTops.push(top); return top + height + density.laneGap; }, laneStart);
-  const laneBottom = laneTops.at(-1)! + laneHeights.at(-1)!;
-  const batchHeights = round.convergenceBatches.map((batch) => {
-    const review = batch.review
-      ? estimateMultipathReviewHeight(batch.review, detailLevel)
-      : batch.missingReview ? estimateStoryHeight(batch.missingReview, detailLevel) : 244;
-    const decision = batch.decision
-      ? estimateMultipathDecisionHeight(batch.decision, detailLevel)
-      : batch.missingDecision ? estimateStoryHeight(batch.missingDecision, detailLevel) : 244;
-    return { review, decision, group: Math.max(review, decision) };
-  });
-  const batchStackHeight = batchHeights.reduce((sum, height) => sum + height.group, 0) + Math.max(0, batchHeights.length - 1) * density.laneGap;
-  const contentHeight = Math.max(
-    laneBottom - laneStart,
-    batchStackHeight,
-    estimateStoryHeight(round.goal, detailLevel),
-    estimateStoryHeight(round.plan.node, detailLevel),
-    estimateStoryHeight(round.overallEvaluation, detailLevel),
-    estimateStoryHeight(round.result, detailLevel),
-  );
-  const regionHeight = REGION_HEADER + density.contentInset + contentHeight + density.contentInset;
-  const contentCenterY = laneStart + contentHeight / 2;
-  const batchStart = laneStart + Math.max(0, (contentHeight - batchStackHeight) / 2);
-  const batchTops: number[] = [];
-  batchHeights.reduce((top, height) => { batchTops.push(top); return top + height.group + density.laneGap; }, batchStart);
-  return { width, height: REGION_TOP + regionHeight + 34, regionHeight, laneTops, laneHeights, contentCenterY, planningX, implementationX, implementationWidth, reviewX, convergenceX, evaluatorX, outcomeX, batchTops, batchHeights };
-}
-
-function addAgentRegions(nodes: Array<Node<CanvasNodeData>>, round: RoundStoryV8, parentId: string, options: LayoutOptions, metrics: RoundMetrics) {
-  const regions = [
-    { id: "planning", x: metrics.planningX, width: PLANNING_W, projection: { label: "主 Agent · 规划", description: "确定本轮目标,并拆成多路方案", tone: "main" as const } },
-    { id: "implementation", x: metrics.implementationX, width: metrics.implementationWidth, projection: { label: "实现 Agent · 多路方案", description: "每个方案独立取数、取舍并形成产出", tone: "implementer" as const } },
-    { id: "multipath-review", x: metrics.reviewX, width: SINGLE_REGION_W, projection: { label: "多方案评审 Agent", description: "比较候选脚本并给出评审建议", tone: "evaluator" as const } },
-    { id: "convergence", x: metrics.convergenceX, width: SINGLE_REGION_W, projection: { label: "主 Agent · 收敛", description: "结合评审处理各个候选方案", tone: "main" as const } },
-    { id: "evaluation", x: metrics.evaluatorX, width: SINGLE_REGION_W, projection: { label: "整体评审 Agent", description: "检查合并后的主脚本是否达标", tone: "evaluator" as const } },
-    { id: "outcome", x: metrics.outcomeX, width: SINGLE_REGION_W, projection: { label: "本轮结果", description: "汇总主脚本和领域信息产出", tone: "outcome" as const } },
-  ];
-  for (const region of regions) nodes.push({
-    id: `region:round:${round.roundIndex}:${region.id}`,
-    type: "agentRegion",
-    position: { x: region.x, y: REGION_TOP },
-    parentId,
-    extent: "parent",
-    data: baseData(options, { region: region.projection }),
-    style: { width: region.width, height: metrics.regionHeight },
-    draggable: false,
-    selectable: false,
-    focusable: false,
-    zIndex: -2,
-  });
-}
-
-function addExpandedRound(nodes: Array<Node<CanvasNodeData>>, edges: Edge[], round: RoundStoryV8, parentId: string, options: LayoutOptions, metrics: RoundMetrics) {
-  const detailLevel = options.detailLevel ?? "full";
-  const addStory = (story: StoryNode, x: number, y: number, branchId?: number, storyParentId = parentId) => {
-    const item = storyItem(story, round.roundIndex, branchId);
-    nodes.push(canvasNode(item, "storyCard", { x, y }, options, storyParentId));
-    return item.id;
-  };
-  const goalId = addStory(round.goal, metrics.planningX + REGION_PAD, centeredRoundY(metrics, estimateStoryHeight(round.goal, detailLevel)));
-  const planId = addStory(round.plan.node, metrics.planningX + REGION_PAD + STORY_W + CARD_GAP, centeredRoundY(metrics, estimateStoryHeight(round.plan.node, detailLevel)));
-  edges.push(flowEdge(goalId, planId));
-  const branchEnds = new Map<number, string>();
-
-  round.branches.forEach((branch, index) => {
-    const laneTop = metrics.laneTops[index];
-    const laneHeight = metrics.laneHeights[index];
-    const expanded = options.expandedBranches.has(branchKey(round.roundIndex, branch.branchId));
-    if (!expanded) {
-      const item = branchSummaryItem(branch, round.roundIndex);
-      nodes.push(canvasNode(item, "candidateCard", { x: metrics.implementationX + REGION_PAD, y: laneTop + Math.max(0, (laneHeight - estimateBranchHeight(branch, detailLevel)) / 2) }, options, parentId));
-      edges.push(flowEdge(planId, item.id, { kind: "delegation" }));
-      branchEnds.set(branch.branchId, item.id);
-      return;
-    }
-    const frameId = `frame:round:${round.roundIndex}:branch:${branch.branchId}`;
-    const frameHeight = branchFrameHeight(branch, detailLevel, options.expandedRetrievalAgents);
-    const frameWidth = branchFrameWidth(branch);
-    nodes.push({
-      id: frameId,
-      type: "branchFrame",
-      position: { x: metrics.implementationX + REGION_PAD, y: laneTop + Math.max(0, (laneHeight - frameHeight) / 2) },
-      parentId,
-      extent: "parent",
-      data: baseData(options, { item: branchSummaryItem(branch, round.roundIndex), expanded: true }),
-      style: { width: frameWidth, height: frameHeight },
-      draggable: false,
-      selectable: true,
-      focusable: false,
-      zIndex: 1,
-    });
-    branchEnds.set(branch.branchId, addExpandedBranch(nodes, edges, round, branch, frameId, options, planId));
-  });
-
-  const batchIds: string[] = [];
-  const covered = new Set<number>();
-  round.convergenceBatches.forEach((batch, index) => {
-    const batchTop = metrics.batchTops[index] ?? centeredRoundY(metrics, 244);
-    const heights = metrics.batchHeights[index] ?? { review: 244, decision: 244, group: 244 };
-    const reviewY = batchTop + Math.max(0, (heights.group - heights.review) / 2);
-    const decisionY = batchTop + Math.max(0, (heights.group - heights.decision) / 2);
-    let reviewId: string;
-    if (batch.review) {
-      const item = multipathReviewItem(batch.review, round.roundIndex);
-      nodes.push(canvasNode(item, "multipathReview", { x: metrics.reviewX + REGION_PAD, y: reviewY }, options, parentId));
-      reviewId = item.id;
-    } else if (batch.missingReview) {
-      reviewId = addStory(batch.missingReview, metrics.reviewX + REGION_PAD, reviewY);
-    } else return;
-    let decisionId: string;
-    if (batch.decision) {
-      const item = multipathDecisionItem(batch.decision, round.roundIndex);
-      nodes.push(canvasNode(item, "multipathDecision", { x: metrics.convergenceX + REGION_PAD, y: decisionY }, options, parentId));
-      decisionId = item.id;
-    } else if (batch.missingDecision) {
-      decisionId = addStory(batch.missingDecision, metrics.convergenceX + REGION_PAD, decisionY);
-    } else return;
-    batchIds.push(decisionId);
-    batch.branchIds.forEach((branchId) => {
-      const source = branchEnds.get(branchId);
-      if (source) { edges.push(flowEdge(source, reviewId, { kind: "delegation" })); covered.add(branchId); }
-    });
-    edges.push(flowEdge(reviewId, decisionId));
-  });
-  const evaluationId = addStory(round.overallEvaluation, metrics.evaluatorX + REGION_PAD, centeredRoundY(metrics, estimateStoryHeight(round.overallEvaluation, detailLevel)));
-  const resultId = addStory(round.result, metrics.outcomeX + REGION_PAD, centeredRoundY(metrics, estimateStoryHeight(round.result, detailLevel)));
-  if (batchIds.length) batchIds.forEach((id) => edges.push(flowEdge(id, evaluationId, { kind: "delegation" })));
-  else edges.push(flowEdge(planId, evaluationId));
-  branchEnds.forEach((id, branchId) => { if (!covered.has(branchId) && batchIds.length) edges.push(flowEdge(id, evaluationId, { kind: "unassigned" })); });
-  edges.push(flowEdge(evaluationId, resultId, { status: round.state }));
-  return { entry: goalId, exit: resultId };
-}
-
-function addExpandedBranch(nodes: Array<Node<CanvasNodeData>>, edges: Edge[], round: RoundStoryV8, branch: BranchStory, parentId: string, options: LayoutOptions, planId: string) {
-  const detailLevel = options.detailLevel ?? "full";
-  const density = densityFor(detailLevel);
-  const contentTop = BRANCH_HEADER + density.contentInset;
-  const frameHeight = branchFrameHeight(branch, detailLevel, options.expandedRetrievalAgents);
-  const contentHeight = frameHeight - contentTop - density.contentInset;
-  const taskItem = storyItem(branch.task, round.roundIndex, branch.branchId);
-  const taskY = contentTop + Math.max(0, (contentHeight - estimateStoryHeight(branch.task, detailLevel)) / 2);
-  nodes.push(canvasNode(taskItem, "storyCard", { x: REGION_PAD, y: taskY }, options, parentId));
-  edges.push(flowEdge(planId, taskItem.id, { kind: "delegation" }));
-
-  const retrievalX = REGION_PAD + STORY_W + CARD_GAP;
-  const retrievalHeight = retrievalFrameHeight(branch.retrievalStage, options.expandedRetrievalAgents, detailLevel);
-  const retrievalY = contentTop + Math.max(0, (contentHeight - retrievalHeight) / 2);
-  const stageItem = retrievalStageItem(branch.retrievalStage, round.roundIndex, branch.branchId);
-  nodes.push({
-    id: `frame:${stageItem.id}`,
-    type: "retrievalStageFrame",
-    position: { x: retrievalX, y: retrievalY },
-    parentId,
-    extent: "parent",
-    data: baseData(options, { item: stageItem }),
-    style: { width: retrievalFrameWidth(branch.retrievalStage), height: retrievalHeight },
-    draggable: false,
-    selectable: true,
-    focusable: false,
-    zIndex: 2,
-  });
-  const retrievalEnds = addRetrievalOperations(nodes, edges, branch.retrievalStage, round.roundIndex, branch.branchId, `frame:${stageItem.id}`, options, taskItem.id);
-
-  let cursorX = retrievalX + retrievalFrameWidth(branch.retrievalStage) + CARD_GAP;
-  let previous = retrievalEnds.length ? retrievalEnds : [taskItem.id];
-  if (branch.dataDecisions.length) {
-    const heights = branch.dataDecisions.map((item) => estimateStoryHeight(item.node, detailLevel));
-    const stackHeight = heights.reduce((sum, height) => sum + height, 0) + Math.max(0, heights.length - 1) * density.laneGap;
-    let top = contentTop + Math.max(0, (contentHeight - stackHeight) / 2);
-    const decisionIds: string[] = [];
-    branch.dataDecisions.forEach((decision, index) => {
-      const item = storyItem(decision.node, round.roundIndex, branch.branchId);
-      nodes.push(canvasNode(item, "storyCard", { x: cursorX, y: top }, options, parentId));
-      decisionIds.push(item.id);
-      top += heights[index] + density.laneGap;
-    });
-    previous.forEach((source) => decisionIds.forEach((target) => edges.push(flowEdge(source, target))));
-    previous = decisionIds;
-    cursorX += STORY_W + CARD_GAP;
-  }
-  const tail = [branch.output.node];
-  tail.forEach((story) => {
-    const item = storyItem(story, round.roundIndex, branch.branchId);
-    const y = contentTop + Math.max(0, (contentHeight - estimateStoryHeight(story, detailLevel)) / 2);
-    nodes.push(canvasNode(item, "storyCard", { x: cursorX, y }, options, parentId));
-    previous.forEach((source) => edges.push(flowEdge(source, item.id)));
-    previous = [item.id];
-    cursorX += STORY_W + CARD_GAP;
-  });
-  return previous[0];
-}
-
-function addRetrievalOperations(nodes: Array<Node<CanvasNodeData>>, edges: Edge[], stage: RetrievalStage, roundIndex: number, branchId: number, parentId: string, options: LayoutOptions, sourceId: string) {
-  const detailLevel = options.detailLevel ?? "full";
-  const operations = retrievalOperations(stage);
-  if (!operations.length) return [];
-  const waveIndexes = stage.waves.length ? stage.waves.map((wave) => wave.index) : [0];
-  const byWave = new Map<number, Array<DirectToolGroup | RetrievalAgentRun>>();
-  operations.forEach((operation) => {
-    // No timestamp means no defensible ordering. Keep every operation in one
-    // neutral column instead of inventing a left-to-right sequence.
-    const wave = operation.waveIndex ?? 0;
-    byWave.set(wave, [...(byWave.get(wave) || []), operation]);
-  });
-  let previous = [sourceId];
-  let top = RETRIEVAL_HEADER + RETRIEVAL_ITEM_GAP;
-  for (const waveIndex of [...new Set([...waveIndexes, ...byWave.keys()])].sort((a, b) => a - b)) {
-    const waveItems = byWave.get(waveIndex) || [];
-    if (!waveItems.length) continue;
-    const heights = waveItems.map((item) => retrievalOperationHeight(item, options.expandedRetrievalAgents, detailLevel));
-    const ids: string[] = [];
-    waveItems.forEach((operation, index) => {
-      const item = operation.kind === "direct-tools"
-        ? retrievalDirectItem(operation, roundIndex, branchId)
-        : retrievalAgentItem(operation, roundIndex, branchId);
-      nodes.push(canvasNode(item, operation.kind === "direct-tools" ? "directToolGroup" : "retrievalAgent", { x: 20, y: top }, options, parentId));
-      ids.push(item.id);
-      top += heights[index] + RETRIEVAL_ITEM_GAP;
-    });
-    previous.forEach((source) => ids.forEach((target) => edges.push(flowEdge(source, target, { kind: "delegation" }))));
-    previous = ids;
-  }
-  return previous;
-}
-
-function retrievalOperations(stage: RetrievalStage): Array<DirectToolGroup | RetrievalAgentRun> {
-  return [...stage.directToolGroups, ...stage.agentRuns].sort((a, b) => {
-    const wave = (a.waveIndex ?? 999) - (b.waveIndex ?? 999);
-    return wave || String(a.startedAt || "").localeCompare(String(b.startedAt || "")) || a.id.localeCompare(b.id);
-  });
-}
-
-function branchFrameWidth(branch: BranchStory) {
-  const decisionColumn = branch.dataDecisions.length ? STORY_W + CARD_GAP : 0;
-  const tailColumns = 1;
-  return REGION_PAD * 2 + STORY_W + CARD_GAP + retrievalFrameWidth(branch.retrievalStage) + CARD_GAP + decisionColumn + tailColumns * STORY_W + (tailColumns - 1) * CARD_GAP;
-}
-
-function retrievalFrameWidth(_stage: RetrievalStage) {
-  return 40 + RETRIEVAL_W;
-}
-
-function retrievalFrameHeight(stage: RetrievalStage, expandedAgents: Set<string>, detailLevel: CanvasDetailLevel) {
-  const operations = retrievalOperations(stage);
-  if (!operations.length) return detailLevel === "overview" ? 156 : 210;
-  const contentHeight = operations.reduce((height, item) => height + retrievalOperationHeight(item, expandedAgents, detailLevel), 0)
-    + Math.max(0, operations.length - 1) * RETRIEVAL_ITEM_GAP;
-  return RETRIEVAL_HEADER + RETRIEVAL_ITEM_GAP + contentHeight + 24;
-}
-
-function retrievalOperationHeight(item: DirectToolGroup | RetrievalAgentRun, expandedAgents: Set<string>, detailLevel: CanvasDetailLevel) {
-  if (detailLevel === "overview") return item.kind === "direct-tools" ? 132 : 148;
-  if (item.kind === "direct-tools") return detailLevel === "compact" ? 196 : 248;
-  const attempts = expandedAgents.has(item.id) ? item.attempts.length : 0;
-  if (detailLevel === "compact") return 220 + (attempts ? 44 + attempts * 40 : 0);
-  return 292 + (attempts ? 52 + attempts * 48 : 0);
-}
-
-function branchFrameHeight(branch: BranchStory, detailLevel: CanvasDetailLevel, expandedAgents: Set<string>) {
-  const density = densityFor(detailLevel);
-  const decisions = branch.dataDecisions.map((item) => estimateStoryHeight(item.node, detailLevel));
-  const decisionStack = decisions.reduce((sum, height) => sum + height, 0) + Math.max(0, decisions.length - 1) * density.laneGap;
-  const storyHeights = [branch.task, branch.output.node].map((story) => estimateStoryHeight(story, detailLevel));
-  return BRANCH_HEADER + density.contentInset + Math.max(density.branchFloor, retrievalFrameHeight(branch.retrievalStage, expandedAgents, detailLevel), decisionStack, ...storyHeights) + density.contentInset;
-}
-
-export function branchFlow(branch: BranchStory): StoryNode[] {
-  return [branch.task, ...branch.dataDecisions.map((item) => item.node), branch.output.node];
-}
-export function storyItem(story: StoryNode, roundIndex?: number, branchId?: number): CanvasItem { return { ...story, itemType: "story", roundIndex, branchId }; }
-export function retrievalStageItem(stage: RetrievalStage, roundIndex: number, branchId: number): CanvasItem { return { itemType: "retrieval-stage", id: stage.id, title: "取数阶段", roundIndex, branchId, stage, detailRef: stage.detailRef }; }
-export function retrievalDirectItem(group: DirectToolGroup, roundIndex: number, branchId: number): CanvasItem { return { itemType: "retrieval-direct", id: group.id, title: "工具取数", roundIndex, branchId, group, detailRef: group.detailRef }; }
-export function retrievalAgentItem(run: RetrievalAgentRun, roundIndex: number, branchId: number): CanvasItem { return { itemType: "retrieval-agent", id: run.id, title: run.businessLabel, roundIndex, branchId, run, detailRef: run.detailRef }; }
-export function queryAttemptItem(attempt: RetrievalAgentRun["attempts"][number], roundIndex: number, branchId: number): CanvasItem { return { itemType: "query-attempt", id: attempt.id, title: attempt.queryLabel, roundIndex, branchId, attempt, detailRef: attempt.detailRef }; }
-export function retrievalActivityItem(activity: import("@/lib/types").RetrievalActivity, roundIndex: number, branchId: number): CanvasItem { return { itemType: "retrieval-activity", id: activity.id, title: activity.label, roundIndex, branchId, activity, detailRef: activity.detailRef }; }
-export function branchSummaryItem(branch: BranchStory, roundIndex: number): CanvasItem { return { itemType: "branch-summary", id: `round:${roundIndex}:branch:${branch.branchId}:summary`, title: branch.title, roundIndex, branchId: branch.branchId, branch, detailRef: branch.detailRef }; }
-export function multipathReviewItem(review: import("@/lib/types").MultipathReview, roundIndex: number): CanvasItem { return { itemType: "multipath-review", id: review.id, title: "多方案评审", roundIndex, review, detailRef: review.detailRef }; }
-export function multipathDecisionItem(decision: import("@/lib/types").MultipathDecisionBatch, roundIndex: number): CanvasItem { return { itemType: "multipath-decision", id: decision.id, title: "主 Agent 多路决策", roundIndex, decision, detailRef: decision.node.detailRef || decision.id }; }
-export function roundSummaryItem(round: RoundStoryV8): CanvasItem { return { itemType: "round-summary", id: `round:${round.roundIndex}:summary`, title: round.summary.title, roundIndex: round.roundIndex, round, detailRef: round.detailRef }; }
-export function finalResultItem(view: ExecutionViewV8): CanvasItem { return { itemType: "final-result", id: view.finalResult.id, title: view.finalResult.title, result: view.finalResult, detailRef: "run:final-result" }; }
-
-function canvasNode(item: CanvasItem, type: string, position: { x: number; y: number }, options: LayoutOptions, parentId?: string): Node<CanvasNodeData> {
-  const resolvedType = hasDecision(item) ? "agentDecision" : type;
-  return { id: item.id, type: resolvedType, position, parentId, extent: parentId ? "parent" : undefined, data: baseData(options, { item, expanded: item.itemType === "branch-summary" ? options.expandedBranches.has(branchKey(item.roundIndex, item.branchId)) : item.itemType === "retrieval-agent" ? options.expandedRetrievalAgents.has(item.id) : undefined }), draggable: true, selectable: true, focusable: false, zIndex: 4 };
-}
-function baseData(options: LayoutOptions, extra: Partial<CanvasNodeData>): CanvasNodeData { return { buildId: options.buildId, onOpen: options.onOpen, onPrompt: options.onPrompt, onToggleRound: options.onToggleRound, onToggleBranch: options.onToggleBranch, onToggleRetrievalAgent: options.onToggleRetrievalAgent, ...extra }; }
-function hasDecision(item: CanvasItem) { return item.itemType === "story" ? Boolean(item.decision) : item.itemType === "multipath-review" ? Boolean(item.review.decisionProjection) : item.itemType === "multipath-decision" ? Boolean(item.decision.decisionProjection) : false; }
-function flowEdge(source: string, target: string, options: { status?: string; kind?: string } = {}): Edge {
-  const color = options.kind === "unassigned" ? "#f4c152" : options.status === "merged" ? "#63c883" : options.status === "parked" ? "#f4c152" : options.status === "discarded" ? "#ff806e" : options.kind === "delegation" ? "#7bb8ff" : "#7e848f";
-  return { id: `edge:${source}:${target}`, source, target, type: "smoothstep", animated: options.status === "running", style: { stroke: color, strokeWidth: 1.7, strokeDasharray: options.kind === "delegation" || options.kind === "unassigned" ? "6 5" : undefined }, markerEnd: { type: MarkerType.ArrowClosed, color, width: 14, height: 14 } };
-}
-function estimateStoryHeight(story: StoryNode, detailLevel: CanvasDetailLevel) {
-  if (detailLevel === "overview") return 120;
-  const visibleValues = [story.card.primary?.value, ...story.card.secondary.map((item) => item.value)].filter(Boolean).slice(0, detailLevel === "compact" ? 1 : undefined) as string[];
-  const lines = visibleValues.reduce((sum, value, index) => sum + Math.min(index === 0 ? 4 : 2, Math.max(1, Math.ceil(value.length / 25))), 0);
-  return Math.max(detailLevel === "compact" ? 196 : 244, (detailLevel === "compact" ? 142 : 164) + lines * 23 + (story.sourceNotice && detailLevel === "full" ? 28 : 0));
-}
-function estimateBranchHeight(branch: BranchStory, detailLevel: CanvasDetailLevel) {
-  if (detailLevel === "overview") return 132;
-  const values = [branch.title, branch.summary.task, branch.summary.output].filter(Boolean) as string[];
-  const lines = values.reduce((sum, value) => sum + Math.min(2, Math.max(1, Math.ceil(value.length / 25))), 0);
-  const sourceAllowance = branch.outcome.sourceNotice || branch.task.sourceNotice || branch.output.node.sourceNotice ? 28 : 0;
-  return Math.max(detailLevel === "compact" ? 232 : 320, (detailLevel === "full" ? 188 : 148) + lines * 23 + sourceAllowance);
-}
-function estimateMultipathReviewHeight(review: import("@/lib/types").MultipathReview, detailLevel: CanvasDetailLevel) {
-  return estimateDecisionCardHeight(review.decisionProjection?.card, detailLevel);
-}
-function estimateMultipathDecisionHeight(decision: import("@/lib/types").MultipathDecisionBatch, detailLevel: CanvasDetailLevel) {
-  return estimateDecisionCardHeight(decision.decisionProjection?.card, detailLevel);
-}
-function estimateDecisionCardHeight(card: import("@/lib/types").CardProjection | undefined, detailLevel: CanvasDetailLevel) {
-  if (detailLevel === "overview") return 132;
-  const values = card ? [card.primary?.value, ...card.secondary.map((item) => item.value)].filter(Boolean) as string[] : [];
-  const visible = values.slice(0, detailLevel === "compact" ? 1 : 4);
-  const lines = visible.reduce((sum, value, index) => sum + Math.min(index === 0 ? 4 : 2, Math.max(1, Math.ceil(value.length / 25))), 0);
-  return Math.max(detailLevel === "compact" ? 216 : 296, (detailLevel === "compact" ? 150 : 190) + lines * 23);
-}
-function densityFor(detailLevel: CanvasDetailLevel) {
-  if (detailLevel === "overview") return { laneGap: 12, contentInset: 12, branchFloor: 148 };
-  if (detailLevel === "compact") return { laneGap: 18, contentInset: 16, branchFloor: 208 };
-  return { laneGap: 28, contentInset: 24, branchFloor: 244 };
-}
-function collapsedRoundHeight(detailLevel: CanvasDetailLevel) {
-  if (detailLevel === "overview") return 164;
-  if (detailLevel === "compact") return 280;
-  return 400;
-}
-function branchKey(roundIndex: number, branchId: number) { return `${roundIndex}:${branchId}`; }
-function centeredRoundY(metrics: RoundMetrics, height: number) { return metrics.contentCenterY - height / 2; }

+ 0 - 26
visualization/frontend/lib/redact.ts

@@ -1,26 +0,0 @@
-const SECRET_PATTERN = /(authorization|api[-_]?key|token|password|secret|dsn|database_url|connection_string)/i;
-
-export function redact(value: unknown, depth = 0): unknown {
-  if (depth > 8) return "[DEPTH_LIMIT]";
-  if (Array.isArray(value)) return value.map((item) => redact(item, depth + 1));
-  if (value && typeof value === "object") {
-    return Object.fromEntries(
-      Object.entries(value as Record<string, unknown>).map(([key, item]) => [
-        key,
-        SECRET_PATTERN.test(key) ? "[REDACTED]" : redact(item, depth + 1)
-      ])
-    );
-  }
-  if (typeof value === "string") {
-    return value
-      .replace(/Bearer\s+[A-Za-z0-9._~+\/-]+/gi, "Bearer [REDACTED]")
-      .replace(/(postgres(?:ql)?:\/\/)[^\s@]+@/gi, "$1[REDACTED]@");
-  }
-  return value;
-}
-
-export function pretty(value: unknown): string {
-  if (value === undefined || value === null || value === "") return "无记录";
-  if (typeof value === "string") return String(redact(value));
-  return JSON.stringify(redact(value), null, 2);
-}

+ 0 - 16
visualization/frontend/lib/status-copy.ts

@@ -1,16 +0,0 @@
-export function runResultFallback(status: string, terminal: boolean): string {
-  switch (status.toLowerCase()) {
-    case "running":
-      return "构建仍在运行。";
-    case "stopped":
-      return "本次构建已停止,流程未完成。";
-    case "cancel":
-    case "cancelled":
-    case "cancle":
-      return "本次构建已取消,流程未完成。";
-    case "failed":
-      return "本次构建失败,流程未完成。";
-    default:
-      return terminal ? "本次构建已经结束。" : "当前运行状态待确认。";
-  }
-}

+ 57 - 759
visualization/frontend/lib/types.ts

@@ -1,780 +1,78 @@
-export type InspectorTab = "business" | "data" | "changes" | "technical";
-export type OpenTarget = InspectorTab | "artifact" | "lineage";
-export type StoryKind = "decision" | "execution" | "result" | "missing";
-export type StoryRole =
-  | "planning-analysis"
-  | "objective"
-  | "goal"
-  | "plan"
-  | "branch-task"
-  | "data-decision"
-  | "candidate-output"
-  | "domain-output"
-  | "multipath-review"
-  | "multipath-decision"
-  | "round-evaluation"
-  | "round-result"
-  | "final-result";
-export type SourceNotice = "runtime-associated" | "process-incomplete" | "record-missing" | "legacy-record";
+export type NodeType =
+  | "input" | "contract" | "operation" | "worker" | "attempt" | "artifact"
+  | "validator" | "validation" | "decision" | "checkpoint" | "publication"
+  | "transaction" | "readback";
 
-export interface CardLine {
-  key: string;
-  label: string;
-  value: string;
-  tone?: "normal" | "success" | "warning" | "failure";
-}
-
-export interface CardProjection {
-  primary?: { label?: string; value: string };
-  secondary: CardLine[];
-}
+export type Phase = "phase-1" | "phase-2" | "phase-3" | "delivery";
 
-export type SemanticKind = "decision" | "execution" | "output";
-export type DecisionType = "direction" | "tradeoff" | "evaluation" | "creative";
-export type DecisionAuthority = "final" | "recommendation" | "implementation-scope";
-export type AgentRole = "main" | "implementer" | "multipath-evaluator" | "overall-evaluator";
-
-export interface DecisionInputRef {
-  label: string;
-  summary?: string | null;
-  observedRelation: "read-by-actor" | "returned-to-actor" | "persisted-before-decision" | "standing-constraint";
-  decisionUse: "explicit-basis" | "not-recorded";
-  detailRef?: string | null;
-}
+export interface Metric { label: string; value: string }
 
-export interface AgentDecisionProjection {
-  id: string;
-  semanticKind: "decision";
-  decisionType: DecisionType;
-  subtype: string;
-  actor: { role: AgentRole; label: string };
-  authority: DecisionAuthority;
-  question: string;
-  inputs: DecisionInputRef[];
-  body: Record<string, unknown> & { type: DecisionType };
-  card: CardProjection;
-  detailRef: string;
-  promptRef?: string | null;
-  artifactRef?: string | null;
-  completeness: "complete" | "partial" | "missing";
-  notices: Array<{ code: string; message: string }>;
-}
-
-export interface BuildSummary {
-  id: string;
-  status: string;
-  title?: string;
-  createdAt?: string;
-  updatedAt?: string;
+export interface RecordEnvelope {
+  model_name: string;
+  schema_version: string | null;
+  payload: Record<string, unknown>;
 }
 
-export interface StoryNode {
+export interface FlowNodeRecord {
   id: string;
-  role: StoryRole;
-  kind: StoryKind;
+  node_type: NodeType;
+  phase: Phase;
+  lane: string;
   title: string;
-  card: CardProjection;
-  hasChanges: boolean;
-  status?: string;
-  detailRef?: string;
-  artifactRef?: string;
-  sourceNotice?: SourceNotice;
-  decision?: AgentDecisionProjection;
-}
-
-export interface RunHeader {
-  id: string;
-  title?: string;
+  subtitle: string;
   status: string;
-  summary?: string;
-  errorMessage?: string;
-  model?: string;
-  totalCost?: number;
-  currentRound?: number;
-  createdAt?: string;
-  updatedAt?: string;
-  direction: { currentValue?: string; note?: string };
-}
-
-export interface PlanProjection {
-  mode?: string | null;
-  rawMode?: string | null;
-  paths: unknown[];
-  note?: string | null;
-  currentOnly: boolean;
-  runtimeRevisionCount: number;
-  runtimeRevisionRefs: string[];
-  node: StoryNode;
-}
-
-export interface DataDecisionProjection {
-  id: string;
-  recordId?: number;
-  decision?: string;
-  reasoning?: string;
-  sources?: unknown[];
-  createdAt?: string;
-  node: StoryNode;
-  decisionProjection?: AgentDecisionProjection;
-}
-
-export interface BranchOutput {
-  type: "script-artifact" | "domain-info" | "missing";
-  summary: string;
-  snapshotRef?: string;
-  paragraphCount?: number;
-  elementCount?: number;
-  linkCount?: number;
-  factCount?: number;
-  factRefs?: string[];
-  accuracy?: { historical?: string; currentProjection?: string };
-  node: StoryNode;
-}
-
-export type RetrievalMode = "single" | "sequential" | "parallel" | "mixed" | "unknown";
-export type QueryStatus = "hit" | "empty" | "failure" | "running" | "interrupted" | "unknown";
-
-export interface QueryAttempt {
-  id: string;
-  eventId: number;
+  agent_role: string | null;
+  task_kind: string | null;
   sequence: number;
-  queryLabel: string;
-  status: QueryStatus;
-  resultCount?: number | null;
-  detailRef: string;
-}
-
-export interface DirectToolCall {
-  id: string;
-  eventId: number;
-  businessLabel: string;
-  status: "success" | "failure" | "running" | "interrupted";
-  resultSummary: string;
-  detailRef: string;
-}
-
-export interface DirectToolGroup {
-  id: string;
-  kind: "direct-tools";
-  owner: "script_implementer";
-  callCount: number;
-  successCount: number;
-  failureCount: number;
-  runningCount: number;
-  interruptedCount?: number;
-  sources: Array<{ businessLabel: string; callCount: number }>;
-  calls: DirectToolCall[];
-  waveIndex?: number | null;
-  startedAt?: string | null;
-  endedAt?: string | null;
-  detailRef: string;
-}
-
-export interface RetrievalAgentRun {
-  id: string;
-  kind: "retrieval-agent";
-  eventId: number;
-  agentType?: string;
-  businessLabel: string;
-  taskPreview?: string | null;
-  status: "running" | "completed" | "failed" | "interrupted";
-  querySummary: { total: number; hit: number; empty: number; failure: number; unknown: number; interrupted?: number };
-  attempts: QueryAttempt[];
-  screening: { state: "available" | "running" | "missing"; preview?: string | null };
-  waveIndex?: number | null;
-  startedAt?: string | null;
-  endedAt?: string | null;
-  durationMs?: number | null;
-  detailRef: string;
-}
-
-export interface RetrievalWave {
-  index: number;
-  operationIds: string[];
-  startedAt?: string | null;
-  endedAt?: string | null;
+  x: number;
+  y: number;
+  width: number;
+  tags: string[];
+  metrics: Metric[];
+  record: RecordEnvelope;
 }
 
-export interface RetrievalStage {
+export interface FlowEdgeRecord {
   id: string;
-  roundIndex: number;
-  branchId: number;
-  implementerEventId?: number;
-  observedMode: RetrievalMode;
-  waves: RetrievalWave[];
-  directToolGroups: DirectToolGroup[];
-  agentRuns: RetrievalAgentRun[];
-  status: "completed" | "running" | "partial" | "missing";
-  startedAt?: string | null;
-  endedAt?: string | null;
-  detailRef: string;
+  source: string;
+  target: string;
+  label: string | null;
+  kind: "control" | "artifact" | "validation" | "publication";
+  animated: boolean;
 }
 
-export interface BranchStory {
+export interface PhaseFrame {
   id: string;
-  branchId: number;
-  pathType: "内容" | "领域信息" | "unknown";
-  status: string;
   title: string;
-  summary: {
-    task?: string | null;
-    output?: string | null;
-  };
-  task: StoryNode;
-  retrievalStage: RetrievalStage;
-  dataDecisions: DataDecisionProjection[];
-  output: BranchOutput;
-  outcome: { status: string; label: string; reasoning?: string; sourceNotice?: SourceNotice };
-  detailRef?: string;
-}
-
-export interface BranchReviewConclusion {
-  branchId: number;
-  conclusion?: string | null;
-  summary?: string | null;
-}
-
-export interface MultipathReview {
-  id: string;
-  eventId: number;
-  roundIndex: number;
-  branchIds: number[];
-  branchConclusions: BranchReviewConclusion[];
-  comparison?: string | null;
-  recommendation?: string | null;
-  conclusion?: string | null;
-  status?: string;
-  startedAt?: string | null;
-  endedAt?: string | null;
-  detailRef: string;
-  promptRef?: string;
-  decisionProjection?: AgentDecisionProjection;
-}
-
-export interface BranchOutcome {
-  branchId: number;
-  status: string;
-  label: string;
-  reasoning?: string | null;
-}
-
-export interface MultipathDecisionBatch {
-  id: string;
-  recordId: number;
-  roundIndex: number;
-  branchIds: number[];
-  decision: string;
-  reasoning?: string;
-  createdAt?: string;
-  completeness: "complete" | "partial";
-  missingBranchIds: number[];
-  branchOutcomes: BranchOutcome[];
-  node: StoryNode;
-  decisionProjection?: AgentDecisionProjection;
-}
-
-export interface ConvergenceBatch {
-  id: string;
-  roundIndex: number;
-  branchIds: number[];
-  review?: MultipathReview | null;
-  decision?: MultipathDecisionBatch | null;
-  missingReview?: StoryNode | null;
-  missingDecision?: StoryNode | null;
-  association: "exact-branch-set-and-time" | "decision-only" | "review-only" | "records-missing" | string;
-}
-
-export interface RoundStoryV8 {
-  id: string;
-  roundIndex: number;
-  state: "running" | "passed" | "partially-passed" | "needs-retry" | "not-evaluated" | "unknown" | string;
-  summary: {
-    title: string;
-    goal: {
-      headline: string;
-      itemCount: number;
-      previewItems: string[];
-      truncated: boolean;
-    };
-    approach: {
-      mode?: string | null;
-      candidateCount: number;
-      plannedPathCount: number;
-      contentBranchCount: number;
-      domainBranchCount: number;
-      summary: string;
-    };
-    decision: {
-      batchCount: number;
-      branchIds: number[];
-      summary: string;
-    };
-    output: {
-      mergedContentCount: number;
-      domainFactCount: number;
-      scriptSummary: string;
-      domainSummary: string;
-      summary: string;
-    };
-  };
-  goal: StoryNode;
-  plan: PlanProjection;
-  branches: BranchStory[];
-  convergenceBatches: ConvergenceBatch[];
-  overallEvaluation: StoryNode;
-  result: StoryNode;
-  detailRef?: string;
+  subtitle: string;
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+  tone: "coral" | "blue" | "purple" | "green";
 }
 
-export interface ArtifactSummary {
-  snapshotRef?: string;
-  paragraphCount?: number;
-  elementCount?: number;
-  linkCount?: number;
-  historicalVersion?: string;
-}
-
-export interface FinalResultStory {
-  id: string;
-  role: "final-result";
-  kind: "result";
+export interface RunSummary {
+  script_build_id: number;
   title: string;
   status: string;
-  terminal: boolean;
-  roundCount: number;
-  branchCounts: Record<string, number> & { total: number };
-  summary?: string;
-  errorMessage?: string;
-  artifact: ArtifactSummary;
-  detailRef?: string;
-  sourceNotice?: SourceNotice;
-}
-
-export interface DisplayWarning { id?: string; message?: string; code?: string; level?: "info" | "warning" | string }
-
-export type CardRelationKind = "single-event" | "event-sequence" | "business-record" | "aggregate" | "calculated" | "missing";
-export type CardDataCompleteness = "complete" | "partial" | "missing";
-export type CardDataSourceKind = "database" | "runtime-event" | "artifact" | "calculation" | "log-anchor";
-export type CardDataRelation = "direct-input" | "previous-result" | "standing-constraint" | "explicit-basis" | "available-upstream" | "persisted-output";
-
-export interface CardDataSource {
-  kind: CardDataSourceKind;
-  label: string;
-  ref?: string;
-  fieldPath?: string;
-}
-
-export interface CardDataField {
-  id: string;
-  label: string;
-  value: unknown;
-  source: CardDataSource;
-  relation: CardDataRelation;
-  completeness: CardDataCompleteness;
-}
-
-export interface RuntimeUnit {
-  id: string;
-  label: string;
-  status?: string;
-  summary?: string;
-  input?: unknown;
-  output?: unknown;
-  durationMs?: number | null;
-  eventRef?: string | null;
-  completeness?: CardDataCompleteness;
-  [key: string]: unknown;
-}
-
-export interface CardBusinessData {
-  cardKind: string;
-  relationKind: CardRelationKind;
-  completeness: CardDataCompleteness;
-  businessInputs: CardDataField[];
-  runtime: {
-    summary: string;
-    units: RuntimeUnit[];
-    calculation?: string;
-  };
-  businessOutputs: CardDataField[];
-  displayUse: {
-    businessModules: Array<{ id: string; title: string; sourceIds: string[] }>;
-    cardFields: Array<{ key: string; label: string; sourceIds: string[]; transform: string }>;
-    unusedSourceIds: string[];
-  };
-  notices: Array<{ level: "info" | "warning"; message: string }>;
-}
-
-export type InspectorCompleteness = "complete" | "partial" | "missing";
-export type InspectorSourceKind = "database" | "runtime-event" | "artifact" | "log-anchor" | "calculation" | "historical-fallback" | "missing" | string;
-export type SourceAvailability = "direct-read" | "returned-to-agent" | "available-upstream" | "produced-by-run" | "visualization-derived" | "unavailable";
-export type SourceAdoption = "explicitly-adopted" | "explicitly-rejected" | "not-recorded" | "not-applicable";
-export type SourceConfidence = "exact" | "safe-association" | "deterministic" | "unconfirmed";
-
-export interface EvidenceSpec {
-  availability: SourceAvailability;
-  adoption: SourceAdoption;
-  confidence: SourceConfidence;
-}
-
-export type SourceSelector =
-  | { kind: "json-pointer"; path: string }
-  | { kind: "text-span"; path: string; startCodePoint: number; endCodePoint: number; exactText: string; sourceDigest: string }
-  | { kind: "members"; members: Array<{ sourceId: string; selector: SourceSelector }>; reducer: string }
-  | { kind: "whole-record" }
-  | { kind: "unresolved"; reason: string; code?: string };
-
-export type SourceTransform =
-  | { kind: "direct" }
-  | { kind: "parsed"; operation: string; outputKey: string }
-  | { kind: "combined"; operation: string }
-  | { kind: "calculated"; operation: string }
-  | { kind: "fallback"; selectedSourceId: string; candidateSourceIds: string[] };
-
-export interface SourceBinding {
-  id: string;
-  sourceId: string;
-  role: "input" | "basis" | "constraint" | "process" | "output" | "status";
-  selector: SourceSelector;
-  transform: SourceTransform;
-  evidence: EvidenceSpec;
-  resolution: "resolved" | "partial" | "missing" | "unsafe";
-}
-
-export interface InspectorBusinessItem {
-  id: string;
-  label?: string;
-  value: unknown;
-  bindings: SourceBinding[];
-}
-
-export interface InspectorModule {
-  id: string;
-  title: string;
-  businessPath?: string;
-  rows?: InspectorComparisonRow[];
-  presentation: "text" | "items" | "plan" | "process" | "review" | "matrix" | "artifact" | "notice";
-  business?: { value?: unknown; items?: InspectorBusinessItem[] };
-  bindings: SourceBinding[];
-  runtimeRefs: string[];
-  defaultOpen: boolean;
-}
-
-export interface InspectorComparisonRow {
-  id: string;
-  label?: string;
-  businessSelector: string;
-  bindingIds: string[];
-  children?: InspectorComparisonRow[];
-}
-
-export interface SourceRecord {
-  id: string;
-  kind: InspectorSourceKind;
-  label: string;
-  locator?: {
-    table?: string;
-    recordId?: string | number;
-    eventId?: string | number;
-    eventName?: string;
-    artifactRef?: string;
-    logAnchor?: string;
-    fieldPath?: string;
-    [key: string]: unknown;
-  } | string;
-  status?: string;
-  durationMs?: number | null;
-  completeness: InspectorCompleteness;
-  resultState?: "present" | "empty" | "missing" | "unavailable";
-  truncated?: boolean;
-  omittedCharacters?: number;
-  selectedValues?: Array<{
-    bindingId?: string;
-    aggregateBindingId?: string;
-    fieldId?: string;
-    label?: string;
-    path?: string | null;
-    value?: unknown;
-    [key: string]: unknown;
-  }>;
-  rawRecord?: unknown;
-  [key: string]: unknown;
-}
-
-export interface InspectorWorkbenchProjection {
-  schemaVersion: "inspector-source-v2";
-  detailRef: string;
-  cardKind: string;
-  completeness: InspectorCompleteness;
-  capturedAt?: string;
-  businessProjection: Omit<InspectorDetailProjection, "technical" | "changes">;
-  modules: InspectorModule[];
-  sources: Record<string, SourceRecord>;
-  notices: Array<{ code?: string; level?: "info" | "warning" | "error" | string; message: string }>;
-}
-
-export interface ExecutionViewV8 {
-  schemaVersion: "8" | string;
-  capturedAt: string;
-  dataShape: "current" | "legacy-incomplete";
-  header: RunHeader;
-  planning?: StoryNode;
-  objective: StoryNode;
-  rounds: RoundStoryV8[];
-  finalResult: FinalResultStory;
-  unassigned: { runtimeEvents: unknown[]; businessRecords: unknown[] };
-  warnings: Array<string | DisplayWarning>;
-}
-
-export type InspectorVisualRole = "key-result" | "section" | "evidence" | "reason" | "notice";
-export type InspectorPresentation = "prose" | "list" | "labelled-list" | "document" | "plan" | "process" | "grouped-review" | "matrix" | "comparison" | "artifact";
-
-export interface InspectorSemanticMeta {
-  visualRole?: InspectorVisualRole;
-  presentation?: InspectorPresentation;
-  collapsible?: boolean;
-  defaultOpen?: boolean;
-}
-
-export interface DetailSection extends InspectorSemanticMeta {
-  id: string;
-  title: string;
-  content?: string;
-  items?: DetailItem[];
-  variant?: "default" | "summary" | "report";
-}
-
-export interface DetailItem {
-  label: string;
-  value: string;
-  detailRef?: string;
-  sourceNotice?: SourceNotice;
-}
-
-export interface InspectorDetailProjection {
-  detailKind?: "activity" | "agent-decision" | "retrieval-agent" | "retrieval-group" | "retrieval-event";
-  businessSections?: DetailSection[];
-  decisionType?: DecisionType;
-  actor?: { role: AgentRole; label: string };
-  authority?: DecisionAuthority;
-  question?: string;
-  promptRef?: string | null;
-  blocks?: DecisionDetailBlock[];
-  changes?: {
-    summary?: string;
-    sections: DetailSection[];
-    artifactRef?: string;
-    exactness: "exact" | "current-only" | "historical-snapshot" | "unknown";
-  };
-  technical: unknown;
-  retrievalKind?: "agent" | "direct-tools";
-  activities?: RetrievalActivity[];
-  missingActivityNotice?: string;
-  eventId?: number;
-  sourceKind?: "agent-query" | "direct-tool";
-  toolName?: string;
-  businessLabel?: string;
-  queryConditions?: RetrievalDetailField[];
-  outcome?: RetrievalOutcome;
-  items?: RetrievalResultItem[];
-  completeness?: RetrievalCompleteness;
-}
-
-export interface RetrievalActivity {
-  id: string;
-  kind: "query" | "tool";
-  label: string;
-  status: QueryStatus | "success";
-  resultCount?: number | null;
-  summary?: string;
-  detailRef: string;
-}
-
-export interface RetrievalDetailField {
-  key: string;
-  label: string;
-  value: unknown;
-}
-
-export interface RetrievalOutcome {
-  state: QueryStatus;
-  count?: number;
-  message: string;
-  errorKind?: "parameter" | "transport" | "upstream" | "unknown";
-}
-
-export interface RetrievalResultSection {
-  title: string;
-  value: unknown;
-}
-
-export interface RetrievalResultItem {
-  id: string;
-  title: string;
-  subtitle?: string;
-  href?: string;
-  meta: Array<{ label: string; value: unknown }>;
-  sections: RetrievalResultSection[];
-}
-
-export interface RetrievalCompleteness {
-  bodyAvailable: boolean;
-  truncated: boolean;
-  omittedCharacters?: number;
-}
-
-export type DecisionDetailBlock = InspectorSemanticMeta & (
-  | { type: "summary"; title: string; value: string }
-  | { type: "items"; title: string; items: Array<string | Record<string, unknown>> }
-  | {
-      type: "creative-process";
-      title: string;
-      steps: Array<{
-        stepIndex: number;
-        action: string;
-        summary?: string | null;
-        plan?: string | null;
-        reasoning?: string | null;
-        eventRef?: string | null;
-      }>;
-    }
-  | {
-      type: "evaluation-branches";
-      title: string;
-      branches: Array<{
-        subject: string;
-        conclusion?: string | null;
-        achievements?: string[];
-        problems?: string[];
-        evidence?: string | null;
-      }>;
-    }
-  | {
-      type: "evaluation-matrix";
-      title: string;
-      rows: Array<{
-        criterion: string;
-        candidates: Array<{ subject: string; value: string }>;
-        basis?: string | null;
-      }>;
-    }
-  | {
-      type: "implementation-plan";
-      title: string;
-      mode?: string | null;
-      reasoning?: string | null;
-      routes: Array<{
-        pathIndex: number;
-        pathType?: string | null;
-        action?: string | null;
-        target?: string | null;
-        method?: string | null;
-        emphasis?: string | null;
-      }>;
-    }
-  | { type: "comparison"; title: string; rows: Array<Record<string, unknown>> }
-  | { type: "notice"; title?: string; level: "info" | "warning"; value: string }
-  | { type: "artifact"; title: string; refs: Array<{ label: string; artifactRef: string }> }
-);
-
-export interface PromptProjection {
-  promptRef: string;
-  actor: { role: AgentRole; label: string };
-  exactTask?: { content: string; accuracy: "run-exact" };
-  systemPrompt?: {
-    content: string;
-    source: "current-db" | "current-file";
-    version?: number | null;
-    accuracy: "current-not-run-snapshot";
-    capturedAt?: string;
-    truncated: boolean;
-  };
-  notices: Array<{ code: string; message: string }>;
+  checkpoint: string;
+  root_trace_id: string;
+  publication_state: string;
+  node_count: number;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface RunGraph {
+  schema_version: "script-build-visualization/v1";
+  data_mode: "fake";
+  generated_at: string;
+  run: RunSummary;
+  frames: PhaseFrame[];
+  nodes: FlowNodeRecord[];
+  edges: FlowEdgeRecord[];
+  schema_catalog: Record<string, string[]>;
+  enum_catalog: Record<string, string[]>;
+  declared_gaps: string[];
 }
-
-export interface ScriptDimension {
-  id?: number | string | null;
-  type?: string | null;
-  name?: string | null;
-  value?: string | null;
-}
-
-export interface ScriptFacet {
-  label: string;
-  description?: string | null;
-  descriptionParts: ScriptDescriptionPart[];
-  dimensions: ScriptDimension[];
-}
-
-export type ScriptDescriptionPart =
-  | { type: "text"; text: string }
-  | { type: "element"; id: number; name?: string | null; resolved: boolean; text: string };
-
-export interface ScriptParagraphRow {
-  id: number | string;
-  rowId: number | string;
-  parentId?: number | string | null;
-  depth: number;
-  index?: number | null;
-  level?: number | null;
-  name: string;
-  contentRange: string[];
-  sections: Record<"theme" | "form" | "function" | "feeling", ScriptFacet>;
-  description?: string | null;
-  fullDescription?: string | null;
-  fullDescriptionParts: ScriptDescriptionPart[];
-}
-
-export interface ScriptElementRow {
-  id: number | string;
-  rowId: number | string;
-  name: string;
-  primaryDimension?: string | null;
-  secondaryDimension?: string | null;
-  paragraphs: Array<{ id: number | string; index?: number | null; name: string }>;
-}
-
-export interface ScriptTableProjection {
-  snapshotRef: string;
-  exactness: "exact" | "current-only" | "historical-snapshot" | "unknown";
-  sourceOrigin: string;
-  counts: { paragraphs: number; elements: number; links: number };
-  maxDepth: number;
-  paragraphs: ScriptParagraphRow[];
-  elements: ScriptElementRow[];
-}
-
-export interface ArtifactContext {
-  type: "final" | "candidate" | "round";
-  exactness: "exact" | "current-only" | "historical-snapshot" | "unknown";
-  roundIndex?: number | null;
-  branchId?: number | null;
-  branchStatus?: string | null;
-  pathType?: string | null;
-  versionKind?: string | null;
-  historicalAccuracy?: string | null;
-  currentProjectionAccuracy?: string | null;
-  note?: string | null;
-}
-
-export interface ArtifactDetailProjection extends InspectorDetailProjection {
-  artifactContext: ArtifactContext;
-  scriptTable: ScriptTableProjection;
-}
-
-export type CanvasItem =
-  | ({ itemType: "story"; roundIndex?: number; branchId?: number } & StoryNode)
-  | { itemType: "retrieval-stage"; id: string; title: string; roundIndex: number; branchId: number; stage: RetrievalStage; detailRef: string }
-  | { itemType: "retrieval-direct"; id: string; title: string; roundIndex: number; branchId: number; group: DirectToolGroup; detailRef: string }
-  | { itemType: "retrieval-agent"; id: string; title: string; roundIndex: number; branchId: number; run: RetrievalAgentRun; detailRef: string }
-  | { itemType: "query-attempt"; id: string; title: string; roundIndex: number; branchId: number; attempt: QueryAttempt; detailRef: string }
-  | { itemType: "retrieval-activity"; id: string; title: string; roundIndex: number; branchId: number; activity: RetrievalActivity; detailRef: string }
-  | { itemType: "branch-summary"; id: string; title: string; roundIndex: number; branchId: number; branch: BranchStory; detailRef?: string }
-  | { itemType: "multipath-review"; id: string; title: string; roundIndex: number; review: MultipathReview; detailRef: string }
-  | { itemType: "multipath-decision"; id: string; title: string; roundIndex: number; decision: MultipathDecisionBatch; detailRef: string }
-  | { itemType: "round-summary"; id: string; title: string; roundIndex: number; round: RoundStoryV8; detailRef?: string }
-  | { itemType: "final-result"; id: string; title: string; result: FinalResultStory; detailRef?: string };
-
-export const TERMINAL_STATUSES = new Set(["success", "partial", "failed", "stopped", "completed", "cancelled", "cancel", "cancle"]);
-export const isTerminalStatus = (status?: string) => TERMINAL_STATUSES.has((status || "").toLowerCase());
-export const warningText = (warning: ExecutionViewV8["warnings"][number]) => typeof warning === "string" ? warning : warning.message || warning.code || "数据记录不完整";

+ 1 - 2
visualization/frontend/next.config.mjs

@@ -1,7 +1,6 @@
 /** @type {import('next').NextConfig} */
 const nextConfig = {
-  allowedDevOrigins: ["127.0.0.1", "localhost"],
-  devIndicators: false
+  poweredByHeader: false,
 };
 
 export default nextConfig;

File diff suppressed because it is too large
+ 118 - 393
visualization/frontend/package-lock.json


+ 2 - 5
visualization/frontend/package.json

@@ -1,5 +1,5 @@
 {
-  "name": "script-build-execution-visualization",
+  "name": "script-build-mission-control",
   "version": "1.0.0",
   "private": true,
   "scripts": {
@@ -7,7 +7,6 @@
     "build": "next build",
     "start": "next start --hostname 127.0.0.1 --port 3008",
     "test": "vitest run",
-    "test:watch": "vitest",
     "test:e2e": "playwright test",
     "typecheck": "tsc --noEmit"
   },
@@ -16,9 +15,7 @@
     "lucide-react": "^0.468.0",
     "next": "^15.0.0",
     "react": "^19.0.0",
-    "react-dom": "^19.0.0",
-    "react-markdown": "^10.1.0",
-    "remark-gfm": "^4.0.1"
+    "react-dom": "^19.0.0"
   },
   "overrides": {
     "postcss": "^8.5.10"

+ 5 - 25
visualization/frontend/playwright.config.ts

@@ -1,33 +1,13 @@
-import { defineConfig, devices } from "@playwright/test";
-
-const localBrowser = process.env.PLAYWRIGHT_USE_SYSTEM_CHROME === "1" ? { channel: "chrome" as const } : {};
+import { defineConfig } from "@playwright/test";
 
 export default defineConfig({
   testDir: "./tests/e2e",
-  outputDir: "../test-artifacts/playwright-results",
-  reporter: [["list"], ["html", { outputFolder: "../test-artifacts/playwright-report", open: "never" }]],
+  timeout: 30_000,
   use: {
     baseURL: "http://127.0.0.1:3008",
+    viewport: { width: 1600, height: 1000 },
+    screenshot: "only-on-failure",
     trace: "retain-on-failure",
-    screenshot: "only-on-failure"
   },
-  projects: [
-    { name: "desktop-chromium", use: { ...devices["Desktop Chrome"], ...localBrowser } },
-    { name: "tablet-chromium", use: { ...devices["Desktop Chrome"], ...localBrowser, viewport: { width: 1024, height: 900 } } },
-    { name: "mobile-chromium", use: { ...devices["Pixel 7"], ...localBrowser } }
-  ],
-  webServer: [
-    {
-      command: "../backend/.venv/bin/uvicorn app.main:app --app-dir ../backend --host 127.0.0.1 --port 8788",
-      url: "http://127.0.0.1:8788/api/health",
-      reuseExistingServer: true,
-      timeout: 30_000
-    },
-    {
-      command: "npm start",
-      url: "http://127.0.0.1:3008",
-      reuseExistingServer: true,
-      timeout: 60_000
-    }
-  ]
+  outputDir: "../test-artifacts/playwright",
 });

+ 0 - 221
visualization/frontend/tests/cards-v8.test.tsx

@@ -1,221 +0,0 @@
-import { fireEvent, render, screen } from "@testing-library/react";
-import { ReactFlowProvider } from "@xyflow/react";
-import { describe, expect, it, vi } from "vitest";
-import { CandidateCard } from "@/components/flow/CandidateCard";
-import { RoundSummaryCard } from "@/components/flow/RoundSummaryCard";
-import { StoryCard } from "@/components/flow/StoryCard";
-import { RunResultCard } from "@/components/flow/RunResultCard";
-import { AgentDecisionCard } from "@/components/decision/AgentDecisionCard";
-import { MobileTimeline } from "@/components/workbench/MobileTimeline";
-import { branchSummaryItem, finalResultItem, multipathDecisionItem, multipathReviewItem, roundSummaryItem, storyItem } from "@/lib/layout";
-import { makeBranch, makeRound, makeStory, makeView } from "@/tests/fixtures";
-
-const base = { onOpen: vi.fn(), onToggleRound: vi.fn(), onToggleBranch: vi.fn() };
-const nodeProps = { selected: false, zIndex: 1, isConnectable: false, positionAbsoluteX: 0, positionAbsoluteY: 0, dragging: false, draggable: true, selectable: true, deletable: false };
-const wrap = (node: React.ReactNode) => render(<ReactFlowProvider>{node}</ReactFlowProvider>);
-
-describe("V8 business cards", () => {
-  it("labels the creation objective action as details while keeping decision actions explicit", () => {
-    const objective = storyItem(makeView().objective);
-    const { unmount } = wrap(<AgentDecisionCard id="objective" type="agentDecision" {...nodeProps} data={{ ...base, item: objective }} />);
-    expect(screen.getByRole("button", { name: "查看详情" })).toBeInTheDocument();
-    expect(screen.queryByRole("button", { name: "查看决策" })).not.toBeInTheDocument();
-
-    unmount();
-    const dataDecision = storyItem(makeStory("data", "data-decision"), 1, 1);
-    wrap(<AgentDecisionCard id="data" type="agentDecision" {...nodeProps} data={{ ...base, item: dataDecision }} />);
-    expect(screen.getByRole("button", { name: "查看决策" })).toBeInTheDocument();
-  });
-
-  it("shows implementer data decisions as data decisions", () => {
-    const item = storyItem(makeStory("data", "data-decision"), 1, 1);
-    wrap(<AgentDecisionCard id="data" type="agentDecision" {...nodeProps} data={{ ...base, item }} />);
-    expect(screen.getByText("数据取舍")).toBeInTheDocument();
-    expect(screen.getByText("实现 Agent")).toBeInTheDocument();
-  });
-
-  it("distinguishes domain-information output from candidate script", () => {
-    const branch = makeBranch(1, 4);
-    wrap(<CandidateCard id="branch" type="candidateCard" {...nodeProps} data={{ ...base, item: branchSummaryItem(branch, 1), expanded: false }} />);
-    expect(screen.getByText(/领域信息/)).toBeInTheDocument();
-    expect(screen.getByText("领域事实")).toBeInTheDocument();
-    expect(screen.getByRole("button", { name: /展开方案过程/ })).toBeInTheDocument();
-    expect(screen.getByRole("button", { name: /查看方案汇总/ })).toBeInTheDocument();
-    expect(screen.queryByRole("button", { name: "查看完整候选脚本" })).not.toBeInTheDocument();
-  });
-
-  it("opens the same artifact viewer from collapsed and expanded candidate output", () => {
-    const onOpen = vi.fn();
-    const branch = makeBranch(1, 1);
-    const summaryItem = branchSummaryItem(branch, 1);
-    const { unmount } = wrap(<CandidateCard id="branch" type="candidateCard" {...nodeProps} data={{ ...base, onOpen, item: summaryItem, expanded: false }} />);
-    fireEvent.click(screen.getByRole("button", { name: "查看完整候选脚本" }));
-    expect(onOpen).toHaveBeenCalledWith(summaryItem, "artifact");
-
-    unmount();
-    onOpen.mockClear();
-    const creativeOutput = makeStory("output", "candidate-output", "decision", { artifactRef: "artifact:branch:1" });
-    const outputItem = storyItem(creativeOutput, 1, 1);
-    wrap(<AgentDecisionCard id="output" type="agentDecision" {...nodeProps} data={{ ...base, onOpen, onPrompt: vi.fn(), item: outputItem }} />);
-    fireEvent.click(screen.getByRole("button", { name: "查看完整候选脚本" }));
-    expect(onOpen).toHaveBeenCalledWith(outputItem, "artifact");
-    expect(screen.queryByRole("button", { name: "查看实现任务与提示词" })).not.toBeInTheDocument();
-  });
-
-  it("keeps the candidate artifact action on an expanded mobile creative decision", () => {
-    const view = makeView(1, 1, 1);
-    const branch = view.rounds[0].branches[0];
-    branch.output.node = makeStory("mobile-output", "candidate-output", "decision", { artifactRef: "artifact:branch:1" });
-    const onOpen = vi.fn();
-    render(<MobileTimeline
-      view={view}
-      expandedRounds={new Set([1])}
-      expandedBranches={new Set(["1:1"])}
-      expandedRetrievalAgents={new Set()}
-      onToggleRound={vi.fn()}
-      onToggleBranch={vi.fn()}
-      onToggleRetrievalAgent={vi.fn()}
-      onOpen={onOpen}
-      onCanvas={vi.fn()}
-    />);
-
-    const artifactButtons = screen.getAllByRole("button", { name: "查看完整候选脚本" });
-    expect(artifactButtons).toHaveLength(2);
-    fireEvent.click(artifactButtons[1]);
-    expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ id: "mobile-output" }), "artifact");
-    expect(screen.queryByText("查看实现任务与提示词")).not.toBeInTheDocument();
-  });
-
-  it("keeps runtime association in technical detail instead of the business card", () => {
-    const story = makeStory("evaluation", "round-evaluation");
-    story.sourceNotice = "runtime-associated";
-    story.detailRef = "event:987";
-    wrap(<StoryCard id="evaluation" type="storyCard" {...nodeProps} data={{ ...base, item: storyItem(story, 1) }} />);
-    expect(screen.queryByText("运行记录关联")).not.toBeInTheDocument();
-    expect(screen.queryByText("event:987")).not.toBeInTheDocument();
-  });
-
-  it("keeps objective and step summaries visible on the mobile timeline", () => {
-    const view = makeView(1, 1, 1);
-    view.objective.card = {
-      primary: { label: "核心目标", value: "形成清晰可执行的创作方向" },
-      secondary: [
-        { key: "inputs", label: "决策前读取", value: "选题 · 账号段落模式 · 账号人设" },
-        { key: "shape", label: "目标结构", value: "2 个创作目标 · 1 个领域评估维度" },
-      ],
-    };
-    view.objective.decision!.card = view.objective.card;
-    view.rounds[0].plan.node.card = {
-      primary: { label: "实现方式", value: "分工 · 2 路实现" },
-      secondary: [
-        { key: "paths", label: "各路任务", value: "方案 1:补充步骤;方案 2:增强信息" },
-        { key: "reason", label: "规划理由", value: "两个任务没有强依赖" },
-      ],
-    };
-    view.rounds[0].plan.node.decision!.card = view.rounds[0].plan.node.card;
-
-    render(<MobileTimeline
-      view={view}
-      expandedRounds={new Set([1])}
-      expandedBranches={new Set()}
-      expandedRetrievalAgents={new Set()}
-      onToggleRound={vi.fn()}
-      onToggleBranch={vi.fn()}
-      onToggleRetrievalAgent={vi.fn()}
-      onOpen={vi.fn()}
-      onCanvas={vi.fn()}
-    />);
-
-    expect(screen.getByText("选题 · 账号段落模式 · 账号人设")).toBeInTheDocument();
-    expect(screen.getByText("2 个创作目标 · 1 个领域评估维度")).toBeInTheDocument();
-    expect(screen.getByText("方案 1:补充步骤;方案 2:增强信息")).toBeInTheDocument();
-    expect(screen.getByText("两个任务没有强依赖")).toBeInTheDocument();
-    expect(screen.getAllByText("创作目标")).toHaveLength(1);
-    expect(screen.getAllByText("本轮目标")).toHaveLength(1);
-    expect(screen.getAllByText("实现规划")).toHaveLength(1);
-  });
-
-  it("keeps candidate review and main decision as two distinct cards", () => {
-    const batch = makeRound(1, 2, 1).convergenceBatches[0];
-    const { unmount } = wrap(<AgentDecisionCard id="review" type="agentDecision" {...nodeProps} data={{ ...base, item: multipathReviewItem(batch.review!, 1) }} />);
-    expect(screen.getByText("多方案评审 Agent")).toBeInTheDocument();
-    expect(screen.getAllByText("评审建议").length).toBeGreaterThanOrEqual(1);
-    unmount();
-
-    wrap(<AgentDecisionCard id="decision" type="agentDecision" {...nodeProps} data={{ ...base, item: multipathDecisionItem(batch.decision!, 1) }} />);
-    expect(screen.getByText("主 Agent")).toBeInTheDocument();
-    expect(screen.getAllByText("最终决定").length).toBeGreaterThanOrEqual(1);
-    expect(screen.getByText("方案 1、方案 2")).toBeInTheDocument();
-  });
-
-  it("describes a stopped open branch as incomplete process instead of missing data", () => {
-    const branch = makeBranch(1, 1);
-    branch.status = "open";
-    branch.outcome.status = "open";
-    branch.outcome.label = "构建停止时尚未处置";
-    branch.outcome.sourceNotice = "process-incomplete";
-    wrap(<CandidateCard id="branch" type="candidateCard" {...nodeProps} data={{ ...base, item: branchSummaryItem(branch, 1), expanded: false }} />);
-    expect(screen.getByText("尚未处置")).toBeInTheDocument();
-    expect(screen.getByText("流程未完成")).toBeInTheDocument();
-    expect(screen.queryByText("记录缺失")).not.toBeInTheDocument();
-  });
-
-  it("keeps round process and detail actions visible", () => {
-    const onOpen = vi.fn();
-    const round = makeRound(1, 3, 2);
-    const item = roundSummaryItem(round);
-    wrap(<RoundSummaryCard id="round" type="roundSummary" {...nodeProps} data={{ ...base, onOpen, item }} />);
-    expect(screen.getByRole("button", { name: /展开本轮/ })).toBeInTheDocument();
-    expect(screen.getByRole("button", { name: /查看详情/ })).toBeInTheDocument();
-    fireEvent.click(screen.getByRole("button", { name: "查看本轮产出脚本表" }));
-    expect(onOpen).toHaveBeenCalledWith(item, "artifact");
-    expect(screen.getByText("本轮目标 1")).toBeInTheDocument();
-    expect(screen.getByText("3 项具体任务 · 完整内容见详情")).toBeInTheDocument();
-    expect(screen.getByText("分工 · 3 个实现方案")).toBeInTheDocument();
-    expect(screen.getByText("2 个决策批次 · 涉及方案 1、2、3")).toBeInTheDocument();
-    expect(screen.getByText("形成第 1 轮内容产出")).toBeInTheDocument();
-  });
-
-  it("opens the round artifact from the expanded round result", () => {
-    const onOpen = vi.fn();
-    const result = makeStory("round:1:result", "round-result", "result", { artifactRef: "artifact:round:1" });
-    const item = storyItem(result, 1);
-    wrap(<StoryCard id="round-result" type="storyCard" {...nodeProps} data={{ ...base, onOpen, item }} />);
-    fireEvent.click(screen.getByRole("button", { name: "查看本轮产出脚本表" }));
-    expect(onOpen).toHaveBeenCalledWith(item, "artifact");
-  });
-
-  it("labels a completed round without an overall evaluation explicitly", () => {
-    const result = makeStory("round:1:result", "round-result", "result", { status: "not-evaluated" });
-    wrap(<StoryCard id="round-result" type="storyCard" {...nodeProps} data={{ ...base, item: storyItem(result, 1) }} />);
-    expect(screen.getByText("未整体评审")).toBeInTheDocument();
-    expect(screen.queryByText("尚无结论")).not.toBeInTheDocument();
-  });
-
-  it("uses the run-aware decision summary instead of a generic missing-record label", () => {
-    const round = makeRound(1, 2, 0);
-    round.summary.decision.summary = "构建停止前尚未形成多路决策";
-    wrap(<RoundSummaryCard id="round" type="roundSummary" {...nodeProps} data={{ ...base, item: roundSummaryItem(round) }} />);
-    expect(screen.getByText("构建停止前尚未形成多路决策")).toBeInTheDocument();
-    expect(screen.queryByText("未记录主 Agent 多路决策")).not.toBeInTheDocument();
-  });
-
-  it("opens the dedicated artifact viewer from the final result", () => {
-    const onOpen = vi.fn();
-    const view = makeView();
-    view.finalResult.summary = "脚本构建终结:已经完成结构、内容和表达检查。\n1. 脚本结构概述:四个段落已经形成完整闭环。";
-    const item = finalResultItem(view);
-    wrap(<RunResultCard id="final" type="finalResult" {...nodeProps} data={{ ...base, onOpen, item }} />);
-    expect(screen.getByText("当前主脚本已经形成")).toBeInTheDocument();
-    expect(screen.getByText("构建摘要")).toBeInTheDocument();
-    expect(screen.getByText("3", { selector: ".artifactStats b" })).toBeInTheDocument();
-    fireEvent.click(screen.getByRole("button", { name: "查看结果详情" }));
-    expect(onOpen).toHaveBeenCalledWith(item, "business");
-    onOpen.mockClear();
-    fireEvent.click(screen.getByRole("button", { name: "查看完整主脚本" }));
-    expect(onOpen).toHaveBeenCalledWith(item, "artifact");
-    onOpen.mockClear();
-    fireEvent.keyDown(screen.getByRole("button", { name: "查看完整主脚本" }), { key: "Enter" });
-    expect(onOpen).not.toHaveBeenCalledWith(item, "business");
-  });
-});

+ 0 - 131
visualization/frontend/tests/data-flow.test.tsx

@@ -1,131 +0,0 @@
-import { fireEvent, render, screen } from "@testing-library/react";
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { DataFlowRenderer } from "@/components/inspector/DataFlowRenderer";
-import { Inspector } from "@/components/inspector/Inspector";
-import { getCardData } from "@/lib/api";
-import { finalResultItem, storyItem } from "@/lib/layout";
-import type { CardBusinessData } from "@/lib/types";
-import { makeStory, makeView } from "@/tests/fixtures";
-
-function makeCardData(overrides: Partial<CardBusinessData> = {}): CardBusinessData {
-  return {
-    cardKind: "data-decision",
-    relationKind: "business-record",
-    completeness: "partial",
-    businessInputs: [{
-      id: "input:cases",
-      label: "候选案例",
-      value: "案例 A、案例 B",
-      source: { kind: "database", label: "取数结果", ref: "retrieval-agent:3786", fieldPath: "output.cases" },
-      relation: "available-upstream",
-      completeness: "complete",
-    }],
-    runtime: {
-      summary: "实现 Agent 根据可用材料形成取舍记录。",
-      units: [{ id: "event:3786", label: "解构 Case Agent", status: "completed", durationMs: 1250, summary: "返回两个候选案例。", input: "查找相关案例", output: { count: 2 }, eventRef: "event:3786", completeness: "complete" }],
-      calculation: "按同一分支和时间顺序聚合,不使用文本相似度补链。",
-    },
-    businessOutputs: [{
-      id: "output:decision",
-      label: "明确取舍",
-      value: "采用案例 A,舍弃案例 B。",
-      source: { kind: "database", label: "数据取舍记录", ref: "data-decision:115" },
-      relation: "persisted-output",
-      completeness: "complete",
-    }],
-    displayUse: {
-      businessModules: [{ id: "decision", title: "明确取舍", sourceIds: ["output:decision"] }],
-      cardFields: [{ key: "headline", label: "取舍结论", sourceIds: ["output:decision"], transform: "直接使用结构化结论" }],
-      unusedSourceIds: ["input:cases"],
-    },
-    notices: [{ level: "warning", message: "上游材料存在,但运行记录没有证明它被本次决策采用。" }],
-    ...overrides,
-  };
-}
-
-describe("card data flow", () => {
-  afterEach(() => vi.unstubAllGlobals());
-
-  it("keeps the old Inspector focused on business, changes and technical detail", () => {
-    const item = storyItem(makeStory("candidate", "candidate-output", "result", { hasChanges: true }));
-    const detail = { businessSections: [], changes: { sections: [], exactness: "exact" }, technical: {} };
-    render(<Inspector item={item} tab="business" onTab={vi.fn()} onClose={vi.fn()} detail={detail} />);
-
-    expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["业务详情", "产出与改动", "技术详情"]);
-  });
-
-  it("renders the vertical lineage, explicit uncertainty and display mappings", () => {
-    render(<DataFlowRenderer data={makeCardData()} />);
-
-    expect(screen.getByRole("heading", { name: "数据关系与完整性" })).toBeInTheDocument();
-    expect(screen.getByText("业务记录")).toBeInTheDocument();
-    expect(screen.getByText("部分记录")).toBeInTheDocument();
-    expect(screen.getByRole("heading", { name: "业务输入" })).toBeInTheDocument();
-    expect(screen.getByRole("heading", { name: "运行过程 / I/O" })).toBeInTheDocument();
-    expect(screen.getByRole("heading", { name: "业务输出" })).toBeInTheDocument();
-    expect(screen.getByRole("heading", { name: "页面如何使用这些数据" })).toBeInTheDocument();
-    expect(screen.getByText("可确认是上游信息,但无法确认是否被本次决策采用")).toBeInTheDocument();
-    expect(screen.getAllByText("数据库")).toHaveLength(2);
-    expect(screen.getByText("直接使用结构化结论")).toBeInTheDocument();
-    expect(screen.getAllByText("F2")).toHaveLength(3);
-  });
-
-  it("uses keyboard-friendly disclosures for runtime units and long values", () => {
-    const longText = "完整业务正文。".repeat(150);
-    render(<DataFlowRenderer data={makeCardData({ businessOutputs: [{
-      id: "output:decision",
-      label: "完整正文",
-      value: longText,
-      source: { kind: "artifact", label: "候选快照" },
-      relation: "persisted-output",
-      completeness: "complete",
-    }] })} />);
-
-    const runtimeSummary = screen.getByText("解构 Case Agent").closest("summary");
-    expect(runtimeSummary).toHaveAttribute("aria-expanded", "false");
-    fireEvent.click(runtimeSummary!);
-    expect(runtimeSummary).toHaveAttribute("aria-expanded", "true");
-    expect(screen.getByText("输入")).toBeInTheDocument();
-
-    const longSummary = screen.getByText("展开完整内容").closest("summary");
-    expect(longSummary).toHaveAttribute("aria-expanded", "false");
-    expect(screen.queryByText(longText)).not.toBeInTheDocument();
-    fireEvent.click(longSummary!);
-    expect(screen.getByText("收起完整内容").closest("summary")).toHaveAttribute("aria-expanded", "true");
-    expect(screen.getByText(longText)).toBeInTheDocument();
-  });
-
-  it("shows dedicated loading, error and missing-reference states", () => {
-    const { rerender } = render(<DataFlowRenderer loading />);
-    expect(screen.getByLabelText("正在加载数据流")).toBeInTheDocument();
-    rerender(<DataFlowRenderer error="API 503" />);
-    expect(screen.getByText("数据流暂时不可用")).toBeInTheDocument();
-    expect(screen.getByText("API 503")).toBeInTheDocument();
-    rerender(<DataFlowRenderer missingRef />);
-    expect(screen.getByText("没有稳定的数据定位")).toBeInTheDocument();
-  });
-
-  it("requests card data with the run and encoded detail reference", async () => {
-    const payload = makeCardData();
-    const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => payload });
-    vi.stubGlobal("fetch", fetchMock);
-
-    await expect(getCardData("443", "run:final-result")).resolves.toEqual(payload);
-    expect(fetchMock).toHaveBeenCalledWith(
-      "http://127.0.0.1:8788/api/script-builds/443/card-data/run%3Afinal-result",
-      expect.objectContaining({ cache: "no-store" }),
-    );
-  });
-
-  it("uses an independent final-result detail while preserving the artifact action", () => {
-    const view = makeView();
-    const item = finalResultItem(view);
-    const onArtifact = vi.fn();
-    expect(item.detailRef).toBe("run:final-result");
-
-    render(<Inspector item={item} tab="business" onTab={vi.fn()} onClose={vi.fn()} onArtifact={onArtifact} detail={{ businessSections: [], technical: {} }} />);
-    fireEvent.click(screen.getByRole("button", { name: "查看当前主脚本" }));
-    expect(onArtifact).toHaveBeenCalledOnce();
-    expect(view.finalResult.artifact.snapshotRef).toBe("artifact:base:current");
-  });
-});

+ 15 - 0
visualization/frontend/tests/e2e/fake-graph.spec.ts

@@ -0,0 +1,15 @@
+import { expect, test } from "@playwright/test";
+
+test("renders the fine-grained fake graph and opens exact fields", async ({ page }) => {
+  await page.goto("/");
+  await expect(page.getByText("Mission Control")).toBeVisible();
+  await expect(page.getByText("FAKE DATA")).toBeVisible();
+  await expect(page.locator(".flowCard")).toHaveCount(103);
+  await page.screenshot({ path: "../test-artifacts/mission-control.png" });
+  await page.locator('[data-node-id="root-delivery:artifact"]').click();
+  await expect(page.locator(".inspector")).toBeVisible();
+  await expect(page.locator(".modelBanner")).toContainText("RootDeliveryManifestV1");
+  await expect(page.locator(".inspectorScroll")).toContainText("legacy_projection_digest");
+  await page.getByRole("button", { name: "字段合同" }).click();
+  await expect(page.locator(".contractFields li")).toHaveCount(8);
+});

+ 0 - 152
visualization/frontend/tests/e2e/real-artifact.spec.ts

@@ -1,152 +0,0 @@
-import { expect, test } from "@playwright/test";
-
-const realRunId = process.env.REAL_RUN_ID;
-
-test("真实 Run 的最终脚本保留原版完整表达", async ({ page }, testInfo) => {
-  test.skip(!realRunId || !testInfo.project.name.includes("desktop"), "仅在显式指定只读真实 Run 时验收");
-
-  await page.goto("/");
-  await page.locator("#build-id").fill(realRunId!);
-  await page.locator("#build-id").press("Enter");
-  await expect(page.locator("#build-id")).toHaveValue(realRunId!);
-  await expect(page.getByRole("article", { name: "最终结果" })).toBeVisible();
-
-  const trigger = page.getByRole("button", { name: "查看完整主脚本" });
-  await expect(trigger).toBeVisible();
-  await trigger.evaluate((element: HTMLElement) => element.click());
-
-  const dialog = page.getByRole("dialog");
-  await expect(dialog).toBeVisible();
-  await expect(dialog.getByRole("columnheader", { name: "创作目录大纲(段本身)" })).toBeVisible();
-  await expect(dialog.getByRole("columnheader", { name: "完整描述" })).toBeVisible();
-  await expect(dialog.getByText(/图\d+(?:-\d+)?/).first()).toBeVisible();
-  await expect(dialog.getByText("标题", { exact: true }).first()).toBeVisible();
-  await expect(dialog.getByText(/正文:/).first()).toBeVisible();
-  await expect(dialog.getByText(/^\[元素:\d+\]$/)).toHaveCount(0);
-  await expect(dialog.locator(".elem-hl-extended").first()).toBeVisible();
-
-  await dialog.screenshot({ path: testInfo.outputPath(`run-${realRunId}-script-table.png`) });
-});
-
-test("真实 Run 的实现方案能展开取数树", async ({ page }, testInfo) => {
-  test.skip(!realRunId || !testInfo.project.name.includes("desktop"), "仅在显式指定只读真实 Run 时验收");
-
-  await page.goto("/");
-  await page.locator("#build-id").fill(realRunId!);
-  await page.locator("#build-id").press("Enter");
-  await expect(page.locator("#build-id")).toHaveValue(realRunId!);
-  const expandRound = page.getByRole("button", { name: /展开本轮/ }).first();
-  if (await expandRound.count()) await expandRound.click();
-  const expandBranch = page.getByRole("button", { name: /展开方案过程/ }).first();
-  await expect(expandBranch).toBeVisible();
-  await expandBranch.click();
-
-  const canvas = page.locator(".flowShell");
-  await expect(canvas.locator(".retrievalStageFrame").first()).toBeAttached();
-  await expect(canvas.locator(".retrievalCard").first()).toBeAttached();
-  await expect(canvas.locator(".retrievalAgentCard").first()).toBeAttached();
-  await expect(canvas.getByText("数据取舍", { exact: true }).first()).toBeAttached();
-  await page.screenshot({ path: testInfo.outputPath(`run-${realRunId}-retrieval-tree.png`), fullPage: true });
-});
-
-test("真实 Run 的多方案评审与主 Agent 决策分层展示", async ({ page }, testInfo) => {
-  test.skip(!realRunId || !testInfo.project.name.includes("desktop"), "仅在显式指定只读真实 Run 时验收");
-
-  await page.goto("/");
-  await page.locator("#build-id").fill(realRunId!);
-  await page.locator("#build-id").press("Enter");
-  await expect(page.locator("#build-id")).toHaveValue(realRunId!);
-  const expandRound = page.getByRole("button", { name: /展开本轮/ }).first();
-  if (await expandRound.count()) await expandRound.click();
-
-  const canvas = page.locator(".flowShell");
-  const review = canvas.locator('[data-id^="multipath-review:"]').first();
-  const decision = canvas.locator('[data-id^="multipath-decision:"]').first();
-  await expect(review).toBeAttached();
-  await expect(decision).toBeAttached();
-  await expect(review.getByText("多方案评审 Agent", { exact: true })).toBeAttached();
-  await expect(review.getByText("评审建议", { exact: true }).first()).toBeAttached();
-  await expect(decision.getByText("主 Agent 多路决策", { exact: true })).toBeAttached();
-  await expect(decision.getByText("最终决定", { exact: true }).first()).toBeAttached();
-  await expect(canvas.getByText("主脚本整体评审", { exact: true }).first()).toBeAttached();
-  await expect(canvas.getByText("独立评审", { exact: true })).toHaveCount(0);
-
-  await review.getByRole("button", { name: "查看决策" }).evaluate((element: HTMLElement) => element.click());
-  const inspector = page.getByRole("dialog", { name: /三列来源详情$/ });
-  await expect(inspector).toBeVisible();
-  await expect(inspector.getByText("评审对象", { exact: true })).toBeVisible();
-  await expect(inspector.getByText("逐项结论", { exact: true })).toBeVisible();
-  await expect(inspector.getByText("script_build_event")).toHaveCount(0);
-});
-
-test("真实 Run 的收敛卡在缩放后仍保持在所属区域内", async ({ page }, testInfo) => {
-  test.skip(!realRunId || !testInfo.project.name.includes("desktop"), "仅在显式指定只读真实 Run 时验收");
-
-  await page.goto("/");
-  await page.locator("#build-id").fill(realRunId!);
-  await page.locator("#build-id").press("Enter");
-  await expect(page.locator("#build-id")).toHaveValue(realRunId!);
-
-  const expandButtons = page.getByRole("button", { name: /展开本轮/ });
-  if (await expandButtons.count()) await expandButtons.last().click();
-  const frame = page.locator(".react-flow__node-roundFrame").last();
-  await expect(frame).toBeAttached();
-  const frameId = await frame.getAttribute("data-id");
-  const roundIndex = frameId?.match(/frame:round:(\d+)/)?.[1];
-  expect(roundIndex).toBeTruthy();
-
-  const assertContained = async (cardSelector: string, regionSuffix: string) => {
-    const region = page.locator(`[data-id="region:round:${roundIndex}:${regionSuffix}"]`);
-    const cards = page.locator(cardSelector);
-    await expect(region).toBeAttached();
-    await expect(cards.first()).toBeAttached();
-    const regionBox = await region.boundingBox();
-    expect(regionBox).not.toBeNull();
-    for (let index = 0; index < await cards.count(); index += 1) {
-      const box = await cards.nth(index).boundingBox();
-      expect(box).not.toBeNull();
-      expect(box!.y, `${cardSelector} 不应越过区域顶部`).toBeGreaterThanOrEqual(regionBox!.y - 1);
-      expect(box!.y + box!.height, `${cardSelector} 不应越过区域底部`).toBeLessThanOrEqual(regionBox!.y + regionBox!.height + 1);
-    }
-  };
-  const assertConvergence = async () => {
-    await assertContained('[data-id^="multipath-review:"]', "multipath-review");
-    await assertContained('[data-id^="multipath-decision:"]', "convergence");
-  };
-
-  await assertConvergence();
-  await page.locator(".react-flow__controls-zoomout").click();
-  await page.locator(".react-flow__controls-zoomout").click();
-  await expect(page.locator(".flowShell")).toHaveAttribute("data-detail-level", "compact");
-  await assertConvergence();
-  await page.locator(".react-flow__controls-zoomout").click();
-  await page.locator(".react-flow__controls-zoomout").click();
-  await page.locator(".react-flow__controls-zoomout").click();
-  await expect(page.locator(".flowShell")).toHaveAttribute("data-detail-level", "overview");
-  await assertConvergence();
-  await expect(page.locator(".react-flow__node-agentRegion").last()).toBeAttached();
-});
-
-test("真实 Run 的最终结果使用结论、摘要和指标分层", async ({ page }, testInfo) => {
-  test.skip(!realRunId || !testInfo.project.name.includes("desktop"), "仅在显式指定只读真实 Run 时验收");
-
-  await page.goto("/");
-  await page.locator("#build-id").fill(realRunId!);
-  await page.locator("#build-id").press("Enter");
-  const card = page.getByRole("article", { name: "最终结果" });
-  await expect(card.locator(".finalOutcome")).toBeAttached();
-  await expect(card.getByText("构建摘要", { exact: true })).toBeAttached();
-  await expect(card.locator(".artifactStats")).toBeAttached();
-  const box = await card.boundingBox();
-  expect(box).not.toBeNull();
-  expect(box!.height).toBeLessThan(560);
-  await page.mouse.move(720, 480);
-  for (let attempt = 0; attempt < 12; attempt += 1) {
-    const current = await card.boundingBox();
-    if (current && current.x >= 0 && current.x + current.width <= 1280) break;
-    await page.mouse.wheel(1200, 0);
-    await page.waitForTimeout(40);
-  }
-  await expect(card).toBeInViewport();
-  await card.screenshot({ path: testInfo.outputPath(`run-${realRunId}-final-result.png`) });
-});

+ 0 - 240
visualization/frontend/tests/e2e/runtime-v8.spec.ts

@@ -1,240 +0,0 @@
-import { expect, test } from "@playwright/test";
-import { makeView } from "../fixtures";
-
-const view = makeView(2, 3, 2, "success");
-const artifactDetail = {
-  businessSections: [{ id: "summary", title: "主脚本规模", content: "1 个段落、1 个元素、1 条关联" }],
-  technical: { source: "read-only-test" },
-  changes: { summary: "当前主脚本内容", sections: [], artifactRef: "artifact:base:current", exactness: "current-only" },
-  scriptTable: {
-    snapshotRef: "artifact:base:current",
-    exactness: "current-only",
-    sourceOrigin: "database",
-    counts: { paragraphs: 1, elements: 1, links: 1 },
-    maxDepth: 0,
-    paragraphs: [{
-      id: 1, rowId: 1, index: 1, level: 1, depth: 0, name: "机制拆解", contentRange: ["正文: 第二段"],
-      sections: {
-        theme: { label: "主题", description: "解释份额换订单", descriptionParts: [{ type: "text", text: "解释份额换订单" }], dimensions: [{ type: "主维度", name: "核心竞争力", value: "份额换订单" }] },
-        form: { label: "形式", description: "案例拆解", descriptionParts: [{ type: "text", text: "案例拆解" }], dimensions: [] },
-        function: { label: "作用", description: "承接上文", descriptionParts: [{ type: "text", text: "承接上文" }], dimensions: [] },
-        feeling: { label: "感受", description: "建立可信度", descriptionParts: [{ type: "text", text: "建立可信度" }], dimensions: [] },
-      },
-      fullDescription: "通过份额换订单解释机制。",
-      fullDescriptionParts: [{ type: "text", text: "通过" }, { type: "element", id: 10, name: "份额换订单", resolved: true, text: "[元素:10]" }, { type: "text", text: "解释机制。" }],
-    }],
-    elements: [{ id: 10, rowId: 10, name: "份额换订单", primaryDimension: "核心竞争力", secondaryDimension: "商业模式", paragraphs: [{ id: 1, index: 1, name: "机制拆解" }] }],
-  },
-};
-
-test.beforeEach(async ({ page }) => {
-  await page.route("**/api/**", async (route) => {
-    const path = new URL(route.request().url()).pathname;
-    if (path === "/api/script-builds") {
-      await route.fulfill({ json: [{ id: view.header.id, status: "success", currentRound: 2, createdAt: view.header.createdAt }] });
-      return;
-    }
-    if (path.endsWith("/execution-view")) {
-      await route.fulfill({ json: view });
-      return;
-    }
-    if (path.includes("/artifacts/")) {
-      await route.fulfill({ json: artifactDetail });
-      return;
-    }
-    if (path.includes("/decision-prompts/")) {
-      await route.fulfill({ json: {
-        promptRef: "prompt:main",
-        actor: { role: "main", label: "主 Agent" },
-        systemPrompt: {
-          content: "当前主 Agent 规则正文。",
-          source: "current-db",
-          version: 8,
-          accuracy: "current-not-run-snapshot",
-          truncated: false,
-        },
-        notices: [{ code: "PROMPT_NOT_SNAPSHOTTED", message: "系统没有保存这次运行所用提示词的历史快照;下方展示当前提示词。" }],
-      } });
-      return;
-    }
-    if (path.includes("/inspector-view/")) {
-      await route.fulfill({ json: {
-        schemaVersion: "inspector-source-v2",
-        detailRef: "event:test",
-        cardKind: "test",
-        completeness: "complete",
-        businessProjection: {
-          businessSections: [{ id: "summary", title: "业务记录", content: "这是按需读取的完整业务记录。" }],
-        },
-        modules: [{
-          id: "summary",
-          title: "业务记录",
-          businessPath: "/businessSections/0",
-          presentation: "text",
-          rows: [{
-            id: "summary:content",
-            businessSelector: "/businessSections/0/content",
-            bindingIds: ["summary:source:1"],
-          }],
-          bindings: [{
-            id: "summary:source:1",
-            sourceId: "event:test",
-            role: "output",
-            selector: { kind: "json-pointer", path: "/output/content" },
-            transform: { kind: "direct" },
-            evidence: { availability: "produced-by-run", adoption: "not-applicable", confidence: "exact" },
-            resolution: "resolved",
-          }],
-          runtimeRefs: ["event:test"],
-          defaultOpen: true,
-        }],
-        sources: {
-          "event:test": {
-            id: "event:test",
-            kind: "runtime-event",
-            label: "测试 Event",
-            locator: { eventId: "test" },
-            completeness: "complete",
-            resultState: "present",
-            truncated: false,
-            selectedValues: [{ bindingId: "summary:source:1", value: "这是按需读取的完整业务记录。" }],
-            rawRecord: { output: { content: "这是按需读取的完整业务记录。" } },
-          },
-        },
-        notices: [],
-      } });
-      return;
-    }
-    await route.fulfill({
-      json: {
-        businessSections: [{ id: "summary", title: "业务记录", content: "这是按需读取的完整业务记录。" }],
-        technical: { source: "read-only-test" },
-      },
-    });
-  });
-  await page.goto("/");
-});
-
-test("V8 在桌面与平板呈现发散、取数、取舍再收敛", async ({ page }, testInfo) => {
-  test.skip(testInfo.project.name.includes("mobile"), "移动端使用轮次列表");
-  await expect(page.getByRole("heading", { name: "脚本构建" })).toBeVisible();
-  await expect(page.locator(".react-flow__minimap")).toHaveCount(0);
-  await page.getByRole("button", { name: /展开本轮/ }).first().click();
-  await expect(page.getByText("主 Agent · 规划", { exact: true })).toBeVisible();
-  await expect(page.getByText("实现 Agent · 多路方案", { exact: true })).toBeVisible();
-  await expect(page.getByText("多方案评审 Agent", { exact: true }).first()).toBeAttached();
-  await expect(page.getByText("主 Agent · 收敛", { exact: true })).toBeAttached();
-  await expect(page.getByText("整体评审 Agent", { exact: true }).first()).toBeAttached();
-  await expect(page.getByTestId("rf__node-multipath-review-1-1").getByText("多方案评审", { exact: true })).toBeAttached();
-  await expect(page.getByTestId("rf__node-multipath-1-1").getByText("第 1 批决策", { exact: true })).toBeAttached();
-  await expect(page.getByTestId("rf__node-multipath-1-2").getByText("第 2 批决策", { exact: true })).toBeAttached();
-  await page.getByRole("button", { name: /展开方案过程/ }).first().click();
-  const canvas = page.locator(".flowShell");
-  await expect(canvas.getByText("取数阶段", { exact: true }).first()).toBeAttached();
-  await expect(canvas.getByText("工具取数", { exact: true }).first()).toBeAttached();
-  await expect(canvas.getByText("Agent 取数", { exact: true }).first()).toBeAttached();
-  await expect(canvas.getByText("数据取舍", { exact: true }).first()).toBeAttached();
-  const details = page.getByRole("button", { name: /查看详情/ }).first();
-  await expect(details).toBeVisible();
-  await details.click();
-  await expect(page.getByRole("dialog", { name: /三列来源详情/ })).toBeVisible();
-  await expect(page.getByRole("columnheader", { name: "业务详情" })).toBeVisible();
-  await expect(page.getByRole("columnheader", { name: "数据依据" })).toBeVisible();
-  await expect(page.getByRole("columnheader", { name: "原始记录与运行" })).toBeVisible();
-});
-
-test("收敛卡在全量、紧凑和概览缩放下都不越出代理区域", async ({ page }, testInfo) => {
-  test.skip(testInfo.project.name.includes("mobile"), "移动端使用轮次列表");
-  await page.getByRole("button", { name: /展开本轮/ }).first().click();
-
-  const assertContained = async (cardSelector: string, regionId: string) => {
-    const region = page.locator(`[data-id="${regionId}"]`);
-    const cards = page.locator(cardSelector);
-    await expect(region).toBeAttached();
-    await expect(cards.first()).toBeAttached();
-    const regionBox = await region.boundingBox();
-    expect(regionBox).not.toBeNull();
-    for (let index = 0; index < await cards.count(); index += 1) {
-      const box = await cards.nth(index).boundingBox();
-      expect(box).not.toBeNull();
-      expect(box!.y).toBeGreaterThanOrEqual(regionBox!.y - 1);
-      expect(box!.y + box!.height).toBeLessThanOrEqual(regionBox!.y + regionBox!.height + 1);
-    }
-  };
-
-  const assertConvergenceLayout = async () => {
-    await assertContained('[data-id^="multipath-review-"]', "region:round:1:multipath-review");
-    await assertContained('[data-id^="multipath-1-"]', "region:round:1:convergence");
-  };
-
-  await assertConvergenceLayout();
-  await page.locator(".react-flow__controls-zoomout").click();
-  await page.locator(".react-flow__controls-zoomout").click();
-  await expect(page.locator(".flowShell")).toHaveAttribute("data-detail-level", "compact");
-  await assertConvergenceLayout();
-  await page.locator(".react-flow__controls-zoomout").click();
-  await page.locator(".react-flow__controls-zoomout").click();
-  await page.locator(".react-flow__controls-zoomout").click();
-  await expect(page.locator(".flowShell")).toHaveAttribute("data-detail-level", "overview");
-  await assertConvergenceLayout();
-  await expect(page.locator(".react-flow__node-agentRegion")).toHaveCount(6);
-});
-
-test("Agent 规则抽屉明确区分当前规则与历史运行快照", async ({ page }, testInfo) => {
-  test.skip(testInfo.project.name.includes("mobile"), "移动端由组件测试覆盖规则抽屉");
-  const objective = page.getByTestId("rf__node-objective");
-  await objective.getByRole("button", { name: "查看详情" }).click();
-  const inspector = page.getByRole("dialog", { name: /三列来源详情$/ });
-  await expect(inspector).toBeVisible();
-  const trigger = inspector.getByRole("button", { name: "查看主 Agent 提示词" });
-  await expect(trigger).toBeVisible();
-  await trigger.click();
-  const dialog = page.getByRole("dialog", { name: "Agent 任务与提示词" });
-  await expect(dialog).toBeVisible();
-  await expect(dialog.getByRole("heading", { name: "当前提示词" })).toBeVisible();
-  await expect(dialog.getByText(/没有保存这次运行所用提示词的历史快照/)).toBeVisible();
-  await expect(dialog.getByText("当前数据库版本 · v8")).toBeVisible();
-  await page.keyboard.press("Escape");
-  await expect(dialog).toHaveCount(0);
-  await expect(inspector).toBeVisible();
-  await expect(trigger).toBeFocused();
-});
-
-test("V8 移动端按轮次阅读并能展开取数树", async ({ page }, testInfo) => {
-  test.skip(!testInfo.project.name.includes("mobile"), "仅验证移动端信息架构");
-  await expect(page.getByRole("heading", { name: "这次构建经历了什么" })).toBeVisible();
-  await page.getByRole("button", { name: "展开第 1 轮" }).click();
-  await expect(page.getByText("候选方案 1", { exact: true })).toBeVisible();
-  await page.getByRole("button", { name: "展开方案过程" }).first().click();
-  await expect(page.getByText("取数阶段", { exact: true }).first()).toBeVisible();
-  await expect(page.getByText("Agent 取数", { exact: true }).first()).toBeVisible();
-  await expect(page.getByText("数据取舍", { exact: true }).first()).toBeVisible();
-  const mobile = page.locator(".mobileTimeline");
-  await expect(mobile.getByText("多方案评审 Agent · 评审判断", { exact: true }).first()).toBeVisible();
-  await expect(mobile.getByText("评审建议", { exact: true }).first()).toBeVisible();
-  await expect(mobile.getByText("主 Agent · 取舍决定", { exact: true }).first()).toBeVisible();
-  await expect(mobile.getByText("最终决定", { exact: true }).first()).toBeVisible();
-  await expect(mobile.getByText("整体评审 Agent · 评审判断", { exact: true }).first()).toBeVisible();
-});
-
-test("完整主脚本在桌面、平板和手机都使用独立可滚动查看器", async ({ page }) => {
-  const trigger = page.getByRole("button", { name: "查看完整主脚本" });
-  await expect(trigger).toHaveCount(1);
-  await trigger.focus();
-  await trigger.evaluate((element: HTMLElement) => element.click());
-
-  const dialog = page.getByRole("dialog");
-  await expect(dialog).toBeVisible();
-  await expect(dialog.getByText("1 个段落 · 1 个元素 · 1 条关联")).toBeVisible();
-  await expect(dialog.getByRole("columnheader", { name: "维度类型" }).first()).toBeVisible();
-  await expect(dialog.getByText("正文: 第二段")).toBeVisible();
-  await expect(dialog.getByText("主", { exact: true }).first()).toBeVisible();
-  await dialog.getByRole("tab", { name: "脚本元素 1" }).click();
-  await expect(dialog.getByRole("row", { name: /份额换订单/ })).toContainText("商业模式");
-
-  const dimensions = await dialog.evaluate((element) => ({ width: element.getBoundingClientRect().width, viewport: window.innerWidth }));
-  expect(dimensions.width).toBeLessThanOrEqual(dimensions.viewport);
-  await dialog.getByRole("button", { name: "关闭完整主脚本" }).click();
-  await expect(dialog).toHaveCount(0);
-  await expect(trigger).toBeFocused();
-});

+ 0 - 23
visualization/frontend/tests/expansion.test.ts

@@ -1,23 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { expandedStateFor, isFullyExpanded } from "@/lib/expansion";
-import { makeView } from "@/tests/fixtures";
-
-describe("canvas expansion", () => {
-  it("expands every round, implementation branch, and retrieval agent", () => {
-    const view = makeView(2, 2, 1, "running");
-    const expanded = expandedStateFor(view);
-
-    expect([...expanded.rounds]).toEqual([1, 2]);
-    expect([...expanded.branches]).toEqual(["1:1", "1:2", "2:1", "2:2"]);
-    expect(expanded.retrievalAgents.size).toBe(8);
-    expect(isFullyExpanded(view, expanded.rounds, expanded.branches, expanded.retrievalAgents)).toBe(true);
-  });
-
-  it("is not fully expanded when an inner retrieval agent is folded", () => {
-    const view = makeView(1, 1, 1, "running");
-    const expanded = expandedStateFor(view);
-    expanded.retrievalAgents.clear();
-
-    expect(isFullyExpanded(view, expanded.rounds, expanded.branches, expanded.retrievalAgents)).toBe(false);
-  });
-});

+ 0 - 106
visualization/frontend/tests/fixtures.ts

@@ -1,106 +0,0 @@
-import type { AgentDecisionProjection, BranchStory, ConvergenceBatch, ExecutionViewV8, MultipathDecisionBatch, RetrievalStage, RoundStoryV8, StoryKind, StoryNode, StoryRole } from "@/lib/types";
-
-export function makeStory(id: string, role: StoryRole, kind: StoryKind = "decision", overrides: Partial<StoryNode> = {}): StoryNode {
-  const card = { primary: { label: kind === "result" ? "产出" : kind === "execution" ? "结果" : "决定", value: `${roleTitle(role)}的业务摘要` }, secondary: [] };
-  const story: StoryNode = { id, role, kind, title: roleTitle(role), hasChanges: kind === "result", card, ...overrides };
-  if (kind === "decision" && !overrides.decision) story.decision = makeDecision(id, role, story.card);
-  return story;
-}
-
-function makeDecision(id: string, role: StoryRole, card: StoryNode["card"]): AgentDecisionProjection {
-  const decisionType = role === "data-decision" || role === "multipath-decision" ? "tradeoff" : role === "multipath-review" || role === "round-evaluation" ? "evaluation" : role === "candidate-output" ? "creative" : "direction";
-  const actor = role === "data-decision" || role === "candidate-output" ? { role: "implementer" as const, label: "实现 Agent" } : role === "multipath-review" ? { role: "multipath-evaluator" as const, label: "多方案评审 Agent" } : role === "round-evaluation" ? { role: "overall-evaluator" as const, label: "整体评审 Agent" } : { role: "main" as const, label: "主 Agent" };
-  const authority = actor.role === "main" ? "final" as const : actor.role.includes("evaluator") ? "recommendation" as const : "implementation-scope" as const;
-  return { id, semanticKind: "decision", decisionType, subtype: role, actor, authority, question: `${roleTitle(role)}要回答什么?`, inputs: [], body: { type: decisionType }, card, detailRef: id, promptRef: actor.role === "main" ? "prompt:main" : `prompt:event:${id.replace(/\D/g, "") || 1}`, completeness: "complete", notices: [] };
-}
-
-export function makeRetrievalStage(roundIndex: number, branchId: number, agentCount = 2, attemptCount = 3, mode: RetrievalStage["observedMode"] = "sequential"): RetrievalStage {
-  const direct = { id: `retrieval-direct-${roundIndex}-${branchId}`, kind: "direct-tools" as const, owner: "script_implementer" as const, callCount: 2, successCount: 2, failureCount: 0, runningCount: 0, sources: [{ businessLabel: "当前脚本", callCount: 1 }, { businessLabel: "选题", callCount: 1 }], calls: [], waveIndex: 0, startedAt: "2026-07-13T08:00:00Z", endedAt: "2026-07-13T08:00:05Z", detailRef: `retrieval-direct:${roundIndex}${branchId}` };
-  const agentRuns = Array.from({ length: agentCount }, (_, index) => ({
-    id: `retrieval-agent-${roundIndex}-${branchId}-${index + 1}`,
-    kind: "retrieval-agent" as const,
-    eventId: roundIndex * 1000 + branchId * 10 + index,
-    agentType: index % 2 ? "retrieve_data_knowledge" : "retrieve_data_decode_case",
-    businessLabel: index % 2 ? "知识 Agent" : "解构 Case Agent",
-    taskPreview: `为方案 ${branchId} 查找可靠依据`,
-    status: "completed" as const,
-    querySummary: { total: attemptCount, hit: Math.ceil(attemptCount / 2), empty: Math.floor(attemptCount / 2), failure: 0, unknown: 0 },
-    attempts: Array.from({ length: attemptCount }, (_, attempt) => ({ id: `event:${roundIndex}${branchId}${index}${attempt}`, eventId: roundIndex * 10000 + branchId * 100 + index * 10 + attempt, sequence: attempt, queryLabel: `查询关键词 ${attempt + 1}`, status: attempt % 2 ? "empty" as const : "hit" as const, resultCount: attempt % 2 ? 0 : 2, detailRef: `event:${roundIndex}${branchId}${index}${attempt}` })),
-    screening: { state: "available" as const, preview: "已经归纳可用证据和待核实内容。" },
-    waveIndex: mode === "parallel" ? 1 : index + 1,
-    startedAt: `2026-07-13T08:0${index + 1}:00Z`,
-    endedAt: `2026-07-13T08:0${index + 1}:30Z`,
-    detailRef: `retrieval-agent:${roundIndex * 1000 + branchId * 10 + index}`,
-  }));
-  const operationIds = [direct.id, ...agentRuns.map((run) => run.id)];
-  const waves = mode === "parallel"
-    ? [{ index: 0, operationIds, startedAt: direct.startedAt, endedAt: agentRuns.at(-1)?.endedAt }]
-    : operationIds.map((id, index) => ({ index, operationIds: [id] }));
-  if (mode === "parallel") { direct.waveIndex = 0; agentRuns.forEach((run) => { run.waveIndex = 0; }); }
-  return { id: `round:${roundIndex}:branch:${branchId}:retrieval`, roundIndex, branchId, implementerEventId: roundIndex * 100 + branchId, observedMode: mode, waves, directToolGroups: [direct], agentRuns, status: "completed", detailRef: `round:${roundIndex}:branch:${branchId}:retrieval` };
-}
-
-export function makeBranch(roundIndex: number, branchId: number): BranchStory {
-  const prefix = `r${roundIndex}-b${branchId}`;
-  const pathType = branchId % 4 === 0 ? "领域信息" as const : "内容" as const;
-  const dataNode = makeStory(`${prefix}-data-1`, "data-decision");
-  return {
-    id: `${prefix}-branch`, branchId, pathType, status: branchId % 3 === 0 ? "discarded" : "merged", title: `方案 ${branchId}`,
-    summary: { task: `完成候选任务 ${branchId}`, output: pathType === "领域信息" ? `新增 ${branchId} 条领域事实` : `形成候选脚本 ${branchId}` },
-    task: makeStory(`${prefix}-task`, "branch-task", "execution"), retrievalStage: makeRetrievalStage(roundIndex, branchId),
-    dataDecisions: [{ id: `${prefix}-data-1`, recordId: branchId * 100, decision: "采用核实数据", sources: [], node: dataNode }],
-    output: { type: pathType === "领域信息" ? "domain-info" : "script-artifact", summary: pathType === "领域信息" ? `新增 ${branchId} 条领域事实` : `形成候选脚本 ${branchId}`, snapshotRef: pathType === "内容" ? `artifact:branch:${branchId}` : undefined, factCount: pathType === "领域信息" ? branchId : undefined, node: makeStory(`${prefix}-output`, pathType === "领域信息" ? "domain-output" : "candidate-output", "result", { artifactRef: pathType === "内容" ? `artifact:branch:${branchId}` : undefined }) },
-    outcome: { status: branchId % 3 === 0 ? "discarded" : "merged", label: branchId % 3 === 0 ? "未采用" : "已采用" },
-    detailRef: `round:${roundIndex}:branch:${branchId}`,
-  };
-}
-
-export function makeRound(roundIndex: number, branchCount = 2, batchCount = 1): RoundStoryV8 {
-  const branches = Array.from({ length: branchCount }, (_, index) => makeBranch(roundIndex, index + 1));
-  const batches: MultipathDecisionBatch[] = Array.from({ length: batchCount }, (_, index) => {
-    const branchIds = branches.filter((_, branchIndex) => branchIndex % batchCount === index).map((branch) => branch.branchId);
-    const id = `multipath-${roundIndex}-${index + 1}`;
-    const decision = `第 ${index + 1} 批决策`;
-    const node = makeStory(id, "multipath-decision", "decision", { card: { primary: { label: "决定", value: decision }, secondary: [{ key: "branches", label: "比较方案", value: branchIds.map((branchId) => `方案 ${branchId}`).join("、") }] } });
-    node.decision = makeDecision(id, "multipath-decision", node.card);
-    return { id, recordId: roundIndex * 100 + index, roundIndex, branchIds, decision, reasoning: "来自主 Agent 真实决策记录", completeness: "complete", missingBranchIds: [], branchOutcomes: branchIds.map((branchId) => ({ branchId, status: branches[branchId - 1].status, label: branches[branchId - 1].outcome.label })), node, decisionProjection: node.decision };
-  });
-  const convergenceBatches: ConvergenceBatch[] = batches.length ? batches.map((decision, index) => ({
-    id: `convergence-${roundIndex}-${index + 1}`,
-    roundIndex,
-    branchIds: decision.branchIds,
-    review: {
-      id: `multipath-review-${roundIndex}-${index + 1}`,
-      eventId: roundIndex * 1000 + index,
-      roundIndex,
-      branchIds: decision.branchIds,
-      branchConclusions: decision.branchIds.map((branchId) => ({ branchId, conclusion: "通过", summary: `方案 ${branchId} 满足评审要求` })),
-      comparison: "候选方案各有侧重",
-      recommendation: `建议结合第 ${index + 1} 批评审`,
-      status: "completed",
-      detailRef: `event:${roundIndex * 1000 + index}`,
-      decisionProjection: makeDecision(`multipath-review-${roundIndex}-${index + 1}`, "multipath-review", { primary: { label: "评审结论", value: "候选方案各有侧重" }, secondary: [{ key: "recommendation", label: "评审建议", value: `建议结合第 ${index + 1} 批评审` }] }),
-    },
-    decision,
-    association: "exact-branch-set-and-time",
-  })) : [{
-    id: `convergence-${roundIndex}-missing`,
-    roundIndex,
-    branchIds: branches.map((branch) => branch.branchId),
-    missingReview: makeStory(`r${roundIndex}-missing-review`, "multipath-review", "missing"),
-    missingDecision: makeStory(`r${roundIndex}-missing-decision`, "multipath-decision", "missing"),
-    association: "records-missing",
-  }];
-  return {
-    id: `round:${roundIndex}`, roundIndex, state: "passed",
-    summary: { title: `第 ${roundIndex} 轮`, goal: { headline: `本轮目标 ${roundIndex}`, itemCount: 3, previewItems: ["补充执行形式", "完善脚本元素", "校正结构约束"], truncated: true }, approach: { mode: "分工", candidateCount: branchCount, plannedPathCount: branchCount, contentBranchCount: branches.filter((branch) => branch.pathType === "内容").length, domainBranchCount: branches.filter((branch) => branch.pathType === "领域信息").length, summary: `分工:${branchCount} 个候选方案` }, decision: { batchCount, branchIds: [...new Set(batches.flatMap((batch) => batch.branchIds))].sort((a, b) => a - b), summary: batchCount ? `${batchCount} 批主 Agent 决策` : "未记录主 Agent 多路决策" }, output: { mergedContentCount: branches.filter((branch) => branch.pathType === "内容" && branch.status === "merged").length, domainFactCount: branches.filter((branch) => branch.pathType === "领域信息").reduce((sum, branch) => sum + (branch.output.factCount || 0), 0), scriptSummary: `形成第 ${roundIndex} 轮内容产出`, domainSummary: "没有新增领域事实记录", summary: `形成第 ${roundIndex} 轮产出` } },
-    goal: makeStory(`r${roundIndex}-goal`, "goal"), plan: { mode: "分工", paths: branches.map((branch) => ({ target: branch.title })), currentOnly: true, runtimeRevisionCount: 0, runtimeRevisionRefs: [], node: makeStory(`r${roundIndex}-plan`, "plan") }, branches, convergenceBatches, overallEvaluation: makeStory(`r${roundIndex}-evaluation`, "round-evaluation", "decision", { title: "主脚本整体评审" }), result: makeStory(`r${roundIndex}-result`, "round-result", "result"), detailRef: `round:${roundIndex}`,
-  };
-}
-
-export function makeView(roundCount = 2, branchCount = 2, batchCount = 1, status = "success"): ExecutionViewV8 {
-  const id = String(7000 + roundCount * 17 + branchCount);
-  return { schemaVersion: "8", capturedAt: "2026-07-13T08:00:00Z", dataShape: "current", header: { id, title: "脚本构建", status, model: "test-model", currentRound: roundCount || undefined, createdAt: "2026-07-13T07:00:00Z", direction: { currentValue: "制作一个清晰可执行的脚本" } }, objective: makeStory("objective", "objective", "decision", { title: "创作目标", card: { primary: { label: "目标", value: "制作一个清晰可执行的脚本" }, secondary: [] } }), rounds: Array.from({ length: roundCount }, (_, index) => makeRound(index + 1, branchCount, batchCount)), finalResult: { id: "run:final-result", role: "final-result", kind: "result", title: "最终结果", status, terminal: status !== "running", roundCount, branchCounts: { total: roundCount * branchCount, merged: roundCount * branchCount, parked: 0, discarded: 0, open: 0 }, summary: "本次构建已经形成当前主脚本。", artifact: { snapshotRef: "artifact:base:current", paragraphCount: 3, elementCount: 5, linkCount: 4, historicalVersion: "current-only" }, detailRef: "artifact:base:current" }, unassigned: { runtimeEvents: [], businessRecords: [] }, warnings: [] };
-}
-
-function roleTitle(role: StoryRole) { return ({ "planning-analysis": "创作规划解析", objective: "创作目标", goal: "本轮目标", plan: "实现规划", "branch-task": "实现任务", "data-decision": "数据取舍", "candidate-output": "候选脚本", "domain-output": "领域事实", "multipath-review": "多方案评审", "multipath-decision": "主 Agent 多路决策", "round-evaluation": "主脚本整体评审", "round-result": "本轮产出", "final-result": "最终结果" } as Record<StoryRole, string>)[role]; }

+ 22 - 0
visualization/frontend/tests/flow.test.ts

@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+import { matchesNode } from "@/lib/flow";
+import type { FlowNodeRecord } from "@/lib/types";
+
+const node: FlowNodeRecord = {
+  id: "root-delivery:artifact", node_type: "artifact", phase: "phase-3",
+  lane: "root-delivery", title: "RootDeliveryManifest", subtitle: "唯一冻结 Manifest",
+  status: "frozen", agent_role: "worker", task_kind: "root-delivery", sequence: 1,
+  x: 0, y: 0, width: 268, tags: ["sha256"], metrics: [],
+  record: { model_name: "RootDeliveryManifestV1", schema_version: "root-delivery-manifest/v1", payload: {} },
+};
+
+describe("matchesNode", () => {
+  it("searches business labels, exact task kinds and schema model names", () => {
+    expect(matchesNode(node, "Manifest")).toBe(true);
+    expect(matchesNode(node, "root-delivery")).toBe(true);
+    expect(matchesNode(node, "sha256")).toBe(true);
+    expect(matchesNode(node, "mysql grant")).toBe(false);
+  });
+  it("keeps all nodes when query is blank", () => expect(matchesNode(node, "  ")).toBe(true));
+});
+

+ 0 - 191
visualization/frontend/tests/inspector-comparison.test.tsx

@@ -1,191 +0,0 @@
-import { fireEvent, render, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
-import { InspectorComparison } from "@/components/inspector/InspectorComparison";
-import { Inspector } from "@/components/inspector/Inspector";
-import { storyItem } from "@/lib/layout";
-import { makeStory } from "@/tests/fixtures";
-import type { InspectorWorkbenchProjection, SourceBinding } from "@/lib/types";
-
-const binding: SourceBinding = {
-  id: "result-1",
-  sourceId: "event:4607",
-  role: "output",
-  selector: { kind: "json-pointer", path: "/output/content/results/0" },
-  transform: { kind: "direct" },
-  evidence: { availability: "returned-to-agent", adoption: "not-recorded", confidence: "exact" },
-  resolution: "resolved",
-};
-
-const payload: InspectorWorkbenchProjection = {
-  schemaVersion: "inspector-source-v2",
-  detailRef: "event:4607",
-  cardKind: "query",
-  completeness: "complete",
-  businessProjection: {
-    detailKind: "activity",
-    businessSections: [{ id: "results", title: "返回结果", items: [{ label: "Case 1", value: "命中内容" }] }],
-  },
-  modules: [{
-    id: "results",
-    title: "返回结果",
-    businessPath: "/businessSections/0",
-    presentation: "items",
-    rows: [{ id: "case-1", label: "Case 1", businessSelector: "/businessSections/0/items/0/value", bindingIds: [binding.id] }],
-    bindings: [binding],
-    runtimeRefs: ["event:4607"],
-    defaultOpen: true,
-  }],
-  sources: {
-    "event:4607": {
-      id: "event:4607",
-      kind: "runtime-event",
-      label: "search_script_decode_case",
-      locator: { table: "script_build_event + script_build_event_body", eventId: 4607, eventName: "search_script_decode_case" },
-      status: "ok",
-      completeness: "complete",
-      resultState: "present",
-      selectedValues: [{ bindingId: binding.id, path: "/output/content/results/0", value: "命中内容" }],
-      rawRecord: { output: { content: { results: ["命中内容"] } } },
-    },
-  },
-  notices: [],
-};
-
-describe("InspectorComparison", () => {
-  it("returns from the source comparison to the readable business Inspector", () => {
-    const onModeChange = vi.fn();
-    const item = storyItem(makeStory("event:4607", "planning-analysis"), 1);
-    render(<Inspector item={item} mode="lineage" onModeChange={onModeChange} onClose={vi.fn()} workbench={payload} />);
-    fireEvent.click(screen.getByRole("button", { name: "返回业务详情" }));
-    expect(onModeChange).toHaveBeenCalledWith("business");
-  });
-
-  it("renders business fields from the same projection used by the readable Inspector", () => {
-    const planPayload: InspectorWorkbenchProjection = {
-      ...payload,
-      detailRef: "round:1:plan",
-      cardKind: "implementation-plan",
-      businessProjection: {
-        detailKind: "agent-decision",
-        blocks: [{
-          type: "implementation-plan",
-          title: "本轮实施路线",
-          mode: "竞争",
-          reasoning: "比较两条路线。",
-          routes: [{ pathIndex: 1, target: "完整段落骨架", method: "产生脉络", emphasis: "先保证结构清楚" }],
-        }],
-      },
-      modules: [{
-        id: "block-1",
-        title: "本轮实施路线",
-        businessPath: "/blocks/0",
-        presentation: "plan",
-        rows: [
-          { id: "mode", label: "规划方式", businessSelector: "/blocks/0/mode", bindingIds: [binding.id] },
-          { id: "target", label: "路线 1 · 作用范围", businessSelector: "/blocks/0/routes/0/target", bindingIds: [binding.id] },
-          { id: "method", label: "路线 1 · 实施方法", businessSelector: "/blocks/0/routes/0/method", bindingIds: [binding.id] },
-          { id: "emphasis", label: "路线 1 · 路线侧重", businessSelector: "/blocks/0/routes/0/emphasis", bindingIds: [binding.id] },
-        ],
-        bindings: [binding],
-        runtimeRefs: [],
-        defaultOpen: true,
-      }],
-    };
-    const { container } = render(<InspectorComparison data={planPayload} />);
-    expect(screen.getByText("竞争")).toBeInTheDocument();
-    expect(screen.getByText("完整段落骨架")).toBeInTheDocument();
-    expect(screen.getByText("产生脉络")).toBeInTheDocument();
-    expect(screen.getByText("先保证结构清楚")).toBeInTheDocument();
-    expect(container.querySelector(".comparisonItemBusiness")?.textContent).not.toContain("pathIndex");
-  });
-
-  it("renders one physical row across all three columns", () => {
-    const { container } = render(<InspectorComparison data={payload} />);
-    expect(screen.getByText("业务详情中的内容")).toBeInTheDocument();
-    expect(screen.getByText("这一项的数据依据")).toBeInTheDocument();
-    expect(screen.getByText("对应的原始字段或原文")).toBeInTheDocument();
-    expect(screen.getByText("返回结果")).toBeInTheDocument();
-    expect(screen.getAllByText("S1").length).toBeGreaterThanOrEqual(2);
-    expect(screen.getByText("已返回给 Agent,是否采用未记录")).toBeInTheDocument();
-    expect(screen.getByText("script_build_event + script_build_event_body")).toBeInTheDocument();
-    expect(container.querySelectorAll(".comparisonItemRow")).toHaveLength(1);
-  });
-
-  it("shows the exact selected source value without opening raw JSON", () => {
-    render(<InspectorComparison data={payload} />);
-    expect(screen.getAllByText("命中内容").length).toBeGreaterThanOrEqual(2);
-    expect(screen.getByText("完整原始记录")).toBeInTheDocument();
-    expect(screen.getByText("/output/content/results/0").closest("details")).not.toHaveAttribute("open");
-    expect(screen.queryByText("found")).not.toBeInTheDocument();
-  });
-
-  it("keeps structured business rows readable instead of exposing object keys", () => {
-    const decisionPayload: InspectorWorkbenchProjection = {
-      ...payload,
-      cardKind: "data-decision",
-      businessProjection: {
-        detailKind: "agent-decision",
-        blocks: [{
-          type: "items",
-          title: "参与判断",
-          items: [{ label: "解构 Case", value: "返回了 2 条可用案例", note: "已读取" }],
-        }],
-      },
-      modules: [{
-        ...payload.modules[0],
-        id: "block-1",
-        title: "参与判断",
-        businessPath: "/blocks/0",
-        rows: [{ id: "source-1", label: "解构 Case", businessSelector: "/blocks/0/items/0", bindingIds: [binding.id] }],
-      }],
-    };
-    const { container } = render(<InspectorComparison data={decisionPayload} />);
-    const business = container.querySelector(".comparisonItemBusiness");
-    expect(business).toHaveTextContent("解构 Case");
-    expect(business).toHaveTextContent("返回了 2 条可用案例");
-    expect(business).toHaveTextContent("说明");
-    expect(business?.textContent).not.toMatch(/\blabel\b|\bvalue\b|\bnote\b/);
-  });
-
-  it("synchronizes binding highlight within the already aligned row", () => {
-    const onChange = vi.fn();
-    const { container } = render(<InspectorComparison data={payload} onActiveBindingChange={onChange} />);
-    fireEvent.click(screen.getByRole("button", { name: /已返回给 Agent/ }));
-    expect(container.querySelectorAll('[data-binding-id="result-1"]').length).toBeGreaterThanOrEqual(2);
-    expect(container.querySelector(".comparisonItemRow.is-active")).toBeInTheDocument();
-    expect(onChange).toHaveBeenLastCalledWith("result-1");
-  });
-
-  it("unmounts module contents when collapsed", () => {
-    render(<InspectorComparison data={payload} />);
-    fireEvent.click(screen.getByRole("button", { name: /返回结果/ }));
-    expect(screen.queryByText("命中内容")).not.toBeInTheDocument();
-  });
-
-  it("explains a successful empty result instead of calling it missing", () => {
-    const empty: InspectorWorkbenchProjection = {
-      ...payload,
-      sources: {
-        "event:4607": { ...payload.sources["event:4607"], resultState: "empty", rawRecord: [] },
-      },
-    };
-    render(<InspectorComparison data={empty} />);
-    expect(screen.getByText("查询完成,返回 0 条")).toBeInTheDocument();
-    expect(screen.getByText("这不是记录缺失。")).toBeInTheDocument();
-  });
-
-  it("hands focus control to an upper prompt or artifact overlay", () => {
-    const item = storyItem(makeStory("event:4607", "planning-analysis"), 1);
-    const onClose = vi.fn();
-    const view = (topOverlayOpen: boolean) => <><button type="button" data-testid="upper-overlay-control">上层弹窗按钮</button><Inspector item={item} onClose={onClose} workbench={payload} topOverlayOpen={topOverlayOpen} /></>;
-    const { rerender } = render(view(false));
-    const outside = screen.getByTestId("upper-overlay-control");
-    outside.focus();
-    expect(screen.getByRole("dialog")).toContainElement(document.activeElement as HTMLElement);
-
-    rerender(view(true));
-    const upperOverlayControl = screen.getByTestId("upper-overlay-control");
-    upperOverlayControl.focus();
-    expect(document.activeElement).toBe(upperOverlayControl);
-  });
-});

+ 0 - 446
visualization/frontend/tests/inspector.test.tsx

@@ -1,446 +0,0 @@
-import { fireEvent, render, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
-import { Inspector } from "@/components/inspector/Inspector";
-import { finalResultItem, storyItem } from "@/lib/layout";
-import { makeStory, makeView } from "@/tests/fixtures";
-
-describe("Inspector information layers", () => {
-  it("keeps the readable Inspector as the default business surface and opens lineage separately", () => {
-    const item = storyItem(makeStory("objective", "objective", "decision", { title: "创作目标", detailRef: "run:objective" }));
-    const onModeChange = vi.fn();
-    render(<Inspector
-      item={item}
-      mode="business"
-      onModeChange={onModeChange}
-      onClose={vi.fn()}
-      detail={{
-        detailKind: "agent-decision",
-        question: "这次构建要形成什么创作方向?",
-        blocks: [{ type: "summary", title: "当前保存的创作方向", value: "把毛驴效应讲清楚,并给出可执行建议。" }],
-        technical: { source: "database" },
-      }}
-    />);
-
-    expect(screen.getByText("这次构建要形成什么创作方向?")).toBeInTheDocument();
-    expect(screen.getByText("把毛驴效应讲清楚,并给出可执行建议。")).toBeInTheDocument();
-    expect(screen.queryByText("数据依据")).not.toBeInTheDocument();
-    fireEvent.click(screen.getByRole("button", { name: "打开数据来源" }));
-    expect(onModeChange).toHaveBeenCalledWith("lineage");
-  });
-
-  it("renders planning analysis as a real pre-build activity with complete prose", () => {
-    const item = storyItem(makeStory("run:planning-analysis", "planning-analysis", "execution", {
-      title: "创作规划解析",
-      detailRef: "event:3760",
-    }));
-    const fullThought = "账号结构模式与领域准确性下限已经完成分析。".repeat(20);
-    render(<Inspector item={item} tab="business" onTab={vi.fn()} onClose={vi.fn()} detail={{
-      businessSections: [
-        { id: "summary", title: "规划概要", content: "首轮竞争不同的段落骨架。", variant: "summary", visualRole: "key-result" },
-        { id: "thought", title: "完整思考", content: fullThought, variant: "report", visualRole: "section" },
-        { id: "plan", title: "执行计划", content: "保存创作目标,然后开启首轮。", visualRole: "section" },
-        { id: "action", title: "下一步", content: "写入创作总目标", visualRole: "reason" },
-      ],
-      technical: { event: { id: 3760, event_name: "think_and_plan" } },
-    }} />);
-
-    expect(screen.getByText("构建开始前的规划")).toBeInTheDocument();
-    expect(screen.getByText("创作规划解析")).toBeInTheDocument();
-    expect(screen.getByText(fullThought)).toBeInTheDocument();
-    expect(screen.getByText("写入创作总目标")).toBeInTheDocument();
-  });
-
-  it("losslessly breaks a consecutively numbered execution plan into readable lines", () => {
-    const item = storyItem(makeStory("run:planning-analysis", "planning-analysis", "execution", {
-      title: "创作规划解析",
-      detailRef: "event:4565",
-    }));
-    const plan = "1)save_script_direction 2)begin_round首轮:立段落结构 3)后续轮次填充元素、金句、行动指南";
-    const { container } = render(<Inspector item={item} tab="business" onTab={vi.fn()} onClose={vi.fn()} detail={{
-      businessSections: [
-        { id: "plan", title: "执行计划", content: plan, visualRole: "section" },
-      ],
-      technical: {},
-    }} />);
-
-    const rendered = container.querySelector(".inspectorPanel .richText");
-    expect(rendered).toHaveTextContent("1)save_script_direction 2)begin_round首轮:立段落结构 3)后续轮次填充元素、金句、行动指南");
-    expect(rendered?.querySelectorAll("br")).toHaveLength(2);
-  });
-
-  it("keeps an unnumbered execution plan unchanged", () => {
-    const item = storyItem(makeStory("run:planning-analysis", "planning-analysis", "execution"));
-    const plan = "准备阶段:写入总目标后进入轮次循环,首轮先立段落结构。";
-    const { container } = render(<Inspector item={item} tab="business" onTab={vi.fn()} onClose={vi.fn()} detail={{
-      businessSections: [{ id: "plan", title: "执行计划", content: plan }],
-      technical: {},
-    }} />);
-
-    expect(screen.getByText(plan)).toBeInTheDocument();
-    expect(container.querySelector(".inspectorPanel .richText br")).toBeNull();
-  });
-
-  it("renders optional implementation-task sections with semantic hierarchy", () => {
-    const item = storyItem(makeStory("round:2:branch:3:task", "branch-task", "execution", {
-      title: "实现任务",
-    }), 2, 3);
-    render(<Inspector item={item} tab="business" onTab={vi.fn()} onClose={vi.fn()} detail={{
-      businessSections: [
-        { id: "task-scope-1", title: "实现范围", content: "五段骨架", visualRole: "section" },
-        { id: "task-goal-2", title: "目标", content: "补齐每段文字。", variant: "summary", visualRole: "key-result" },
-        { id: "task-materials-3", title: "必用素材(已核实)", content: "- 领域事实 A", variant: "report", visualRole: "evidence", presentation: "document" },
-        { id: "task-avoid-4", title: "需要避免", content: "不得改动原文。", variant: "report", visualRole: "notice", presentation: "document" },
-      ],
-      technical: {},
-    }} />);
-
-    expect(screen.getByRole("heading", { name: "实现范围" }).closest("section")).toHaveAttribute("data-visual-role", "section");
-    expect(screen.getByRole("heading", { name: "目标" }).closest("section")).toHaveAttribute("data-visual-role", "key-result");
-    expect(screen.getByRole("heading", { name: "必用素材(已核实)" }).closest("section")).toHaveAttribute("data-visual-role", "evidence");
-    expect(screen.getByRole("heading", { name: "需要避免" }).closest("section")).toHaveAttribute("data-visual-role", "notice");
-    expect(screen.queryAllByRole("heading", { name: "实现任务" })).toHaveLength(1);
-  });
-
-  it("keeps business prose separate from technical records", () => {
-    const item = storyItem(makeStory("evaluation", "multipath-review"), 1);
-    render(<Inspector
-      item={item}
-      tab="business"
-      onTab={vi.fn()}
-      onClose={vi.fn()}
-      detail={{
-        businessSections: [
-          { id: "conclusion", title: "评审结论", content: "部分通过", variant: "summary" },
-          { id: "report", title: "评审说明", content: "- 结构清晰\n- 仍需补足正文", variant: "report" },
-        ],
-        technical: { event_name: "script_multipath_evaluator", input_preview: "raw-json" },
-      }}
-    />);
-
-    expect(screen.getByText("部分通过")).toBeInTheDocument();
-    expect(screen.getByText("结构清晰")).toBeInTheDocument();
-    expect(screen.queryByText(/script_multipath_evaluator/)).not.toBeInTheDocument();
-    expect(screen.queryByText("运行记录关联")).not.toBeInTheDocument();
-  });
-
-  it("shows raw fields only after opening technical records", () => {
-    const onTab = vi.fn();
-    const item = storyItem(makeStory("evaluation", "multipath-review"), 1);
-    const detail = {
-      businessSections: [{ id: "conclusion", title: "评审结论", content: "通过", variant: "summary" as const }],
-      technical: { event_name: "script_multipath_evaluator" },
-    };
-    const { rerender } = render(<Inspector item={item} tab="business" onTab={onTab} onClose={vi.fn()} detail={detail} />);
-    fireEvent.click(screen.getByRole("tab", { name: "技术详情" }));
-    expect(onTab).toHaveBeenCalledWith("technical");
-
-    rerender(<Inspector item={item} tab="technical" onTab={onTab} onClose={vi.fn()} detail={detail} />);
-    expect(screen.getByText("原始记录")).toBeInTheDocument();
-    expect(screen.getByText(/script_multipath_evaluator/)).toBeInTheDocument();
-  });
-
-  it("prefers structured business items over a large markdown fallback", () => {
-    const item = storyItem(makeStory("objective", "objective"), 1);
-    render(<Inspector
-      item={item}
-      tab="business"
-      onTab={vi.fn()}
-      onClose={vi.fn()}
-      detail={{
-        businessSections: [{
-          id: "inputs",
-          title: "决策前读取",
-          content: "这段巨型 Markdown 不应在存在结构化列表时展示",
-          items: [
-            { label: "选题", value: "米豆腐炒肉", detailRef: "event:2146" },
-            { label: "账号段落模式", value: "成品展示 → 食材与热量 → 教学步骤", sourceNotice: "record-missing" },
-          ],
-        }],
-        technical: { event_ids: [2146] },
-      }}
-    />);
-
-    expect(screen.getByText("选题")).toBeInTheDocument();
-    expect(screen.getByText("米豆腐炒肉")).toBeInTheDocument();
-    expect(screen.getByText("账号段落模式")).toBeInTheDocument();
-    expect(screen.getByText("记录缺失")).toBeInTheDocument();
-    expect(screen.queryByText(/巨型 Markdown/)).not.toBeInTheDocument();
-    expect(screen.queryByText("event:2146")).not.toBeInTheDocument();
-  });
-
-  it("keeps the full build summary when final artifact details load", () => {
-    const view = makeView();
-    view.finalResult.summary = "构建总结全文:脚本已经完成结构与表达检查。";
-    render(<Inspector
-      item={finalResultItem(view)}
-      tab="business"
-      onTab={vi.fn()}
-      onClose={vi.fn()}
-      detail={{
-        businessSections: [
-          { id: "build-summary", title: "构建总结", content: "构建总结全文:脚本已经完成结构与表达检查。" },
-          { id: "summary", title: "主脚本规模", content: "3 个段落、5 个元素、4 条关联" },
-        ],
-        changes: { summary: "当前主脚本内容", sections: [], exactness: "current-only" },
-        technical: { source: "database" },
-      }}
-    />);
-
-    expect(screen.getByText("构建总结全文:脚本已经完成结构与表达检查。")).toBeInTheDocument();
-    expect(screen.getByText("3 个段落、5 个元素、4 条关联")).toBeInTheDocument();
-  });
-
-  it("renders V8 decision blocks without leaking technical fields into business detail", () => {
-    const story = makeStory("evaluation", "round-evaluation");
-    const item = storyItem(story, 1);
-    render(<Inspector item={item} tab="business" onTab={vi.fn()} onClose={vi.fn()} detail={{
-      detailKind: "agent-decision",
-      decisionType: "evaluation",
-      actor: { role: "overall-evaluator", label: "整体评审 Agent" },
-      authority: "recommendation",
-      question: "当前主脚本是否达到目标?",
-      blocks: [
-        { type: "summary", title: "总结论", value: "部分通过" },
-        { type: "items", title: "问题", items: ["结尾仍需补充行动建议"] },
-      ],
-      technical: { source: { table: "script_build_event", eventId: 41 } },
-    }} />);
-    expect(screen.getByText("部分通过")).toBeInTheDocument();
-    expect(screen.getByText("结尾仍需补充行动建议")).toBeInTheDocument();
-    expect(screen.queryByText(/script_build_event|eventId/)).not.toBeInTheDocument();
-  });
-
-  it("keeps arrows and sentence punctuation in the original prose", () => {
-    const item = storyItem(makeStory("objective", "objective"), 1);
-    const prose = "概念引入→拆解分析→归纳提炼→行动指南。第二句仍属于同一段。";
-    const { container } = render(<Inspector item={item} tab="business" onTab={vi.fn()} onClose={vi.fn()} detail={{
-      detailKind: "agent-decision",
-      decisionType: "direction",
-      blocks: [{ type: "summary", title: "明确理由", value: prose }],
-      technical: {},
-    }} />);
-
-    expect(screen.getByText(prose)).toBeInTheDocument();
-    expect(container.querySelector(".decisionFlowList")).toBeNull();
-    expect(container.querySelectorAll(".decisionReadableText p")).toHaveLength(1);
-  });
-
-  it("only inserts line breaks and indentation around an existing numbered sequence", () => {
-    const item = storyItem(makeStory("objective", "objective"), 1);
-    const prose = "根据原有信息形成五个部分。 1. 原文第一项; 2. 原文第二项; 3. 原文第三项。";
-    const { container } = render(<Inspector item={item} tab="business" onTab={vi.fn()} onClose={vi.fn()} detail={{
-      detailKind: "agent-decision",
-      decisionType: "direction",
-      blocks: [{ type: "summary", title: "明确理由", value: prose }],
-      technical: {},
-    }} />);
-
-    expect(screen.getByText("根据原有信息形成五个部分。")).toBeInTheDocument();
-    expect(screen.getByText("原文第一项;")).toBeInTheDocument();
-    expect(screen.getByText("原文第二项;")).toBeInTheDocument();
-    expect(screen.getByText("原文第三项。")).toBeInTheDocument();
-    expect(container.querySelectorAll(".decisionReadableText ol > li")).toHaveLength(3);
-  });
-
-  it("groups creative steps and multipath evaluation content by its real semantic owner", () => {
-    const item = storyItem(makeStory("creative", "candidate-output"), 1, 2);
-    const { container } = render(<Inspector item={item} tab="business" onTab={vi.fn()} onClose={vi.fn()} detail={{
-      detailKind: "agent-decision",
-      decisionType: "creative",
-      blocks: [
-        {
-          type: "creative-process",
-          title: "创作处理",
-          steps: [{ stepIndex: 4, action: "新增脚本段落", summary: "起草四元组并开始创建段落骨架。", plan: "1. 创建段落 1:封面导引\n2. 创建段落 2:概念定义", reasoning: "输入依据:账号标准科普模式。" }],
-        },
-        {
-          type: "evaluation-branches",
-          title: "逐方案评审",
-          branches: [
-            { subject: "方案 1", conclusion: "部分通过", achievements: ["领域信息库新增 5 条事实(id 54-58)。"], problems: ["元素创建尚未完整落地。"], evidence: "来源可指位。" },
-            { subject: "方案 2", conclusion: "部分通过", achievements: ["已搭建五段骨架。"], problems: ["数量红线违规。"] },
-          ],
-        },
-        {
-          type: "evaluation-matrix",
-          title: "评审标准与方案对比",
-          rows: [{ criterion: "结构完备性", candidates: [{ subject: "方案 1", value: "不适用" }, { subject: "方案 2", value: "部分通过" }], basis: "仅方案二涉及" }],
-        },
-      ],
-      technical: {},
-    }} />);
-
-    expect(screen.queryByText("6 个真实执行步骤")).not.toBeInTheDocument();
-    expect(screen.getByText("1 个真实执行步骤")).toBeInTheDocument();
-    expect(screen.getByText("新增脚本段落")).toBeInTheDocument();
-    expect(screen.getByText("创建段落 1:封面导引")).toBeInTheDocument();
-    expect(screen.getByText("领域信息库新增 5 条事实(id 54-58)。")).toBeInTheDocument();
-    expect(screen.getByText("数量红线违规。")).toBeInTheDocument();
-    expect(screen.getByRole("rowheader", { name: "结构完备性" })).toBeInTheDocument();
-    expect(screen.getByRole("cell", { name: "仅方案二涉及" })).toBeInTheDocument();
-    expect(container.querySelectorAll(".evaluationBranchList > article")).toHaveLength(2);
-  });
-
-  it("renders the complete saved script direction as its original hierarchy", () => {
-    const item = storyItem(makeStory("objective", "objective"), 1);
-    const sourceDocument = `## 选题价值主张
-用经典心理学效应帮助读者理解决策瘫痪,并给出**"先行动再调整"**。
-
-## 创作总目标
-### 目标 1
-- 目标语句:把毛驴效应科普清楚。
-- 评估维度:
-  - name:科普清晰度
-  - 通过标准:普通读者能准确理解定义与来源。`;
-    const { container } = render(<Inspector item={item} tab="business" onTab={vi.fn()} onClose={vi.fn()} detail={{
-      detailKind: "agent-decision",
-      decisionType: "direction",
-      blocks: [
-        { type: "summary", title: "当前保存的创作方向", value: sourceDocument, visualRole: "key-result", presentation: "document" },
-      ],
-      technical: {},
-    }} />);
-
-    expect(screen.getByRole("heading", { name: "选题价值主张" })).toBeInTheDocument();
-    expect(screen.getByRole("heading", { name: "创作总目标" })).toBeInTheDocument();
-    expect(screen.getByRole("heading", { name: "目标 1" })).toBeInTheDocument();
-    expect(screen.getByText("通过标准:普通读者能准确理解定义与来源。")).toBeInTheDocument();
-    expect(screen.getByText("先行动再调整").tagName).toBe("STRONG");
-    expect(screen.queryByText(/\*\*"先行动再调整"\*\*/)).not.toBeInTheDocument();
-    expect(container.querySelector('[data-presentation="document"]')).toBeInTheDocument();
-  });
-
-  it("renders the complete round goal without inferred sentence roles", () => {
-    const item = storyItem(makeStory("goal", "goal"), 1);
-    const completeGoal = "立起脚本的段落骨架。竞争不同的框架切分策略。本轮只做段落结构,不填充文案。";
-    render(<Inspector item={item} tab="business" onTab={vi.fn()} onClose={vi.fn()} detail={{
-      detailKind: "agent-decision",
-      decisionType: "direction",
-      blocks: [{
-        type: "summary",
-        title: "本轮目标构成",
-        visualRole: "key-result",
-        presentation: "document",
-        value: completeGoal,
-      }],
-      technical: {},
-    }} />);
-
-    expect(screen.getByText(completeGoal)).toBeInTheDocument();
-    expect(screen.queryByText("核心目标")).not.toBeInTheDocument();
-    expect(screen.queryByText("实现策略")).not.toBeInTheDocument();
-    expect(screen.queryByText("范围边界")).not.toBeInTheDocument();
-  });
-
-  it("renders implementation plans as structured routes with collapsible evidence", () => {
-    const item = storyItem(makeStory("plan", "plan"), 4);
-    render(<Inspector item={item} tab="business" onTab={vi.fn()} onClose={vi.fn()} detail={{
-      detailKind: "agent-decision",
-      decisionType: "direction",
-      actor: { role: "main", label: "主 Agent" },
-      authority: "final",
-      question: "本轮应如何组织实现方案?",
-      blocks: [
-        {
-          type: "implementation-plan",
-          title: "本轮实施路线",
-          visualRole: "section",
-          presentation: "plan",
-          mode: "单路",
-          reasoning: "这是收敛阶段的定点修复,不需要竞争。",
-          routes: [{ pathIndex: 1, pathType: "内容", action: "改", target: "段落 2207、2208 的形式层", method: "产生元素", emphasis: "结构完整性" }],
-        },
-        { type: "items", title: "根据以下信息做出的决策", visualRole: "evidence", presentation: "list", collapsible: false, items: [{ label: "本轮目标", value: "完整目标内容", note: "持续约束;本轮必须遵守" }] },
-      ],
-      technical: {},
-    }} />);
-
-    expect(screen.getByText("本轮实施路线")).toBeInTheDocument();
-    expect(screen.getByText("路线 1")).toBeInTheDocument();
-    expect(screen.getByText("段落 2207、2208 的形式层")).toBeInTheDocument();
-    expect(screen.getByText("产生元素")).toBeInTheDocument();
-    expect(screen.getByText("结构完整性")).toBeInTheDocument();
-    expect(screen.getByText("为什么这样规划")).toBeInTheDocument();
-    expect(screen.getByText("根据以下信息做出的决策")).toBeInTheDocument();
-    expect(screen.getByText("完整目标内容")).toBeVisible();
-  });
-
-  it("uses semantic roles instead of Chinese titles for hierarchy and disclosure", () => {
-    const item = storyItem(makeStory("tradeoff", "data-decision"), 1);
-    const { container } = render(<Inspector item={item} tab="business" onTab={vi.fn()} onClose={vi.fn()} detail={{
-      detailKind: "agent-decision",
-      decisionType: "tradeoff",
-      blocks: [
-        { type: "items", title: "参考资料", visualRole: "evidence", collapsible: true, items: ["完整依据"] },
-        { type: "summary", title: "执行结论", visualRole: "key-result", value: "采用方案 A" },
-      ],
-      technical: {},
-    }} />);
-
-    expect(screen.getByText("完整依据")).not.toBeVisible();
-    expect(container.querySelector('[data-visual-role="key-result"]')).toHaveClass("is-role-key-result");
-    fireEvent.click(screen.getByText("参考资料"));
-    expect(screen.getByText("完整依据")).toBeVisible();
-  });
-
-  it("opens the task and rules from inside a decision Inspector", () => {
-    const onPrompt = vi.fn();
-    const item = storyItem(makeStory("creative", "candidate-output"), 1, 2);
-    render(<Inspector item={item} tab="business" onTab={vi.fn()} onClose={vi.fn()} onPrompt={onPrompt} detail={{
-      detailKind: "agent-decision",
-      decisionType: "creative",
-      actor: { role: "implementer", label: "实现 Agent" },
-      authority: "implementation-scope",
-      question: "当前实现方案如何形成候选产出?",
-      promptRef: "prompt:event:2977",
-      blocks: [],
-      technical: {},
-    }} />);
-
-    fireEvent.click(screen.getByRole("button", { name: "查看实现任务与提示词" }));
-    expect(onPrompt).toHaveBeenCalledWith("prompt:event:2977");
-  });
-
-  it("does not invent a recommendation-handling column without an explicit record", () => {
-    const item = storyItem(makeStory("tradeoff", "multipath-decision"), 1);
-    render(<Inspector item={item} tab="business" onTab={vi.fn()} onClose={vi.fn()} detail={{
-      detailKind: "agent-decision",
-      decisionType: "tradeoff",
-      blocks: [{
-        type: "comparison",
-        title: "评审建议与最终决定",
-        rows: [{ candidate: "方案 1", recommendation: "建议采用", finalDecision: "已采用" }],
-      }],
-      technical: {},
-    }} />);
-    expect(screen.getByText("评审建议")).toBeInTheDocument();
-    expect(screen.getByText("最终决定")).toBeInTheDocument();
-    expect(screen.queryByText("对建议的处理")).not.toBeInTheDocument();
-    expect(screen.queryByText("没有明确记录")).not.toBeInTheDocument();
-  });
-
-  it("renders structured evaluator issues once and keeps nested outcomes readable", () => {
-    const item = storyItem(makeStory("evaluation", "round-evaluation"), 1);
-    render(<Inspector item={item} tab="business" onTab={vi.fn()} onClose={vi.fn()} detail={{
-      detailKind: "agent-decision",
-      decisionType: "evaluation",
-      blocks: [{
-        type: "items",
-        title: "逐项结论",
-        items: [{
-          summary: "结尾仍需补充行动建议",
-          severity: "中",
-          achievements: ["主线结构已经清晰"],
-          problems: ["结尾行动指引不足"],
-        }],
-      }],
-      technical: {},
-    }} />);
-    expect(screen.getByText("问题 · 中")).toBeInTheDocument();
-    expect(screen.getAllByText("结尾仍需补充行动建议")).toHaveLength(1);
-    expect(screen.getByText("已达成")).toBeInTheDocument();
-    expect(screen.getByText("主线结构已经清晰")).toBeInTheDocument();
-    expect(screen.getByText("需关注")).toBeInTheDocument();
-    expect(screen.getByText("结尾行动指引不足")).toBeInTheDocument();
-  });
-});

+ 0 - 161
visualization/frontend/tests/layout-v8.test.ts

@@ -1,161 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { buildHorizontalLayout } from "@/lib/layout";
-import { makeStory, makeView } from "@/tests/fixtures";
-
-const callbacks = { onOpen: vi.fn(), onToggleRound: vi.fn(), onToggleBranch: vi.fn(), onToggleRetrievalAgent: vi.fn(), expandedRetrievalAgents: new Set<string>() };
-
-describe("V8 divergence, review, and convergence layout", () => {
-  it("places the real planning analysis before the persisted creative objective", () => {
-    const view = makeView(1, 1, 1);
-    view.planning = makeStory("run:planning-analysis", "planning-analysis", "execution", {
-      title: "创作规划解析",
-      detailRef: "event:3760",
-    });
-    const result = buildHorizontalLayout(view, { expandedRounds: new Set(), expandedBranches: new Set(), ...callbacks });
-    const planning = result.nodes.find((node) => node.id === "run:planning-analysis")!;
-    const objective = result.nodes.find((node) => node.id === view.objective.id)!;
-
-    expect(planning.position.x).toBeLessThan(objective.position.x);
-    expect(result.edges.some((edge) => edge.source === planning.id && edge.target === objective.id)).toBe(true);
-  });
-
-  it("places collapsed rounds in vertically separated rows", () => {
-    const view = makeView(10, 2, 1);
-    const result = buildHorizontalLayout(view, { expandedRounds: new Set(), expandedBranches: new Set(), ...callbacks });
-    const rounds = view.rounds.map((round) => result.nodes.find((node) => node.id === `round:${round.roundIndex}:summary`)!);
-    expect(new Set(rounds.map((node) => node.position.x)).size).toBe(1);
-    expect(rounds.map((node) => node.position.y)).toEqual([...rounds.map((node) => node.position.y)].sort((a, b) => a - b));
-    rounds.slice(1).forEach((node, index) => expect(node.position.y).toBeGreaterThanOrEqual(rounds[index].position.y + 400 + 104));
-  });
-
-  it("places expanded rounds on separate rows without adding their widths together", () => {
-    const view = makeView(4, 2, 1);
-    const expandedRounds = new Set(view.rounds.map((round) => round.roundIndex));
-    const result = buildHorizontalLayout(view, { expandedRounds, expandedBranches: new Set(), ...callbacks });
-    const rounds = view.rounds.map((round) => result.nodes.find((node) => node.id === `frame:round:${round.roundIndex}`)!);
-
-    expect(new Set(rounds.map((node) => node.position.x)).size).toBe(1);
-    rounds.slice(1).forEach((node, index) => {
-      const previous = rounds[index];
-      expect(node.position.y).toBeGreaterThanOrEqual(previous.position.y + Number(previous.style?.height) + 104);
-    });
-  });
-
-  it("fans branches out and converges each branch into its recorded decision batch", () => {
-    const view = makeView(1, 6, 2);
-    const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(), ...callbacks });
-    const candidates = result.nodes.filter((node) => node.type === "candidateCard");
-    expect(new Set(candidates.map((node) => node.position.y)).size).toBe(6);
-    for (const batch of view.rounds[0].convergenceBatches) {
-      expect(result.nodes.find((node) => node.id === batch.review?.id)?.type).toBe("agentDecision");
-      expect(result.nodes.find((node) => node.id === batch.decision?.id)?.type).toBe("agentDecision");
-    }
-    for (const batch of view.rounds[0].convergenceBatches) {
-      for (const branchId of batch.branchIds) expect(result.edges.some((edge) => edge.source === `round:1:branch:${branchId}:summary` && edge.target === batch.review?.id)).toBe(true);
-      expect(result.edges.some((edge) => edge.source === batch.review?.id && edge.target === batch.decision?.id)).toBe(true);
-    }
-  });
-
-  it("uses backend roles and ids rather than Chinese titles", () => {
-    const view = makeView(1, 1, 1);
-    view.rounds[0].goal.title = "completely renamed";
-    view.rounds[0].convergenceBatches[0].decision!.node.title = "also renamed";
-    const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(), ...callbacks });
-    expect(result.nodes.some((node) => node.id === "r1-goal")).toBe(true);
-    expect(result.nodes.some((node) => node.id === "multipath-1-1")).toBe(true);
-  });
-
-  it("keeps 10 expanded implementation lanes separated", () => {
-    const view = makeView(1, 10, 2);
-    const expandedBranches = new Set(view.rounds[0].branches.map((branch) => `1:${branch.branchId}`));
-    const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches, ...callbacks });
-    const frames = result.nodes.filter((node) => node.type === "branchFrame");
-    const boxes = frames.map((node) => ({ top: node.position.y, bottom: node.position.y + Number(node.style?.height || 0) })).sort((a, b) => a.top - b.top);
-    expect(boxes).toHaveLength(10);
-    boxes.slice(1).forEach((box, index) => expect(box.top).toBeGreaterThan(boxes[index].bottom));
-  });
-
-  it("renders a missing decision stage without inventing a batch", () => {
-    const view = makeView(1, 2, 0);
-    const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(), ...callbacks });
-    expect(view.rounds[0].convergenceBatches[0].decision).toBeUndefined();
-    expect(result.nodes.some((node) => node.id === "r1-missing-review")).toBe(true);
-    expect(result.nodes.some((node) => node.id === "r1-missing-decision")).toBe(true);
-  });
-
-  it("tightens expanded-round spacing as semantic zoom hides card details", () => {
-    const view = makeView(1, 3, 1);
-    view.rounds[0].branches[0].summary.task = "很长的候选任务说明。".repeat(40);
-    const heightAt = (detailLevel: "full" | "compact" | "overview") => {
-      const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(), detailLevel, ...callbacks });
-      return Number(result.nodes.find((node) => node.id === "frame:round:1")?.style?.height || 0);
-    };
-    expect(heightAt("overview")).toBeLessThan(heightAt("compact"));
-    expect(heightAt("compact")).toBeLessThan(heightAt("full"));
-  });
-
-  it("keeps compact branch cards separated without hiding agent regions", () => {
-    const view = makeView(1, 10, 2);
-    const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(), detailLevel: "compact", ...callbacks });
-    const candidates = result.nodes.filter((node) => node.type === "candidateCard").sort((a, b) => a.position.y - b.position.y);
-    expect(candidates).toHaveLength(10);
-    expect(result.nodes.filter((node) => node.type === "agentRegion")).toHaveLength(6);
-    candidates.slice(1).forEach((node, index) => {
-      const gap = node.position.y - candidates[index].position.y - 232;
-      expect(gap).toBeGreaterThanOrEqual(18);
-      expect(gap).toBeLessThanOrEqual(24);
-    });
-  });
-
-  it("keeps implementation and retrieval agents visible in overview mode", () => {
-    const view = makeView(1, 1, 1);
-    const result = buildHorizontalLayout(view, {
-      expandedRounds: new Set([1]),
-      expandedBranches: new Set(["1:1"]),
-      detailLevel: "overview",
-      ...callbacks,
-    });
-
-    expect(result.nodes.some((node) => node.type === "roundFrame")).toBe(true);
-    expect(result.nodes.some((node) => node.type === "branchFrame")).toBe(true);
-    expect(result.nodes.some((node) => node.type === "retrievalAgent")).toBe(true);
-    expect(result.nodes.filter((node) => node.type === "agentRegion")).toHaveLength(6);
-    expect(result.nodes.some((node) => node.type === "roundSummary")).toBe(false);
-  });
-
-  it("centers cards by their own height instead of sharing one top coordinate", () => {
-    const view = makeView(1, 1, 1);
-    view.rounds[0].goal.card.primary!.value = "这是一个较长的本轮目标,用来验证高卡片依然围绕同一条中线排列。".repeat(8);
-    const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(), detailLevel: "full", ...callbacks });
-    const goal = result.nodes.find((node) => node.id === "r1-goal")!;
-    const output = result.nodes.find((node) => node.id === "r1-result")!;
-
-    expect(goal.position.y).toBeLessThan(output.position.y);
-  });
-
-  it("stacks retrieval operations vertically even when they belong to different waves", () => {
-    const view = makeView(1, 1, 1);
-    const branch = view.rounds[0].branches[0];
-    const result = buildHorizontalLayout(view, { expandedRounds: new Set([1]), expandedBranches: new Set(["1:1"]), ...callbacks });
-    const operationIds = [...branch.retrievalStage.directToolGroups, ...branch.retrievalStage.agentRuns].map((operation) => operation.id);
-    const operations = operationIds.map((id) => result.nodes.find((node) => node.id === id)!);
-    const stage = result.nodes.find((node) => node.id === `frame:${branch.retrievalStage.id}`)!;
-
-    expect(new Set(operations.map((node) => node.position.x))).toEqual(new Set([20]));
-    expect(operations.map((node) => node.position.y)).toEqual([...operations.map((node) => node.position.y)].sort((a, b) => a - b));
-    expect(Number(stage.style?.width)).toBe(380);
-  });
-
-  it("grows expanded retrieval frames upward as query details make the stack taller", () => {
-    const view = makeView(1, 1, 1);
-    const branch = view.rounds[0].branches[0];
-    const baseOptions = { expandedRounds: new Set([1]), expandedBranches: new Set(["1:1"]), ...callbacks };
-    const collapsed = buildHorizontalLayout(view, baseOptions);
-    const expanded = buildHorizontalLayout(view, { ...baseOptions, expandedRetrievalAgents: new Set(branch.retrievalStage.agentRuns.map((run) => run.id)) });
-    const collapsedRound = collapsed.nodes.find((node) => node.id === "frame:round:1")!;
-    const expandedRound = expanded.nodes.find((node) => node.id === "frame:round:1")!;
-
-    expect(Number(expandedRound.style?.height)).toBeGreaterThan(Number(collapsedRound.style?.height));
-    expect(expandedRound.position.y).toBeLessThan(collapsedRound.position.y);
-  });
-});

+ 0 - 40
visualization/frontend/tests/prompt-drawer.test.tsx

@@ -1,40 +0,0 @@
-import { fireEvent, render, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
-import { PromptDrawer } from "@/components/decision/PromptDrawer";
-
-describe("PromptDrawer truth labels", () => {
-  const prompt = {
-    promptRef: "prompt:event:50",
-    actor: { role: "implementer" as const, label: "实现 Agent" },
-    exactTask: { content: "补齐烹饪步骤", accuracy: "run-exact" as const },
-    systemPrompt: {
-      content: "按当前实现 Agent 规则完成任务。",
-      source: "current-db" as const,
-      version: 8,
-      accuracy: "current-not-run-snapshot" as const,
-      truncated: false,
-    },
-    notices: [{
-      code: "PROMPT_NOT_SNAPSHOTTED",
-      message: "系统没有保存这次运行所用提示词的历史快照;下方展示当前提示词。",
-    }],
-  };
-
-  it("separates the exact run task from the current rule version", () => {
-    render(<PromptDrawer prompt={prompt} loading={false} onClose={vi.fn()} />);
-    expect(screen.getByRole("heading", { name: "本次真实任务" })).toBeInTheDocument();
-    expect(screen.getByText("补齐烹饪步骤")).toBeInTheDocument();
-    expect(screen.getByRole("heading", { name: "当前提示词" })).toBeInTheDocument();
-    expect(screen.getByText("当前数据库版本 · v8")).toBeInTheDocument();
-    expect(screen.getByText(/没有保存这次运行所用提示词的历史快照/)).toBeInTheDocument();
-    expect(screen.queryByText("当次运行完整 Prompt")).not.toBeInTheDocument();
-  });
-
-  it("closes with Escape and starts focus on the close button", () => {
-    const onClose = vi.fn();
-    render(<PromptDrawer prompt={prompt} loading={false} onClose={onClose} />);
-    expect(screen.getByRole("button", { name: "关闭提示词" })).toHaveFocus();
-    fireEvent.keyDown(window, { key: "Escape" });
-    expect(onClose).toHaveBeenCalledTimes(1);
-  });
-});

+ 0 - 9
visualization/frontend/tests/redact.test.ts

@@ -1,9 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { pretty, redact } from "@/lib/redact";
-
-describe("redaction", () => {
-  it("redacts credential-like fields and bearer values", () => {
-    expect(redact({ apiKey: "secret", nested: { authorization: "Bearer abc.def" }, safe: "ok" })).toEqual({ apiKey: "[REDACTED]", nested: { authorization: "[REDACTED]" }, safe: "ok" });
-    expect(pretty("Authorization: Bearer abc.def")).toContain("Bearer [REDACTED]");
-  });
-});

+ 0 - 135
visualization/frontend/tests/retrieval-inspector.test.tsx

@@ -1,135 +0,0 @@
-import { fireEvent, render, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
-import { Inspector } from "@/components/inspector/Inspector";
-import { retrievalActivityItem, retrievalAgentItem } from "@/lib/layout";
-import type { RetrievalActivity, RetrievalAgentRun } from "@/lib/types";
-
-const queryActivity: RetrievalActivity = {
-  id: "event:4607",
-  kind: "query",
-  label: "毛驴效应 心理学科普",
-  status: "hit",
-  resultCount: 2,
-  detailRef: "event:4607",
-};
-
-function agentRun(): RetrievalAgentRun {
-  return {
-    id: "retrieval-agent:4601",
-    kind: "retrieval-agent",
-    eventId: 4601,
-    agentType: "retrieve_data_decode_case",
-    businessLabel: "解构 Case Agent",
-    taskPreview: "查找毛驴效应案例",
-    status: "completed",
-    querySummary: { total: 1, hit: 1, empty: 0, failure: 0, unknown: 0 },
-    attempts: [],
-    screening: { state: "available", preview: "初筛完成" },
-    detailRef: "retrieval-agent:4601",
-  };
-}
-
-describe("retrieval Inspector", () => {
-  it("lists agent queries and delegates same-drawer navigation", () => {
-    const onNavigate = vi.fn();
-    render(<Inspector
-      item={retrievalAgentItem(agentRun(), 1, 2)}
-      tab="business"
-      onTab={vi.fn()}
-      onClose={vi.fn()}
-      onNavigate={onNavigate}
-      detail={{
-        detailKind: "retrieval-agent",
-        retrievalKind: "agent",
-        businessSections: [
-          { id: "task", title: "取数目标", content: "查找毛驴效应案例" },
-          { id: "screening", title: "初筛整理", content: "保留两条相关案例" },
-        ],
-        activities: [queryActivity],
-        technical: {},
-      }}
-    />);
-
-    expect(screen.getByText("查询记录")).toBeInTheDocument();
-    expect(screen.getByText("2 条")).toBeInTheDocument();
-    fireEvent.click(screen.getByRole("button", { name: /毛驴效应 心理学科普/ }));
-    expect(onNavigate).toHaveBeenCalledWith(queryActivity);
-  });
-
-  it("shows an explicit notice when Pattern Relation Agent has no structured records", () => {
-    render(<Inspector
-      item={retrievalAgentItem({ ...agentRun(), agentType: "retrieve_data_pattern_relation", businessLabel: "Pattern / Relation Agent" }, 1, 2)}
-      tab="business"
-      onTab={vi.fn()}
-      onClose={vi.fn()}
-      detail={{
-        detailKind: "retrieval-agent",
-        retrievalKind: "agent",
-        activities: [],
-        missingActivityNotice: "本次运行未保存可下钻的结构化查询记录。",
-        technical: {},
-      }}
-    />);
-
-    expect(screen.getByText("本次运行未保存可下钻的结构化查询记录。")).toBeInTheDocument();
-  });
-
-  it("renders hit cases with expandable complete content", () => {
-    render(<Inspector
-      item={retrievalActivityItem(queryActivity, 1, 2)}
-      tab="business"
-      onTab={vi.fn()}
-      onClose={vi.fn()}
-      onBack={vi.fn()}
-      backLabel="返回解构 Case Agent"
-      detail={{
-        detailKind: "retrieval-event",
-        sourceKind: "agent-query",
-        eventId: 4607,
-        queryConditions: [{ key: "keyword", label: "关键词", value: "毛驴效应" }],
-        outcome: { state: "hit", count: 2, message: "查询成功,返回 2 条结果。" },
-        items: [{
-          id: "case-a",
-          title: "每天心理学 · Case 1",
-          subtitle: "case-a",
-          meta: [{ label: "匹配分数", value: 0.7032 }],
-          sections: [{ title: "完整解构内容", value: [{ id: "段落1", description: "完整解构原文" }] }],
-        }],
-        completeness: { bodyAvailable: true, truncated: false },
-        technical: {},
-      }}
-    />);
-
-    expect(screen.getByText("查询成功,返回 2 条结果。")).toBeInTheDocument();
-    expect(screen.getByText("毛驴效应")).toBeInTheDocument();
-    expect(screen.getByText("每天心理学 · Case 1")).toBeInTheDocument();
-    expect(screen.getByText(/\"完整解构原文\"/)).toBeInTheDocument();
-    expect(screen.getByRole("button", { name: "返回解构 Case Agent" })).toBeInTheDocument();
-  });
-
-  it("distinguishes successful empty from technical failure", () => {
-    const item = retrievalActivityItem({ ...queryActivity, id: "event:4603", detailRef: "event:4603", status: "empty", resultCount: 0 }, 1, 2);
-    const { rerender } = render(<Inspector item={item} tab="business" onTab={vi.fn()} onClose={vi.fn()} detail={{
-      detailKind: "retrieval-event",
-      sourceKind: "agent-query",
-      outcome: { state: "empty", count: 0, message: "查询执行成功,没有匹配数据。" },
-      queryConditions: [{ key: "account_name", label: "账号", value: "每天一点心理学" }],
-      items: [],
-      completeness: { bodyAvailable: true, truncated: false },
-      technical: {},
-    }} />);
-    expect(screen.getByText("查询执行成功,没有匹配数据。")).toBeInTheDocument();
-
-    rerender(<Inspector item={{ ...item, id: "event:4066" }} tab="business" onTab={vi.fn()} onClose={vi.fn()} detail={{
-      detailKind: "retrieval-event",
-      sourceKind: "agent-query",
-      outcome: { state: "failure", message: "查询执行失败:return_field 仅支持主脉络。", errorKind: "parameter" },
-      queryConditions: [{ key: "return_field", label: "返回内容", value: "写法" }],
-      items: [],
-      completeness: { bodyAvailable: true, truncated: false },
-      technical: {},
-    }} />);
-    expect(screen.getByText("查询执行失败:return_field 仅支持主脉络。")).toBeInTheDocument();
-    expect(screen.getByText("写法")).toBeInTheDocument();
-  });
-});

+ 0 - 115
visualization/frontend/tests/retrieval-v8.test.tsx

@@ -1,115 +0,0 @@
-import { fireEvent, render, screen, within } from "@testing-library/react";
-import { ReactFlowProvider } from "@xyflow/react";
-import { describe, expect, it, vi } from "vitest";
-import { DirectToolGroupCard } from "@/components/flow/DirectToolGroupCard";
-import { RetrievalAgentCard } from "@/components/flow/RetrievalAgentCard";
-import { buildHorizontalLayout, retrievalAgentItem, retrievalDirectItem } from "@/lib/layout";
-import { makeBranch, makeRetrievalStage, makeView } from "@/tests/fixtures";
-
-const base = { onOpen: vi.fn(), onToggleRound: vi.fn(), onToggleBranch: vi.fn(), onToggleRetrievalAgent: vi.fn() };
-const nodeProps = { selected: false, zIndex: 1, isConnectable: false, positionAbsoluteX: 0, positionAbsoluteY: 0, dragging: false, draggable: true, selectable: true, deletable: false };
-const wrap = (node: React.ReactNode) => render(<ReactFlowProvider>{node}</ReactFlowProvider>);
-
-describe("V8 retrieval stage", () => {
-  it("shows an immediate Agent retrieval preview on keyboard focus", () => {
-    const branch = makeBranch(1, 1);
-    const item = retrievalAgentItem(branch.retrievalStage.agentRuns[0], 1, 1);
-    const { container } = wrap(<RetrievalAgentCard id={item.id} type="retrievalAgent" {...nodeProps} data={{ ...base, buildId: "443", item, expanded: false }} />);
-    fireEvent.focus(container.querySelector(".retrievalAgentCard")!);
-    const preview = screen.getByRole("dialog", { name: /取数预览/ });
-    expect(within(preview).getByText("命中了什么")).toBeInTheDocument();
-    expect(within(preview).getByText("初筛得到")).toBeInTheDocument();
-  });
-
-  it("loads and presents real tool results only after the preview opens", async () => {
-    const branch = makeBranch(1, 1);
-    const item = retrievalDirectItem(branch.retrievalStage.directToolGroups[0], 1, 1);
-    if (item.itemType !== "retrieval-direct") throw new Error("expected retrieval-direct item");
-    item.group.calls = [{ id: "event:4047", eventId: 4047, businessLabel: "领域信息", status: "success", resultSummary: "已读取", detailRef: "event:4047" }];
-    item.group.callCount = 1;
-    item.group.successCount = 1;
-    const fetchMock = vi.fn().mockResolvedValue({
-      ok: true,
-      json: async () => ({ detailKind: "retrieval-event", outcome: { state: "hit", count: 1, message: "读取成功,共返回 1 条记录。" }, items: [{ id: "fact-1", title: "领域事实 1", subtitle: "核实了布里丹寓言的史实背景。", meta: [], sections: [] }], technical: {} }),
-    });
-    vi.stubGlobal("fetch", fetchMock);
-    const { container } = wrap(<DirectToolGroupCard id={item.id} type="directToolGroup" {...nodeProps} data={{ ...base, buildId: "443", item }} />);
-    expect(fetchMock).not.toHaveBeenCalled();
-    fireEvent.focus(container.querySelector(".directToolCard")!);
-    const preview = screen.getByRole("dialog", { name: /取数预览/ });
-    expect(await within(preview).findByText("领域事实 1")).toBeInTheDocument();
-    expect(fetchMock).toHaveBeenCalledTimes(1);
-    vi.unstubAllGlobals();
-  });
-
-  it("presents direct tools and delegated agents as peer retrieval operations", () => {
-    const branch = makeBranch(1, 1);
-    const direct = retrievalDirectItem(branch.retrievalStage.directToolGroups[0], 1, 1);
-    const agent = retrievalAgentItem(branch.retrievalStage.agentRuns[0], 1, 1);
-    wrap(<><DirectToolGroupCard id={direct.id} type="directToolGroup" {...nodeProps} data={{ ...base, item: direct }} /><RetrievalAgentCard id={agent.id} type="retrievalAgent" {...nodeProps} data={{ ...base, item: agent, expanded: false }} /></>);
-    expect(screen.getByText("工具取数")).toBeInTheDocument();
-    expect(screen.getByText("Agent 取数")).toBeInTheDocument();
-    expect(screen.getByText("初筛整理")).toBeInTheDocument();
-    expect(screen.queryByText("数据取舍")).not.toBeInTheDocument();
-  });
-
-  it("keeps repeated attempts inside one agent and opens an individual query", () => {
-    const branch = makeBranch(1, 1);
-    const run = { ...branch.retrievalStage.agentRuns[0], attempts: branch.retrievalStage.agentRuns[0].attempts.concat(Array.from({ length: 10 }, (_, index) => ({ ...branch.retrievalStage.agentRuns[0].attempts[0], id: `event:extra-${index}`, eventId: 900 + index, queryLabel: `额外查询 ${index + 1}`, detailRef: `event:${900 + index}` }))) };
-    run.querySummary = { ...run.querySummary, total: 13 };
-    const item = retrievalAgentItem(run, 1, 1);
-    const onOpen = vi.fn();
-    wrap(<RetrievalAgentCard id={item.id} type="retrievalAgent" {...nodeProps} data={{ ...base, onOpen, item, expanded: true }} />);
-    expect(screen.getByText((_, element) => element?.tagName === "P" && element.textContent?.startsWith("13 次") === true)).toBeInTheDocument();
-    expect(screen.getAllByRole("listitem")).toHaveLength(13);
-    fireEvent.click(screen.getByText("额外查询 1"));
-    expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ itemType: "query-attempt", detailRef: "event:900" }), "business");
-  });
-
-  it("stacks both parallel and sequential retrieval operations vertically", () => {
-    const parallelView = makeView(1, 1, 1);
-    parallelView.rounds[0].branches[0].retrievalStage = makeRetrievalStage(1, 1, 2, 1, "parallel");
-    const options = { expandedRounds: new Set([1]), expandedBranches: new Set(["1:1"]), expandedRetrievalAgents: new Set<string>(), ...base };
-    const parallel = buildHorizontalLayout(parallelView, options).nodes.filter((node) => node.type === "directToolGroup" || node.type === "retrievalAgent");
-    expect(new Set(parallel.map((node) => node.position.x)).size).toBe(1);
-    expect(new Set(parallel.map((node) => node.position.y)).size).toBe(parallel.length);
-
-    const sequentialView = makeView(1, 1, 1);
-    const sequential = buildHorizontalLayout(sequentialView, options).nodes.filter((node) => node.type === "directToolGroup" || node.type === "retrievalAgent");
-    expect(new Set(sequential.map((node) => node.position.x)).size).toBe(1);
-    expect(new Set(sequential.map((node) => node.position.y)).size).toBe(sequential.length);
-  });
-
-  it("keeps unknown-time operations in one neutral column inside the retrieval frame", () => {
-    const view = makeView(1, 1, 1);
-    const stage = view.rounds[0].branches[0].retrievalStage;
-    stage.observedMode = "unknown";
-    stage.waves = [];
-    stage.directToolGroups.forEach((item) => { item.waveIndex = undefined; item.startedAt = undefined; item.endedAt = undefined; });
-    stage.agentRuns.forEach((item) => { item.waveIndex = undefined; item.startedAt = undefined; item.endedAt = undefined; });
-    const options = { expandedRounds: new Set([1]), expandedBranches: new Set(["1:1"]), expandedRetrievalAgents: new Set<string>(), ...base };
-    const layout = buildHorizontalLayout(view, options);
-    const operations = layout.nodes.filter((node) => node.type === "directToolGroup" || node.type === "retrievalAgent");
-    const frame = layout.nodes.find((node) => node.type === "retrievalStageFrame");
-    expect(new Set(operations.map((node) => node.position.x)).size).toBe(1);
-    expect(Math.max(...operations.map((node) => node.position.x + Number(node.style?.width || 340)))).toBeLessThanOrEqual(Number(frame?.style?.width || 0));
-  });
-
-  it("marks expanded query lists so semantic zoom cannot hide a successful expansion", () => {
-    const branch = makeBranch(1, 1);
-    const item = retrievalAgentItem(branch.retrievalStage.agentRuns[0], 1, 1);
-    const { container } = wrap(<RetrievalAgentCard id={item.id} type="retrievalAgent" {...nodeProps} data={{ ...base, item, expanded: true }} />);
-    expect(container.querySelector(".retrievalAgentCard")).toHaveClass("isQueryExpanded");
-    expect(container.querySelector(".queryAttemptList")).toBeInTheDocument();
-  });
-
-  it("grows the branch frame when query rows are expanded", () => {
-    const view = makeView(1, 1, 1);
-    const agentId = view.rounds[0].branches[0].retrievalStage.agentRuns[0].id;
-    const baseOptions = { expandedRounds: new Set([1]), expandedBranches: new Set(["1:1"]), ...base };
-    const collapsed = buildHorizontalLayout(view, { ...baseOptions, expandedRetrievalAgents: new Set<string>() });
-    const expanded = buildHorizontalLayout(view, { ...baseOptions, expandedRetrievalAgents: new Set([agentId]) });
-    const height = (nodes: typeof collapsed.nodes) => Number(nodes.find((node) => node.type === "branchFrame")?.style?.height || 0);
-    expect(height(expanded.nodes)).toBeGreaterThan(height(collapsed.nodes));
-  });
-});

+ 0 - 14
visualization/frontend/tests/rich-text.test.tsx

@@ -1,14 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-import { RichText } from "@/components/ui/RichText";
-
-describe("RichText", () => {
-  it("renders evaluator markdown as readable structure instead of raw markers", () => {
-    const { container } = render(<RichText value={"## 整体结论\n\n**部分通过**\n\n- 目标一\n- 目标二"} />);
-    expect(screen.getByRole("heading", { name: "整体结论" })).toBeInTheDocument();
-    expect(screen.getByText("部分通过").tagName).toBe("STRONG");
-    expect(screen.getAllByRole("listitem")).toHaveLength(2);
-    expect(container.textContent).not.toContain("##");
-    expect(container.textContent).not.toContain("**");
-  });
-});

+ 0 - 33
visualization/frontend/tests/run-selection.test.ts

@@ -1,33 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { centeredOverviewViewport } from "@/components/workbench/FlowCanvas";
-import { initialBuildId } from "@/hooks/useExecutionView";
-import { moduleAuditUrl } from "@/lib/api";
-
-const builds = [
-  { id: "444", status: "success" },
-  { id: "443", status: "success" },
-] as const;
-
-describe("run selection persistence and initial canvas placement", () => {
-  it("prefers the run in the URL, including an older run outside the recent list", () => {
-    expect(initialBuildId([...builds], "?run=439", "443")).toBe("439");
-  });
-
-  it("uses a valid remembered run and ignores a stale remembered run", () => {
-    expect(initialBuildId([...builds], "", "443")).toBe("443");
-    expect(initialBuildId([...builds], "", "100")).toBe("444");
-  });
-
-  it("centers the graph bounds horizontally at overview zoom", () => {
-    const viewport = centeredOverviewViewport({ x: 60, width: 3200 }, 1440);
-    const renderedCenter = viewport.x + (60 + 1600) * viewport.zoom;
-
-    expect(viewport.zoom).toBe(.2);
-    expect(renderedCenter).toBe(720);
-    expect(viewport.y).toBe(94);
-  });
-
-  it("builds the audit page URL from the currently selected run", () => {
-    expect(moduleAuditUrl("439")).toBe("http://127.0.0.1:8788/audit/439");
-  });
-});

+ 0 - 58
visualization/frontend/tests/script-table.test.tsx

@@ -1,58 +0,0 @@
-import { fireEvent, render, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
-import { ScriptTable } from "@/components/artifact/ScriptTable";
-import { ScriptElementTable } from "@/components/artifact/ScriptElementTable";
-import type { ScriptElementRow, ScriptParagraphRow } from "@/lib/types";
-
-const paragraph: ScriptParagraphRow = {
-  id: 1,
-  rowId: 1,
-  index: 1,
-  level: 1,
-  depth: 0,
-  name: "机制拆解",
-  contentRange: ["图1-2", "标题", "正文: 第二段"],
-  sections: {
-    theme: { label: "主题", description: "解释份额换订单", descriptionParts: [{ type: "text", text: "解释" }, { type: "element", id: 10, name: "份额换订单", resolved: true, text: "[元素:10]" }], dimensions: [{ type: "主维度", name: "核心竞争力", value: "份额换订单" }] },
-    form: { label: "形式", description: "案例拆解", descriptionParts: [{ type: "text", text: "案例拆解" }], dimensions: [] },
-    function: { label: "作用", description: "承接上文", descriptionParts: [{ type: "text", text: "承接上文" }], dimensions: [] },
-    feeling: { label: "感受", description: "建立可信度", descriptionParts: [{ type: "text", text: "建立可信度" }], dimensions: [] },
-  },
-  fullDescription: "通过元素解释机制。",
-  fullDescriptionParts: [
-    { type: "text", text: "通过" },
-    { type: "element", id: 10, name: "份额换订单", resolved: true, text: "[元素:10]" },
-    { type: "text", text: "解释机制。" },
-  ],
-};
-
-const element: ScriptElementRow = {
-  id: 10,
-  rowId: 10,
-  name: "份额换订单",
-  primaryDimension: "核心竞争力",
-  secondaryDimension: "商业模式",
-  paragraphs: [{ id: 1, index: 1, name: "机制拆解" }],
-};
-
-describe("full script table", () => {
-  it("matches the original complete table fields and element references", () => {
-    const openElement = vi.fn();
-    render(<ScriptTable paragraphs={[paragraph]} elements={[element]} onOpenElement={openElement} />);
-    expect(screen.getAllByText("维度类型")).toHaveLength(4);
-    expect(screen.getByText("核心竞争力")).toBeInTheDocument();
-    expect(screen.getByText("图1-2")).toBeInTheDocument();
-    expect(screen.getByText("正文: 第二段")).toBeInTheDocument();
-    fireEvent.click(screen.getAllByRole("button", { name: "份额换订单" })[0]);
-    expect(openElement).toHaveBeenCalledWith(10);
-    expect(screen.getByText("创作目录大纲(段本身)")).toBeInTheDocument();
-  });
-
-  it("shows element dimensions and linked paragraphs", () => {
-    render(<ScriptElementTable elements={[element]} selectedId={10} />);
-    expect(screen.getByRole("row", { name: /份额换订单/ })).toHaveClass("isSelected");
-    expect(screen.getByText("商业模式")).toBeInTheDocument();
-    expect(screen.getByText("第 1 段")).toBeInTheDocument();
-    expect(screen.getByText("机制拆解")).toBeInTheDocument();
-  });
-});

+ 0 - 26
visualization/frontend/tests/setup.ts

@@ -1,26 +0,0 @@
-import "@testing-library/jest-dom/vitest";
-import { cleanup } from "@testing-library/react";
-import { afterEach } from "vitest";
-
-afterEach(() => cleanup());
-
-class ResizeObserverMock {
-  observe() {}
-  unobserve() {}
-  disconnect() {}
-}
-
-Object.defineProperty(globalThis, "ResizeObserver", { value: ResizeObserverMock });
-Object.defineProperty(window, "matchMedia", {
-  writable: true,
-  value: (query: string) => ({
-    matches: false,
-    media: query,
-    onchange: null,
-    addEventListener() {},
-    removeEventListener() {},
-    addListener() {},
-    removeListener() {},
-    dispatchEvent() { return false; },
-  }),
-});

+ 0 - 11
visualization/frontend/tests/status-copy.test.ts

@@ -1,11 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { runResultFallback } from "@/lib/status-copy";
-
-describe("run result business wording", () => {
-  it("distinguishes an interrupted run from a normally completed run", () => {
-    expect(runResultFallback("stopped", true)).toBe("本次构建已停止,流程未完成。");
-    expect(runResultFallback("failed", true)).toBe("本次构建失败,流程未完成。");
-    expect(runResultFallback("success", true)).toBe("本次构建已经结束。");
-    expect(runResultFallback("running", false)).toBe("构建仍在运行。");
-  });
-});

+ 3 - 4
visualization/frontend/tsconfig.json

@@ -1,6 +1,6 @@
 {
   "compilerOptions": {
-    "target": "ES2017",
+    "target": "ES2022",
     "lib": ["dom", "dom.iterable", "esnext"],
     "allowJs": false,
     "skipLibCheck": true,
@@ -9,13 +9,12 @@
     "esModuleInterop": true,
     "module": "esnext",
     "moduleResolution": "bundler",
-    "baseUrl": ".",
-    "paths": { "@/*": ["./*"] },
     "resolveJsonModule": true,
     "isolatedModules": true,
     "jsx": "preserve",
     "incremental": true,
-    "plugins": [{ "name": "next" }]
+    "plugins": [{ "name": "next" }],
+    "paths": { "@/*": ["./*"] }
   },
   "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
   "exclude": ["node_modules"]

+ 4 - 6
visualization/frontend/vitest.config.ts

@@ -1,13 +1,11 @@
-import { defineConfig } from "vitest/config";
+import { configDefaults, defineConfig } from "vitest/config";
 import path from "node:path";
 
 export default defineConfig({
-  esbuild: { jsx: "automatic" },
   resolve: { alias: { "@": path.resolve(__dirname, ".") } },
   test: {
     environment: "jsdom",
-    setupFiles: ["./tests/setup.ts"],
-    css: false,
-    exclude: ["tests/e2e/**", "node_modules/**", ".next/**"]
-  }
+    globals: true,
+    exclude: [...configDefaults.exclude, "tests/e2e/**"],
+  },
 });

Some files were not shown because too many files changed in this diff