index.js 76 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const { build } = require('esbuild');
  5. const { convertV8ToGraph } = require('./convert_v8_to_graph');
  6. const { convertV8ToGraphV2 } = require('./convert_v8_to_graph_v2');
  7. // 读取命令行参数
  8. const args = process.argv.slice(2);
  9. if (args.length === 0) {
  10. console.error('Usage: node index.js <path-to-run_context.json> [output.html]');
  11. process.exit(1);
  12. }
  13. const inputFile = args[0];
  14. const outputFile = args[1] || 'query_graph_output.html';
  15. // 读取输入数据
  16. const inputData = JSON.parse(fs.readFileSync(inputFile, 'utf-8'));
  17. // 检测数据格式并转换
  18. let data;
  19. if (inputData.rounds && inputData.o) {
  20. // v6.1.2.8 格式,需要转换
  21. console.log('✨ 检测到 v6.1.2.8 格式,正在转换为图结构...');
  22. // 尝试读取 search_results.json(兼容旧版本)
  23. let searchResults = null;
  24. const searchResultsPath = path.join(path.dirname(inputFile), 'search_results.json');
  25. if (fs.existsSync(searchResultsPath)) {
  26. console.log('📄 读取外部搜索结果数据(兼容模式)...');
  27. searchResults = JSON.parse(fs.readFileSync(searchResultsPath, 'utf-8'));
  28. } else {
  29. console.log('✅ 使用 run_context.json 中的内嵌搜索结果');
  30. }
  31. // 使用新的转换函数(按 Round > 步骤 > 数据 组织)
  32. const graphData = convertV8ToGraphV2(inputData, searchResults);
  33. data = {
  34. nodes: graphData.nodes,
  35. edges: graphData.edges,
  36. iterations: graphData.iterations
  37. };
  38. console.log(`✅ 转换完成: ${Object.keys(data.nodes).length} 个节点, ${data.edges.length} 条边`);
  39. } else if (inputData.nodes && inputData.edges) {
  40. // v6.1.2.5 格式,直接使用
  41. console.log('✨ 检测到 v6.1.2.5 格式,直接使用');
  42. data = inputData;
  43. } else {
  44. console.error('❌ 无法识别的数据格式');
  45. process.exit(1);
  46. }
  47. // 创建临时 React 组件文件
  48. const reactComponentPath = path.join(__dirname, 'temp_flow_component_v2.jsx');
  49. const reactComponent = `
  50. import React, { useState, useCallback, useMemo, useEffect } from 'react';
  51. import { createRoot } from 'react-dom/client';
  52. import {
  53. ReactFlow,
  54. Controls,
  55. Background,
  56. useNodesState,
  57. useEdgesState,
  58. Handle,
  59. Position,
  60. useReactFlow,
  61. ReactFlowProvider,
  62. } from '@xyflow/react';
  63. import '@xyflow/react/dist/style.css';
  64. const data = ${JSON.stringify(data, null, 2)};
  65. // 根据节点类型获取边框颜色
  66. function getNodeTypeColor(type) {
  67. const typeColors = {
  68. 'root': '#6b21a8', // 紫色 - 根节点
  69. 'round': '#7c3aed', // 深紫 - Round节点
  70. 'step': '#f59e0b', // 橙色 - 步骤节点
  71. 'seg': '#10b981', // 绿色 - 分词
  72. 'q': '#3b82f6', // 蓝色 - Query
  73. 'sug': '#06b6d4', // 青色 - Sug建议词
  74. 'seed': '#84cc16', // 黄绿 - Seed
  75. 'add_word': '#22c55e', // 绿色 - 加词生成
  76. 'search_word': '#8b5cf6', // 紫色 - 搜索词
  77. 'post': '#ec4899', // 粉色 - 帖子
  78. 'filtered_sug': '#14b8a6',// 青绿 - 筛选的sug
  79. 'next_q': '#2563eb', // 深蓝 - 下轮查询
  80. 'next_seed': '#65a30d', // 深黄绿 - 下轮种子
  81. 'search': '#8b5cf6', // 深紫 - 搜索(兼容旧版)
  82. 'operation': '#f59e0b', // 橙色 - 操作节点(兼容旧版)
  83. 'query': '#3b82f6', // 蓝色 - 查询(兼容旧版)
  84. 'note': '#ec4899', // 粉色 - 帖子(兼容旧版)
  85. };
  86. return typeColors[type] || '#9ca3af';
  87. }
  88. // 查询节点组件 - 卡片样式
  89. function QueryNode({ id, data, sourcePosition, targetPosition }) {
  90. // 所有节点默认展开
  91. const expanded = true;
  92. // 获取节点类型颜色
  93. const typeColor = getNodeTypeColor(data.nodeType || 'query');
  94. return (
  95. <div>
  96. <Handle
  97. type="target"
  98. position={targetPosition || Position.Left}
  99. style={{ background: typeColor, width: 8, height: 8 }}
  100. />
  101. <div
  102. style={{
  103. padding: '12px',
  104. borderRadius: '8px',
  105. border: data.isHighlighted ? \`3px solid \${typeColor}\` :
  106. data.isCollapsed ? \`2px solid \${typeColor}\` :
  107. data.isSelected === false ? '2px dashed #d1d5db' :
  108. \`2px solid \${typeColor}\`,
  109. background: data.isHighlighted ? '#eef2ff' :
  110. data.isSelected === false ? '#f9fafb' : 'white',
  111. minWidth: '200px',
  112. maxWidth: '280px',
  113. boxShadow: data.isHighlighted ? '0 0 0 4px rgba(102, 126, 234, 0.25), 0 4px 16px rgba(102, 126, 234, 0.4)' :
  114. data.isCollapsed ? '0 4px 12px rgba(102, 126, 234, 0.15)' :
  115. data.level === 0 ? '0 4px 12px rgba(139, 92, 246, 0.15)' : '0 2px 6px rgba(0, 0, 0, 0.06)',
  116. transition: 'all 0.3s ease',
  117. cursor: 'pointer',
  118. position: 'relative',
  119. opacity: data.isSelected === false ? 0.6 : 1,
  120. }}
  121. >
  122. {/* 折叠当前节点按钮 - 左边 */}
  123. <div
  124. style={{
  125. position: 'absolute',
  126. top: '6px',
  127. left: '6px',
  128. width: '20px',
  129. height: '20px',
  130. borderRadius: '50%',
  131. background: '#f59e0b',
  132. color: 'white',
  133. display: 'flex',
  134. alignItems: 'center',
  135. justifyContent: 'center',
  136. fontSize: '11px',
  137. fontWeight: 'bold',
  138. cursor: 'pointer',
  139. transition: 'all 0.2s ease',
  140. zIndex: 10,
  141. }}
  142. onClick={(e) => {
  143. e.stopPropagation();
  144. if (data.onHideSelf) {
  145. data.onHideSelf();
  146. }
  147. }}
  148. onMouseEnter={(e) => {
  149. e.currentTarget.style.background = '#d97706';
  150. }}
  151. onMouseLeave={(e) => {
  152. e.currentTarget.style.background = '#f59e0b';
  153. }}
  154. title="隐藏当前节点"
  155. >
  156. ×
  157. </div>
  158. {/* 聚焦按钮 - 右上角 */}
  159. <div
  160. style={{
  161. position: 'absolute',
  162. top: '6px',
  163. right: '6px',
  164. width: '20px',
  165. height: '20px',
  166. borderRadius: '50%',
  167. background: data.isFocused ? '#10b981' : '#e5e7eb',
  168. color: data.isFocused ? 'white' : '#6b7280',
  169. display: 'flex',
  170. alignItems: 'center',
  171. justifyContent: 'center',
  172. fontSize: '11px',
  173. fontWeight: 'bold',
  174. cursor: 'pointer',
  175. transition: 'all 0.2s ease',
  176. zIndex: 10,
  177. }}
  178. onClick={(e) => {
  179. e.stopPropagation();
  180. if (data.onFocus) {
  181. data.onFocus();
  182. }
  183. }}
  184. onMouseEnter={(e) => {
  185. if (!data.isFocused) {
  186. e.currentTarget.style.background = '#d1d5db';
  187. }
  188. }}
  189. onMouseLeave={(e) => {
  190. if (!data.isFocused) {
  191. e.currentTarget.style.background = '#e5e7eb';
  192. }
  193. }}
  194. title={data.isFocused ? '取消聚焦' : '聚焦到此节点'}
  195. >
  196. 🎯
  197. </div>
  198. {/* 折叠/展开子节点按钮 - 右边第二个位置 */}
  199. {data.hasChildren && (
  200. <div
  201. style={{
  202. position: 'absolute',
  203. top: '6px',
  204. right: '30px',
  205. width: '20px',
  206. height: '20px',
  207. borderRadius: '50%',
  208. background: data.isCollapsed ? '#667eea' : '#e5e7eb',
  209. color: data.isCollapsed ? 'white' : '#6b7280',
  210. display: 'flex',
  211. alignItems: 'center',
  212. justifyContent: 'center',
  213. fontSize: '11px',
  214. fontWeight: 'bold',
  215. cursor: 'pointer',
  216. transition: 'all 0.2s ease',
  217. zIndex: 10,
  218. }}
  219. onClick={(e) => {
  220. e.stopPropagation();
  221. data.onToggleCollapse();
  222. }}
  223. title={data.isCollapsed ? '展开子节点' : '折叠子节点'}
  224. >
  225. {data.isCollapsed ? '+' : '−'}
  226. </div>
  227. )}
  228. {/* 卡片内容 */}
  229. <div>
  230. {/* 标题行 */}
  231. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px', paddingLeft: '24px', paddingRight: data.hasChildren ? '54px' : '28px' }}>
  232. <div style={{ flex: 1 }}>
  233. <div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '3px' }}>
  234. <div style={{
  235. fontSize: '13px',
  236. fontWeight: data.level === 0 ? '700' : '600',
  237. color: data.level === 0 ? '#6b21a8' : '#1f2937',
  238. lineHeight: '1.3',
  239. flex: 1,
  240. }}>
  241. {data.title}
  242. </div>
  243. {data.isSelected === false && (
  244. <div style={{
  245. fontSize: '9px',
  246. padding: '1px 4px',
  247. borderRadius: '3px',
  248. background: '#fee2e2',
  249. color: '#991b1b',
  250. fontWeight: '500',
  251. flexShrink: 0,
  252. }}>
  253. 未选中
  254. </div>
  255. )}
  256. </div>
  257. </div>
  258. </div>
  259. {/* 展开的详细信息 - 始终显示 */}
  260. <div style={{ fontSize: '11px', lineHeight: 1.4 }}>
  261. <div style={{ display: 'flex', gap: '4px', marginBottom: '6px', flexWrap: 'wrap' }}>
  262. <span style={{
  263. display: 'inline-block',
  264. padding: '1px 6px',
  265. borderRadius: '10px',
  266. background: '#eff6ff',
  267. color: '#3b82f6',
  268. fontSize: '10px',
  269. fontWeight: '500',
  270. }}>
  271. Lv.{data.level}
  272. </span>
  273. <span style={{
  274. display: 'inline-block',
  275. padding: '1px 6px',
  276. borderRadius: '10px',
  277. background: '#f0fdf4',
  278. color: '#16a34a',
  279. fontSize: '10px',
  280. fontWeight: '500',
  281. }}>
  282. {data.score}
  283. </span>
  284. {data.strategy && data.strategy !== 'root' && (
  285. <span style={{
  286. display: 'inline-block',
  287. padding: '1px 6px',
  288. borderRadius: '10px',
  289. background: '#fef3c7',
  290. color: '#92400e',
  291. fontSize: '10px',
  292. fontWeight: '500',
  293. }}>
  294. {data.strategy}
  295. </span>
  296. )}
  297. </div>
  298. {data.parent && (
  299. <div style={{ color: '#6b7280', fontSize: '10px', marginTop: '4px', paddingTop: '4px', borderTop: '1px solid #f3f4f6' }}>
  300. <strong>Parent:</strong> {data.parent}
  301. </div>
  302. )}
  303. {data.selectedWord && (
  304. <div style={{
  305. marginTop: '6px',
  306. paddingTop: '6px',
  307. borderTop: '1px solid #f3f4f6',
  308. fontSize: '10px',
  309. color: '#6b7280',
  310. lineHeight: '1.5',
  311. }}>
  312. <strong style={{ color: '#4b5563' }}>选择词:</strong>
  313. <span style={{ marginLeft: '4px', color: '#3b82f6', fontWeight: '500' }}>{data.selectedWord}</span>
  314. </div>
  315. )}
  316. {data.evaluationReason && (
  317. <div style={{
  318. marginTop: '6px',
  319. paddingTop: '6px',
  320. borderTop: '1px solid #f3f4f6',
  321. fontSize: '10px',
  322. color: '#6b7280',
  323. lineHeight: '1.5',
  324. }}>
  325. <strong style={{ color: '#4b5563' }}>评估:</strong>
  326. <div style={{ marginTop: '2px' }}>{data.evaluationReason}</div>
  327. </div>
  328. )}
  329. </div>
  330. </div>
  331. </div>
  332. <Handle
  333. type="source"
  334. position={sourcePosition || Position.Right}
  335. style={{ background: '#667eea', width: 8, height: 8 }}
  336. />
  337. </div>
  338. );
  339. }
  340. // 笔记节点组件 - 卡片样式,带轮播图
  341. function NoteNode({ id, data, sourcePosition, targetPosition }) {
  342. const [currentImageIndex, setCurrentImageIndex] = useState(0);
  343. const expanded = true;
  344. const hasImages = data.imageList && data.imageList.length > 0;
  345. const nextImage = (e) => {
  346. e.stopPropagation();
  347. if (hasImages) {
  348. setCurrentImageIndex((prev) => (prev + 1) % data.imageList.length);
  349. }
  350. };
  351. const prevImage = (e) => {
  352. e.stopPropagation();
  353. if (hasImages) {
  354. setCurrentImageIndex((prev) => (prev - 1 + data.imageList.length) % data.imageList.length);
  355. }
  356. };
  357. return (
  358. <div>
  359. <Handle
  360. type="target"
  361. position={targetPosition || Position.Left}
  362. style={{ background: '#ec4899', width: 8, height: 8 }}
  363. />
  364. <div
  365. style={{
  366. padding: '14px',
  367. borderRadius: '20px',
  368. border: data.isHighlighted ? '3px solid #ec4899' : '2px solid #fce7f3',
  369. background: data.isHighlighted ? '#eef2ff' : 'white',
  370. minWidth: '220px',
  371. maxWidth: '300px',
  372. 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)',
  373. transition: 'all 0.3s ease',
  374. cursor: 'pointer',
  375. }}
  376. >
  377. {/* 笔记标题 */}
  378. <div style={{ display: 'flex', alignItems: 'flex-start', marginBottom: '8px' }}>
  379. <div style={{ flex: 1 }}>
  380. <div style={{
  381. fontSize: '13px',
  382. fontWeight: '600',
  383. color: '#831843',
  384. lineHeight: '1.4',
  385. marginBottom: '4px',
  386. }}>
  387. {data.title}
  388. </div>
  389. </div>
  390. </div>
  391. {/* 轮播图 */}
  392. {hasImages && (
  393. <div style={{
  394. position: 'relative',
  395. marginBottom: '8px',
  396. borderRadius: '12px',
  397. overflow: 'hidden',
  398. }}>
  399. <img
  400. src={data.imageList[currentImageIndex].image_url}
  401. alt={\`Image \${currentImageIndex + 1}\`}
  402. style={{
  403. width: '100%',
  404. height: '160px',
  405. objectFit: 'cover',
  406. display: 'block',
  407. }}
  408. onError={(e) => {
  409. e.target.style.display = 'none';
  410. }}
  411. />
  412. {data.imageList.length > 1 && (
  413. <>
  414. {/* 左右切换按钮 */}
  415. <button
  416. onClick={prevImage}
  417. style={{
  418. position: 'absolute',
  419. left: '4px',
  420. top: '50%',
  421. transform: 'translateY(-50%)',
  422. background: 'rgba(0, 0, 0, 0.5)',
  423. color: 'white',
  424. border: 'none',
  425. borderRadius: '50%',
  426. width: '24px',
  427. height: '24px',
  428. cursor: 'pointer',
  429. display: 'flex',
  430. alignItems: 'center',
  431. justifyContent: 'center',
  432. fontSize: '14px',
  433. }}
  434. >
  435. </button>
  436. <button
  437. onClick={nextImage}
  438. style={{
  439. position: 'absolute',
  440. right: '4px',
  441. top: '50%',
  442. transform: 'translateY(-50%)',
  443. background: 'rgba(0, 0, 0, 0.5)',
  444. color: 'white',
  445. border: 'none',
  446. borderRadius: '50%',
  447. width: '24px',
  448. height: '24px',
  449. cursor: 'pointer',
  450. display: 'flex',
  451. alignItems: 'center',
  452. justifyContent: 'center',
  453. fontSize: '14px',
  454. }}
  455. >
  456. </button>
  457. {/* 图片计数 */}
  458. <div style={{
  459. position: 'absolute',
  460. bottom: '4px',
  461. right: '4px',
  462. background: 'rgba(0, 0, 0, 0.6)',
  463. color: 'white',
  464. padding: '2px 6px',
  465. borderRadius: '10px',
  466. fontSize: '10px',
  467. }}>
  468. {currentImageIndex + 1}/{data.imageList.length}
  469. </div>
  470. </>
  471. )}
  472. </div>
  473. )}
  474. {/* 互动数据 */}
  475. {data.interact_info && (
  476. <div style={{
  477. display: 'flex',
  478. gap: '8px',
  479. marginBottom: '8px',
  480. flexWrap: 'wrap',
  481. fontSize: '11px',
  482. color: '#9f1239',
  483. }}>
  484. {data.interact_info.liked_count > 0 && (
  485. <span style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
  486. ❤️ {data.interact_info.liked_count}
  487. </span>
  488. )}
  489. {data.interact_info.collected_count > 0 && (
  490. <span style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
  491. ⭐ {data.interact_info.collected_count}
  492. </span>
  493. )}
  494. {data.interact_info.comment_count > 0 && (
  495. <span style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
  496. 💬 {data.interact_info.comment_count}
  497. </span>
  498. )}
  499. {data.interact_info.shared_count > 0 && (
  500. <span style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
  501. 🔗 {data.interact_info.shared_count}
  502. </span>
  503. )}
  504. </div>
  505. )}
  506. {/* 标签 */}
  507. {(data.matchLevel || data.score) && (
  508. <div style={{ display: 'flex', gap: '6px', marginBottom: '8px', flexWrap: 'wrap' }}>
  509. {data.matchLevel && (
  510. <span style={{
  511. display: 'inline-block',
  512. padding: '2px 8px',
  513. borderRadius: '12px',
  514. background: '#fff1f2',
  515. color: '#be123c',
  516. fontSize: '10px',
  517. fontWeight: '500',
  518. }}>
  519. {data.matchLevel}
  520. </span>
  521. )}
  522. {data.score && (
  523. <span style={{
  524. display: 'inline-block',
  525. padding: '2px 8px',
  526. borderRadius: '12px',
  527. background: '#fff7ed',
  528. color: '#c2410c',
  529. fontSize: '10px',
  530. fontWeight: '500',
  531. }}>
  532. Score: {data.score}
  533. </span>
  534. )}
  535. </div>
  536. )}
  537. {/* 描述 */}
  538. {expanded && data.description && (
  539. <div style={{
  540. fontSize: '11px',
  541. color: '#9f1239',
  542. lineHeight: '1.5',
  543. paddingTop: '8px',
  544. borderTop: '1px solid #fbcfe8',
  545. }}>
  546. {data.description}
  547. </div>
  548. )}
  549. {/* 评估理由 */}
  550. {expanded && data.evaluationReason && (
  551. <div style={{
  552. fontSize: '10px',
  553. color: '#831843',
  554. lineHeight: '1.5',
  555. paddingTop: '8px',
  556. marginTop: '8px',
  557. borderTop: '1px solid #fbcfe8',
  558. }}>
  559. <strong style={{ color: '#9f1239' }}>评估:</strong>
  560. <div style={{ marginTop: '2px' }}>{data.evaluationReason}</div>
  561. </div>
  562. )}
  563. </div>
  564. <Handle
  565. type="source"
  566. position={sourcePosition || Position.Right}
  567. style={{ background: '#ec4899', width: 8, height: 8 }}
  568. />
  569. </div>
  570. );
  571. }
  572. const nodeTypes = {
  573. query: QueryNode,
  574. note: NoteNode,
  575. };
  576. // 根据 score 获取颜色
  577. function getScoreColor(score) {
  578. if (score >= 0.7) return '#10b981'; // 绿色 - 高分
  579. if (score >= 0.4) return '#f59e0b'; // 橙色 - 中分
  580. return '#ef4444'; // 红色 - 低分
  581. }
  582. // 截断文本,保留头尾,中间显示省略号
  583. function truncateMiddle(text, maxLength = 20) {
  584. if (!text || text.length <= maxLength) return text;
  585. const headLength = Math.ceil(maxLength * 0.4);
  586. const tailLength = Math.floor(maxLength * 0.4);
  587. const head = text.substring(0, headLength);
  588. const tail = text.substring(text.length - tailLength);
  589. return \`\${head}...\${tail}\`;
  590. }
  591. // 根据策略获取颜色
  592. function getStrategyColor(strategy) {
  593. const strategyColors = {
  594. '初始分词': '#10b981',
  595. '调用sug': '#06b6d4',
  596. '同义改写': '#f59e0b',
  597. '加词': '#3b82f6',
  598. '抽象改写': '#8b5cf6',
  599. '基于部分匹配改进': '#ec4899',
  600. '结果分支-抽象改写': '#a855f7',
  601. '结果分支-同义改写': '#fb923c',
  602. // v6.1.2.8 新增策略
  603. '原始问题': '#6b21a8',
  604. '来自分词': '#10b981',
  605. '加词生成': '#ef4444',
  606. '建议词': '#06b6d4',
  607. '执行搜索': '#8b5cf6',
  608. };
  609. return strategyColors[strategy] || '#9ca3af';
  610. }
  611. // 树节点组件
  612. function TreeNode({ node, level, children, isCollapsed, onToggle, isSelected, onSelect }) {
  613. const hasChildren = children && children.length > 0;
  614. const score = node.data.score ? parseFloat(node.data.score) : 0;
  615. const strategy = node.data.strategy || '';
  616. const strategyColor = getStrategyColor(strategy);
  617. const nodeActualType = node.data.nodeType || node.type; // 获取实际节点类型
  618. return (
  619. <div style={{ marginLeft: level * 12 + 'px' }}>
  620. <div
  621. style={{
  622. padding: '6px 8px',
  623. borderRadius: '4px',
  624. cursor: 'pointer',
  625. background: 'transparent',
  626. border: isSelected ? '1px solid #3b82f6' : '1px solid transparent',
  627. display: 'flex',
  628. alignItems: 'center',
  629. gap: '6px',
  630. transition: 'all 0.2s ease',
  631. position: 'relative',
  632. overflow: 'visible',
  633. }}
  634. onMouseEnter={(e) => {
  635. if (!isSelected) e.currentTarget.style.background = '#f9fafb';
  636. }}
  637. onMouseLeave={(e) => {
  638. if (!isSelected) e.currentTarget.style.background = 'transparent';
  639. }}
  640. >
  641. {/* 策略类型竖线 */}
  642. <div style={{
  643. width: '3px',
  644. height: '20px',
  645. background: strategyColor,
  646. borderRadius: '2px',
  647. flexShrink: 0,
  648. position: 'relative',
  649. zIndex: 1,
  650. }} />
  651. {hasChildren && (
  652. <span
  653. style={{
  654. fontSize: '10px',
  655. color: '#6b7280',
  656. cursor: 'pointer',
  657. width: '16px',
  658. textAlign: 'center',
  659. position: 'relative',
  660. zIndex: 1,
  661. }}
  662. onClick={(e) => {
  663. e.stopPropagation();
  664. onToggle();
  665. }}
  666. >
  667. {isCollapsed ? '▶' : '▼'}
  668. </span>
  669. )}
  670. {!hasChildren && <span style={{ width: '16px', position: 'relative', zIndex: 1 }}></span>}
  671. <div
  672. style={{
  673. flex: 1,
  674. fontSize: '12px',
  675. color: '#374151',
  676. position: 'relative',
  677. zIndex: 1,
  678. minWidth: 0,
  679. display: 'flex',
  680. flexDirection: 'column',
  681. gap: '4px',
  682. }}
  683. onClick={onSelect}
  684. >
  685. <div style={{
  686. display: 'flex',
  687. alignItems: 'center',
  688. gap: '8px',
  689. }}>
  690. <div style={{
  691. fontWeight: level === 0 ? '600' : '400',
  692. maxWidth: '180px',
  693. flex: 1,
  694. minWidth: 0,
  695. color: (node.type === 'note' ? node.data.matchLevel === 'unsatisfied' : node.data.isSelected === false) ? '#ef4444' : '#374151',
  696. }}
  697. title={node.data.title || node.id}
  698. >
  699. {truncateMiddle(node.data.title || node.id, 18)}
  700. </div>
  701. {/* 分数显示 - 步骤和轮次节点不显示分数 */}
  702. {nodeActualType !== 'step' && nodeActualType !== 'round' && (
  703. <span style={{
  704. fontSize: '11px',
  705. color: '#6b7280',
  706. fontWeight: '500',
  707. flexShrink: 0,
  708. }}>
  709. {score.toFixed(2)}
  710. </span>
  711. )}
  712. </div>
  713. {/* 分数下划线 - 步骤和轮次节点不显示 */}
  714. {nodeActualType !== 'step' && nodeActualType !== 'round' && (
  715. <div style={{
  716. width: (score * 100) + '%',
  717. height: '2px',
  718. background: getScoreColor(score),
  719. borderRadius: '1px',
  720. }} />
  721. )}
  722. </div>
  723. </div>
  724. {hasChildren && !isCollapsed && (
  725. <div>
  726. {children}
  727. </div>
  728. )}
  729. </div>
  730. );
  731. }
  732. // 使用 dagre 自动布局
  733. function getLayoutedElements(nodes, edges, direction = 'LR') {
  734. console.log('🎯 Starting layout with dagre...');
  735. console.log('Input:', nodes.length, 'nodes,', edges.length, 'edges');
  736. // 检查 dagre 是否加载
  737. if (typeof window === 'undefined' || typeof window.dagre === 'undefined') {
  738. console.warn('⚠️ Dagre not loaded, using fallback layout');
  739. // 降级到简单布局
  740. const levelGroups = {};
  741. nodes.forEach(node => {
  742. const level = node.data.level || 0;
  743. if (!levelGroups[level]) levelGroups[level] = [];
  744. levelGroups[level].push(node);
  745. });
  746. Object.entries(levelGroups).forEach(([level, nodeList]) => {
  747. const x = parseInt(level) * 350;
  748. nodeList.forEach((node, index) => {
  749. node.position = { x, y: index * 150 };
  750. node.targetPosition = 'left';
  751. node.sourcePosition = 'right';
  752. });
  753. });
  754. return { nodes, edges };
  755. }
  756. try {
  757. const dagreGraph = new window.dagre.graphlib.Graph();
  758. dagreGraph.setDefaultEdgeLabel(() => ({}));
  759. const isHorizontal = direction === 'LR';
  760. dagreGraph.setGraph({
  761. rankdir: direction,
  762. nodesep: 120, // 垂直间距 - 增加以避免节点重叠
  763. ranksep: 280, // 水平间距 - 增加以容纳更宽的节点
  764. });
  765. // 添加节点 - 根据节点类型设置不同的尺寸
  766. nodes.forEach((node) => {
  767. let nodeWidth = 280;
  768. let nodeHeight = 180;
  769. // note 节点有轮播图,需要更大的空间
  770. if (node.type === 'note') {
  771. nodeWidth = 320;
  772. nodeHeight = 350; // 增加高度以容纳轮播图
  773. }
  774. dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
  775. });
  776. // 添加边
  777. edges.forEach((edge) => {
  778. dagreGraph.setEdge(edge.source, edge.target);
  779. });
  780. // 计算布局
  781. window.dagre.layout(dagreGraph);
  782. console.log('✅ Dagre layout completed');
  783. // 更新节点位置和 handle 位置
  784. nodes.forEach((node) => {
  785. const nodeWithPosition = dagreGraph.node(node.id);
  786. if (!nodeWithPosition) {
  787. console.warn('Node position not found for:', node.id);
  788. return;
  789. }
  790. node.targetPosition = isHorizontal ? 'left' : 'top';
  791. node.sourcePosition = isHorizontal ? 'right' : 'bottom';
  792. // 根据节点类型获取尺寸
  793. let nodeWidth = 280;
  794. let nodeHeight = 180;
  795. if (node.type === 'note') {
  796. nodeWidth = 320;
  797. nodeHeight = 350;
  798. }
  799. // 将 dagre 的中心点位置转换为 React Flow 的左上角位置
  800. node.position = {
  801. x: nodeWithPosition.x - nodeWidth / 2,
  802. y: nodeWithPosition.y - nodeHeight / 2,
  803. };
  804. });
  805. console.log('✅ Layout completed, sample node:', nodes[0]);
  806. return { nodes, edges };
  807. } catch (error) {
  808. console.error('❌ Error in dagre layout:', error);
  809. console.error('Error details:', error.message, error.stack);
  810. // 降级处理
  811. console.log('Using fallback layout...');
  812. const levelGroups = {};
  813. nodes.forEach(node => {
  814. const level = node.data.level || 0;
  815. if (!levelGroups[level]) levelGroups[level] = [];
  816. levelGroups[level].push(node);
  817. });
  818. Object.entries(levelGroups).forEach(([level, nodeList]) => {
  819. const x = parseInt(level) * 350;
  820. nodeList.forEach((node, index) => {
  821. node.position = { x, y: index * 150 };
  822. node.targetPosition = 'left';
  823. node.sourcePosition = 'right';
  824. });
  825. });
  826. return { nodes, edges };
  827. }
  828. }
  829. function transformData(data) {
  830. const nodes = [];
  831. const edges = [];
  832. const originalIdToCanvasId = {}; // 原始ID -> 画布ID的映射
  833. const canvasIdToNodeData = {}; // 避免重复创建相同的节点
  834. // 创建节点
  835. Object.entries(data.nodes).forEach(([originalId, node]) => {
  836. // 统一处理所有类型的节点
  837. const nodeType = node.type || 'query';
  838. // 直接使用originalId作为canvasId,避免冲突
  839. const canvasId = originalId;
  840. originalIdToCanvasId[originalId] = canvasId;
  841. // 如果这个 canvasId 还没有创建过节点,则创建
  842. if (!canvasIdToNodeData[canvasId]) {
  843. canvasIdToNodeData[canvasId] = true;
  844. // 根据节点类型创建不同的数据结构
  845. if (nodeType === 'note' || nodeType === 'post') {
  846. nodes.push({
  847. id: canvasId,
  848. originalId: originalId,
  849. type: 'note',
  850. data: {
  851. title: node.query || node.title || '帖子',
  852. matchLevel: node.match_level,
  853. score: node.relevance_score ? node.relevance_score.toFixed(2) : '0.00',
  854. description: node.body_text || node.desc || '',
  855. isSelected: node.is_selected !== undefined ? node.is_selected : true,
  856. imageList: node.image_list || [],
  857. noteUrl: node.note_url || '',
  858. evaluationReason: node.evaluationReason || node.evaluation_reason || '',
  859. interact_info: node.interact_info || {},
  860. nodeType: nodeType,
  861. },
  862. position: { x: 0, y: 0 },
  863. });
  864. } else {
  865. // query, seg, q, search, root 等节点
  866. let displayTitle = node.query || originalId;
  867. nodes.push({
  868. id: canvasId,
  869. originalId: originalId,
  870. type: 'query', // 使用 query 组件渲染所有非note节点
  871. data: {
  872. title: displayTitle,
  873. level: node.level || 0,
  874. score: node.relevance_score ? node.relevance_score.toFixed(2) : '0.00',
  875. strategy: node.strategy || '',
  876. parent: node.parent_query || '',
  877. isSelected: node.is_selected !== undefined ? node.is_selected : true,
  878. evaluationReason: node.evaluationReason || node.evaluation_reason || '',
  879. nodeType: nodeType, // 传递实际节点类型用于样式
  880. searchCount: node.search_count, // search 节点特有
  881. totalPosts: node.total_posts, // search 节点特有
  882. selectedWord: node.selected_word || '', // 加词节点特有 - 显示选择的词
  883. },
  884. position: { x: 0, y: 0 },
  885. });
  886. }
  887. }
  888. });
  889. // 创建边 - 使用虚线样式,映射到画布ID
  890. data.edges.forEach((edge, index) => {
  891. const edgeColors = {
  892. '初始分词': '#10b981',
  893. '调用sug': '#06b6d4',
  894. '同义改写': '#f59e0b',
  895. '加词': '#3b82f6',
  896. '抽象改写': '#8b5cf6',
  897. '基于部分匹配改进': '#ec4899',
  898. '结果分支-抽象改写': '#a855f7',
  899. '结果分支-同义改写': '#fb923c',
  900. 'query_to_note': '#ec4899',
  901. };
  902. const color = edgeColors[edge.strategy] || edgeColors[edge.edge_type] || '#d1d5db';
  903. const isNoteEdge = edge.edge_type === 'query_to_note';
  904. edges.push({
  905. id: \`edge-\${index}\`,
  906. source: originalIdToCanvasId[edge.from], // 使用画布ID
  907. target: originalIdToCanvasId[edge.to], // 使用画布ID
  908. type: 'simplebezier', // 使用简单贝塞尔曲线
  909. animated: isNoteEdge,
  910. style: {
  911. stroke: color,
  912. strokeWidth: isNoteEdge ? 2.5 : 2,
  913. strokeDasharray: isNoteEdge ? '5,5' : '8,4',
  914. },
  915. markerEnd: {
  916. type: 'arrowclosed',
  917. color: color,
  918. width: 20,
  919. height: 20,
  920. },
  921. });
  922. });
  923. // 使用 dagre 自动计算布局 - 从左到右
  924. return getLayoutedElements(nodes, edges, 'LR');
  925. }
  926. function FlowContent() {
  927. const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
  928. console.log('🔍 Transforming data...');
  929. const result = transformData(data);
  930. console.log('✅ Transformed:', result.nodes.length, 'nodes,', result.edges.length, 'edges');
  931. return result;
  932. }, []);
  933. // 初始化:找出所有有子节点的节点,默认折叠(画布节点)
  934. const initialCollapsedNodes = useMemo(() => {
  935. const nodesWithChildren = new Set();
  936. initialEdges.forEach(edge => {
  937. nodesWithChildren.add(edge.source);
  938. });
  939. // 排除根节点(level 0),让根节点默认展开
  940. const rootNode = initialNodes.find(n => n.data.level === 0);
  941. if (rootNode) {
  942. nodesWithChildren.delete(rootNode.id);
  943. }
  944. return nodesWithChildren;
  945. }, [initialNodes, initialEdges]);
  946. // 树节点的折叠状态需要在树构建后初始化
  947. const [collapsedNodes, setCollapsedNodes] = useState(() => initialCollapsedNodes);
  948. const [collapsedTreeNodes, setCollapsedTreeNodes] = useState(new Set());
  949. const [selectedNodeId, setSelectedNodeId] = useState(null);
  950. const [hiddenNodes, setHiddenNodes] = useState(new Set()); // 用户手动隐藏的节点
  951. const [focusMode, setFocusMode] = useState(false); // 全局聚焦模式,默认关闭
  952. const [focusedNodeId, setFocusedNodeId] = useState(null); // 单独聚焦的节点ID
  953. // 获取 React Flow 实例以控制画布
  954. const { setCenter, fitView } = useReactFlow();
  955. // 获取某个节点的所有后代节点ID
  956. const getDescendants = useCallback((nodeId) => {
  957. const descendants = new Set();
  958. const queue = [nodeId];
  959. while (queue.length > 0) {
  960. const current = queue.shift();
  961. initialEdges.forEach(edge => {
  962. if (edge.source === current && !descendants.has(edge.target)) {
  963. descendants.add(edge.target);
  964. queue.push(edge.target);
  965. }
  966. });
  967. }
  968. return descendants;
  969. }, [initialEdges]);
  970. // 获取直接父节点
  971. const getDirectParents = useCallback((nodeId) => {
  972. const parents = [];
  973. initialEdges.forEach(edge => {
  974. if (edge.target === nodeId) {
  975. parents.push(edge.source);
  976. }
  977. });
  978. return parents;
  979. }, [initialEdges]);
  980. // 获取直接子节点
  981. const getDirectChildren = useCallback((nodeId) => {
  982. const children = [];
  983. initialEdges.forEach(edge => {
  984. if (edge.source === nodeId) {
  985. children.push(edge.target);
  986. }
  987. });
  988. return children;
  989. }, [initialEdges]);
  990. // 切换节点折叠状态
  991. const toggleNodeCollapse = useCallback((nodeId) => {
  992. setCollapsedNodes(prev => {
  993. const newSet = new Set(prev);
  994. const descendants = getDescendants(nodeId);
  995. if (newSet.has(nodeId)) {
  996. // 展开:移除此节点,但保持其他折叠的节点
  997. newSet.delete(nodeId);
  998. } else {
  999. // 折叠:添加此节点
  1000. newSet.add(nodeId);
  1001. }
  1002. return newSet;
  1003. });
  1004. }, [getDescendants]);
  1005. // 过滤可见的节点和边,并重新计算布局
  1006. const { nodes, edges } = useMemo(() => {
  1007. const nodesToHide = new Set();
  1008. // 判断使用哪个节点ID进行聚焦:优先使用单独聚焦的节点,否则使用全局聚焦模式的选中节点
  1009. const effectiveFocusNodeId = focusedNodeId || (focusMode ? selectedNodeId : null);
  1010. // 聚焦模式:只显示聚焦节点、其父节点和直接子节点
  1011. if (effectiveFocusNodeId) {
  1012. const visibleInFocus = new Set([effectiveFocusNodeId]);
  1013. // 添加所有父节点
  1014. initialEdges.forEach(edge => {
  1015. if (edge.target === effectiveFocusNodeId) {
  1016. visibleInFocus.add(edge.source);
  1017. }
  1018. });
  1019. // 添加所有直接子节点
  1020. initialEdges.forEach(edge => {
  1021. if (edge.source === effectiveFocusNodeId) {
  1022. visibleInFocus.add(edge.target);
  1023. }
  1024. });
  1025. // 隐藏不在聚焦范围内的节点
  1026. initialNodes.forEach(node => {
  1027. if (!visibleInFocus.has(node.id)) {
  1028. nodesToHide.add(node.id);
  1029. }
  1030. });
  1031. } else {
  1032. // 非聚焦模式:使用原有的折叠逻辑
  1033. // 收集所有被折叠节点的后代
  1034. collapsedNodes.forEach(collapsedId => {
  1035. const descendants = getDescendants(collapsedId);
  1036. descendants.forEach(id => nodesToHide.add(id));
  1037. });
  1038. }
  1039. // 添加用户手动隐藏的节点
  1040. hiddenNodes.forEach(id => nodesToHide.add(id));
  1041. const visibleNodes = initialNodes
  1042. .filter(node => !nodesToHide.has(node.id))
  1043. .map(node => ({
  1044. ...node,
  1045. data: {
  1046. ...node.data,
  1047. isCollapsed: collapsedNodes.has(node.id),
  1048. hasChildren: initialEdges.some(e => e.source === node.id),
  1049. onToggleCollapse: () => toggleNodeCollapse(node.id),
  1050. onHideSelf: () => {
  1051. setHiddenNodes(prev => {
  1052. const newSet = new Set(prev);
  1053. newSet.add(node.id);
  1054. return newSet;
  1055. });
  1056. },
  1057. onFocus: () => {
  1058. // 切换聚焦状态
  1059. if (focusedNodeId === node.id) {
  1060. setFocusedNodeId(null); // 如果已经聚焦,则取消聚焦
  1061. } else {
  1062. // 先取消之前的聚焦,然后聚焦到当前节点
  1063. setFocusedNodeId(node.id);
  1064. // 延迟聚焦视图到该节点
  1065. setTimeout(() => {
  1066. fitView({
  1067. nodes: [{ id: node.id }],
  1068. duration: 800,
  1069. padding: 0.3,
  1070. });
  1071. }, 100);
  1072. }
  1073. },
  1074. isFocused: focusedNodeId === node.id,
  1075. isHighlighted: selectedNodeId === node.id,
  1076. }
  1077. }));
  1078. const visibleEdges = initialEdges.filter(
  1079. edge => !nodesToHide.has(edge.source) && !nodesToHide.has(edge.target)
  1080. );
  1081. // 重新计算布局 - 只对可见节点
  1082. if (typeof window !== 'undefined' && typeof window.dagre !== 'undefined') {
  1083. try {
  1084. const dagreGraph = new window.dagre.graphlib.Graph();
  1085. dagreGraph.setDefaultEdgeLabel(() => ({}));
  1086. dagreGraph.setGraph({
  1087. rankdir: 'LR',
  1088. nodesep: 120, // 垂直间距 - 增加以避免节点重叠
  1089. ranksep: 280, // 水平间距 - 增加以容纳更宽的节点
  1090. });
  1091. visibleNodes.forEach((node) => {
  1092. let nodeWidth = 280;
  1093. let nodeHeight = 180;
  1094. // note 节点有轮播图,需要更大的空间
  1095. if (node.type === 'note') {
  1096. nodeWidth = 320;
  1097. nodeHeight = 350;
  1098. }
  1099. dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
  1100. });
  1101. visibleEdges.forEach((edge) => {
  1102. dagreGraph.setEdge(edge.source, edge.target);
  1103. });
  1104. window.dagre.layout(dagreGraph);
  1105. visibleNodes.forEach((node) => {
  1106. const nodeWithPosition = dagreGraph.node(node.id);
  1107. if (nodeWithPosition) {
  1108. // 根据节点类型获取对应的尺寸
  1109. let nodeWidth = 280;
  1110. let nodeHeight = 180;
  1111. if (node.type === 'note') {
  1112. nodeWidth = 320;
  1113. nodeHeight = 350;
  1114. }
  1115. node.position = {
  1116. x: nodeWithPosition.x - nodeWidth / 2,
  1117. y: nodeWithPosition.y - nodeHeight / 2,
  1118. };
  1119. node.targetPosition = 'left';
  1120. node.sourcePosition = 'right';
  1121. }
  1122. });
  1123. console.log('✅ Dynamic layout recalculated for', visibleNodes.length, 'visible nodes');
  1124. } catch (error) {
  1125. console.error('❌ Error in dynamic layout:', error);
  1126. }
  1127. }
  1128. return { nodes: visibleNodes, edges: visibleEdges };
  1129. }, [initialNodes, initialEdges, collapsedNodes, hiddenNodes, focusMode, focusedNodeId, getDescendants, toggleNodeCollapse, selectedNodeId]);
  1130. // 构建树形结构 - 允许一个节点有多个父节点
  1131. const buildTree = useCallback(() => {
  1132. const nodeMap = new Map();
  1133. initialNodes.forEach(node => {
  1134. nodeMap.set(node.id, node);
  1135. });
  1136. // 为每个节点创建树节点的副本(允许多次出现)
  1137. const createTreeNode = (nodeId, pathKey) => {
  1138. const node = nodeMap.get(nodeId);
  1139. if (!node) return null;
  1140. return {
  1141. ...node,
  1142. treeKey: pathKey, // 唯一的树路径key,用于React key
  1143. children: []
  1144. };
  1145. };
  1146. // 构建父子关系映射:记录每个节点的所有父节点,去重边
  1147. const parentToChildren = new Map();
  1148. const childToParents = new Map();
  1149. initialEdges.forEach(edge => {
  1150. // 记录父->子关系(去重:同一个父节点到同一个子节点只记录一次)
  1151. if (!parentToChildren.has(edge.source)) {
  1152. parentToChildren.set(edge.source, []);
  1153. }
  1154. const children = parentToChildren.get(edge.source);
  1155. if (!children.includes(edge.target)) {
  1156. children.push(edge.target);
  1157. }
  1158. // 记录子->父关系(用于判断是否有多个父节点,也去重)
  1159. if (!childToParents.has(edge.target)) {
  1160. childToParents.set(edge.target, []);
  1161. }
  1162. const parents = childToParents.get(edge.target);
  1163. if (!parents.includes(edge.source)) {
  1164. parents.push(edge.source);
  1165. }
  1166. });
  1167. // 递归构建树
  1168. const buildSubtree = (nodeId, pathKey, visitedInPath) => {
  1169. // 避免循环引用:如果当前路径中已经访问过这个节点,跳过
  1170. if (visitedInPath.has(nodeId)) {
  1171. return null;
  1172. }
  1173. const treeNode = createTreeNode(nodeId, pathKey);
  1174. if (!treeNode) return null;
  1175. const newVisitedInPath = new Set(visitedInPath);
  1176. newVisitedInPath.add(nodeId);
  1177. const children = parentToChildren.get(nodeId) || [];
  1178. treeNode.children = children
  1179. .map((childId, index) => buildSubtree(childId, pathKey + '-' + childId + '-' + index, newVisitedInPath))
  1180. .filter(child => child !== null);
  1181. return treeNode;
  1182. };
  1183. // 找出所有根节点(没有入边的节点)
  1184. const hasParent = new Set();
  1185. initialEdges.forEach(edge => {
  1186. hasParent.add(edge.target);
  1187. });
  1188. const roots = [];
  1189. initialNodes.forEach((node, index) => {
  1190. if (!hasParent.has(node.id)) {
  1191. const treeNode = buildSubtree(node.id, 'root-' + node.id + '-' + index, new Set());
  1192. if (treeNode) roots.push(treeNode);
  1193. }
  1194. });
  1195. return roots;
  1196. }, [initialNodes, initialEdges]);
  1197. const treeRoots = useMemo(() => buildTree(), [buildTree]);
  1198. // 生成树形文本结构
  1199. const generateTreeText = useCallback(() => {
  1200. const lines = [];
  1201. // 递归生成树形文本
  1202. const traverse = (nodes, prefix = '', isLast = true, depth = 0) => {
  1203. nodes.forEach((node, index) => {
  1204. const isLastNode = index === nodes.length - 1;
  1205. const nodeData = initialNodes.find(n => n.id === node.id)?.data || {};
  1206. const nodeType = nodeData.nodeType || node.data?.nodeType || 'unknown';
  1207. const title = nodeData.title || node.data?.title || node.id;
  1208. // 优先从node.data获取score,然后从nodeData获取
  1209. let score = null;
  1210. if (node.data?.score !== undefined && node.data?.score !== null) {
  1211. score = node.data.score;
  1212. } else if (node.data?.relevance_score !== undefined && node.data?.relevance_score !== null) {
  1213. score = node.data.relevance_score;
  1214. } else if (nodeData.score !== undefined && nodeData.score !== null) {
  1215. score = nodeData.score;
  1216. } else if (nodeData.relevance_score !== undefined && nodeData.relevance_score !== null) {
  1217. score = nodeData.relevance_score;
  1218. }
  1219. const strategy = nodeData.strategy || node.data?.strategy || '';
  1220. // 构建当前行 - score可能是数字或字符串,step/round节点不显示分数
  1221. const connector = isLastNode ? '└─' : '├─';
  1222. let scoreText = '';
  1223. if (nodeType !== 'step' && nodeType !== 'round' && score !== null && score !== undefined) {
  1224. // score可能已经是字符串格式(如 "0.05"),也可能是数字
  1225. const scoreStr = typeof score === 'number' ? score.toFixed(2) : score;
  1226. scoreText = \` (分数: \${scoreStr})\`;
  1227. }
  1228. const strategyText = strategy ? \` [\${strategy}]\` : '';
  1229. lines.push(\`\${prefix}\${connector} \${title}\${scoreText}\${strategyText}\`);
  1230. // 递归处理子节点
  1231. if (node.children && node.children.length > 0) {
  1232. const childPrefix = prefix + (isLastNode ? ' ' : '│ ');
  1233. traverse(node.children, childPrefix, isLastNode, depth + 1);
  1234. }
  1235. });
  1236. };
  1237. // 添加标题
  1238. const rootNode = initialNodes.find(n => n.data?.level === 0);
  1239. if (rootNode) {
  1240. lines.push(\`📊 查询扩展树形结构\`);
  1241. lines.push(\`原始问题: \${rootNode.data.title || rootNode.data.query}\`);
  1242. lines.push('');
  1243. }
  1244. traverse(treeRoots);
  1245. return lines.join('\\n');
  1246. }, [treeRoots, initialNodes]);
  1247. // 复制树形结构到剪贴板
  1248. const copyTreeToClipboard = useCallback(async () => {
  1249. try {
  1250. const treeText = generateTreeText();
  1251. await navigator.clipboard.writeText(treeText);
  1252. alert('✅ 树形结构已复制到剪贴板!');
  1253. } catch (err) {
  1254. console.error('复制失败:', err);
  1255. alert('❌ 复制失败,请手动复制');
  1256. }
  1257. }, [generateTreeText]);
  1258. // 初始化树节点折叠状态
  1259. useEffect(() => {
  1260. const getAllTreeKeys = (nodes) => {
  1261. const keys = new Set();
  1262. const traverse = (node) => {
  1263. if (node.children && node.children.length > 0) {
  1264. // 排除根节点
  1265. if (node.data.level !== 0) {
  1266. keys.add(node.treeKey);
  1267. }
  1268. node.children.forEach(traverse);
  1269. }
  1270. };
  1271. nodes.forEach(traverse);
  1272. return keys;
  1273. };
  1274. setCollapsedTreeNodes(getAllTreeKeys(treeRoots));
  1275. }, [treeRoots]);
  1276. const renderTree = useCallback((treeNodes, level = 0) => {
  1277. return treeNodes.map(node => {
  1278. // 使用 treeKey 来区分树中的不同实例
  1279. const isCollapsed = collapsedTreeNodes.has(node.treeKey);
  1280. const isSelected = selectedNodeId === node.id;
  1281. return (
  1282. <TreeNode
  1283. key={node.treeKey}
  1284. node={node}
  1285. level={level}
  1286. isCollapsed={isCollapsed}
  1287. isSelected={isSelected}
  1288. onToggle={() => {
  1289. setCollapsedTreeNodes(prev => {
  1290. const newSet = new Set(prev);
  1291. if (newSet.has(node.treeKey)) {
  1292. newSet.delete(node.treeKey);
  1293. } else {
  1294. newSet.add(node.treeKey);
  1295. }
  1296. return newSet;
  1297. });
  1298. }}
  1299. onSelect={() => {
  1300. const nodeId = node.id;
  1301. // 展开所有祖先节点
  1302. const ancestorIds = [nodeId];
  1303. const findAncestors = (id) => {
  1304. initialEdges.forEach(edge => {
  1305. if (edge.target === id && !ancestorIds.includes(edge.source)) {
  1306. ancestorIds.push(edge.source);
  1307. findAncestors(edge.source);
  1308. }
  1309. });
  1310. };
  1311. findAncestors(nodeId);
  1312. // 如果节点或其祖先被隐藏,先恢复它们
  1313. setHiddenNodes(prev => {
  1314. const newSet = new Set(prev);
  1315. ancestorIds.forEach(id => newSet.delete(id));
  1316. return newSet;
  1317. });
  1318. setSelectedNodeId(nodeId);
  1319. // 获取选中节点的直接子节点
  1320. const childrenIds = [];
  1321. initialEdges.forEach(edge => {
  1322. if (edge.source === nodeId) {
  1323. childrenIds.push(edge.target);
  1324. }
  1325. });
  1326. setCollapsedNodes(prev => {
  1327. const newSet = new Set(prev);
  1328. // 展开所有祖先节点
  1329. ancestorIds.forEach(id => newSet.delete(id));
  1330. // 展开选中节点本身
  1331. newSet.delete(nodeId);
  1332. // 展开选中节点的直接子节点
  1333. childrenIds.forEach(id => newSet.delete(id));
  1334. return newSet;
  1335. });
  1336. // 延迟聚焦,等待节点展开和布局重新计算
  1337. setTimeout(() => {
  1338. fitView({
  1339. nodes: [{ id: nodeId }],
  1340. duration: 800,
  1341. padding: 0.3,
  1342. });
  1343. }, 300);
  1344. }}
  1345. >
  1346. {node.children && node.children.length > 0 && renderTree(node.children, level + 1)}
  1347. </TreeNode>
  1348. );
  1349. });
  1350. }, [collapsedTreeNodes, selectedNodeId, nodes, setCenter, initialEdges, setCollapsedNodes, fitView]);
  1351. console.log('📊 Rendering with', nodes.length, 'visible nodes and', edges.length, 'visible edges');
  1352. if (nodes.length === 0) {
  1353. return (
  1354. <div style={{ padding: 50, color: 'red', fontSize: 20 }}>
  1355. ERROR: No nodes to display!
  1356. </div>
  1357. );
  1358. }
  1359. return (
  1360. <div style={{ width: '100vw', height: '100vh', background: '#f9fafb', display: 'flex', flexDirection: 'column' }}>
  1361. {/* 顶部面包屑导航栏 */}
  1362. <div style={{
  1363. minHeight: '48px',
  1364. maxHeight: '120px',
  1365. background: 'white',
  1366. borderBottom: '1px solid #e5e7eb',
  1367. display: 'flex',
  1368. alignItems: 'flex-start',
  1369. padding: '12px 24px',
  1370. zIndex: 1000,
  1371. boxShadow: '0 1px 3px rgba(0, 0, 0, 0.05)',
  1372. flexShrink: 0,
  1373. overflowY: 'auto',
  1374. }}>
  1375. <div style={{ width: '100%' }}>
  1376. {selectedNodeId ? (
  1377. <div style={{ fontSize: '12px', color: '#6b7280' }}>
  1378. {/* 面包屑导航 - 显示所有路径 */}
  1379. {(() => {
  1380. const selectedNode = nodes.find(n => n.id === selectedNodeId);
  1381. if (!selectedNode) return null;
  1382. // 找到所有从根节点到当前节点的路径
  1383. const findAllPaths = (targetId) => {
  1384. const paths = [];
  1385. const buildPath = (nodeId, currentPath) => {
  1386. const node = initialNodes.find(n => n.id === nodeId);
  1387. if (!node) return;
  1388. const newPath = [node, ...currentPath];
  1389. // 找到所有父节点
  1390. const parents = initialEdges.filter(e => e.target === nodeId).map(e => e.source);
  1391. if (parents.length === 0) {
  1392. // 到达根节点
  1393. paths.push(newPath);
  1394. } else {
  1395. // 递归处理所有父节点
  1396. parents.forEach(parentId => {
  1397. buildPath(parentId, newPath);
  1398. });
  1399. }
  1400. };
  1401. buildPath(targetId, []);
  1402. return paths;
  1403. };
  1404. const allPaths = findAllPaths(selectedNodeId);
  1405. // 去重:将路径转换为字符串进行比较
  1406. const uniquePaths = [];
  1407. const pathStrings = new Set();
  1408. allPaths.forEach(path => {
  1409. const pathString = path.map(n => n.id).join('->');
  1410. if (!pathStrings.has(pathString)) {
  1411. pathStrings.add(pathString);
  1412. uniquePaths.push(path);
  1413. }
  1414. });
  1415. return (
  1416. <div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
  1417. {uniquePaths.map((path, pathIndex) => (
  1418. <div key={pathIndex} style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
  1419. {pathIndex > 0 && <span style={{ color: '#d1d5db', marginRight: '4px' }}>或</span>}
  1420. {path.map((node, index) => {
  1421. // 获取节点的 score、strategy 和 isSelected
  1422. const nodeScore = node.data.score ? parseFloat(node.data.score) : 0;
  1423. const nodeStrategy = node.data.strategy || '';
  1424. const strategyColor = getStrategyColor(nodeStrategy);
  1425. const nodeIsSelected = node.type === 'note' ? node.data.matchLevel !== 'unsatisfied' : node.data.isSelected !== false;
  1426. const nodeActualType = node.data.nodeType || node.type; // 获取实际节点类型
  1427. return (
  1428. <React.Fragment key={node.id + '-' + index}>
  1429. <span
  1430. onClick={() => {
  1431. const nodeId = node.id;
  1432. // 找到所有祖先节点
  1433. const ancestorIds = [nodeId];
  1434. const findAncestors = (id) => {
  1435. initialEdges.forEach(edge => {
  1436. if (edge.target === id && !ancestorIds.includes(edge.source)) {
  1437. ancestorIds.push(edge.source);
  1438. findAncestors(edge.source);
  1439. }
  1440. });
  1441. };
  1442. findAncestors(nodeId);
  1443. // 如果节点或其祖先被隐藏,先恢复它们
  1444. setHiddenNodes(prev => {
  1445. const newSet = new Set(prev);
  1446. ancestorIds.forEach(id => newSet.delete(id));
  1447. return newSet;
  1448. });
  1449. // 展开目录树中到达该节点的路径
  1450. // 需要找到所有包含该节点的树路径的 treeKey,并展开它们的父节点
  1451. setCollapsedTreeNodes(prev => {
  1452. const newSet = new Set(prev);
  1453. // 清空所有折叠状态,让目录树完全展开到选中节点
  1454. // 这样可以确保选中节点在目录中可见
  1455. return new Set();
  1456. });
  1457. setSelectedNodeId(nodeId);
  1458. setTimeout(() => {
  1459. fitView({
  1460. nodes: [{ id: nodeId }],
  1461. duration: 800,
  1462. padding: 0.3,
  1463. });
  1464. }, 100);
  1465. }}
  1466. style={{
  1467. padding: '6px 8px',
  1468. borderRadius: '4px',
  1469. background: 'white',
  1470. border: index === path.length - 1 ? '2px solid #3b82f6' : '1px solid #d1d5db',
  1471. color: '#374151',
  1472. fontWeight: index === path.length - 1 ? '600' : '400',
  1473. width: '180px',
  1474. cursor: 'pointer',
  1475. transition: 'all 0.2s ease',
  1476. position: 'relative',
  1477. display: 'inline-flex',
  1478. flexDirection: 'column',
  1479. gap: '4px',
  1480. }}
  1481. onMouseEnter={(e) => {
  1482. e.currentTarget.style.opacity = '0.8';
  1483. }}
  1484. onMouseLeave={(e) => {
  1485. e.currentTarget.style.opacity = '1';
  1486. }}
  1487. title={\`\${node.data.title || node.id} (Score: \${nodeScore.toFixed(2)}, Strategy: \${nodeStrategy}, Selected: \${nodeIsSelected})\`}
  1488. >
  1489. {/* 上半部分:竖线 + 图标 + 文字 + 分数 */}
  1490. <div style={{
  1491. display: 'flex',
  1492. alignItems: 'center',
  1493. gap: '6px',
  1494. }}>
  1495. {/* 策略类型竖线 */}
  1496. <div style={{
  1497. width: '3px',
  1498. height: '16px',
  1499. background: strategyColor,
  1500. borderRadius: '2px',
  1501. flexShrink: 0,
  1502. }} />
  1503. {/* 节点文字 */}
  1504. <span style={{
  1505. flex: 1,
  1506. fontSize: '12px',
  1507. color: nodeIsSelected ? '#374151' : '#ef4444',
  1508. }}>
  1509. {truncateMiddle(node.data.title || node.id, 18)}
  1510. </span>
  1511. {/* 分数显示 - 步骤和轮次节点不显示分数 */}
  1512. {nodeActualType !== 'step' && nodeActualType !== 'round' && (
  1513. <span style={{
  1514. fontSize: '10px',
  1515. color: '#6b7280',
  1516. fontWeight: '500',
  1517. flexShrink: 0,
  1518. }}>
  1519. {nodeScore.toFixed(2)}
  1520. </span>
  1521. )}
  1522. </div>
  1523. {/* 分数下划线 - 步骤和轮次节点不显示 */}
  1524. {nodeActualType !== 'step' && nodeActualType !== 'round' && (
  1525. <div style={{
  1526. width: (nodeScore * 100) + '%',
  1527. height: '2px',
  1528. background: getScoreColor(nodeScore),
  1529. borderRadius: '1px',
  1530. marginLeft: '9px',
  1531. }} />
  1532. )}
  1533. </span>
  1534. {index < path.length - 1 && <span style={{ color: '#9ca3af' }}>›</span>}
  1535. </React.Fragment>
  1536. )})}
  1537. </div>
  1538. ))}
  1539. </div>
  1540. );
  1541. })()}
  1542. </div>
  1543. ) : (
  1544. <div style={{ fontSize: '13px', color: '#9ca3af', textAlign: 'center' }}>
  1545. 选择一个节点查看路径
  1546. </div>
  1547. )}
  1548. </div>
  1549. </div>
  1550. {/* 主内容区:目录 + 画布 */}
  1551. <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
  1552. {/* 左侧目录树 */}
  1553. <div style={{
  1554. width: '320px',
  1555. background: 'white',
  1556. borderRight: '1px solid #e5e7eb',
  1557. display: 'flex',
  1558. flexDirection: 'column',
  1559. flexShrink: 0,
  1560. }}>
  1561. <div style={{
  1562. padding: '12px 16px',
  1563. borderBottom: '1px solid #e5e7eb',
  1564. display: 'flex',
  1565. justifyContent: 'space-between',
  1566. alignItems: 'center',
  1567. }}>
  1568. <span style={{
  1569. fontWeight: '600',
  1570. fontSize: '14px',
  1571. color: '#111827',
  1572. }}>
  1573. 节点目录
  1574. </span>
  1575. <div style={{ display: 'flex', gap: '6px' }}>
  1576. <button
  1577. onClick={() => {
  1578. setCollapsedTreeNodes(new Set());
  1579. }}
  1580. style={{
  1581. fontSize: '11px',
  1582. padding: '4px 8px',
  1583. borderRadius: '4px',
  1584. border: '1px solid #d1d5db',
  1585. background: 'white',
  1586. color: '#6b7280',
  1587. cursor: 'pointer',
  1588. fontWeight: '500',
  1589. }}
  1590. title="展开全部节点"
  1591. >
  1592. 全部展开
  1593. </button>
  1594. <button
  1595. onClick={() => {
  1596. const getAllTreeKeys = (nodes) => {
  1597. const keys = new Set();
  1598. const traverse = (node) => {
  1599. if (node.children && node.children.length > 0) {
  1600. keys.add(node.treeKey);
  1601. node.children.forEach(traverse);
  1602. }
  1603. };
  1604. nodes.forEach(traverse);
  1605. return keys;
  1606. };
  1607. setCollapsedTreeNodes(getAllTreeKeys(treeRoots));
  1608. }}
  1609. style={{
  1610. fontSize: '11px',
  1611. padding: '4px 8px',
  1612. borderRadius: '4px',
  1613. border: '1px solid #d1d5db',
  1614. background: 'white',
  1615. color: '#6b7280',
  1616. cursor: 'pointer',
  1617. fontWeight: '500',
  1618. }}
  1619. title="折叠全部节点"
  1620. >
  1621. 全部折叠
  1622. </button>
  1623. <button
  1624. onClick={copyTreeToClipboard}
  1625. style={{
  1626. fontSize: '11px',
  1627. padding: '4px 8px',
  1628. borderRadius: '4px',
  1629. border: '1px solid #3b82f6',
  1630. background: '#3b82f6',
  1631. color: 'white',
  1632. cursor: 'pointer',
  1633. fontWeight: '500',
  1634. transition: 'all 0.2s',
  1635. }}
  1636. onMouseEnter={(e) => e.currentTarget.style.background = '#2563eb'}
  1637. onMouseLeave={(e) => e.currentTarget.style.background = '#3b82f6'}
  1638. title="复制树形结构为文本格式"
  1639. >
  1640. 📋 复制树形结构
  1641. </button>
  1642. </div>
  1643. </div>
  1644. <div style={{
  1645. flex: 1,
  1646. overflowX: 'auto',
  1647. overflowY: 'auto',
  1648. padding: '8px',
  1649. }}>
  1650. <div style={{ minWidth: 'fit-content' }}>
  1651. {renderTree(treeRoots)}
  1652. </div>
  1653. </div>
  1654. </div>
  1655. {/* 画布区域 */}
  1656. <div style={{ flex: 1, position: 'relative' }}>
  1657. {/* 右侧图例 */}
  1658. <div style={{
  1659. position: 'absolute',
  1660. top: '20px',
  1661. right: '20px',
  1662. background: 'white',
  1663. padding: '16px',
  1664. borderRadius: '12px',
  1665. boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
  1666. zIndex: 1000,
  1667. maxWidth: '260px',
  1668. border: '1px solid #e5e7eb',
  1669. }}>
  1670. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
  1671. <h3 style={{ fontSize: '14px', fontWeight: '600', color: '#111827', margin: 0 }}>图例</h3>
  1672. <button
  1673. onClick={() => setFocusMode(!focusMode)}
  1674. style={{
  1675. fontSize: '11px',
  1676. padding: '4px 8px',
  1677. borderRadius: '4px',
  1678. border: '1px solid',
  1679. borderColor: focusMode ? '#3b82f6' : '#d1d5db',
  1680. background: focusMode ? '#3b82f6' : 'white',
  1681. color: focusMode ? 'white' : '#6b7280',
  1682. cursor: 'pointer',
  1683. fontWeight: '500',
  1684. }}
  1685. title={focusMode ? '关闭聚焦模式' : '开启聚焦模式'}
  1686. >
  1687. {focusMode ? '🎯 聚焦' : '📊 全图'}
  1688. </button>
  1689. </div>
  1690. <div style={{ fontSize: '12px' }}>
  1691. {/* 画布节点展开/折叠控制 */}
  1692. <div style={{ marginBottom: '12px', paddingBottom: '12px', borderBottom: '1px solid #f3f4f6' }}>
  1693. <div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>节点控制</div>
  1694. <div style={{ display: 'flex', gap: '6px' }}>
  1695. <button
  1696. onClick={() => {
  1697. setCollapsedNodes(new Set());
  1698. }}
  1699. style={{
  1700. fontSize: '11px',
  1701. padding: '4px 8px',
  1702. borderRadius: '4px',
  1703. border: '1px solid #d1d5db',
  1704. background: 'white',
  1705. color: '#6b7280',
  1706. cursor: 'pointer',
  1707. fontWeight: '500',
  1708. flex: 1,
  1709. }}
  1710. title="展开画布中所有节点的子节点"
  1711. >
  1712. 全部展开
  1713. </button>
  1714. <button
  1715. onClick={() => {
  1716. const allNodeIds = new Set(initialNodes.map(n => n.id));
  1717. setCollapsedNodes(allNodeIds);
  1718. }}
  1719. style={{
  1720. fontSize: '11px',
  1721. padding: '4px 8px',
  1722. borderRadius: '4px',
  1723. border: '1px solid #d1d5db',
  1724. background: 'white',
  1725. color: '#6b7280',
  1726. cursor: 'pointer',
  1727. fontWeight: '500',
  1728. flex: 1,
  1729. }}
  1730. title="折叠画布中所有节点的子节点"
  1731. >
  1732. 全部折叠
  1733. </button>
  1734. </div>
  1735. </div>
  1736. <div style={{ paddingTop: '12px', borderTop: '1px solid #f3f4f6' }}>
  1737. <div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>策略类型</div>
  1738. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1739. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#10b981', opacity: 0.7 }}></div>
  1740. <span style={{ color: '#6b7280', fontSize: '11px' }}>初始分词</span>
  1741. </div>
  1742. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1743. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#06b6d4', opacity: 0.7 }}></div>
  1744. <span style={{ color: '#6b7280', fontSize: '11px' }}>调用sug</span>
  1745. </div>
  1746. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1747. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#f59e0b', opacity: 0.7 }}></div>
  1748. <span style={{ color: '#6b7280', fontSize: '11px' }}>同义改写</span>
  1749. </div>
  1750. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1751. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#3b82f6', opacity: 0.7 }}></div>
  1752. <span style={{ color: '#6b7280', fontSize: '11px' }}>加词</span>
  1753. </div>
  1754. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1755. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#8b5cf6', opacity: 0.7 }}></div>
  1756. <span style={{ color: '#6b7280', fontSize: '11px' }}>抽象改写</span>
  1757. </div>
  1758. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1759. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#ec4899', opacity: 0.7 }}></div>
  1760. <span style={{ color: '#6b7280', fontSize: '11px' }}>基于部分匹配改进</span>
  1761. </div>
  1762. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1763. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#a855f7', opacity: 0.7 }}></div>
  1764. <span style={{ color: '#6b7280', fontSize: '11px' }}>结果分支-抽象改写</span>
  1765. </div>
  1766. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1767. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#fb923c', opacity: 0.7 }}></div>
  1768. <span style={{ color: '#6b7280', fontSize: '11px' }}>结果分支-同义改写</span>
  1769. </div>
  1770. </div>
  1771. <div style={{
  1772. marginTop: '12px',
  1773. paddingTop: '12px',
  1774. borderTop: '1px solid #f3f4f6',
  1775. fontSize: '11px',
  1776. color: '#9ca3af',
  1777. lineHeight: '1.5',
  1778. }}>
  1779. 💡 点击节点左上角 × 隐藏节点
  1780. </div>
  1781. {/* 隐藏节点列表 - 在图例内部 */}
  1782. {hiddenNodes.size > 0 && (
  1783. <div style={{
  1784. marginTop: '12px',
  1785. paddingTop: '12px',
  1786. borderTop: '1px solid #f3f4f6',
  1787. }}>
  1788. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
  1789. <h4 style={{ fontSize: '12px', fontWeight: '600', color: '#111827' }}>已隐藏节点</h4>
  1790. <button
  1791. onClick={() => setHiddenNodes(new Set())}
  1792. style={{
  1793. fontSize: '10px',
  1794. color: '#3b82f6',
  1795. background: 'none',
  1796. border: 'none',
  1797. cursor: 'pointer',
  1798. textDecoration: 'underline',
  1799. }}
  1800. >
  1801. 全部恢复
  1802. </button>
  1803. </div>
  1804. <div style={{ fontSize: '12px', maxHeight: '200px', overflow: 'auto' }}>
  1805. {Array.from(hiddenNodes).map(nodeId => {
  1806. const node = initialNodes.find(n => n.id === nodeId);
  1807. if (!node) return null;
  1808. return (
  1809. <div
  1810. key={nodeId}
  1811. style={{
  1812. display: 'flex',
  1813. justifyContent: 'space-between',
  1814. alignItems: 'center',
  1815. padding: '6px 8px',
  1816. margin: '4px 0',
  1817. background: '#f9fafb',
  1818. borderRadius: '6px',
  1819. fontSize: '11px',
  1820. }}
  1821. >
  1822. <span
  1823. style={{
  1824. flex: 1,
  1825. overflow: 'hidden',
  1826. textOverflow: 'ellipsis',
  1827. whiteSpace: 'nowrap',
  1828. color: '#374151',
  1829. }}
  1830. title={node.data.title || nodeId}
  1831. >
  1832. {node.data.title || nodeId}
  1833. </span>
  1834. <button
  1835. onClick={() => {
  1836. setHiddenNodes(prev => {
  1837. const newSet = new Set(prev);
  1838. newSet.delete(nodeId);
  1839. return newSet;
  1840. });
  1841. }}
  1842. style={{
  1843. marginLeft: '8px',
  1844. fontSize: '10px',
  1845. color: '#10b981',
  1846. background: 'none',
  1847. border: 'none',
  1848. cursor: 'pointer',
  1849. flexShrink: 0,
  1850. }}
  1851. >
  1852. 恢复
  1853. </button>
  1854. </div>
  1855. );
  1856. })}
  1857. </div>
  1858. </div>
  1859. )}
  1860. </div>
  1861. </div>
  1862. {/* React Flow 画布 */}
  1863. <ReactFlow
  1864. nodes={nodes}
  1865. edges={edges}
  1866. nodeTypes={nodeTypes}
  1867. fitView
  1868. fitViewOptions={{ padding: 0.2, duration: 500 }}
  1869. minZoom={0.1}
  1870. maxZoom={1.5}
  1871. nodesDraggable={true}
  1872. nodesConnectable={false}
  1873. elementsSelectable={true}
  1874. defaultEdgeOptions={{
  1875. type: 'smoothstep',
  1876. }}
  1877. proOptions={{ hideAttribution: true }}
  1878. onNodeClick={(event, clickedNode) => {
  1879. setSelectedNodeId(clickedNode.id);
  1880. }}
  1881. >
  1882. <Controls style={{ bottom: '20px', left: 'auto', right: '20px' }} />
  1883. <Background variant="dots" gap={20} size={1} color="#e5e7eb" />
  1884. </ReactFlow>
  1885. </div>
  1886. </div>
  1887. </div>
  1888. );
  1889. }
  1890. function App() {
  1891. return (
  1892. <ReactFlowProvider>
  1893. <FlowContent />
  1894. </ReactFlowProvider>
  1895. );
  1896. }
  1897. const root = createRoot(document.getElementById('root'));
  1898. root.render(<App />);
  1899. `;
  1900. fs.writeFileSync(reactComponentPath, reactComponent);
  1901. // 使用 esbuild 打包
  1902. console.log('🎨 Building modern visualization...');
  1903. build({
  1904. entryPoints: [reactComponentPath],
  1905. bundle: true,
  1906. outfile: path.join(__dirname, 'bundle_v2.js'),
  1907. format: 'iife',
  1908. loader: {
  1909. '.css': 'css',
  1910. },
  1911. minify: false,
  1912. sourcemap: 'inline',
  1913. // 强制所有 React 引用指向同一个位置,避免多副本
  1914. alias: {
  1915. 'react': path.join(__dirname, 'node_modules/react'),
  1916. 'react-dom': path.join(__dirname, 'node_modules/react-dom'),
  1917. 'react/jsx-runtime': path.join(__dirname, 'node_modules/react/jsx-runtime'),
  1918. 'react/jsx-dev-runtime': path.join(__dirname, 'node_modules/react/jsx-dev-runtime'),
  1919. },
  1920. }).then(() => {
  1921. // 读取打包后的 JS
  1922. const bundleJs = fs.readFileSync(path.join(__dirname, 'bundle_v2.js'), 'utf-8');
  1923. // 读取 CSS
  1924. const cssPath = path.join(__dirname, 'node_modules/@xyflow/react/dist/style.css');
  1925. const css = fs.readFileSync(cssPath, 'utf-8');
  1926. // 生成最终 HTML
  1927. const html = `<!DOCTYPE html>
  1928. <html lang="zh-CN">
  1929. <head>
  1930. <meta charset="UTF-8">
  1931. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  1932. <title>查询图可视化</title>
  1933. <link rel="preconnect" href="https://fonts.googleapis.com">
  1934. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  1935. <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
  1936. <script src="https://unpkg.com/dagre@0.8.5/dist/dagre.min.js"></script>
  1937. <script>
  1938. // 过滤特定的 React 警告
  1939. const originalError = console.error;
  1940. console.error = (...args) => {
  1941. if (typeof args[0] === 'string' && args[0].includes('Each child in a list should have a unique "key" prop')) {
  1942. return;
  1943. }
  1944. originalError.apply(console, args);
  1945. };
  1946. </script>
  1947. <style>
  1948. * {
  1949. margin: 0;
  1950. padding: 0;
  1951. box-sizing: border-box;
  1952. }
  1953. body {
  1954. font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  1955. overflow: hidden;
  1956. -webkit-font-smoothing: antialiased;
  1957. -moz-osx-font-smoothing: grayscale;
  1958. }
  1959. #root {
  1960. width: 100vw;
  1961. height: 100vh;
  1962. }
  1963. ${css}
  1964. /* 自定义样式覆盖 */
  1965. .react-flow__edge-path {
  1966. stroke-linecap: round;
  1967. }
  1968. .react-flow__controls {
  1969. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  1970. border: 1px solid #e5e7eb;
  1971. border-radius: 8px;
  1972. }
  1973. .react-flow__controls-button {
  1974. border: none;
  1975. border-bottom: 1px solid #e5e7eb;
  1976. }
  1977. .react-flow__controls-button:hover {
  1978. background: #f9fafb;
  1979. }
  1980. </style>
  1981. </head>
  1982. <body>
  1983. <div id="root"></div>
  1984. <script>${bundleJs}</script>
  1985. </body>
  1986. </html>`;
  1987. // 写入输出文件
  1988. fs.writeFileSync(outputFile, html);
  1989. // 清理临时文件
  1990. fs.unlinkSync(reactComponentPath);
  1991. fs.unlinkSync(path.join(__dirname, 'bundle_v2.js'));
  1992. console.log('✅ Visualization generated: ' + outputFile);
  1993. console.log('📊 Nodes: ' + Object.keys(data.nodes).length);
  1994. console.log('🔗 Edges: ' + data.edges.length);
  1995. }).catch(error => {
  1996. console.error('❌ Build error:', error);
  1997. process.exit(1);
  1998. });