layout.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  1. import { MarkerType, type Edge, type Node } from "@xyflow/react";
  2. import type {
  3. BranchStory,
  4. CanvasItem,
  5. DirectToolGroup,
  6. ExecutionViewV8,
  7. OpenTarget,
  8. RetrievalAgentRun,
  9. RetrievalStage,
  10. RoundStoryV8,
  11. StoryNode,
  12. } from "@/lib/types";
  13. const STORY_W = 320;
  14. const RETRIEVAL_W = 340;
  15. const SUMMARY_W = 360;
  16. const ROUND_GAP = 104;
  17. const ROUND_HEADER = 88;
  18. const REGION_TOP = ROUND_HEADER + 16;
  19. const REGION_HEADER = 54;
  20. const REGION_PAD = 24;
  21. const REGION_GAP = 32;
  22. const CARD_GAP = 32;
  23. const BRANCH_HEADER = 66;
  24. const RETRIEVAL_HEADER = 72;
  25. const RETRIEVAL_ITEM_GAP = 18;
  26. const FLOW_CENTER_Y = 350;
  27. const FINAL_RESULT_H = 368;
  28. const PLANNING_W = STORY_W * 2 + CARD_GAP + REGION_PAD * 2;
  29. const IMPLEMENT_COMPACT_W = SUMMARY_W + REGION_PAD * 2;
  30. const SINGLE_REGION_W = STORY_W + REGION_PAD * 2;
  31. export type AgentRegionTone = "main" | "implementer" | "evaluator" | "outcome";
  32. export type CanvasDetailLevel = "full" | "compact" | "overview";
  33. export interface AgentRegionProjection { label: string; description: string; tone: AgentRegionTone }
  34. export interface CanvasNodeData extends Record<string, unknown> {
  35. buildId?: string;
  36. item?: CanvasItem;
  37. round?: RoundStoryV8;
  38. region?: AgentRegionProjection;
  39. expanded?: boolean;
  40. onOpen: (item: CanvasItem, target: OpenTarget) => void;
  41. onPrompt?: (promptRef: string) => void;
  42. onToggleRound: (roundIndex: number) => void;
  43. onToggleBranch: (roundIndex: number, branchId: number) => void;
  44. onToggleRetrievalAgent: (agentId: string) => void;
  45. }
  46. export interface LayoutOptions {
  47. buildId?: string;
  48. expandedRounds: Set<number>;
  49. expandedBranches: Set<string>;
  50. expandedRetrievalAgents: Set<string>;
  51. detailLevel?: CanvasDetailLevel;
  52. onOpen: CanvasNodeData["onOpen"];
  53. onPrompt?: CanvasNodeData["onPrompt"];
  54. onToggleRound: CanvasNodeData["onToggleRound"];
  55. onToggleBranch: CanvasNodeData["onToggleBranch"];
  56. onToggleRetrievalAgent: CanvasNodeData["onToggleRetrievalAgent"];
  57. }
  58. export interface LayoutResult { nodes: Array<Node<CanvasNodeData>>; edges: Edge[] }
  59. interface RoundMetrics {
  60. width: number;
  61. height: number;
  62. regionHeight: number;
  63. laneTops: number[];
  64. laneHeights: number[];
  65. contentCenterY: number;
  66. planningX: number;
  67. implementationX: number;
  68. implementationWidth: number;
  69. reviewX: number;
  70. convergenceX: number;
  71. evaluatorX: number;
  72. outcomeX: number;
  73. batchTops: number[];
  74. batchHeights: Array<{ review: number; decision: number; group: number }>;
  75. }
  76. export function buildHorizontalLayout(view: ExecutionViewV8, options: LayoutOptions): LayoutResult {
  77. const nodes: Array<Node<CanvasNodeData>> = [];
  78. const edges: Edge[] = [];
  79. const planning = view.planning ? storyItem(view.planning) : undefined;
  80. const objective = storyItem(view.objective);
  81. if (planning) nodes.push(canvasNode(planning, "storyCard", { x: 60, y: 210 }, options));
  82. const objectiveX = planning ? 60 + STORY_W + ROUND_GAP : 60;
  83. nodes.push(canvasNode(objective, "storyCard", { x: objectiveX, y: 210 }, options));
  84. if (planning) edges.push(flowEdge(planning.id, objective.id));
  85. const roundX = objectiveX + STORY_W + ROUND_GAP;
  86. let previousRowBottom = 0;
  87. let previous = objective.id;
  88. let finalX = roundX;
  89. let finalY = 170;
  90. for (const [roundPosition, round] of [...view.rounds].sort((a, b) => a.roundIndex - b.roundIndex).entries()) {
  91. if (!options.expandedRounds.has(round.roundIndex)) {
  92. const item = roundSummaryItem(round);
  93. const rowHeight = collapsedRoundHeight(options.detailLevel ?? "full");
  94. const rowY = roundPosition === 0 ? 148 : previousRowBottom + ROUND_GAP;
  95. nodes.push(canvasNode(item, "roundSummary", { x: roundX, y: rowY }, options));
  96. edges.push(flowEdge(previous, item.id, { status: round.state }));
  97. previous = item.id;
  98. previousRowBottom = rowY + rowHeight;
  99. finalX = roundX + SUMMARY_W + ROUND_GAP;
  100. finalY = rowY + (rowHeight - FINAL_RESULT_H) / 2;
  101. continue;
  102. }
  103. const groupId = `frame:round:${round.roundIndex}`;
  104. const metrics = expandedRoundMetrics(round, options);
  105. const rowY = roundPosition === 0
  106. ? Math.min(42, FLOW_CENTER_Y - metrics.contentCenterY)
  107. : previousRowBottom + ROUND_GAP;
  108. nodes.push({
  109. id: groupId,
  110. type: "roundFrame",
  111. position: { x: roundX, y: rowY },
  112. data: baseData(options, { round, expanded: true }),
  113. style: { width: metrics.width, height: metrics.height },
  114. draggable: false,
  115. selectable: true,
  116. focusable: false,
  117. zIndex: -3,
  118. });
  119. addAgentRegions(nodes, round, groupId, options, metrics);
  120. const result = addExpandedRound(nodes, edges, round, groupId, options, metrics);
  121. edges.push(flowEdge(previous, result.entry));
  122. previous = result.exit;
  123. previousRowBottom = rowY + metrics.height;
  124. finalX = roundX + metrics.width + ROUND_GAP;
  125. finalY = rowY + metrics.contentCenterY - FINAL_RESULT_H / 2;
  126. }
  127. const finalItem = finalResultItem(view);
  128. nodes.push(canvasNode(finalItem, "finalResult", { x: finalX, y: finalY }, options));
  129. edges.push(flowEdge(previous, finalItem.id, { status: view.finalResult.status }));
  130. return { nodes, edges };
  131. }
  132. function expandedRoundMetrics(round: RoundStoryV8, options: LayoutOptions): RoundMetrics {
  133. const detailLevel = options.detailLevel ?? "full";
  134. const density = densityFor(detailLevel);
  135. const expandedWidths = round.branches
  136. .filter((branch) => options.expandedBranches.has(branchKey(round.roundIndex, branch.branchId)))
  137. .map((branch) => branchFrameWidth(branch));
  138. const implementationWidth = expandedWidths.length
  139. ? Math.max(...expandedWidths) + REGION_PAD * 2
  140. : IMPLEMENT_COMPACT_W;
  141. const planningX = 32;
  142. const implementationX = planningX + PLANNING_W + REGION_GAP;
  143. const reviewX = implementationX + implementationWidth + REGION_GAP;
  144. const convergenceX = reviewX + SINGLE_REGION_W + REGION_GAP;
  145. const evaluatorX = convergenceX + SINGLE_REGION_W + REGION_GAP;
  146. const outcomeX = evaluatorX + SINGLE_REGION_W + REGION_GAP;
  147. const width = outcomeX + SINGLE_REGION_W + 32;
  148. const branches: Array<BranchStory | undefined> = round.branches.length ? round.branches : [undefined];
  149. const laneHeights = branches.map((branch) => {
  150. if (!branch) return 240;
  151. return options.expandedBranches.has(branchKey(round.roundIndex, branch.branchId))
  152. ? branchFrameHeight(branch, detailLevel, options.expandedRetrievalAgents)
  153. : estimateBranchHeight(branch, detailLevel);
  154. });
  155. const laneTops: number[] = [];
  156. const laneStart = REGION_TOP + REGION_HEADER + density.contentInset;
  157. laneHeights.reduce((top, height) => { laneTops.push(top); return top + height + density.laneGap; }, laneStart);
  158. const laneBottom = laneTops.at(-1)! + laneHeights.at(-1)!;
  159. const batchHeights = round.convergenceBatches.map((batch) => {
  160. const review = batch.review
  161. ? estimateMultipathReviewHeight(batch.review, detailLevel)
  162. : batch.missingReview ? estimateStoryHeight(batch.missingReview, detailLevel) : 244;
  163. const decision = batch.decision
  164. ? estimateMultipathDecisionHeight(batch.decision, detailLevel)
  165. : batch.missingDecision ? estimateStoryHeight(batch.missingDecision, detailLevel) : 244;
  166. return { review, decision, group: Math.max(review, decision) };
  167. });
  168. const batchStackHeight = batchHeights.reduce((sum, height) => sum + height.group, 0) + Math.max(0, batchHeights.length - 1) * density.laneGap;
  169. const contentHeight = Math.max(
  170. laneBottom - laneStart,
  171. batchStackHeight,
  172. estimateStoryHeight(round.goal, detailLevel),
  173. estimateStoryHeight(round.plan.node, detailLevel),
  174. estimateStoryHeight(round.overallEvaluation, detailLevel),
  175. estimateStoryHeight(round.result, detailLevel),
  176. );
  177. const regionHeight = REGION_HEADER + density.contentInset + contentHeight + density.contentInset;
  178. const contentCenterY = laneStart + contentHeight / 2;
  179. const batchStart = laneStart + Math.max(0, (contentHeight - batchStackHeight) / 2);
  180. const batchTops: number[] = [];
  181. batchHeights.reduce((top, height) => { batchTops.push(top); return top + height.group + density.laneGap; }, batchStart);
  182. return { width, height: REGION_TOP + regionHeight + 34, regionHeight, laneTops, laneHeights, contentCenterY, planningX, implementationX, implementationWidth, reviewX, convergenceX, evaluatorX, outcomeX, batchTops, batchHeights };
  183. }
  184. function addAgentRegions(nodes: Array<Node<CanvasNodeData>>, round: RoundStoryV8, parentId: string, options: LayoutOptions, metrics: RoundMetrics) {
  185. const regions = [
  186. { id: "planning", x: metrics.planningX, width: PLANNING_W, projection: { label: "主 Agent · 规划", description: "确定本轮目标,并拆成多路方案", tone: "main" as const } },
  187. { id: "implementation", x: metrics.implementationX, width: metrics.implementationWidth, projection: { label: "实现 Agent · 多路方案", description: "每个方案独立取数、取舍并形成产出", tone: "implementer" as const } },
  188. { id: "multipath-review", x: metrics.reviewX, width: SINGLE_REGION_W, projection: { label: "多方案评审 Agent", description: "比较候选脚本并给出评审建议", tone: "evaluator" as const } },
  189. { id: "convergence", x: metrics.convergenceX, width: SINGLE_REGION_W, projection: { label: "主 Agent · 收敛", description: "结合评审处理各个候选方案", tone: "main" as const } },
  190. { id: "evaluation", x: metrics.evaluatorX, width: SINGLE_REGION_W, projection: { label: "整体评审 Agent", description: "检查合并后的主脚本是否达标", tone: "evaluator" as const } },
  191. { id: "outcome", x: metrics.outcomeX, width: SINGLE_REGION_W, projection: { label: "本轮结果", description: "汇总主脚本和领域信息产出", tone: "outcome" as const } },
  192. ];
  193. for (const region of regions) nodes.push({
  194. id: `region:round:${round.roundIndex}:${region.id}`,
  195. type: "agentRegion",
  196. position: { x: region.x, y: REGION_TOP },
  197. parentId,
  198. extent: "parent",
  199. data: baseData(options, { region: region.projection }),
  200. style: { width: region.width, height: metrics.regionHeight },
  201. draggable: false,
  202. selectable: false,
  203. focusable: false,
  204. zIndex: -2,
  205. });
  206. }
  207. function addExpandedRound(nodes: Array<Node<CanvasNodeData>>, edges: Edge[], round: RoundStoryV8, parentId: string, options: LayoutOptions, metrics: RoundMetrics) {
  208. const detailLevel = options.detailLevel ?? "full";
  209. const addStory = (story: StoryNode, x: number, y: number, branchId?: number, storyParentId = parentId) => {
  210. const item = storyItem(story, round.roundIndex, branchId);
  211. nodes.push(canvasNode(item, "storyCard", { x, y }, options, storyParentId));
  212. return item.id;
  213. };
  214. const goalId = addStory(round.goal, metrics.planningX + REGION_PAD, centeredRoundY(metrics, estimateStoryHeight(round.goal, detailLevel)));
  215. const planId = addStory(round.plan.node, metrics.planningX + REGION_PAD + STORY_W + CARD_GAP, centeredRoundY(metrics, estimateStoryHeight(round.plan.node, detailLevel)));
  216. edges.push(flowEdge(goalId, planId));
  217. const branchEnds = new Map<number, string>();
  218. round.branches.forEach((branch, index) => {
  219. const laneTop = metrics.laneTops[index];
  220. const laneHeight = metrics.laneHeights[index];
  221. const expanded = options.expandedBranches.has(branchKey(round.roundIndex, branch.branchId));
  222. if (!expanded) {
  223. const item = branchSummaryItem(branch, round.roundIndex);
  224. nodes.push(canvasNode(item, "candidateCard", { x: metrics.implementationX + REGION_PAD, y: laneTop + Math.max(0, (laneHeight - estimateBranchHeight(branch, detailLevel)) / 2) }, options, parentId));
  225. edges.push(flowEdge(planId, item.id, { kind: "delegation" }));
  226. branchEnds.set(branch.branchId, item.id);
  227. return;
  228. }
  229. const frameId = `frame:round:${round.roundIndex}:branch:${branch.branchId}`;
  230. const frameHeight = branchFrameHeight(branch, detailLevel, options.expandedRetrievalAgents);
  231. const frameWidth = branchFrameWidth(branch);
  232. nodes.push({
  233. id: frameId,
  234. type: "branchFrame",
  235. position: { x: metrics.implementationX + REGION_PAD, y: laneTop + Math.max(0, (laneHeight - frameHeight) / 2) },
  236. parentId,
  237. extent: "parent",
  238. data: baseData(options, { item: branchSummaryItem(branch, round.roundIndex), expanded: true }),
  239. style: { width: frameWidth, height: frameHeight },
  240. draggable: false,
  241. selectable: true,
  242. focusable: false,
  243. zIndex: 1,
  244. });
  245. branchEnds.set(branch.branchId, addExpandedBranch(nodes, edges, round, branch, frameId, options, planId));
  246. });
  247. const batchIds: string[] = [];
  248. const covered = new Set<number>();
  249. round.convergenceBatches.forEach((batch, index) => {
  250. const batchTop = metrics.batchTops[index] ?? centeredRoundY(metrics, 244);
  251. const heights = metrics.batchHeights[index] ?? { review: 244, decision: 244, group: 244 };
  252. const reviewY = batchTop + Math.max(0, (heights.group - heights.review) / 2);
  253. const decisionY = batchTop + Math.max(0, (heights.group - heights.decision) / 2);
  254. let reviewId: string;
  255. if (batch.review) {
  256. const item = multipathReviewItem(batch.review, round.roundIndex);
  257. nodes.push(canvasNode(item, "multipathReview", { x: metrics.reviewX + REGION_PAD, y: reviewY }, options, parentId));
  258. reviewId = item.id;
  259. } else if (batch.missingReview) {
  260. reviewId = addStory(batch.missingReview, metrics.reviewX + REGION_PAD, reviewY);
  261. } else return;
  262. let decisionId: string;
  263. if (batch.decision) {
  264. const item = multipathDecisionItem(batch.decision, round.roundIndex);
  265. nodes.push(canvasNode(item, "multipathDecision", { x: metrics.convergenceX + REGION_PAD, y: decisionY }, options, parentId));
  266. decisionId = item.id;
  267. } else if (batch.missingDecision) {
  268. decisionId = addStory(batch.missingDecision, metrics.convergenceX + REGION_PAD, decisionY);
  269. } else return;
  270. batchIds.push(decisionId);
  271. batch.branchIds.forEach((branchId) => {
  272. const source = branchEnds.get(branchId);
  273. if (source) { edges.push(flowEdge(source, reviewId, { kind: "delegation" })); covered.add(branchId); }
  274. });
  275. edges.push(flowEdge(reviewId, decisionId));
  276. });
  277. const evaluationId = addStory(round.overallEvaluation, metrics.evaluatorX + REGION_PAD, centeredRoundY(metrics, estimateStoryHeight(round.overallEvaluation, detailLevel)));
  278. const resultId = addStory(round.result, metrics.outcomeX + REGION_PAD, centeredRoundY(metrics, estimateStoryHeight(round.result, detailLevel)));
  279. if (batchIds.length) batchIds.forEach((id) => edges.push(flowEdge(id, evaluationId, { kind: "delegation" })));
  280. else edges.push(flowEdge(planId, evaluationId));
  281. branchEnds.forEach((id, branchId) => { if (!covered.has(branchId) && batchIds.length) edges.push(flowEdge(id, evaluationId, { kind: "unassigned" })); });
  282. edges.push(flowEdge(evaluationId, resultId, { status: round.state }));
  283. return { entry: goalId, exit: resultId };
  284. }
  285. function addExpandedBranch(nodes: Array<Node<CanvasNodeData>>, edges: Edge[], round: RoundStoryV8, branch: BranchStory, parentId: string, options: LayoutOptions, planId: string) {
  286. const detailLevel = options.detailLevel ?? "full";
  287. const density = densityFor(detailLevel);
  288. const contentTop = BRANCH_HEADER + density.contentInset;
  289. const frameHeight = branchFrameHeight(branch, detailLevel, options.expandedRetrievalAgents);
  290. const contentHeight = frameHeight - contentTop - density.contentInset;
  291. const taskItem = storyItem(branch.task, round.roundIndex, branch.branchId);
  292. const taskY = contentTop + Math.max(0, (contentHeight - estimateStoryHeight(branch.task, detailLevel)) / 2);
  293. nodes.push(canvasNode(taskItem, "storyCard", { x: REGION_PAD, y: taskY }, options, parentId));
  294. edges.push(flowEdge(planId, taskItem.id, { kind: "delegation" }));
  295. const retrievalX = REGION_PAD + STORY_W + CARD_GAP;
  296. const retrievalHeight = retrievalFrameHeight(branch.retrievalStage, options.expandedRetrievalAgents, detailLevel);
  297. const retrievalY = contentTop + Math.max(0, (contentHeight - retrievalHeight) / 2);
  298. const stageItem = retrievalStageItem(branch.retrievalStage, round.roundIndex, branch.branchId);
  299. nodes.push({
  300. id: `frame:${stageItem.id}`,
  301. type: "retrievalStageFrame",
  302. position: { x: retrievalX, y: retrievalY },
  303. parentId,
  304. extent: "parent",
  305. data: baseData(options, { item: stageItem }),
  306. style: { width: retrievalFrameWidth(branch.retrievalStage), height: retrievalHeight },
  307. draggable: false,
  308. selectable: true,
  309. focusable: false,
  310. zIndex: 2,
  311. });
  312. const retrievalEnds = addRetrievalOperations(nodes, edges, branch.retrievalStage, round.roundIndex, branch.branchId, `frame:${stageItem.id}`, options, taskItem.id);
  313. let cursorX = retrievalX + retrievalFrameWidth(branch.retrievalStage) + CARD_GAP;
  314. let previous = retrievalEnds.length ? retrievalEnds : [taskItem.id];
  315. if (branch.dataDecisions.length) {
  316. const heights = branch.dataDecisions.map((item) => estimateStoryHeight(item.node, detailLevel));
  317. const stackHeight = heights.reduce((sum, height) => sum + height, 0) + Math.max(0, heights.length - 1) * density.laneGap;
  318. let top = contentTop + Math.max(0, (contentHeight - stackHeight) / 2);
  319. const decisionIds: string[] = [];
  320. branch.dataDecisions.forEach((decision, index) => {
  321. const item = storyItem(decision.node, round.roundIndex, branch.branchId);
  322. nodes.push(canvasNode(item, "storyCard", { x: cursorX, y: top }, options, parentId));
  323. decisionIds.push(item.id);
  324. top += heights[index] + density.laneGap;
  325. });
  326. previous.forEach((source) => decisionIds.forEach((target) => edges.push(flowEdge(source, target))));
  327. previous = decisionIds;
  328. cursorX += STORY_W + CARD_GAP;
  329. }
  330. const tail = [branch.output.node];
  331. tail.forEach((story) => {
  332. const item = storyItem(story, round.roundIndex, branch.branchId);
  333. const y = contentTop + Math.max(0, (contentHeight - estimateStoryHeight(story, detailLevel)) / 2);
  334. nodes.push(canvasNode(item, "storyCard", { x: cursorX, y }, options, parentId));
  335. previous.forEach((source) => edges.push(flowEdge(source, item.id)));
  336. previous = [item.id];
  337. cursorX += STORY_W + CARD_GAP;
  338. });
  339. return previous[0];
  340. }
  341. function addRetrievalOperations(nodes: Array<Node<CanvasNodeData>>, edges: Edge[], stage: RetrievalStage, roundIndex: number, branchId: number, parentId: string, options: LayoutOptions, sourceId: string) {
  342. const detailLevel = options.detailLevel ?? "full";
  343. const operations = retrievalOperations(stage);
  344. if (!operations.length) return [];
  345. const waveIndexes = stage.waves.length ? stage.waves.map((wave) => wave.index) : [0];
  346. const byWave = new Map<number, Array<DirectToolGroup | RetrievalAgentRun>>();
  347. operations.forEach((operation) => {
  348. // No timestamp means no defensible ordering. Keep every operation in one
  349. // neutral column instead of inventing a left-to-right sequence.
  350. const wave = operation.waveIndex ?? 0;
  351. byWave.set(wave, [...(byWave.get(wave) || []), operation]);
  352. });
  353. let previous = [sourceId];
  354. let top = RETRIEVAL_HEADER + RETRIEVAL_ITEM_GAP;
  355. for (const waveIndex of [...new Set([...waveIndexes, ...byWave.keys()])].sort((a, b) => a - b)) {
  356. const waveItems = byWave.get(waveIndex) || [];
  357. if (!waveItems.length) continue;
  358. const heights = waveItems.map((item) => retrievalOperationHeight(item, options.expandedRetrievalAgents, detailLevel));
  359. const ids: string[] = [];
  360. waveItems.forEach((operation, index) => {
  361. const item = operation.kind === "direct-tools"
  362. ? retrievalDirectItem(operation, roundIndex, branchId)
  363. : retrievalAgentItem(operation, roundIndex, branchId);
  364. nodes.push(canvasNode(item, operation.kind === "direct-tools" ? "directToolGroup" : "retrievalAgent", { x: 20, y: top }, options, parentId));
  365. ids.push(item.id);
  366. top += heights[index] + RETRIEVAL_ITEM_GAP;
  367. });
  368. previous.forEach((source) => ids.forEach((target) => edges.push(flowEdge(source, target, { kind: "delegation" }))));
  369. previous = ids;
  370. }
  371. return previous;
  372. }
  373. function retrievalOperations(stage: RetrievalStage): Array<DirectToolGroup | RetrievalAgentRun> {
  374. return [...stage.directToolGroups, ...stage.agentRuns].sort((a, b) => {
  375. const wave = (a.waveIndex ?? 999) - (b.waveIndex ?? 999);
  376. return wave || String(a.startedAt || "").localeCompare(String(b.startedAt || "")) || a.id.localeCompare(b.id);
  377. });
  378. }
  379. function branchFrameWidth(branch: BranchStory) {
  380. const decisionColumn = branch.dataDecisions.length ? STORY_W + CARD_GAP : 0;
  381. const tailColumns = 1;
  382. return REGION_PAD * 2 + STORY_W + CARD_GAP + retrievalFrameWidth(branch.retrievalStage) + CARD_GAP + decisionColumn + tailColumns * STORY_W + (tailColumns - 1) * CARD_GAP;
  383. }
  384. function retrievalFrameWidth(_stage: RetrievalStage) {
  385. return 40 + RETRIEVAL_W;
  386. }
  387. function retrievalFrameHeight(stage: RetrievalStage, expandedAgents: Set<string>, detailLevel: CanvasDetailLevel) {
  388. const operations = retrievalOperations(stage);
  389. if (!operations.length) return detailLevel === "overview" ? 156 : 210;
  390. const contentHeight = operations.reduce((height, item) => height + retrievalOperationHeight(item, expandedAgents, detailLevel), 0)
  391. + Math.max(0, operations.length - 1) * RETRIEVAL_ITEM_GAP;
  392. return RETRIEVAL_HEADER + RETRIEVAL_ITEM_GAP + contentHeight + 24;
  393. }
  394. function retrievalOperationHeight(item: DirectToolGroup | RetrievalAgentRun, expandedAgents: Set<string>, detailLevel: CanvasDetailLevel) {
  395. if (detailLevel === "overview") return item.kind === "direct-tools" ? 132 : 148;
  396. if (item.kind === "direct-tools") return detailLevel === "compact" ? 196 : 248;
  397. const attempts = expandedAgents.has(item.id) ? item.attempts.length : 0;
  398. if (detailLevel === "compact") return 220 + (attempts ? 44 + attempts * 40 : 0);
  399. return 292 + (attempts ? 52 + attempts * 48 : 0);
  400. }
  401. function branchFrameHeight(branch: BranchStory, detailLevel: CanvasDetailLevel, expandedAgents: Set<string>) {
  402. const density = densityFor(detailLevel);
  403. const decisions = branch.dataDecisions.map((item) => estimateStoryHeight(item.node, detailLevel));
  404. const decisionStack = decisions.reduce((sum, height) => sum + height, 0) + Math.max(0, decisions.length - 1) * density.laneGap;
  405. const storyHeights = [branch.task, branch.output.node].map((story) => estimateStoryHeight(story, detailLevel));
  406. return BRANCH_HEADER + density.contentInset + Math.max(density.branchFloor, retrievalFrameHeight(branch.retrievalStage, expandedAgents, detailLevel), decisionStack, ...storyHeights) + density.contentInset;
  407. }
  408. export function branchFlow(branch: BranchStory): StoryNode[] {
  409. return [branch.task, ...branch.dataDecisions.map((item) => item.node), branch.output.node];
  410. }
  411. export function storyItem(story: StoryNode, roundIndex?: number, branchId?: number): CanvasItem { return { ...story, itemType: "story", roundIndex, branchId }; }
  412. export function retrievalStageItem(stage: RetrievalStage, roundIndex: number, branchId: number): CanvasItem { return { itemType: "retrieval-stage", id: stage.id, title: "取数阶段", roundIndex, branchId, stage, detailRef: stage.detailRef }; }
  413. export function retrievalDirectItem(group: DirectToolGroup, roundIndex: number, branchId: number): CanvasItem { return { itemType: "retrieval-direct", id: group.id, title: "工具取数", roundIndex, branchId, group, detailRef: group.detailRef }; }
  414. 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 }; }
  415. 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 }; }
  416. 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 }; }
  417. 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 }; }
  418. export function multipathReviewItem(review: import("@/lib/types").MultipathReview, roundIndex: number): CanvasItem { return { itemType: "multipath-review", id: review.id, title: "多方案评审", roundIndex, review, detailRef: review.detailRef }; }
  419. 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 }; }
  420. 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 }; }
  421. 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" }; }
  422. function canvasNode(item: CanvasItem, type: string, position: { x: number; y: number }, options: LayoutOptions, parentId?: string): Node<CanvasNodeData> {
  423. const resolvedType = hasDecision(item) ? "agentDecision" : type;
  424. 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 };
  425. }
  426. 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 }; }
  427. 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; }
  428. function flowEdge(source: string, target: string, options: { status?: string; kind?: string } = {}): Edge {
  429. const color = options.kind === "unassigned" ? "#f4c152" : options.status === "merged" ? "#63c883" : options.status === "parked" ? "#f4c152" : options.status === "discarded" ? "#ff806e" : options.kind === "delegation" ? "#7bb8ff" : "#7e848f";
  430. 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 } };
  431. }
  432. function estimateStoryHeight(story: StoryNode, detailLevel: CanvasDetailLevel) {
  433. if (detailLevel === "overview") return 120;
  434. const visibleValues = [story.card.primary?.value, ...story.card.secondary.map((item) => item.value)].filter(Boolean).slice(0, detailLevel === "compact" ? 1 : undefined) as string[];
  435. const lines = visibleValues.reduce((sum, value, index) => sum + Math.min(index === 0 ? 4 : 2, Math.max(1, Math.ceil(value.length / 25))), 0);
  436. return Math.max(detailLevel === "compact" ? 196 : 244, (detailLevel === "compact" ? 142 : 164) + lines * 23 + (story.sourceNotice && detailLevel === "full" ? 28 : 0));
  437. }
  438. function estimateBranchHeight(branch: BranchStory, detailLevel: CanvasDetailLevel) {
  439. if (detailLevel === "overview") return 132;
  440. const values = [branch.title, branch.summary.task, branch.summary.output].filter(Boolean) as string[];
  441. const lines = values.reduce((sum, value) => sum + Math.min(2, Math.max(1, Math.ceil(value.length / 25))), 0);
  442. const sourceAllowance = branch.outcome.sourceNotice || branch.task.sourceNotice || branch.output.node.sourceNotice ? 28 : 0;
  443. return Math.max(detailLevel === "compact" ? 232 : 320, (detailLevel === "full" ? 188 : 148) + lines * 23 + sourceAllowance);
  444. }
  445. function estimateMultipathReviewHeight(review: import("@/lib/types").MultipathReview, detailLevel: CanvasDetailLevel) {
  446. return estimateDecisionCardHeight(review.decisionProjection?.card, detailLevel);
  447. }
  448. function estimateMultipathDecisionHeight(decision: import("@/lib/types").MultipathDecisionBatch, detailLevel: CanvasDetailLevel) {
  449. return estimateDecisionCardHeight(decision.decisionProjection?.card, detailLevel);
  450. }
  451. function estimateDecisionCardHeight(card: import("@/lib/types").CardProjection | undefined, detailLevel: CanvasDetailLevel) {
  452. if (detailLevel === "overview") return 132;
  453. const values = card ? [card.primary?.value, ...card.secondary.map((item) => item.value)].filter(Boolean) as string[] : [];
  454. const visible = values.slice(0, detailLevel === "compact" ? 1 : 4);
  455. const lines = visible.reduce((sum, value, index) => sum + Math.min(index === 0 ? 4 : 2, Math.max(1, Math.ceil(value.length / 25))), 0);
  456. return Math.max(detailLevel === "compact" ? 216 : 296, (detailLevel === "compact" ? 150 : 190) + lines * 23);
  457. }
  458. function densityFor(detailLevel: CanvasDetailLevel) {
  459. if (detailLevel === "overview") return { laneGap: 12, contentInset: 12, branchFloor: 148 };
  460. if (detailLevel === "compact") return { laneGap: 18, contentInset: 16, branchFloor: 208 };
  461. return { laneGap: 28, contentInset: 24, branchFloor: 244 };
  462. }
  463. function collapsedRoundHeight(detailLevel: CanvasDetailLevel) {
  464. if (detailLevel === "overview") return 164;
  465. if (detailLevel === "compact") return 280;
  466. return 400;
  467. }
  468. function branchKey(roundIndex: number, branchId: number) { return `${roundIndex}:${branchId}`; }
  469. function centeredRoundY(metrics: RoundMetrics, height: number) { return metrics.contentCenterY - height / 2; }