index.js 73 KB

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