JourneyCanvas.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. import {
  2. ArrowDown,
  3. Brain,
  4. CheckCircle2,
  5. CircleDot,
  6. Database,
  7. GitBranch,
  8. Play,
  9. RefreshCcw,
  10. Route,
  11. SearchCheck,
  12. Wrench,
  13. } from "lucide-react";
  14. import { buildTaskCycles, groupParallelSteps, statusLabel } from "@/lib/journey";
  15. import type { JourneyEdge, JourneyStep, JourneyView, StepType, ZoomLevel } from "@/lib/types";
  16. const STEP_META: Record<StepType, { label: string; icon: typeof Brain }> = {
  17. plan: { label: "规划", icon: Route },
  18. execute: { label: "执行", icon: Play },
  19. validate: { label: "验收", icon: SearchCheck },
  20. decide: { label: "决策", icon: GitBranch },
  21. };
  22. interface JourneyCanvasProps {
  23. journey: JourneyView;
  24. zoom: ZoomLevel;
  25. selectedStepId: string | null;
  26. onSelectStep: (stepId: string) => void;
  27. }
  28. export function JourneyCanvas({ journey, zoom, selectedStepId, onSelectStep }: JourneyCanvasProps) {
  29. if (zoom === "journey") {
  30. return <JourneyOverview journey={journey} onSelectStep={onSelectStep} />;
  31. }
  32. const groups = groupParallelSteps(journey.steps);
  33. const loops = new Map(
  34. journey.edges.filter((edge) => edge.relation === "loop").map((edge) => [edge.source, edge]),
  35. );
  36. return (
  37. <section className="flow-canvas" aria-label="创作步骤">
  38. <div className="flow-heading">
  39. <div>
  40. <span>按真实发生顺序</span>
  41. <h2>Planner 如何一步步完成目标</h2>
  42. </div>
  43. <p>点击任何步骤后切换到“证据”,可查看对应的合同、工具调用和验收记录。</p>
  44. </div>
  45. <div className="causal-spine">
  46. {groups.map((group, index) => (
  47. <div className={`flow-unit ${group.kind}`} key={group.id}>
  48. {group.kind === "parallel" ? (
  49. <div className="parallel-frame">
  50. <div className="parallel-heading">
  51. <GitBranch size={16} />
  52. <span>Host 并行派发 {group.steps.length} 个任务</span>
  53. </div>
  54. <div className="parallel-lanes">
  55. {group.steps.map((step) => (
  56. <StepCard
  57. key={step.step_id}
  58. step={step}
  59. loop={loops.get(step.step_id)}
  60. selected={step.step_id === selectedStepId}
  61. onSelect={() => onSelectStep(step.step_id)}
  62. />
  63. ))}
  64. </div>
  65. </div>
  66. ) : (
  67. <StepCard
  68. step={group.steps[0]}
  69. loop={loops.get(group.steps[0].step_id)}
  70. selected={group.steps[0].step_id === selectedStepId}
  71. onSelect={() => onSelectStep(group.steps[0].step_id)}
  72. />
  73. )}
  74. {index < groups.length - 1 ? (
  75. <span className="flow-connector" aria-hidden="true">
  76. <ArrowDown size={15} />
  77. </span>
  78. ) : null}
  79. </div>
  80. ))}
  81. </div>
  82. {journey.warnings.map((warning) => (
  83. <p className="projection-note" key={warning}>{warning}</p>
  84. ))}
  85. </section>
  86. );
  87. }
  88. function JourneyOverview({
  89. journey,
  90. onSelectStep,
  91. }: {
  92. journey: JourneyView;
  93. onSelectStep: (stepId: string) => void;
  94. }) {
  95. const cycles = buildTaskCycles(journey.steps, journey.edges);
  96. return (
  97. <section className="overview-canvas" aria-label="全局创作旅程">
  98. <div className="overview-heading">
  99. <h2>这次创作经过了 {cycles.length} 个任务回合</h2>
  100. <p>默认只看业务因果。点开一个回合,再看 Planner、Worker 和 Validator 如何协作。</p>
  101. </div>
  102. <ol className="cycle-line">
  103. {cycles.map((cycle, index) => (
  104. <li key={cycle.taskId}>
  105. <button type="button" onClick={() => onSelectStep(cycle.steps[0].step_id)}>
  106. <span className="cycle-index">{String(index + 1).padStart(2, "0")}</span>
  107. <div className="cycle-copy">
  108. <div className="cycle-title-row">
  109. <h3>{cycle.title}</h3>
  110. <span className={`status-chip status-${cycle.status}`}>{statusLabel(cycle.status)}</span>
  111. </div>
  112. <p>{cycle.steps[0].reasoning}</p>
  113. <div className="cycle-facts">
  114. {cycle.goals.length ? <span>目标 {cycle.goals.join("、")}</span> : null}
  115. <span>{cycle.steps.length} 个真实步骤</span>
  116. {cycle.parallel ? <span><GitBranch size={12} /> 包含并行</span> : null}
  117. {cycle.loopCount ? <span><RefreshCcw size={12} /> 返工 {cycle.loopCount} 次</span> : null}
  118. </div>
  119. <div className="cycle-lifecycle" aria-label="回合生命周期">
  120. {(["plan", "execute", "validate", "decide"] as StepType[]).map((type) => {
  121. const present = cycle.steps.some((step) => step.step_type === type);
  122. const Icon = STEP_META[type].icon;
  123. return (
  124. <span className={present ? "present" : ""} key={type}>
  125. <Icon size={13} /> {STEP_META[type].label}
  126. </span>
  127. );
  128. })}
  129. </div>
  130. </div>
  131. <ArrowDown className="cycle-open" size={17} />
  132. </button>
  133. </li>
  134. ))}
  135. </ol>
  136. </section>
  137. );
  138. }
  139. function StepCard({
  140. step,
  141. loop,
  142. selected,
  143. onSelect,
  144. }: {
  145. step: JourneyStep;
  146. loop?: JourneyEdge;
  147. selected: boolean;
  148. onSelect: () => void;
  149. }) {
  150. const meta = STEP_META[step.step_type];
  151. const Icon = meta.icon;
  152. return (
  153. <article className={`step-node type-${step.step_type} ${selected ? "selected" : ""}`}>
  154. <button type="button" className="step-hit-area" onClick={onSelect} aria-label={`查看${step.title}的原始证据`} />
  155. <header className="step-header">
  156. <span className="step-type"><Icon size={15} /> {meta.label}</span>
  157. <span className={`status-chip status-${step.status}`}>{statusLabel(step.status)}</span>
  158. </header>
  159. <h3>{step.title}</h3>
  160. <div className="step-layer reasoning-layer">
  161. <span><Brain size={14} /> 思考</span>
  162. <p>{step.reasoning}</p>
  163. <small>{step.reasoning_source === "observable" ? "Planner / Agent 主动说明" : "由真实冻结记录转述"}</small>
  164. </div>
  165. <div className="step-layer process-layer">
  166. <span><Wrench size={14} /> 动作</span>
  167. <p>{step.process.label}</p>
  168. <small>{step.process.actor}{step.process.tool_name ? ` · ${step.process.tool_name}` : ""}</small>
  169. </div>
  170. <div className="data-flow">
  171. <div>
  172. <span><Database size={13} /> 输入</span>
  173. {step.input_data.map((item, index) => <b key={`${item.label}-${index}`}>{item.label}</b>)}
  174. </div>
  175. <ArrowDown size={14} aria-hidden="true" />
  176. <div>
  177. <span><CircleDot size={13} /> 产出</span>
  178. {step.output_data.map((item, index) => <b key={`${item.label}-${index}`}>{item.label}</b>)}
  179. </div>
  180. </div>
  181. {step.contract ? (
  182. <div className="contract-summary">
  183. <strong>任务合同</strong>
  184. <span>{step.contract.goals.length ? `覆盖 ${step.contract.goals.join("、")}` : "不限定单一 Goal"}</span>
  185. <span>产出 {step.contract.output}</span>
  186. <span>{step.contract.criteria.length} 项验收标准</span>
  187. </div>
  188. ) : null}
  189. {step.parallel_total ? (
  190. <span className="parallel-badge">并行 {step.parallel_position}/{step.parallel_total}</span>
  191. ) : null}
  192. {loop ? (
  193. <div className="loop-ribbon"><RefreshCcw size={14} /> {loop.label}·回到前面继续</div>
  194. ) : null}
  195. <footer className="step-metrics">
  196. {step.duration_ms !== null ? <span>{formatDuration(step.duration_ms)}</span> : null}
  197. {step.tokens !== null ? <span>{step.tokens.toLocaleString()} tokens</span> : null}
  198. {step.cost !== null ? <span>cost {step.cost.toFixed(4)}</span> : null}
  199. {step.model ? <span>{step.model}</span> : null}
  200. <span className="evidence-cue"><CheckCircle2 size={13} /> 可追溯</span>
  201. </footer>
  202. </article>
  203. );
  204. }
  205. function formatDuration(value: number): string {
  206. if (value < 1000) return `${value}ms`;
  207. if (value < 60_000) return `${(value / 1000).toFixed(1)}s`;
  208. return `${Math.floor(value / 60_000)}m ${Math.round((value % 60_000) / 1000)}s`;
  209. }