visualize_v2.js 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const { build } = require('esbuild');
  5. // 读取命令行参数
  6. const args = process.argv.slice(2);
  7. if (args.length === 0) {
  8. console.error('Usage: node visualize_v2.js <path-to-query_graph.json> [output.html]');
  9. process.exit(1);
  10. }
  11. const inputFile = args[0];
  12. const outputFile = args[1] || 'query_graph_output.html';
  13. // 读取输入数据
  14. const data = JSON.parse(fs.readFileSync(inputFile, 'utf-8'));
  15. // 创建临时 React 组件文件
  16. const reactComponentPath = path.join(__dirname, 'temp_flow_component_v2.jsx');
  17. const reactComponent = `
  18. import React, { useState, useCallback, useMemo, useEffect } from 'react';
  19. import { createRoot } from 'react-dom/client';
  20. import {
  21. ReactFlow,
  22. Controls,
  23. Background,
  24. useNodesState,
  25. useEdgesState,
  26. Handle,
  27. Position,
  28. useReactFlow,
  29. ReactFlowProvider,
  30. } from '@xyflow/react';
  31. import '@xyflow/react/dist/style.css';
  32. const data = ${JSON.stringify(data, null, 2)};
  33. // 查询节点组件 - 卡片样式
  34. function QueryNode({ id, data, sourcePosition, targetPosition }) {
  35. // 所有节点默认展开
  36. const expanded = true;
  37. return (
  38. <div>
  39. <Handle
  40. type="target"
  41. position={targetPosition || Position.Left}
  42. style={{ background: '#667eea', width: 8, height: 8 }}
  43. />
  44. <div
  45. style={{
  46. padding: '12px',
  47. borderRadius: '8px',
  48. border: data.isHighlighted ? '3px solid #667eea' :
  49. data.isCollapsed ? '2px solid #667eea' :
  50. data.isSelected === false ? '2px dashed #d1d5db' :
  51. data.level === 0 ? '2px solid #8b5cf6' : '1px solid #e5e7eb',
  52. background: data.isHighlighted ? '#eef2ff' :
  53. data.isSelected === false ? '#f9fafb' :
  54. data.level === 0 ? 'linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%)' : 'white',
  55. minWidth: '200px',
  56. maxWidth: '280px',
  57. boxShadow: data.isHighlighted ? '0 0 0 4px rgba(102, 126, 234, 0.25), 0 4px 16px rgba(102, 126, 234, 0.4)' :
  58. data.isCollapsed ? '0 4px 12px rgba(102, 126, 234, 0.15)' :
  59. data.level === 0 ? '0 4px 12px rgba(139, 92, 246, 0.15)' : '0 2px 6px rgba(0, 0, 0, 0.06)',
  60. transition: 'all 0.3s ease',
  61. cursor: 'pointer',
  62. position: 'relative',
  63. opacity: data.isSelected === false ? 0.6 : 1,
  64. }}
  65. >
  66. {/* 折叠/展开子节点按钮 */}
  67. {data.hasChildren && (
  68. <div
  69. style={{
  70. position: 'absolute',
  71. top: '6px',
  72. right: '6px',
  73. width: '20px',
  74. height: '20px',
  75. borderRadius: '50%',
  76. background: data.isCollapsed ? '#667eea' : '#e5e7eb',
  77. color: data.isCollapsed ? 'white' : '#6b7280',
  78. display: 'flex',
  79. alignItems: 'center',
  80. justifyContent: 'center',
  81. fontSize: '11px',
  82. fontWeight: 'bold',
  83. cursor: 'pointer',
  84. transition: 'all 0.2s ease',
  85. zIndex: 10,
  86. }}
  87. onClick={(e) => {
  88. e.stopPropagation();
  89. data.onToggleCollapse();
  90. }}
  91. title={data.isCollapsed ? '展开子节点' : '折叠子节点'}
  92. >
  93. {data.isCollapsed ? '+' : '−'}
  94. </div>
  95. )}
  96. {/* 卡片内容 */}
  97. <div>
  98. {/* 标题行 */}
  99. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px', paddingRight: data.hasChildren ? '24px' : '0' }}>
  100. <div style={{ flex: 1 }}>
  101. <div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '3px' }}>
  102. <div style={{
  103. fontSize: '13px',
  104. fontWeight: data.level === 0 ? '700' : '600',
  105. color: data.level === 0 ? '#6b21a8' : '#1f2937',
  106. lineHeight: '1.3',
  107. flex: 1,
  108. }}>
  109. {data.title}
  110. </div>
  111. {data.isSelected === false && (
  112. <div style={{
  113. fontSize: '9px',
  114. padding: '1px 4px',
  115. borderRadius: '3px',
  116. background: '#fee2e2',
  117. color: '#991b1b',
  118. fontWeight: '500',
  119. flexShrink: 0,
  120. }}>
  121. 未选中
  122. </div>
  123. )}
  124. </div>
  125. </div>
  126. </div>
  127. {/* 展开的详细信息 - 始终显示 */}
  128. <div style={{ fontSize: '11px', lineHeight: 1.4 }}>
  129. <div style={{ display: 'flex', gap: '4px', marginBottom: '6px', flexWrap: 'wrap' }}>
  130. <span style={{
  131. display: 'inline-block',
  132. padding: '1px 6px',
  133. borderRadius: '10px',
  134. background: '#eff6ff',
  135. color: '#3b82f6',
  136. fontSize: '10px',
  137. fontWeight: '500',
  138. }}>
  139. Lv.{data.level}
  140. </span>
  141. <span style={{
  142. display: 'inline-block',
  143. padding: '1px 6px',
  144. borderRadius: '10px',
  145. background: '#f0fdf4',
  146. color: '#16a34a',
  147. fontSize: '10px',
  148. fontWeight: '500',
  149. }}>
  150. {data.score}
  151. </span>
  152. {data.strategy && data.strategy !== 'root' && (
  153. <span style={{
  154. display: 'inline-block',
  155. padding: '1px 6px',
  156. borderRadius: '10px',
  157. background: '#fef3c7',
  158. color: '#92400e',
  159. fontSize: '10px',
  160. fontWeight: '500',
  161. }}>
  162. {data.strategy}
  163. </span>
  164. )}
  165. </div>
  166. {data.parent && (
  167. <div style={{ color: '#6b7280', fontSize: '10px', marginTop: '4px', paddingTop: '4px', borderTop: '1px solid #f3f4f6' }}>
  168. <strong>Parent:</strong> {data.parent}
  169. </div>
  170. )}
  171. {data.evaluationReason && (
  172. <div style={{
  173. marginTop: '6px',
  174. paddingTop: '6px',
  175. borderTop: '1px solid #f3f4f6',
  176. fontSize: '10px',
  177. color: '#6b7280',
  178. lineHeight: '1.4',
  179. maxHeight: '60px',
  180. overflow: 'auto',
  181. }}>
  182. <strong style={{ color: '#4b5563' }}>评估:</strong>
  183. <div style={{ marginTop: '2px' }}>{data.evaluationReason}</div>
  184. </div>
  185. )}
  186. </div>
  187. </div>
  188. </div>
  189. <Handle
  190. type="source"
  191. position={sourcePosition || Position.Right}
  192. style={{ background: '#667eea', width: 8, height: 8 }}
  193. />
  194. </div>
  195. );
  196. }
  197. // 笔记节点组件 - 卡片样式
  198. function NoteNode({ id, data, sourcePosition, targetPosition }) {
  199. // 所有节点默认展开
  200. const expanded = true;
  201. return (
  202. <div>
  203. <Handle
  204. type="target"
  205. position={targetPosition || Position.Left}
  206. style={{ background: '#ec4899', width: 8, height: 8 }}
  207. />
  208. <div
  209. style={{
  210. padding: '14px',
  211. borderRadius: '12px',
  212. border: data.isHighlighted ? '3px solid #ec4899' : '2px solid #fce7f3',
  213. background: data.isHighlighted ? '#fef1f7' : 'linear-gradient(135deg, #fdf2f8 0%, #fce7f3 100%)',
  214. minWidth: '220px',
  215. maxWidth: '300px',
  216. boxShadow: data.isHighlighted ? '0 0 0 4px rgba(236, 72, 153, 0.25), 0 4px 16px rgba(236, 72, 153, 0.4)' : '0 4px 12px rgba(236, 72, 153, 0.15)',
  217. transition: 'all 0.3s ease',
  218. cursor: 'pointer',
  219. }}
  220. >
  221. {/* 笔记图标和标题 */}
  222. <div style={{ display: 'flex', alignItems: 'flex-start', marginBottom: '8px' }}>
  223. <span style={{ fontSize: '16px', marginRight: '8px' }}>📝</span>
  224. <div style={{ flex: 1 }}>
  225. <div style={{
  226. fontSize: '13px',
  227. fontWeight: '600',
  228. color: '#831843',
  229. lineHeight: '1.4',
  230. marginBottom: '4px',
  231. }}>
  232. {data.title}
  233. </div>
  234. </div>
  235. </div>
  236. {/* 标签 */}
  237. <div style={{ display: 'flex', gap: '6px', marginBottom: '8px', flexWrap: 'wrap' }}>
  238. <span style={{
  239. display: 'inline-block',
  240. padding: '2px 8px',
  241. borderRadius: '12px',
  242. background: '#fff1f2',
  243. color: '#be123c',
  244. fontSize: '10px',
  245. fontWeight: '500',
  246. }}>
  247. {data.matchLevel}
  248. </span>
  249. <span style={{
  250. display: 'inline-block',
  251. padding: '2px 8px',
  252. borderRadius: '12px',
  253. background: '#fff7ed',
  254. color: '#c2410c',
  255. fontSize: '10px',
  256. fontWeight: '500',
  257. }}>
  258. Score: {data.score}
  259. </span>
  260. </div>
  261. {/* 描述 */}
  262. {expanded && data.description && (
  263. <div style={{
  264. fontSize: '11px',
  265. color: '#9f1239',
  266. lineHeight: '1.5',
  267. paddingTop: '8px',
  268. borderTop: '1px solid #fbcfe8',
  269. maxHeight: '100px',
  270. overflow: 'auto',
  271. }}>
  272. {data.description}
  273. </div>
  274. )}
  275. </div>
  276. <Handle
  277. type="source"
  278. position={sourcePosition || Position.Right}
  279. style={{ background: '#ec4899', width: 8, height: 8 }}
  280. />
  281. </div>
  282. );
  283. }
  284. const nodeTypes = {
  285. query: QueryNode,
  286. note: NoteNode,
  287. };
  288. // 树节点组件
  289. function TreeNode({ node, level, children, isCollapsed, onToggle, isSelected, onSelect }) {
  290. const hasChildren = children && children.length > 0;
  291. return (
  292. <div style={{ marginLeft: level * 12 + 'px' }}>
  293. <div
  294. style={{
  295. padding: '6px 8px',
  296. borderRadius: '4px',
  297. cursor: 'pointer',
  298. background: isSelected ? '#eff6ff' : 'transparent',
  299. borderLeft: isSelected ? '3px solid #3b82f6' : '3px solid transparent',
  300. display: 'flex',
  301. alignItems: 'center',
  302. gap: '6px',
  303. transition: 'all 0.2s ease',
  304. }}
  305. onMouseEnter={(e) => {
  306. if (!isSelected) e.currentTarget.style.background = '#f9fafb';
  307. }}
  308. onMouseLeave={(e) => {
  309. if (!isSelected) e.currentTarget.style.background = 'transparent';
  310. }}
  311. >
  312. {hasChildren && (
  313. <span
  314. style={{
  315. fontSize: '10px',
  316. color: '#6b7280',
  317. cursor: 'pointer',
  318. width: '16px',
  319. textAlign: 'center',
  320. }}
  321. onClick={(e) => {
  322. e.stopPropagation();
  323. onToggle();
  324. }}
  325. >
  326. {isCollapsed ? '▶' : '▼'}
  327. </span>
  328. )}
  329. {!hasChildren && <span style={{ width: '16px' }}></span>}
  330. <div
  331. style={{ flex: 1, fontSize: '12px', color: '#374151' }}
  332. onClick={onSelect}
  333. >
  334. <div style={{
  335. fontWeight: level === 0 ? '600' : '400',
  336. overflow: 'hidden',
  337. textOverflow: 'ellipsis',
  338. whiteSpace: 'nowrap',
  339. fontFamily: 'monospace',
  340. }}>
  341. {node.id}
  342. </div>
  343. {node.data.isSelected === false && (
  344. <span style={{
  345. fontSize: '9px',
  346. color: '#ef4444',
  347. marginLeft: '4px',
  348. }}>
  349. </span>
  350. )}
  351. </div>
  352. </div>
  353. {hasChildren && !isCollapsed && (
  354. <div>
  355. {children}
  356. </div>
  357. )}
  358. </div>
  359. );
  360. }
  361. // 使用 dagre 自动布局
  362. function getLayoutedElements(nodes, edges, direction = 'LR') {
  363. console.log('🎯 Starting layout with dagre...');
  364. console.log('Input:', nodes.length, 'nodes,', edges.length, 'edges');
  365. // 检查 dagre 是否加载
  366. if (typeof window === 'undefined' || typeof window.dagre === 'undefined') {
  367. console.warn('⚠️ Dagre not loaded, using fallback layout');
  368. // 降级到简单布局
  369. const levelGroups = {};
  370. nodes.forEach(node => {
  371. const level = node.data.level || 0;
  372. if (!levelGroups[level]) levelGroups[level] = [];
  373. levelGroups[level].push(node);
  374. });
  375. Object.entries(levelGroups).forEach(([level, nodeList]) => {
  376. const x = parseInt(level) * 350;
  377. nodeList.forEach((node, index) => {
  378. node.position = { x, y: index * 150 };
  379. node.targetPosition = 'left';
  380. node.sourcePosition = 'right';
  381. });
  382. });
  383. return { nodes, edges };
  384. }
  385. try {
  386. const dagreGraph = new window.dagre.graphlib.Graph();
  387. dagreGraph.setDefaultEdgeLabel(() => ({}));
  388. const nodeWidth = 280;
  389. const nodeHeight = 180;
  390. const isHorizontal = direction === 'LR';
  391. dagreGraph.setGraph({
  392. rankdir: direction,
  393. nodesep: 80,
  394. ranksep: 300,
  395. });
  396. // 添加节点
  397. nodes.forEach((node) => {
  398. dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
  399. });
  400. // 添加边
  401. edges.forEach((edge) => {
  402. dagreGraph.setEdge(edge.source, edge.target);
  403. });
  404. // 计算布局
  405. window.dagre.layout(dagreGraph);
  406. console.log('✅ Dagre layout completed');
  407. // 更新节点位置和 handle 位置
  408. nodes.forEach((node) => {
  409. const nodeWithPosition = dagreGraph.node(node.id);
  410. if (!nodeWithPosition) {
  411. console.warn('Node position not found for:', node.id);
  412. return;
  413. }
  414. node.targetPosition = isHorizontal ? 'left' : 'top';
  415. node.sourcePosition = isHorizontal ? 'right' : 'bottom';
  416. // 将 dagre 的中心点位置转换为 React Flow 的左上角位置
  417. node.position = {
  418. x: nodeWithPosition.x - nodeWidth / 2,
  419. y: nodeWithPosition.y - nodeHeight / 2,
  420. };
  421. });
  422. console.log('✅ Layout completed, sample node:', nodes[0]);
  423. return { nodes, edges };
  424. } catch (error) {
  425. console.error('❌ Error in dagre layout:', error);
  426. console.error('Error details:', error.message, error.stack);
  427. // 降级处理
  428. console.log('Using fallback layout...');
  429. const levelGroups = {};
  430. nodes.forEach(node => {
  431. const level = node.data.level || 0;
  432. if (!levelGroups[level]) levelGroups[level] = [];
  433. levelGroups[level].push(node);
  434. });
  435. Object.entries(levelGroups).forEach(([level, nodeList]) => {
  436. const x = parseInt(level) * 350;
  437. nodeList.forEach((node, index) => {
  438. node.position = { x, y: index * 150 };
  439. node.targetPosition = 'left';
  440. node.sourcePosition = 'right';
  441. });
  442. });
  443. return { nodes, edges };
  444. }
  445. }
  446. function transformData(data) {
  447. const nodes = [];
  448. const edges = [];
  449. // 创建节点
  450. Object.entries(data.nodes).forEach(([id, node]) => {
  451. if (node.type === 'query') {
  452. nodes.push({
  453. id: id, // 使用循环中的 id 参数,不是 node.id
  454. type: 'query',
  455. data: {
  456. title: node.query,
  457. level: node.level,
  458. score: node.relevance_score.toFixed(2),
  459. strategy: node.strategy,
  460. parent: node.parent_query,
  461. isSelected: node.is_selected,
  462. evaluationReason: node.evaluation_reason || '',
  463. },
  464. position: { x: 0, y: 0 }, // 初始位置,会被 dagre 覆盖
  465. });
  466. } else if (node.type === 'note') {
  467. nodes.push({
  468. id: id, // 使用循环中的 id 参数,不是 node.id
  469. type: 'note',
  470. data: {
  471. title: node.title,
  472. matchLevel: node.match_level,
  473. score: node.relevance_score.toFixed(2),
  474. description: node.desc,
  475. isSelected: node.is_selected !== undefined ? node.is_selected : true,
  476. },
  477. position: { x: 0, y: 0 },
  478. });
  479. }
  480. });
  481. // 创建边 - 使用虚线样式
  482. data.edges.forEach((edge, index) => {
  483. const edgeColors = {
  484. direct_sug: '#10b981',
  485. rewrite_synonym: '#f59e0b',
  486. add_word: '#3b82f6',
  487. rewrite_abstract: '#8b5cf6',
  488. query_to_note: '#ec4899',
  489. };
  490. const color = edgeColors[edge.strategy] || edgeColors[edge.edge_type] || '#d1d5db';
  491. const isNoteEdge = edge.edge_type === 'query_to_note';
  492. edges.push({
  493. id: \`edge-\${index}\`,
  494. source: edge.from,
  495. target: edge.to,
  496. type: 'smoothstep',
  497. animated: isNoteEdge,
  498. style: {
  499. stroke: color,
  500. strokeWidth: isNoteEdge ? 2.5 : 2,
  501. strokeDasharray: isNoteEdge ? '5,5' : '8,4',
  502. },
  503. markerEnd: {
  504. type: 'arrowclosed',
  505. color: color,
  506. width: 20,
  507. height: 20,
  508. },
  509. });
  510. });
  511. // 使用 dagre 自动计算布局 - 从左到右
  512. return getLayoutedElements(nodes, edges, 'LR');
  513. }
  514. function FlowContent() {
  515. const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
  516. console.log('🔍 Transforming data...');
  517. const result = transformData(data);
  518. console.log('✅ Transformed:', result.nodes.length, 'nodes,', result.edges.length, 'edges');
  519. return result;
  520. }, []);
  521. // 初始化:找出所有有子节点的节点,默认折叠(画布节点)
  522. const initialCollapsedNodes = useMemo(() => {
  523. const nodesWithChildren = new Set();
  524. initialEdges.forEach(edge => {
  525. nodesWithChildren.add(edge.source);
  526. });
  527. // 排除根节点(level 0),让根节点默认展开
  528. const rootNode = initialNodes.find(n => n.data.level === 0);
  529. if (rootNode) {
  530. nodesWithChildren.delete(rootNode.id);
  531. }
  532. return nodesWithChildren;
  533. }, [initialNodes, initialEdges]);
  534. // 树节点的折叠状态需要在树构建后初始化
  535. const [collapsedNodes, setCollapsedNodes] = useState(() => initialCollapsedNodes);
  536. const [collapsedTreeNodes, setCollapsedTreeNodes] = useState(new Set());
  537. const [selectedNodeId, setSelectedNodeId] = useState(null);
  538. // 获取 React Flow 实例以控制画布
  539. const { setCenter, fitView } = useReactFlow();
  540. // 获取某个节点的所有后代节点ID
  541. const getDescendants = useCallback((nodeId) => {
  542. const descendants = new Set();
  543. const queue = [nodeId];
  544. while (queue.length > 0) {
  545. const current = queue.shift();
  546. initialEdges.forEach(edge => {
  547. if (edge.source === current && !descendants.has(edge.target)) {
  548. descendants.add(edge.target);
  549. queue.push(edge.target);
  550. }
  551. });
  552. }
  553. return descendants;
  554. }, [initialEdges]);
  555. // 获取直接父节点
  556. const getDirectParents = useCallback((nodeId) => {
  557. const parents = [];
  558. initialEdges.forEach(edge => {
  559. if (edge.target === nodeId) {
  560. parents.push(edge.source);
  561. }
  562. });
  563. return parents;
  564. }, [initialEdges]);
  565. // 获取直接子节点
  566. const getDirectChildren = useCallback((nodeId) => {
  567. const children = [];
  568. initialEdges.forEach(edge => {
  569. if (edge.source === nodeId) {
  570. children.push(edge.target);
  571. }
  572. });
  573. return children;
  574. }, [initialEdges]);
  575. // 切换节点折叠状态
  576. const toggleNodeCollapse = useCallback((nodeId) => {
  577. setCollapsedNodes(prev => {
  578. const newSet = new Set(prev);
  579. const descendants = getDescendants(nodeId);
  580. if (newSet.has(nodeId)) {
  581. // 展开:移除此节点,但保持其他折叠的节点
  582. newSet.delete(nodeId);
  583. } else {
  584. // 折叠:添加此节点
  585. newSet.add(nodeId);
  586. }
  587. return newSet;
  588. });
  589. }, [getDescendants]);
  590. // 过滤可见的节点和边,并重新计算布局
  591. const { nodes, edges } = useMemo(() => {
  592. const hiddenNodes = new Set();
  593. // 收集所有被折叠节点的后代
  594. collapsedNodes.forEach(collapsedId => {
  595. const descendants = getDescendants(collapsedId);
  596. descendants.forEach(id => hiddenNodes.add(id));
  597. });
  598. const visibleNodes = initialNodes
  599. .filter(node => !hiddenNodes.has(node.id))
  600. .map(node => ({
  601. ...node,
  602. data: {
  603. ...node.data,
  604. isCollapsed: collapsedNodes.has(node.id),
  605. hasChildren: initialEdges.some(e => e.source === node.id),
  606. onToggleCollapse: () => toggleNodeCollapse(node.id),
  607. isHighlighted: selectedNodeId === node.id,
  608. }
  609. }));
  610. const visibleEdges = initialEdges.filter(
  611. edge => !hiddenNodes.has(edge.source) && !hiddenNodes.has(edge.target)
  612. );
  613. // 重新计算布局 - 只对可见节点
  614. if (typeof window !== 'undefined' && typeof window.dagre !== 'undefined') {
  615. try {
  616. const dagreGraph = new window.dagre.graphlib.Graph();
  617. dagreGraph.setDefaultEdgeLabel(() => ({}));
  618. const nodeWidth = 280;
  619. const nodeHeight = 180;
  620. dagreGraph.setGraph({
  621. rankdir: 'LR',
  622. nodesep: 80,
  623. ranksep: 300,
  624. });
  625. visibleNodes.forEach((node) => {
  626. dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
  627. });
  628. visibleEdges.forEach((edge) => {
  629. dagreGraph.setEdge(edge.source, edge.target);
  630. });
  631. window.dagre.layout(dagreGraph);
  632. visibleNodes.forEach((node) => {
  633. const nodeWithPosition = dagreGraph.node(node.id);
  634. if (nodeWithPosition) {
  635. node.position = {
  636. x: nodeWithPosition.x - nodeWidth / 2,
  637. y: nodeWithPosition.y - nodeHeight / 2,
  638. };
  639. node.targetPosition = 'left';
  640. node.sourcePosition = 'right';
  641. }
  642. });
  643. console.log('✅ Dynamic layout recalculated for', visibleNodes.length, 'visible nodes');
  644. } catch (error) {
  645. console.error('❌ Error in dynamic layout:', error);
  646. }
  647. }
  648. return { nodes: visibleNodes, edges: visibleEdges };
  649. }, [initialNodes, initialEdges, collapsedNodes, getDescendants, toggleNodeCollapse, selectedNodeId]);
  650. // 构建树形结构 - 允许一个节点有多个父节点
  651. const buildTree = useCallback(() => {
  652. const nodeMap = new Map();
  653. initialNodes.forEach(node => {
  654. nodeMap.set(node.id, node);
  655. });
  656. // 为每个节点创建树节点的副本(允许多次出现)
  657. const createTreeNode = (nodeId, pathKey) => {
  658. const node = nodeMap.get(nodeId);
  659. if (!node) return null;
  660. return {
  661. ...node,
  662. treeKey: pathKey, // 唯一的树路径key,用于React key
  663. children: []
  664. };
  665. };
  666. // 构建父子关系映射:记录每个节点的所有父节点,去重边
  667. const parentToChildren = new Map();
  668. const childToParents = new Map();
  669. initialEdges.forEach(edge => {
  670. // 记录父->子关系(去重:同一个父节点到同一个子节点只记录一次)
  671. if (!parentToChildren.has(edge.source)) {
  672. parentToChildren.set(edge.source, []);
  673. }
  674. const children = parentToChildren.get(edge.source);
  675. if (!children.includes(edge.target)) {
  676. children.push(edge.target);
  677. }
  678. // 记录子->父关系(用于判断是否有多个父节点,也去重)
  679. if (!childToParents.has(edge.target)) {
  680. childToParents.set(edge.target, []);
  681. }
  682. const parents = childToParents.get(edge.target);
  683. if (!parents.includes(edge.source)) {
  684. parents.push(edge.source);
  685. }
  686. });
  687. // 递归构建树
  688. const buildSubtree = (nodeId, pathKey, visitedInPath) => {
  689. // 避免循环引用:如果当前路径中已经访问过这个节点,跳过
  690. if (visitedInPath.has(nodeId)) {
  691. return null;
  692. }
  693. const treeNode = createTreeNode(nodeId, pathKey);
  694. if (!treeNode) return null;
  695. const newVisitedInPath = new Set(visitedInPath);
  696. newVisitedInPath.add(nodeId);
  697. const children = parentToChildren.get(nodeId) || [];
  698. treeNode.children = children
  699. .map((childId, index) => buildSubtree(childId, pathKey + '-' + childId + '-' + index, newVisitedInPath))
  700. .filter(child => child !== null);
  701. return treeNode;
  702. };
  703. // 找出所有根节点(没有入边的节点)
  704. const hasParent = new Set();
  705. initialEdges.forEach(edge => {
  706. hasParent.add(edge.target);
  707. });
  708. const roots = [];
  709. initialNodes.forEach((node, index) => {
  710. if (!hasParent.has(node.id)) {
  711. const treeNode = buildSubtree(node.id, 'root-' + node.id + '-' + index, new Set());
  712. if (treeNode) roots.push(treeNode);
  713. }
  714. });
  715. return roots;
  716. }, [initialNodes, initialEdges]);
  717. const treeRoots = useMemo(() => buildTree(), [buildTree]);
  718. // 初始化树节点折叠状态
  719. useEffect(() => {
  720. const getAllTreeKeys = (nodes) => {
  721. const keys = new Set();
  722. const traverse = (node) => {
  723. if (node.children && node.children.length > 0) {
  724. // 排除根节点
  725. if (node.data.level !== 0) {
  726. keys.add(node.treeKey);
  727. }
  728. node.children.forEach(traverse);
  729. }
  730. };
  731. nodes.forEach(traverse);
  732. return keys;
  733. };
  734. setCollapsedTreeNodes(getAllTreeKeys(treeRoots));
  735. }, [treeRoots]);
  736. const renderTree = useCallback((treeNodes, level = 0) => {
  737. return treeNodes.map(node => {
  738. // 使用 treeKey 来区分树中的不同实例
  739. const isCollapsed = collapsedTreeNodes.has(node.treeKey);
  740. const isSelected = selectedNodeId === node.id;
  741. return (
  742. <TreeNode
  743. key={node.treeKey}
  744. node={node}
  745. level={level}
  746. isCollapsed={isCollapsed}
  747. isSelected={isSelected}
  748. onToggle={() => {
  749. setCollapsedTreeNodes(prev => {
  750. const newSet = new Set(prev);
  751. if (newSet.has(node.treeKey)) {
  752. newSet.delete(node.treeKey);
  753. } else {
  754. newSet.add(node.treeKey);
  755. }
  756. return newSet;
  757. });
  758. }}
  759. onSelect={() => {
  760. const nodeId = node.id;
  761. setSelectedNodeId(nodeId);
  762. // 展开所有祖先节点
  763. const ancestorIds = [];
  764. const findAncestors = (id) => {
  765. initialEdges.forEach(edge => {
  766. if (edge.target === id) {
  767. ancestorIds.push(edge.source);
  768. findAncestors(edge.source);
  769. }
  770. });
  771. };
  772. findAncestors(nodeId);
  773. setCollapsedNodes(prev => {
  774. const newSet = new Set(prev);
  775. ancestorIds.forEach(id => newSet.delete(id));
  776. return newSet;
  777. });
  778. // 延迟聚焦,等待节点展开和布局重新计算
  779. setTimeout(() => {
  780. fitView({
  781. nodes: [{ id: nodeId }],
  782. duration: 800,
  783. padding: 0.3,
  784. });
  785. }, 300);
  786. }}
  787. >
  788. {node.children && node.children.length > 0 && renderTree(node.children, level + 1)}
  789. </TreeNode>
  790. );
  791. });
  792. }, [collapsedTreeNodes, selectedNodeId, nodes, setCenter, initialEdges, setCollapsedNodes, fitView]);
  793. console.log('📊 Rendering with', nodes.length, 'visible nodes and', edges.length, 'visible edges');
  794. if (nodes.length === 0) {
  795. return (
  796. <div style={{ padding: 50, color: 'red', fontSize: 20 }}>
  797. ERROR: No nodes to display!
  798. </div>
  799. );
  800. }
  801. return (
  802. <div style={{ width: '100vw', height: '100vh', background: '#f9fafb' }}>
  803. {/* 顶部标题栏 */}
  804. <div style={{
  805. position: 'absolute',
  806. top: 0,
  807. left: 0,
  808. right: 0,
  809. height: '60px',
  810. background: 'white',
  811. borderBottom: '1px solid #e5e7eb',
  812. display: 'flex',
  813. alignItems: 'center',
  814. padding: '0 24px',
  815. zIndex: 1000,
  816. boxShadow: '0 1px 3px rgba(0, 0, 0, 0.05)',
  817. }}>
  818. <h1 style={{ fontSize: '18px', fontWeight: '600', color: '#111827', margin: 0 }}>
  819. 查询图可视化
  820. </h1>
  821. <div style={{ marginLeft: 'auto', display: 'flex', gap: '12px', fontSize: '13px', color: '#6b7280' }}>
  822. <span>📊 {nodes.length} 节点</span>
  823. <span>🔗 {edges.length} 连线</span>
  824. </div>
  825. </div>
  826. {/* 左侧目录树 */}
  827. <div style={{
  828. position: 'absolute',
  829. top: '80px',
  830. left: '20px',
  831. bottom: '20px',
  832. width: '280px',
  833. background: 'white',
  834. borderRadius: '12px',
  835. boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
  836. zIndex: 1000,
  837. border: '1px solid #e5e7eb',
  838. overflow: 'hidden',
  839. display: 'flex',
  840. flexDirection: 'column',
  841. }}>
  842. <div style={{
  843. padding: '16px',
  844. borderBottom: '1px solid #e5e7eb',
  845. fontWeight: '600',
  846. fontSize: '14px',
  847. color: '#111827',
  848. }}>
  849. 节点目录
  850. </div>
  851. <div style={{
  852. flex: 1,
  853. overflow: 'auto',
  854. padding: '8px',
  855. }}>
  856. {renderTree(treeRoots)}
  857. </div>
  858. </div>
  859. {/* 右侧图例 */}
  860. <div style={{
  861. position: 'absolute',
  862. top: '80px',
  863. right: '20px',
  864. background: 'white',
  865. padding: '16px',
  866. borderRadius: '12px',
  867. boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
  868. zIndex: 1000,
  869. maxWidth: '260px',
  870. border: '1px solid #e5e7eb',
  871. }}>
  872. <h3 style={{ fontSize: '14px', fontWeight: '600', marginBottom: '12px', color: '#111827' }}>图例</h3>
  873. <div style={{ fontSize: '12px' }}>
  874. <div style={{ marginBottom: '12px' }}>
  875. <div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>节点类型</div>
  876. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  877. <div style={{ width: '16px', height: '16px', borderRadius: '4px', marginRight: '8px', background: 'white', border: '2px solid #e5e7eb' }}></div>
  878. <span style={{ color: '#6b7280' }}>查询节点</span>
  879. </div>
  880. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  881. <div style={{ width: '16px', height: '16px', borderRadius: '4px', marginRight: '8px', background: 'linear-gradient(135deg, #fdf2f8 0%, #fce7f3 100%)', border: '2px solid #fce7f3' }}></div>
  882. <span style={{ color: '#6b7280' }}>笔记节点</span>
  883. </div>
  884. </div>
  885. <div style={{ paddingTop: '12px', borderTop: '1px solid #f3f4f6' }}>
  886. <div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>策略类型</div>
  887. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  888. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#10b981', opacity: 0.7 }}></div>
  889. <span style={{ color: '#6b7280', fontSize: '11px' }}>direct_sug</span>
  890. </div>
  891. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  892. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#f59e0b', opacity: 0.7 }}></div>
  893. <span style={{ color: '#6b7280', fontSize: '11px' }}>rewrite_synonym</span>
  894. </div>
  895. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  896. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#3b82f6', opacity: 0.7 }}></div>
  897. <span style={{ color: '#6b7280', fontSize: '11px' }}>add_word</span>
  898. </div>
  899. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  900. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#8b5cf6', opacity: 0.7 }}></div>
  901. <span style={{ color: '#6b7280', fontSize: '11px' }}>rewrite_abstract</span>
  902. </div>
  903. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  904. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#ec4899', opacity: 0.7 }}></div>
  905. <span style={{ color: '#6b7280', fontSize: '11px' }}>query_to_note</span>
  906. </div>
  907. </div>
  908. <div style={{
  909. marginTop: '12px',
  910. paddingTop: '12px',
  911. borderTop: '1px solid #f3f4f6',
  912. fontSize: '11px',
  913. color: '#9ca3af',
  914. lineHeight: '1.5',
  915. }}>
  916. 💡 点击节点选中并高亮
  917. </div>
  918. </div>
  919. </div>
  920. {/* React Flow 画布 */}
  921. <div style={{
  922. position: 'absolute',
  923. top: '60px',
  924. left: 0,
  925. right: 0,
  926. bottom: 0,
  927. }}>
  928. <ReactFlow
  929. nodes={nodes}
  930. edges={edges}
  931. nodeTypes={nodeTypes}
  932. fitView
  933. fitViewOptions={{ padding: 0.2, duration: 500 }}
  934. minZoom={0.1}
  935. maxZoom={1.5}
  936. defaultEdgeOptions={{
  937. type: 'smoothstep',
  938. }}
  939. proOptions={{ hideAttribution: true }}
  940. onNodeClick={(event, clickedNode) => {
  941. setSelectedNodeId(clickedNode.id);
  942. }}
  943. >
  944. <Controls style={{ bottom: '20px', left: 'auto', right: '20px' }} />
  945. <Background variant="dots" gap={20} size={1} color="#e5e7eb" />
  946. </ReactFlow>
  947. </div>
  948. </div>
  949. );
  950. }
  951. function App() {
  952. return (
  953. <ReactFlowProvider>
  954. <FlowContent />
  955. </ReactFlowProvider>
  956. );
  957. }
  958. const root = createRoot(document.getElementById('root'));
  959. root.render(<App />);
  960. `;
  961. fs.writeFileSync(reactComponentPath, reactComponent);
  962. // 使用 esbuild 打包
  963. console.log('🎨 Building modern visualization...');
  964. build({
  965. entryPoints: [reactComponentPath],
  966. bundle: true,
  967. outfile: path.join(__dirname, 'bundle_v2.js'),
  968. format: 'iife',
  969. loader: {
  970. '.css': 'css',
  971. },
  972. minify: false,
  973. sourcemap: 'inline',
  974. }).then(() => {
  975. // 读取打包后的 JS
  976. const bundleJs = fs.readFileSync(path.join(__dirname, 'bundle_v2.js'), 'utf-8');
  977. // 读取 CSS
  978. const cssPath = path.join(__dirname, 'node_modules/@xyflow/react/dist/style.css');
  979. const css = fs.readFileSync(cssPath, 'utf-8');
  980. // 生成最终 HTML
  981. const html = `<!DOCTYPE html>
  982. <html lang="zh-CN">
  983. <head>
  984. <meta charset="UTF-8">
  985. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  986. <title>查询图可视化</title>
  987. <link rel="preconnect" href="https://fonts.googleapis.com">
  988. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  989. <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
  990. <script src="https://unpkg.com/dagre@0.8.5/dist/dagre.min.js"></script>
  991. <script>
  992. // 过滤特定的 React 警告
  993. const originalError = console.error;
  994. console.error = (...args) => {
  995. if (typeof args[0] === 'string' && args[0].includes('Each child in a list should have a unique "key" prop')) {
  996. return;
  997. }
  998. originalError.apply(console, args);
  999. };
  1000. </script>
  1001. <style>
  1002. * {
  1003. margin: 0;
  1004. padding: 0;
  1005. box-sizing: border-box;
  1006. }
  1007. body {
  1008. font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  1009. overflow: hidden;
  1010. -webkit-font-smoothing: antialiased;
  1011. -moz-osx-font-smoothing: grayscale;
  1012. }
  1013. #root {
  1014. width: 100vw;
  1015. height: 100vh;
  1016. }
  1017. ${css}
  1018. /* 自定义样式覆盖 */
  1019. .react-flow__edge-path {
  1020. stroke-linecap: round;
  1021. }
  1022. .react-flow__controls {
  1023. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  1024. border: 1px solid #e5e7eb;
  1025. border-radius: 8px;
  1026. }
  1027. .react-flow__controls-button {
  1028. border: none;
  1029. border-bottom: 1px solid #e5e7eb;
  1030. }
  1031. .react-flow__controls-button:hover {
  1032. background: #f9fafb;
  1033. }
  1034. </style>
  1035. </head>
  1036. <body>
  1037. <div id="root"></div>
  1038. <script>${bundleJs}</script>
  1039. </body>
  1040. </html>`;
  1041. // 写入输出文件
  1042. fs.writeFileSync(outputFile, html);
  1043. // 清理临时文件
  1044. fs.unlinkSync(reactComponentPath);
  1045. fs.unlinkSync(path.join(__dirname, 'bundle_v2.js'));
  1046. console.log('✅ Visualization generated: ' + outputFile);
  1047. console.log('📊 Nodes: ' + Object.keys(data.nodes).length);
  1048. console.log('🔗 Edges: ' + data.edges.length);
  1049. }).catch(error => {
  1050. console.error('❌ Build error:', error);
  1051. process.exit(1);
  1052. });