trace_template.html 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985
  1. <!doctype html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8" />
  5. <meta
  6. name="viewport"
  7. content="width=device-width, initial-scale=1.0"
  8. />
  9. <title>Trace 执行流程可视化</title>
  10. <script src="https://d3js.org/d3.v7.min.js"></script>
  11. <style>
  12. body {
  13. margin: 0;
  14. padding: 20px;
  15. font-family: "Microsoft YaHei", Arial, sans-serif;
  16. background: #f5f5f5;
  17. }
  18. .main-container {
  19. display: flex;
  20. gap: 20px;
  21. height: calc(100vh - 40px);
  22. }
  23. .container {
  24. flex: 1;
  25. background: white;
  26. border-radius: 8px;
  27. padding: 20px;
  28. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  29. overflow: hidden;
  30. display: flex;
  31. flex-direction: column;
  32. }
  33. /* 顶部导航栏 */
  34. .top-nav {
  35. margin-bottom: 20px;
  36. padding: 15px;
  37. background: #f8f9fa;
  38. border-radius: 8px;
  39. border: 1px solid #dee2e6;
  40. }
  41. .top-nav h1 {
  42. margin: 0 0 15px 0;
  43. color: #333;
  44. font-size: 24px;
  45. }
  46. .filter-section {
  47. display: flex;
  48. gap: 15px;
  49. align-items: center;
  50. flex-wrap: wrap;
  51. }
  52. .filter-item {
  53. display: flex;
  54. align-items: center;
  55. gap: 8px;
  56. }
  57. .filter-item label {
  58. font-weight: bold;
  59. color: #333;
  60. font-size: 14px;
  61. }
  62. .filter-item select,
  63. .filter-item button {
  64. padding: 8px 12px;
  65. font-size: 14px;
  66. border: 1px solid #ced4da;
  67. border-radius: 4px;
  68. background: white;
  69. cursor: pointer;
  70. }
  71. .filter-item select:hover,
  72. .filter-item button:hover {
  73. border-color: #4e79a7;
  74. }
  75. .filter-item button {
  76. background: #4e79a7;
  77. color: white;
  78. border-color: #4e79a7;
  79. }
  80. .filter-item button:hover {
  81. background: #356391;
  82. }
  83. /* 主体内容区域 */
  84. #chart {
  85. flex: 1;
  86. min-height: 0;
  87. position: relative;
  88. }
  89. svg {
  90. width: 100%;
  91. height: 100%;
  92. border: 1px solid #ddd;
  93. border-radius: 4px;
  94. background: #fff;
  95. }
  96. /* 节点样式 */
  97. .node {
  98. cursor: pointer;
  99. }
  100. .node rect {
  101. fill: transparent;
  102. stroke: none;
  103. stroke-width: 0;
  104. }
  105. .node.selected rect {
  106. stroke: none;
  107. }
  108. .node text {
  109. font-size: 10px;
  110. font-family: sans-serif;
  111. text-anchor: middle;
  112. }
  113. .node.running rect {
  114. stroke: none;
  115. fill: transparent;
  116. }
  117. .node.trace_root text {
  118. font-size: 16px;
  119. font-weight: bold;
  120. }
  121. /* 连线样式 */
  122. .link {
  123. fill: none;
  124. stroke: #5ba85f;
  125. stroke-width: 2px;
  126. cursor: pointer;
  127. }
  128. .link.highlighted {
  129. stroke: #ff6b6b;
  130. }
  131. .link-text {
  132. font-size: 12px;
  133. fill: #5ba85f;
  134. font-weight: bold;
  135. text-anchor: middle;
  136. pointer-events: auto;
  137. cursor: pointer;
  138. text-shadow:
  139. -2px -2px 0 #fff,
  140. 2px -2px 0 #fff,
  141. -2px 2px 0 #fff,
  142. 2px 2px 0 #fff;
  143. }
  144. /* 右侧详情面板 */
  145. .resizer {
  146. width: 8px;
  147. background: #e0e0e0;
  148. cursor: col-resize;
  149. flex-shrink: 0;
  150. position: relative;
  151. transition: background 0.2s;
  152. }
  153. .resizer:hover {
  154. background: #4e79a7;
  155. }
  156. .resizer::before {
  157. content: "";
  158. position: absolute;
  159. left: 50%;
  160. top: 0;
  161. bottom: 0;
  162. width: 2px;
  163. background: #999;
  164. transform: translateX(-50%);
  165. }
  166. .resizer:hover::before {
  167. background: #4e79a7;
  168. }
  169. .detail-panel {
  170. width: 400px;
  171. min-width: 250px;
  172. max-width: 60%;
  173. background: white;
  174. border-radius: 8px;
  175. padding: 20px;
  176. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  177. overflow-y: auto;
  178. max-height: calc(100vh - 40px);
  179. flex-shrink: 0;
  180. transition:
  181. width 0.2s ease,
  182. padding 0.2s ease;
  183. }
  184. .detail-panel.collapsed {
  185. width: 32px;
  186. min-width: 32px;
  187. max-width: 32px;
  188. padding: 8px 4px;
  189. overflow: hidden;
  190. }
  191. .detail-panel.collapsed #detail-content {
  192. display: none;
  193. }
  194. .detail-panel.collapsed h2 {
  195. margin: 0;
  196. padding: 0;
  197. border-bottom: none;
  198. writing-mode: vertical-rl;
  199. text-orientation: mixed;
  200. font-size: 12px;
  201. display: flex;
  202. align-items: center;
  203. justify-content: center;
  204. }
  205. .detail-panel h2 {
  206. margin-top: 0;
  207. margin-bottom: 15px;
  208. color: #333;
  209. font-size: 18px;
  210. border-bottom: 2px solid #4e79a7;
  211. padding-bottom: 10px;
  212. display: flex;
  213. align-items: center;
  214. gap: 8px;
  215. }
  216. .detail-toggle {
  217. margin-left: auto;
  218. padding: 2px 8px;
  219. font-size: 12px;
  220. border: 1px solid #4e79a7;
  221. border-radius: 4px;
  222. background: #f0f4f8;
  223. color: #4e79a7;
  224. cursor: pointer;
  225. }
  226. .detail-toggle:hover {
  227. background: #e1e9f0;
  228. }
  229. .detail-content {
  230. color: #666;
  231. line-height: 1.6;
  232. }
  233. .detail-item {
  234. margin-bottom: 20px;
  235. }
  236. .detail-item label {
  237. font-weight: bold;
  238. color: #333;
  239. display: block;
  240. margin-bottom: 8px;
  241. font-size: 14px;
  242. }
  243. .detail-item-value {
  244. color: #666;
  245. word-break: break-word;
  246. font-size: 14px;
  247. line-height: 1.6;
  248. padding: 10px;
  249. background: #f8f9fa;
  250. border-radius: 4px;
  251. }
  252. .detail-empty {
  253. color: #999;
  254. font-style: italic;
  255. text-align: center;
  256. padding: 40px 20px;
  257. }
  258. /* 统计信息样式 */
  259. .stats-grid {
  260. display: grid;
  261. grid-template-columns: repeat(2, 1fr);
  262. gap: 10px;
  263. margin-top: 10px;
  264. }
  265. .stat-item {
  266. padding: 8px;
  267. background: #f0f4f8;
  268. border-radius: 4px;
  269. text-align: center;
  270. }
  271. .stat-label {
  272. font-size: 12px;
  273. color: #666;
  274. margin-bottom: 4px;
  275. }
  276. .stat-value {
  277. font-size: 16px;
  278. font-weight: bold;
  279. color: #4e79a7;
  280. }
  281. /* 消息列表样式 */
  282. .message-list {
  283. margin-top: 10px;
  284. }
  285. .message-item {
  286. padding: 12px;
  287. margin-bottom: 10px;
  288. background: #f8f9fa;
  289. border-radius: 4px;
  290. border-left: 3px solid #4e79a7;
  291. }
  292. .message-item.assistant {
  293. border-left-color: #4caf50;
  294. }
  295. .message-item.tool {
  296. border-left-color: #ff9800;
  297. }
  298. .message-header {
  299. display: flex;
  300. justify-content: space-between;
  301. margin-bottom: 8px;
  302. font-size: 12px;
  303. color: #666;
  304. }
  305. .message-role {
  306. font-weight: bold;
  307. color: #333;
  308. }
  309. .message-content {
  310. font-size: 13px;
  311. color: #666;
  312. line-height: 1.5;
  313. }
  314. .tool-calls {
  315. margin-top: 8px;
  316. padding: 8px;
  317. background: #fff;
  318. border-radius: 4px;
  319. font-size: 12px;
  320. }
  321. .tool-call-item {
  322. margin-bottom: 4px;
  323. }
  324. .tool-call-name {
  325. font-weight: bold;
  326. color: #ff9800;
  327. }
  328. .node.message rect {
  329. stroke: none;
  330. fill: transparent;
  331. }
  332. .node.message text {
  333. fill: #666;
  334. }
  335. </style>
  336. </head>
  337. <body>
  338. <div class="main-container">
  339. <div class="container">
  340. <!-- 顶部导航栏 -->
  341. <div class="top-nav">
  342. <h1 id="task-title">Trace 执行流程可视化</h1>
  343. <div class="filter-section">
  344. <div class="filter-item">
  345. <label for="status-filter">状态筛选:</label>
  346. <select id="status-filter">
  347. <option value="">全部</option>
  348. <option value="running">运行中</option>
  349. <option value="completed">已完成</option>
  350. <option value="failed">失败</option>
  351. </select>
  352. </div>
  353. <div class="filter-item">
  354. <button id="refresh-btn">刷新</button>
  355. </div>
  356. </div>
  357. </div>
  358. <!-- 主体内容区域 -->
  359. <div id="chart"></div>
  360. </div>
  361. <!-- 拖拽调整器 -->
  362. <div
  363. class="resizer"
  364. id="resizer"
  365. ></div>
  366. <!-- 右侧详情面板 -->
  367. <div
  368. class="detail-panel"
  369. id="detail-panel"
  370. >
  371. <h2>
  372. <span>详情信息</span>
  373. <button
  374. id="detail-toggle"
  375. class="detail-toggle"
  376. >
  377. 收起
  378. </button>
  379. </h2>
  380. <div
  381. id="detail-content"
  382. class="detail-content"
  383. >
  384. <div class="detail-empty">点击节点或边查看详情</div>
  385. </div>
  386. </div>
  387. </div>
  388. <script>
  389. const rawGoalList = "__GOAL_LIST__";
  390. const goalList = typeof rawGoalList === "string" && rawGoalList === "__GOAL_LIST__" ? [] : rawGoalList;
  391. console.log("%c [ goalList ]-401", "font-size:13px; background:pink; color:#bf2c9f;", goalList);
  392. const rawMsgGroups = "__MSG_GROUPS__";
  393. const msgGroups = typeof rawMsgGroups === "string" && rawMsgGroups === "__MSG_GROUPS__" ? {} : rawMsgGroups;
  394. console.log("%c [ msgGroups ]-404", "font-size:13px; background:pink; color:#bf2c9f;", msgGroups);
  395. let selectedNode = null;
  396. let selectedLink = null;
  397. let svg, g, zoom;
  398. let root;
  399. // Set to store IDs of expanded nodes (both goals and messages)
  400. const expandedState = new Set();
  401. // 初始化
  402. function init() {
  403. updateTitle();
  404. // Initialize: Ensure at least the first goal is in the chain if needed,
  405. // but our logic handles it.
  406. renderWrapper();
  407. bindEvents();
  408. }
  409. function renderWrapper() {
  410. root = setupData();
  411. renderGraph();
  412. }
  413. // 更新标题
  414. function updateTitle() {
  415. if (goalList.length > 0 && goalList[0].description) {
  416. document.getElementById("task-title").textContent = goalList[0].description;
  417. }
  418. }
  419. // 构建数据
  420. function setupData() {
  421. const goals = Array.isArray(goalList) ? goalList : [];
  422. const nodeMap = new Map();
  423. // Helper to create a node object
  424. const createNode = (data, type) => ({
  425. ...data,
  426. type: type,
  427. children: [],
  428. _original: data,
  429. });
  430. // 1. Identify Top-Level Goals and Child Goals
  431. const topLevelGoals = [];
  432. const childGoalsMap = new Map(); // parent_id -> [goals]
  433. goals.forEach((goal) => {
  434. if (goal.parent_id === null) {
  435. topLevelGoals.push(goal);
  436. } else {
  437. if (!childGoalsMap.has(goal.parent_id)) {
  438. childGoalsMap.set(goal.parent_id, []);
  439. }
  440. childGoalsMap.get(goal.parent_id).push(goal);
  441. }
  442. });
  443. // 2. Build the Tree
  444. const virtualRoot = {
  445. id: "VIRTUAL_ROOT",
  446. children: [],
  447. };
  448. let currentParent = virtualRoot;
  449. topLevelGoals.forEach((goal, index) => {
  450. const goalNode = createNode(goal, "goal");
  451. const nextGoal = index < topLevelGoals.length - 1 ? topLevelGoals[index + 1] : null;
  452. goalNode._nextGoalId = nextGoal ? nextGoal.id : null;
  453. if (childGoalsMap.has(goal.id)) {
  454. childGoalsMap.get(goal.id).forEach((subGoal) => {
  455. goalNode.children.push(createNode(subGoal, "goal"));
  456. });
  457. }
  458. currentParent.children.push(goalNode);
  459. if (msgGroups && msgGroups[goal.id] && expandedState.has(goal.id)) {
  460. const msgs = msgGroups[goal.id];
  461. let msgParent = goalNode;
  462. for (let i = 0; i < msgs.length; i++) {
  463. const msg = msgs[i];
  464. const msgNode = createNode(msg, "message");
  465. msgNode.id = `${goal.id}-msg-${i}`;
  466. msgParent.children.push(msgNode);
  467. msgParent = msgNode;
  468. }
  469. }
  470. currentParent = goalNode;
  471. });
  472. return d3.hierarchy(virtualRoot);
  473. }
  474. function buildLayoutData(node) {
  475. const children = Array.isArray(node.children)
  476. ? node.children.filter((child) => child.type !== "message").map((child) => buildLayoutData(child))
  477. : [];
  478. return { ...node, children };
  479. }
  480. // 渲染图表
  481. function renderGraph() {
  482. const chartDiv = document.getElementById("chart");
  483. const width = chartDiv.offsetWidth || 1000;
  484. const height = chartDiv.offsetHeight || 700;
  485. // 清除之前的内容
  486. d3.select("#chart").selectAll("svg").remove();
  487. // 创建 SVG
  488. svg = d3
  489. .select("#chart")
  490. .append("svg")
  491. .attr("viewBox", `0 0 ${width} ${height}`)
  492. .attr("preserveAspectRatio", "xMidYMid meet");
  493. // 定义箭头
  494. const defs = svg.append("defs");
  495. // 默认蓝色箭头
  496. defs
  497. .append("marker")
  498. .attr("id", "arrowhead")
  499. .attr("viewBox", "0 -5 10 10")
  500. .attr("refX", 10)
  501. .attr("refY", 0)
  502. .attr("markerWidth", 6)
  503. .attr("markerHeight", 6)
  504. .attr("orient", "auto")
  505. .append("path")
  506. .attr("d", "M0,-5L10,0L0,5")
  507. .attr("fill", "#4e79a7");
  508. // 选中红色箭头
  509. defs
  510. .append("marker")
  511. .attr("id", "arrowhead-selected")
  512. .attr("viewBox", "0 -5 10 10")
  513. .attr("refX", 10)
  514. .attr("refY", 0)
  515. .attr("markerWidth", 6)
  516. .attr("markerHeight", 6)
  517. .attr("orient", "auto")
  518. .append("path")
  519. .attr("d", "M0,-5L10,0L0,5")
  520. .attr("fill", "#ff6b6b");
  521. g = svg.append("g");
  522. const treeLayout = d3
  523. .tree()
  524. .size([height - 100, width - 200])
  525. .separation((a, b) => (a.parent == b.parent ? 1.2 : 1.5));
  526. const layoutData = buildLayoutData(root.data);
  527. const layoutRoot = d3.hierarchy(layoutData);
  528. treeLayout(layoutRoot);
  529. const positionMap = new Map();
  530. layoutRoot.descendants().forEach((d) => {
  531. if (d.data && d.data.id !== undefined) {
  532. positionMap.set(d.data.id, { x: d.x, y: d.y });
  533. }
  534. });
  535. root.descendants().forEach((d) => {
  536. if (d.data && d.data.type !== "message" && positionMap.has(d.data.id)) {
  537. const pos = positionMap.get(d.data.id);
  538. d.x = pos.x;
  539. d.y = pos.y;
  540. }
  541. });
  542. const getMessageChain = (goalNode) => {
  543. const chain = [];
  544. if (!goalNode.children) return chain;
  545. let current = goalNode.children.find((child) => child.data && child.data.type === "message");
  546. while (current && current.data && current.data.type === "message") {
  547. chain.push(current);
  548. if (!current.children) break;
  549. const next = current.children.find((child) => child.data && child.data.type === "message");
  550. current = next || null;
  551. }
  552. return chain;
  553. };
  554. const goalNodes = root.descendants().filter((d) => d.data && d.data.type === "goal");
  555. const goalNodeMap = new Map();
  556. goalNodes.forEach((goalNode) => {
  557. goalNodeMap.set(goalNode.data.id, goalNode);
  558. });
  559. const msgBaseDown = 80;
  560. const msgStepDown = 70;
  561. const msgIndent = 30;
  562. goalNodes.forEach((goalNode) => {
  563. const messageNodes = getMessageChain(goalNode);
  564. if (messageNodes.length === 0) return;
  565. messageNodes.forEach((messageNode, idx) => {
  566. messageNode.x = goalNode.x + msgBaseDown + idx * msgStepDown;
  567. messageNode.y = goalNode.y + msgIndent;
  568. });
  569. });
  570. const nodesData = root.descendants().filter((d) => d.depth > 0);
  571. const baseLinksData = root.links().filter((d) => d.source.depth > 0);
  572. const extraLinks = [];
  573. goalNodes.forEach((goalNode) => {
  574. if (!expandedState.has(goalNode.data.id)) return;
  575. const messageNodes = getMessageChain(goalNode);
  576. if (messageNodes.length === 0) return;
  577. const lastMessage = messageNodes[messageNodes.length - 1];
  578. const nextGoalId = goalNode.data._nextGoalId;
  579. const nextGoalNode = nextGoalId ? goalNodeMap.get(nextGoalId) : null;
  580. if (nextGoalNode) {
  581. extraLinks.push({
  582. source: lastMessage,
  583. target: nextGoalNode,
  584. _linkType: "message-to-next-goal",
  585. });
  586. }
  587. });
  588. const linksData = baseLinksData.concat(extraLinks);
  589. // 绘制连线
  590. const linkGroups = g.selectAll(".link-group").data(linksData).enter().append("g").attr("class", "link-group");
  591. linkGroups
  592. .append("path")
  593. .attr("class", (d) => {
  594. // 保持高亮状态
  595. return d === selectedLink ? "link highlighted" : "link";
  596. })
  597. .attr("d", (d) => {
  598. const sourceY = d.source.x + 50;
  599. const targetY = d.target.x + 50;
  600. if (d._linkType === "message-to-next-goal") {
  601. const sourceX = d.source.y + 100;
  602. const targetX = d.target.y + 100 - 80;
  603. const controlX = (sourceX + targetX) / 2;
  604. const controlY = Math.max(sourceY, targetY) + 90;
  605. return `M${sourceX},${sourceY} Q${controlX},${controlY} ${targetX},${targetY}`;
  606. }
  607. if (d.target.data.type === "message") {
  608. const sourceX = d.source.y + 100;
  609. const targetX = d.target.y + 100;
  610. // Use smooth S-curve (cubic bezier) for parent->message and message->message
  611. // This avoids the "looping" effect and provides a clean flow
  612. return `M${sourceX},${sourceY} C${sourceX},${(sourceY + targetY) / 2} ${targetX},${(sourceY + targetY) / 2} ${targetX},${targetY}`;
  613. }
  614. const sourceX = d.source.y + 100 + 80;
  615. const targetX = d.target.y + 100 - 80;
  616. return `M${sourceX},${sourceY} C${(sourceX + targetX) / 2},${sourceY} ${(sourceX + targetX) / 2},${targetY} ${targetX},${targetY}`;
  617. })
  618. .attr("marker-end", (d) => {
  619. // 如果选中,使用红色箭头
  620. if (d === selectedLink) return "url(#arrowhead-selected)";
  621. return "url(#arrowhead)";
  622. })
  623. .attr("stroke-dasharray", (d) => {
  624. if (d._linkType === "message-to-next-goal") return "5,5";
  625. if (d.target.data.type === "message") return "5,5";
  626. return null;
  627. })
  628. .on("click", function (event, d) {
  629. event.stopPropagation();
  630. selectedLink = d;
  631. renderWrapper(); // 重绘以更新样式
  632. showEdgeDetail(d);
  633. });
  634. // 绘制节点
  635. const nodes = g
  636. .selectAll(".node")
  637. .data(nodesData)
  638. .enter()
  639. .append("g")
  640. .attr("class", (d) => `node ${d.data.status} ${d.data.type}`)
  641. .attr("transform", (d) => `translate(${d.y + 100},${d.x + 50})`)
  642. .on("click", function (event, d) {
  643. event.stopPropagation();
  644. handleNodeClick(d);
  645. });
  646. // 节点文本
  647. const textNode = nodes
  648. .append("text")
  649. .attr("dy", 5)
  650. .text((d) => {
  651. const text = d.data.description || "";
  652. const limit = d.data.type === "message" ? 6 : 15; // Message limited to 6 chars
  653. return text.length > limit ? text.substring(0, limit) + "..." : text;
  654. });
  655. // Add tooltip for full description
  656. textNode.append("title").text((d) => d.data.description || "");
  657. // 缩放
  658. zoom = d3
  659. .zoom()
  660. .scaleExtent([0.1, 5])
  661. .on("zoom", function (event) {
  662. g.attr("transform", event.transform);
  663. });
  664. svg.call(zoom);
  665. // 初始自适应(仅在第一次或重置时?)
  666. // 为简单起见,这里不每次重置视图,除非是第一次
  667. // 或者保留当前的 transform
  668. }
  669. function handleNodeClick(d) {
  670. // Toggle expanded state
  671. if (expandedState.has(d.data.id)) {
  672. expandedState.delete(d.data.id);
  673. } else {
  674. expandedState.add(d.data.id);
  675. }
  676. // 2. 选中逻辑
  677. selectedNode = d;
  678. // 3. 重绘
  679. renderWrapper();
  680. // 4. 显示详情
  681. selectNode(d);
  682. }
  683. // 自动缩放以适应屏幕
  684. function fitToView() {
  685. if (!g || !svg) return;
  686. const bounds = g.node().getBBox();
  687. if (bounds.width === 0 || bounds.height === 0) return;
  688. const chartDiv = document.getElementById("chart");
  689. const width = chartDiv.offsetWidth;
  690. const height = chartDiv.offsetHeight;
  691. const scale = 0.85 / Math.max(bounds.width / width, bounds.height / height);
  692. const clampedScale = Math.max(0.1, Math.min(3, scale));
  693. const centerX = bounds.x + bounds.width / 2;
  694. const centerY = bounds.y + bounds.height / 2;
  695. svg.call(
  696. zoom.transform,
  697. d3.zoomIdentity
  698. .translate(width / 2 - clampedScale * centerX, height / 2 - clampedScale * centerY)
  699. .scale(clampedScale),
  700. );
  701. }
  702. // 选择节点
  703. function selectNode(d) {
  704. // 移除之前的选中状态
  705. d3.selectAll(".node").classed("selected", false);
  706. // 添加选中状态
  707. d3.select(event.currentTarget).classed("selected", true);
  708. selectedNode = d;
  709. // 高亮路径
  710. highlightPath(d);
  711. // 显示详情
  712. showNodeDetail(d);
  713. }
  714. // 高亮路径
  715. function highlightPath(d) {
  716. // 移除之前的高亮
  717. d3.selectAll(".link").classed("highlighted", false).attr("marker-end", "url(#arrowhead)");
  718. // 找到从根节点到当前节点的路径
  719. const path = d.ancestors().reverse();
  720. // 高亮路径上的连线
  721. d3.selectAll(".link").each(function (linkData) {
  722. const sourceInPath = path.includes(linkData.source);
  723. const targetInPath = path.includes(linkData.target);
  724. if (sourceInPath && targetInPath) {
  725. d3.select(this).classed("highlighted", true).attr("marker-end", "url(#arrowhead-selected)");
  726. }
  727. });
  728. }
  729. // 显示节点详情
  730. function showNodeDetail(d) {
  731. const detailContent = document.getElementById("detail-content");
  732. const goal = d.data;
  733. let html = `
  734. <div class="detail-item">
  735. <label>节点 ID:</label>
  736. <div class="detail-item-value">${goal.id}</div>
  737. </div>
  738. <div class="detail-item">
  739. <label>描述:</label>
  740. <div class="detail-item-value">${goal.description}</div>
  741. </div>
  742. <div class="detail-item">
  743. <label>创建理由:</label>
  744. <div class="detail-item-value">${goal.reason}</div>
  745. </div>
  746. <div class="detail-item">
  747. <label>状态:</label>
  748. <div class="detail-item-value">${goal.status}</div>
  749. </div>
  750. `;
  751. if (goal.summary) {
  752. html += `
  753. <div class="detail-item">
  754. <label>总结:</label>
  755. <div class="detail-item-value">${goal.summary}</div>
  756. </div>
  757. `;
  758. }
  759. // 显示统计信息
  760. if (goal.self_stats) {
  761. html += `
  762. <div class="detail-item">
  763. <label>当前节点统计:</label>
  764. <div class="stats-grid">
  765. <div class="stat-item">
  766. <div class="stat-label">消息数</div>
  767. <div class="stat-value">${goal.self_stats.message_count}</div>
  768. </div>
  769. <div class="stat-item">
  770. <div class="stat-label">Token 数</div>
  771. <div class="stat-value">${goal.self_stats.total_tokens}</div>
  772. </div>
  773. <div class="stat-item">
  774. <div class="stat-label">成本</div>
  775. <div class="stat-value">$${goal.self_stats.total_cost.toFixed(3)}</div>
  776. </div>
  777. <div class="stat-item">
  778. <div class="stat-label">预览</div>
  779. <div class="stat-value" style="font-size: 12px;">${goal.self_stats.preview || "-"}</div>
  780. </div>
  781. </div>
  782. </div>
  783. `;
  784. }
  785. if (goal.cumulative_stats) {
  786. html += `
  787. <div class="detail-item">
  788. <label>累计统计:</label>
  789. <div class="stats-grid">
  790. <div class="stat-item">
  791. <div class="stat-label">消息数</div>
  792. <div class="stat-value">${goal.cumulative_stats.message_count}</div>
  793. </div>
  794. <div class="stat-item">
  795. <div class="stat-label">Token 数</div>
  796. <div class="stat-value">${goal.cumulative_stats.total_tokens}</div>
  797. </div>
  798. <div class="stat-item">
  799. <div class="stat-label">成本</div>
  800. <div class="stat-value">$${goal.cumulative_stats.total_cost.toFixed(3)}</div>
  801. </div>
  802. <div class="stat-item">
  803. <div class="stat-label">预览</div>
  804. <div class="stat-value" style="font-size: 12px;">${goal.cumulative_stats.preview || "-"}</div>
  805. </div>
  806. </div>
  807. </div>
  808. `;
  809. }
  810. const messages = msgGroups[goal.id] || [];
  811. if (Array.isArray(messages) && messages.length > 0) {
  812. const messageHtml = messages
  813. .map((message) => {
  814. const role = message.role || message.sender || "unknown";
  815. const content = message.content || message.text || "";
  816. return `
  817. <div class="message-item ${role}">
  818. <div class="message-header">
  819. <span class="message-role">${role}</span>
  820. <span class="message-time">${message.created_at || ""}</span>
  821. </div>
  822. <div class="message-content">${content}</div>
  823. </div>
  824. `;
  825. })
  826. .join("");
  827. html += `
  828. <div class="detail-item">
  829. <label>节点内容:</label>
  830. <div class="message-list">${messageHtml}</div>
  831. </div>
  832. `;
  833. }
  834. detailContent.innerHTML = html;
  835. }
  836. // 显示边详情
  837. function showEdgeDetail(d) {
  838. const detailContent = document.getElementById("detail-content");
  839. const html = `
  840. <div class="detail-item">
  841. <label>连线:</label>
  842. <div class="detail-item-value">${d.source.data.description} → ${d.target.data.description}</div>
  843. </div>
  844. <div class="detail-item">
  845. <label>目标节点:</label>
  846. <div class="detail-item-value">${d.target.data.description}</div>
  847. </div>
  848. `;
  849. detailContent.innerHTML = html;
  850. }
  851. // 绑定事件
  852. function bindEvents() {
  853. // Trace 选择器
  854. document.getElementById("trace-select").addEventListener("change", function () {
  855. updateTitle();
  856. renderGraph();
  857. });
  858. // 刷新按钮
  859. document.getElementById("refresh-btn").addEventListener("click", function () {
  860. renderGraph();
  861. });
  862. // 详情面板收起/展开
  863. const detailToggle = document.getElementById("detail-toggle");
  864. const detailPanel = document.getElementById("detail-panel");
  865. let isDetailCollapsed = false;
  866. detailToggle.addEventListener("click", function () {
  867. isDetailCollapsed = !isDetailCollapsed;
  868. if (isDetailCollapsed) {
  869. detailPanel.classList.add("collapsed");
  870. detailToggle.textContent = "展开";
  871. } else {
  872. detailPanel.classList.remove("collapsed");
  873. detailToggle.textContent = "收起";
  874. }
  875. setTimeout(() => {
  876. renderGraph();
  877. }, 0);
  878. });
  879. // 窗口大小变化
  880. let resizeTimer;
  881. window.addEventListener("resize", function () {
  882. clearTimeout(resizeTimer);
  883. resizeTimer = setTimeout(function () {
  884. renderGraph();
  885. }, 250);
  886. });
  887. }
  888. // 启动
  889. init();
  890. </script>
  891. </body>
  892. </html>