index.js 121 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294
  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'); // 已废弃,使用v3版本
  6. const { convertV8ToGraphV2, convertV8ToGraphSimplified } = require('./convert_v8_to_graph_v3');
  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] [--simplified]');
  11. process.exit(1);
  12. }
  13. const inputFile = args[0];
  14. const outputFile = args[1] || 'query_graph_output.html';
  15. const useSimplified = args.includes('--simplified');
  16. // 读取输入数据
  17. const inputData = JSON.parse(fs.readFileSync(inputFile, 'utf-8'));
  18. // 检测数据格式并转换
  19. let data;
  20. if (inputData.rounds && inputData.o) {
  21. // v6.1.2.8 格式,需要转换
  22. console.log('✨ 检测到 v6.1.2.8 格式,正在转换为图结构...');
  23. // 尝试读取 search_results.json(兼容旧版本)
  24. let searchResults = null;
  25. const searchResultsPath = path.join(path.dirname(inputFile), 'search_results.json');
  26. if (fs.existsSync(searchResultsPath)) {
  27. console.log('📄 读取外部搜索结果数据(兼容模式)...');
  28. searchResults = JSON.parse(fs.readFileSync(searchResultsPath, 'utf-8'));
  29. } else {
  30. console.log('✅ 使用 run_context.json 中的内嵌搜索结果');
  31. }
  32. // 尝试读取 search_extract.json(多模态提取数据)
  33. let extractionData = null;
  34. const extractionPath = path.join(path.dirname(inputFile), 'search_extract.json');
  35. if (fs.existsSync(extractionPath)) {
  36. console.log('📸 读取多模态提取数据...');
  37. extractionData = JSON.parse(fs.readFileSync(extractionPath, 'utf-8'));
  38. } else {
  39. console.log('ℹ️ 未找到 search_extract.json,跳过多模态展示');
  40. }
  41. // 选择转换函数
  42. let graphData;
  43. let fullData = null; // 用于目录的完整数据
  44. if (useSimplified) {
  45. console.log('🎨 使用简化视图(合并query节点)');
  46. // 生成简化版用于画布
  47. graphData = convertV8ToGraphSimplified(inputData, searchResults, extractionData);
  48. // 生成完整版用于目录
  49. const fullGraphData = convertV8ToGraphV2(inputData, searchResults, extractionData);
  50. fullData = {
  51. nodes: fullGraphData.nodes,
  52. edges: fullGraphData.edges,
  53. iterations: fullGraphData.iterations
  54. };
  55. console.log(`✅ 简化版: ${Object.keys(graphData.nodes).length} 个节点, ${graphData.edges.length} 条边`);
  56. console.log(`📋 完整版(用于目录): ${Object.keys(fullData.nodes).length} 个节点`);
  57. } else {
  58. console.log('📊 使用详细视图(完整流程)');
  59. graphData = convertV8ToGraphV2(inputData, searchResults, extractionData);
  60. console.log(`✅ 转换完成: ${Object.keys(graphData.nodes).length} 个节点, ${graphData.edges.length} 条边`);
  61. }
  62. data = {
  63. nodes: graphData.nodes,
  64. edges: graphData.edges,
  65. iterations: graphData.iterations,
  66. fullData: fullData // 传递完整数据
  67. };
  68. } else if (inputData.nodes && inputData.edges) {
  69. // v6.1.2.5 格式,直接使用
  70. console.log('✨ 检测到 v6.1.2.5 格式,直接使用');
  71. data = inputData;
  72. } else {
  73. console.error('❌ 无法识别的数据格式');
  74. process.exit(1);
  75. }
  76. // 创建临时 React 组件文件
  77. const reactComponentPath = path.join(__dirname, 'temp_flow_component_v2.jsx');
  78. const reactComponent = `
  79. import React, { useState, useCallback, useMemo, useEffect } from 'react';
  80. import { createRoot } from 'react-dom/client';
  81. import {
  82. ReactFlow,
  83. Controls,
  84. Background,
  85. useNodesState,
  86. useEdgesState,
  87. Handle,
  88. Position,
  89. useReactFlow,
  90. ReactFlowProvider,
  91. } from '@xyflow/react';
  92. import '@xyflow/react/dist/style.css';
  93. const data = ${JSON.stringify(data, null, 2)};
  94. // 根据节点类型获取边框颜色
  95. function getNodeTypeColor(type) {
  96. const typeColors = {
  97. 'root': '#6b21a8', // 紫色 - 根节点
  98. 'round': '#7c3aed', // 深紫 - Round节点
  99. 'step': '#f59e0b', // 橙色 - 步骤节点
  100. 'seg': '#10b981', // 绿色 - 分词
  101. 'q': '#3b82f6', // 蓝色 - Query
  102. 'sug': '#06b6d4', // 青色 - Sug建议词
  103. 'seed': '#84cc16', // 黄绿 - Seed
  104. 'add_word': '#22c55e', // 绿色 - 加词生成
  105. 'search_word': '#8b5cf6', // 紫色 - 搜索词
  106. 'post': '#ec4899', // 粉色 - 帖子
  107. 'filtered_sug': '#14b8a6',// 青绿 - 筛选的sug
  108. 'next_q': '#2563eb', // 深蓝 - 下轮查询
  109. 'next_seed': '#65a30d', // 深黄绿 - 下轮种子
  110. 'search': '#8b5cf6', // 深紫 - 搜索(兼容旧版)
  111. 'operation': '#f59e0b', // 橙色 - 操作节点(兼容旧版)
  112. 'query': '#3b82f6', // 蓝色 - 查询(兼容旧版)
  113. 'note': '#ec4899', // 粉色 - 帖子(兼容旧版)
  114. };
  115. return typeColors[type] || '#9ca3af';
  116. }
  117. // 查询节点组件 - 卡片样式
  118. function QueryNode({ id, data, sourcePosition, targetPosition }) {
  119. // 所有节点默认展开
  120. const expanded = true;
  121. // 获取节点类型颜色
  122. const typeColor = getNodeTypeColor(data.nodeType || 'query');
  123. return (
  124. <div>
  125. <Handle
  126. type="target"
  127. position={targetPosition || Position.Left}
  128. style={{ background: typeColor, width: 8, height: 8 }}
  129. />
  130. <div
  131. style={{
  132. padding: '12px',
  133. borderRadius: '8px',
  134. border: data.isHighlighted ? \`3px solid \${typeColor}\` :
  135. data.isCollapsed ? \`2px solid \${typeColor}\` :
  136. data.isSelected === false ? '2px dashed #d1d5db' :
  137. \`2px solid \${typeColor}\`,
  138. background: data.isHighlighted ? '#eef2ff' :
  139. data.isSelected === false ? '#f9fafb' : 'white',
  140. minWidth: '200px',
  141. maxWidth: '280px',
  142. boxShadow: data.isHighlighted ? '0 0 0 4px rgba(102, 126, 234, 0.25), 0 4px 16px rgba(102, 126, 234, 0.4)' :
  143. data.isCollapsed ? '0 4px 12px rgba(102, 126, 234, 0.15)' :
  144. data.level === 0 ? '0 4px 12px rgba(139, 92, 246, 0.15)' : '0 2px 6px rgba(0, 0, 0, 0.06)',
  145. transition: 'all 0.3s ease',
  146. cursor: 'pointer',
  147. position: 'relative',
  148. opacity: data.isSelected === false ? 0.6 : 1,
  149. }}
  150. >
  151. {/* 折叠当前节点按钮 - 左边 */}
  152. <div
  153. style={{
  154. position: 'absolute',
  155. top: '6px',
  156. left: '6px',
  157. width: '20px',
  158. height: '20px',
  159. borderRadius: '50%',
  160. background: '#f59e0b',
  161. color: 'white',
  162. display: 'flex',
  163. alignItems: 'center',
  164. justifyContent: 'center',
  165. fontSize: '11px',
  166. fontWeight: 'bold',
  167. cursor: 'pointer',
  168. transition: 'all 0.2s ease',
  169. zIndex: 10,
  170. }}
  171. onClick={(e) => {
  172. e.stopPropagation();
  173. if (data.onHideSelf) {
  174. data.onHideSelf();
  175. }
  176. }}
  177. onMouseEnter={(e) => {
  178. e.currentTarget.style.background = '#d97706';
  179. }}
  180. onMouseLeave={(e) => {
  181. e.currentTarget.style.background = '#f59e0b';
  182. }}
  183. title="隐藏当前节点"
  184. >
  185. ×
  186. </div>
  187. {/* 聚焦按钮 - 右上角 */}
  188. <div
  189. style={{
  190. position: 'absolute',
  191. top: '6px',
  192. right: '6px',
  193. width: '20px',
  194. height: '20px',
  195. borderRadius: '50%',
  196. background: data.isFocused ? '#10b981' : '#e5e7eb',
  197. color: data.isFocused ? 'white' : '#6b7280',
  198. display: 'flex',
  199. alignItems: 'center',
  200. justifyContent: 'center',
  201. fontSize: '11px',
  202. fontWeight: 'bold',
  203. cursor: 'pointer',
  204. transition: 'all 0.2s ease',
  205. zIndex: 10,
  206. }}
  207. onClick={(e) => {
  208. e.stopPropagation();
  209. if (data.onFocus) {
  210. data.onFocus();
  211. }
  212. }}
  213. onMouseEnter={(e) => {
  214. if (!data.isFocused) {
  215. e.currentTarget.style.background = '#d1d5db';
  216. }
  217. }}
  218. onMouseLeave={(e) => {
  219. if (!data.isFocused) {
  220. e.currentTarget.style.background = '#e5e7eb';
  221. }
  222. }}
  223. title={data.isFocused ? '取消聚焦' : '聚焦到此节点'}
  224. >
  225. 🎯
  226. </div>
  227. {/* 折叠/展开子节点按钮 - 右边第二个位置 */}
  228. {data.hasChildren && (
  229. <div
  230. style={{
  231. position: 'absolute',
  232. top: '6px',
  233. right: '30px',
  234. width: '20px',
  235. height: '20px',
  236. borderRadius: '50%',
  237. background: data.isCollapsed ? '#667eea' : '#e5e7eb',
  238. color: data.isCollapsed ? 'white' : '#6b7280',
  239. display: 'flex',
  240. alignItems: 'center',
  241. justifyContent: 'center',
  242. fontSize: '11px',
  243. fontWeight: 'bold',
  244. cursor: 'pointer',
  245. transition: 'all 0.2s ease',
  246. zIndex: 10,
  247. }}
  248. onClick={(e) => {
  249. e.stopPropagation();
  250. data.onToggleCollapse();
  251. }}
  252. title={data.isCollapsed ? '展开子节点' : '折叠子节点'}
  253. >
  254. {data.isCollapsed ? '+' : '−'}
  255. </div>
  256. )}
  257. {/* 卡片内容 */}
  258. <div>
  259. {/* 标题行 */}
  260. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px', paddingLeft: '24px', paddingRight: data.hasChildren ? '54px' : '28px' }}>
  261. <div style={{ flex: 1 }}>
  262. <div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '3px' }}>
  263. <div style={{
  264. fontSize: '13px',
  265. fontWeight: data.level === 0 ? '700' : '600',
  266. color: data.level === 0 ? '#6b21a8' : '#1f2937',
  267. lineHeight: '1.3',
  268. flex: 1,
  269. }}>
  270. {data.title}
  271. </div>
  272. {data.isSelected === false && (
  273. <div style={{
  274. fontSize: '9px',
  275. padding: '1px 4px',
  276. borderRadius: '3px',
  277. background: '#fee2e2',
  278. color: '#991b1b',
  279. fontWeight: '500',
  280. flexShrink: 0,
  281. }}>
  282. 未选中
  283. </div>
  284. )}
  285. </div>
  286. </div>
  287. </div>
  288. {/* 展开的详细信息 - 始终显示 */}
  289. <div style={{ fontSize: '11px', lineHeight: 1.4 }}>
  290. <div style={{ display: 'flex', gap: '4px', marginBottom: '6px', flexWrap: 'wrap' }}>
  291. <span style={{
  292. display: 'inline-block',
  293. padding: '1px 6px',
  294. borderRadius: '10px',
  295. background: '#eff6ff',
  296. color: '#3b82f6',
  297. fontSize: '10px',
  298. fontWeight: '500',
  299. }}>
  300. Lv.{data.level}
  301. </span>
  302. <span style={{
  303. display: 'inline-block',
  304. padding: '1px 6px',
  305. borderRadius: '10px',
  306. background: '#f0fdf4',
  307. color: '#16a34a',
  308. fontSize: '10px',
  309. fontWeight: '500',
  310. }}>
  311. {data.score}
  312. </span>
  313. {data.strategy && data.strategy !== 'root' && (
  314. <span style={{
  315. display: 'inline-block',
  316. padding: '1px 6px',
  317. borderRadius: '10px',
  318. background: '#fef3c7',
  319. color: '#92400e',
  320. fontSize: '10px',
  321. fontWeight: '500',
  322. }}>
  323. {data.strategy}
  324. </span>
  325. )}
  326. {(data.typeLabel || data.type_label) && (
  327. <span style={{
  328. display: 'inline-block',
  329. padding: '1px 6px',
  330. borderRadius: '10px',
  331. background: '#fce7f3',
  332. color: '#9f1239',
  333. fontSize: '10px',
  334. fontWeight: '500',
  335. }}>
  336. {data.typeLabel || data.type_label}
  337. </span>
  338. )}
  339. {data.is_suggestion && data.suggestion_label && (
  340. <span style={{
  341. display: 'inline-block',
  342. padding: '1px 6px',
  343. borderRadius: '10px',
  344. background: '#ede9fe',
  345. color: '#6d28d9',
  346. fontSize: '10px',
  347. fontWeight: '600',
  348. }}>
  349. {data.suggestion_label}
  350. </span>
  351. )}
  352. </div>
  353. {data.parent && (
  354. <div style={{ color: '#6b7280', fontSize: '10px', marginTop: '4px', paddingTop: '4px', borderTop: '1px solid #f3f4f6' }}>
  355. <strong>Parent:</strong> {data.parent}
  356. </div>
  357. )}
  358. {data.nodeType === 'domain_combination' && Array.isArray(data.source_word_details) && data.source_word_details.length > 0 && (
  359. <div style={{
  360. marginTop: '6px',
  361. paddingTop: '6px',
  362. borderTop: '1px solid #f3f4f6',
  363. fontSize: '10px',
  364. color: '#6b7280',
  365. lineHeight: '1.5',
  366. }}>
  367. <strong style={{ color: '#4b5563' }}>来源词得分:</strong>
  368. <div style={{ marginTop: '4px', display: 'flex', flexDirection: 'column', gap: '4px' }}>
  369. {data.source_word_details.map((detail, idx) => {
  370. const words = (detail.words || []).map((w) => {
  371. const numericScore = typeof w.score === 'number' ? w.score : parseFloat(w.score || '0');
  372. const formattedScore = Number.isFinite(numericScore) ? numericScore.toFixed(2) : '0.00';
  373. return w.text + ' (' + formattedScore + ')';
  374. }).join(' + ');
  375. return (
  376. <div key={idx} style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', alignItems: 'center' }}>
  377. <span style={{ color: '#2563eb' }}>{words}</span>
  378. </div>
  379. );
  380. })}
  381. </div>
  382. <div style={{ marginTop: '4px', fontWeight: '500', color: data.is_above_sources ? '#16a34a' : '#dc2626' }}>
  383. {data.is_above_sources ? '✅ 组合得分高于所有来源词' : '⚠️ 组合得分未超过全部来源词'}
  384. </div>
  385. </div>
  386. )}
  387. {data.selectedWord && (
  388. <div style={{
  389. marginTop: '6px',
  390. paddingTop: '6px',
  391. borderTop: '1px solid #f3f4f6',
  392. fontSize: '10px',
  393. color: '#6b7280',
  394. lineHeight: '1.5',
  395. }}>
  396. <strong style={{ color: '#4b5563' }}>选择词:</strong>
  397. <span style={{ marginLeft: '4px', color: '#3b82f6', fontWeight: '500' }}>{data.selectedWord}</span>
  398. {data.seed_score !== undefined && (
  399. <div style={{ marginTop: '4px' }}>
  400. <strong style={{ color: '#4b5563' }}>种子得分:</strong>
  401. <span style={{ marginLeft: '4px', color: '#16a34a', fontWeight: '500' }}>
  402. {typeof data.seed_score === 'number' ? data.seed_score.toFixed(2) : data.seed_score}
  403. </span>
  404. </div>
  405. )}
  406. </div>
  407. )}
  408. {data.evaluationReason && (
  409. <div style={{
  410. marginTop: '6px',
  411. paddingTop: '6px',
  412. borderTop: '1px solid #f3f4f6',
  413. fontSize: '10px',
  414. color: '#6b7280',
  415. lineHeight: '1.5',
  416. }}>
  417. <strong style={{ color: '#4b5563' }}>评估:</strong>
  418. <div style={{ marginTop: '2px' }}>{data.evaluationReason}</div>
  419. </div>
  420. )}
  421. {data.occurrences && data.occurrences.length > 1 && (
  422. <div style={{
  423. marginTop: '6px',
  424. paddingTop: '6px',
  425. borderTop: '1px solid #f3f4f6',
  426. fontSize: '10px',
  427. color: '#6b7280',
  428. }}>
  429. <strong style={{ color: '#4b5563' }}>演化历史 ({data.occurrences.length}次):</strong>
  430. <div style={{ marginTop: '4px' }}>
  431. {data.occurrences.map((occ, idx) => (
  432. <div key={idx} style={{ marginTop: '2px', paddingLeft: '8px' }}>
  433. <span style={{ color: '#3b82f6', fontWeight: '500' }}>R{occ.round}</span>
  434. {' · '}
  435. <span>{occ.strategy}</span>
  436. {occ.score !== undefined && (
  437. <span style={{ color: '#16a34a', marginLeft: '4px' }}>
  438. ({typeof occ.score === 'number' ? occ.score.toFixed(2) : occ.score})
  439. </span>
  440. )}
  441. </div>
  442. ))}
  443. </div>
  444. </div>
  445. )}
  446. {data.hasSearchResults && (
  447. <div style={{
  448. marginTop: '6px',
  449. paddingTop: '6px',
  450. borderTop: '1px solid #f3f4f6',
  451. fontSize: '10px',
  452. background: '#fef3c7',
  453. padding: '4px 6px',
  454. borderRadius: '4px',
  455. color: '#92400e',
  456. fontWeight: '500',
  457. }}>
  458. 🔍 找到 {data.postCount} 个帖子
  459. </div>
  460. )}
  461. </div>
  462. </div>
  463. </div>
  464. <Handle
  465. type="source"
  466. position={sourcePosition || Position.Right}
  467. style={{ background: '#667eea', width: 8, height: 8 }}
  468. />
  469. </div>
  470. );
  471. }
  472. // 笔记节点组件 - 卡片样式,带轮播图
  473. function NoteNode({ id, data, sourcePosition, targetPosition }) {
  474. const [currentImageIndex, setCurrentImageIndex] = useState(0);
  475. const expanded = true;
  476. const hasImages = data.imageList && data.imageList.length > 0;
  477. const nextImage = (e) => {
  478. e.stopPropagation();
  479. if (hasImages) {
  480. setCurrentImageIndex((prev) => (prev + 1) % data.imageList.length);
  481. }
  482. };
  483. const prevImage = (e) => {
  484. e.stopPropagation();
  485. if (hasImages) {
  486. setCurrentImageIndex((prev) => (prev - 1 + data.imageList.length) % data.imageList.length);
  487. }
  488. };
  489. const handleCardClick = (e) => {
  490. // 如果点击的是链接或按钮(或其子元素),不处理(避免双重触发)
  491. if (e.target.closest('a') || e.target.closest('button')) {
  492. return;
  493. }
  494. // 打开原帖链接
  495. if (data.note_url) {
  496. window.open(data.note_url, '_blank', 'noopener,noreferrer');
  497. }
  498. };
  499. return (
  500. <div>
  501. <Handle
  502. type="target"
  503. position={targetPosition || Position.Left}
  504. style={{ background: '#ec4899', width: 8, height: 8 }}
  505. />
  506. <div
  507. onClick={handleCardClick}
  508. style={{
  509. padding: '28px',
  510. borderRadius: '40px',
  511. border: data.isHighlighted ? '6px solid #ec4899' : '4px solid #fce7f3',
  512. background: data.isHighlighted ? '#eef2ff' : 'white',
  513. minWidth: '880px',
  514. maxWidth: '1200px',
  515. boxShadow: data.isHighlighted ? '0 0 0 8px rgba(236, 72, 153, 0.25), 0 8px 32px rgba(236, 72, 153, 0.4)' : '0 8px 24px rgba(236, 72, 153, 0.15)',
  516. transition: 'all 0.3s ease',
  517. cursor: 'pointer',
  518. }}
  519. >
  520. {/* 笔记标题 */}
  521. <div style={{ display: 'flex', alignItems: 'flex-start', marginBottom: '16px' }}>
  522. <div style={{ flex: 1 }}>
  523. <div style={{
  524. fontSize: '40px',
  525. fontWeight: '600',
  526. color: '#831843',
  527. lineHeight: '1.4',
  528. marginBottom: '8px',
  529. }}>
  530. {data.title}
  531. </div>
  532. </div>
  533. </div>
  534. {/* 🆕 原始问题展示 - 在标题下方 */}
  535. {data.originalQuestion && (
  536. <div style={{
  537. marginBottom: '24px',
  538. paddingBottom: '24px',
  539. borderBottom: '2px solid #fce7f3',
  540. }}>
  541. <div style={{
  542. fontSize: '40px',
  543. color: '#6b21a8',
  544. lineHeight: '1.4',
  545. fontWeight: '600',
  546. }}>
  547. <strong style={{ fontWeight: '700' }}>[原始需求问题]</strong> {data.originalQuestion}
  548. </div>
  549. </div>
  550. )}
  551. {/* 评估信息区域 (V2) */}
  552. {(data.knowledge_score !== undefined || data.post_relevance_score !== undefined || data.is_knowledge !== undefined) && (
  553. <div style={{
  554. marginBottom: '20px',
  555. paddingBottom: '16px',
  556. borderBottom: '2px solid #fce7f3',
  557. }}>
  558. {/* 知识评估 (V2) */}
  559. {(data.knowledge_score !== undefined || data.is_knowledge !== undefined) && (
  560. <div style={{ marginBottom: '16px' }}>
  561. <div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '8px' }}>
  562. {/* 星级评分 */}
  563. {data.knowledge_level && (
  564. <span style={{ fontSize: '24px', lineHeight: '1' }}>
  565. {'⭐'.repeat(data.knowledge_level)}
  566. </span>
  567. )}
  568. {/* 综合得分 */}
  569. {data.knowledge_score != null && (
  570. <span style={{
  571. fontSize: '34px',
  572. fontWeight: '700',
  573. color: data.knowledge_score >= 70 ? '#166534' : data.knowledge_score >= 40 ? '#854d0e' : '#991b1b',
  574. }}>
  575. 知识: {data.knowledge_score.toFixed(0)}分
  576. </span>
  577. )}
  578. {/* 兼容旧版: 知识判定标签 */}
  579. {!data.knowledge_score && data.is_knowledge !== undefined && (
  580. <span style={{
  581. display: 'inline-block',
  582. padding: '6px 20px',
  583. borderRadius: '24px',
  584. fontSize: '34px',
  585. fontWeight: '600',
  586. background: data.is_knowledge ? '#dcfce7' : '#fee2e2',
  587. color: data.is_knowledge ? '#166534' : '#991b1b',
  588. }}>
  589. {data.is_knowledge ? '✓ 知识' : '✗ 非知识'}
  590. </span>
  591. )}
  592. </div>
  593. {/* 知识评估总结 */}
  594. {data.knowledge_evaluation?.summary && (
  595. <div style={{
  596. fontSize: '30px',
  597. color: '#9f1239',
  598. lineHeight: '1.4',
  599. marginTop: '8px',
  600. }}>
  601. {data.knowledge_evaluation.summary}
  602. </div>
  603. )}
  604. {/* 兼容旧版: 知识理由 */}
  605. {!data.knowledge_evaluation?.summary && data.knowledge_reason && (
  606. <div style={{
  607. fontSize: '30px',
  608. color: '#9f1239',
  609. lineHeight: '1.4',
  610. marginTop: '8px',
  611. }}>
  612. {data.knowledge_reason}
  613. </div>
  614. )}
  615. </div>
  616. )}
  617. {/* 相关性评估 (V2) */}
  618. {data.post_relevance_score != null && (
  619. <div>
  620. <div style={{
  621. display: 'flex',
  622. alignItems: 'center',
  623. gap: '12px',
  624. marginBottom: '8px',
  625. }}>
  626. {/* V2: 0-100分制 (统一显示分数) */}
  627. <span style={{
  628. fontSize: '34px',
  629. fontWeight: '600',
  630. color: '#9f1239',
  631. }}>
  632. 相关性: {data.post_relevance_score.toFixed(0)}分
  633. </span>
  634. {/* V2结论标签 */}
  635. {data.relevance_conclusion && (
  636. <span style={{
  637. padding: '4px 16px',
  638. borderRadius: '20px',
  639. fontSize: '30px',
  640. fontWeight: '600',
  641. background:
  642. data.relevance_conclusion.includes('高度') ? '#dcfce7' :
  643. data.relevance_conclusion.includes('中度') ? '#fef3c7' : '#fee2e2',
  644. color:
  645. data.relevance_conclusion.includes('高度') ? '#166534' :
  646. data.relevance_conclusion.includes('中度') ? '#854d0e' : '#991b1b',
  647. }}>
  648. {data.relevance_conclusion}
  649. </span>
  650. )}
  651. {/* V1兼容: 显示旧的相关性等级 */}
  652. {!data.relevance_conclusion && data.relevance_level && (
  653. <span style={{
  654. padding: '4px 16px',
  655. borderRadius: '20px',
  656. fontSize: '30px',
  657. fontWeight: '600',
  658. background:
  659. data.relevance_level === '高度相关' ? '#dcfce7' :
  660. data.relevance_level === '中度相关' ? '#fef3c7' : '#fee2e2',
  661. color:
  662. data.relevance_level === '高度相关' ? '#166534' :
  663. data.relevance_level === '中度相关' ? '#854d0e' : '#991b1b',
  664. }}>
  665. {data.relevance_level}
  666. </span>
  667. )}
  668. </div>
  669. {/* 相关性评估总结 (V2) */}
  670. {data.relevance_evaluation?.summary && (
  671. <div style={{
  672. fontSize: '30px',
  673. color: '#9f1239',
  674. lineHeight: '1.4',
  675. }}>
  676. {data.relevance_evaluation.summary}
  677. </div>
  678. )}
  679. {/* 目的性和品类得分 (V2) */}
  680. {data.relevance_evaluation?.purpose_score != null && data.relevance_evaluation?.category_score != null && (
  681. <div style={{
  682. fontSize: '28px',
  683. color: '#9f1239',
  684. marginTop: '6px',
  685. opacity: 0.8,
  686. }}>
  687. 目的性:{data.relevance_evaluation.purpose_score.toFixed(0)}分(70%) |
  688. 品类:{data.relevance_evaluation.category_score.toFixed(0)}分(30%)
  689. </div>
  690. )}
  691. {/* 兼容旧版: 相关性理由 */}
  692. {!data.relevance_evaluation?.summary && data.relevance_reason && (
  693. <div style={{
  694. fontSize: '30px',
  695. color: '#9f1239',
  696. lineHeight: '1.4',
  697. }}>
  698. {data.relevance_reason}
  699. </div>
  700. )}
  701. </div>
  702. )}
  703. </div>
  704. )}
  705. {/* 轮播图 */}
  706. {hasImages && (
  707. <div style={{
  708. position: 'relative',
  709. marginBottom: '16px',
  710. borderRadius: '24px',
  711. overflow: 'hidden',
  712. }}>
  713. <img
  714. src={data.imageList[currentImageIndex].image_url}
  715. alt={\`Image \${currentImageIndex + 1}\`}
  716. style={{
  717. width: '100%',
  718. aspectRatio: '1/1',
  719. height: 'auto',
  720. objectFit: 'cover',
  721. display: 'block',
  722. }}
  723. onError={(e) => {
  724. e.target.style.display = 'none';
  725. }}
  726. />
  727. {data.imageList.length > 1 && (
  728. <>
  729. {/* 左右切换按钮 */}
  730. <button
  731. onClick={prevImage}
  732. style={{
  733. position: 'absolute',
  734. left: '8px',
  735. top: '50%',
  736. transform: 'translateY(-50%)',
  737. background: 'rgba(0, 0, 0, 0.5)',
  738. color: 'white',
  739. border: 'none',
  740. borderRadius: '50%',
  741. width: '48px',
  742. height: '48px',
  743. cursor: 'pointer',
  744. display: 'flex',
  745. alignItems: 'center',
  746. justifyContent: 'center',
  747. fontSize: '28px',
  748. }}
  749. >
  750. </button>
  751. <button
  752. onClick={nextImage}
  753. style={{
  754. position: 'absolute',
  755. right: '8px',
  756. top: '50%',
  757. transform: 'translateY(-50%)',
  758. background: 'rgba(0, 0, 0, 0.5)',
  759. color: 'white',
  760. border: 'none',
  761. borderRadius: '50%',
  762. width: '48px',
  763. height: '48px',
  764. cursor: 'pointer',
  765. display: 'flex',
  766. alignItems: 'center',
  767. justifyContent: 'center',
  768. fontSize: '28px',
  769. }}
  770. >
  771. </button>
  772. {/* 图片计数 */}
  773. <div style={{
  774. position: 'absolute',
  775. bottom: '8px',
  776. right: '8px',
  777. background: 'rgba(0, 0, 0, 0.6)',
  778. color: 'white',
  779. padding: '4px 12px',
  780. borderRadius: '20px',
  781. fontSize: '20px',
  782. }}>
  783. {currentImageIndex + 1}/{data.imageList.length}
  784. </div>
  785. </>
  786. )}
  787. </div>
  788. )}
  789. {/* 互动数据 */}
  790. {data.interact_info && (
  791. <div style={{
  792. display: 'flex',
  793. gap: '16px',
  794. marginBottom: '16px',
  795. flexWrap: 'wrap',
  796. fontSize: '22px',
  797. color: '#9f1239',
  798. }}>
  799. {data.interact_info.liked_count > 0 && (
  800. <span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
  801. ❤️ {data.interact_info.liked_count}
  802. </span>
  803. )}
  804. {data.interact_info.collected_count > 0 && (
  805. <span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
  806. ⭐ {data.interact_info.collected_count}
  807. </span>
  808. )}
  809. {data.interact_info.comment_count > 0 && (
  810. <span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
  811. 💬 {data.interact_info.comment_count}
  812. </span>
  813. )}
  814. {data.interact_info.shared_count > 0 && (
  815. <span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
  816. 🔗 {data.interact_info.shared_count}
  817. </span>
  818. )}
  819. </div>
  820. )}
  821. {/* 被哪些query找到 */}
  822. {data.foundByQueries && data.foundByQueries.length > 0 && (
  823. <div style={{
  824. marginBottom: '16px',
  825. padding: '12px 16px',
  826. background: '#f0fdf4',
  827. borderRadius: '12px',
  828. fontSize: '20px',
  829. }}>
  830. <strong style={{ color: '#16a34a' }}>🔍 被找到:</strong>
  831. <div style={{ marginTop: '8px', display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
  832. {data.foundByQueries.map((query, idx) => (
  833. <span key={idx} style={{
  834. display: 'inline-block',
  835. padding: '4px 12px',
  836. background: '#dcfce7',
  837. color: '#166534',
  838. borderRadius: '8px',
  839. fontSize: '18px',
  840. }}>
  841. {query}
  842. </span>
  843. ))}
  844. </div>
  845. {data.foundInRounds && data.foundInRounds.length > 0 && (
  846. <div style={{ marginTop: '8px', color: '#6b7280' }}>
  847. 出现在: Round {data.foundInRounds.join(', ')}
  848. </div>
  849. )}
  850. </div>
  851. )}
  852. {/* 标签 */}
  853. {(data.matchLevel || data.score) && (
  854. <div style={{ display: 'flex', gap: '12px', marginBottom: '16px', flexWrap: 'wrap' }}>
  855. {data.matchLevel && (
  856. <span style={{
  857. display: 'inline-block',
  858. padding: '4px 16px',
  859. borderRadius: '24px',
  860. background: '#fff1f2',
  861. color: '#be123c',
  862. fontSize: '20px',
  863. fontWeight: '500',
  864. }}>
  865. {data.matchLevel}
  866. </span>
  867. )}
  868. {/* Score标签已隐藏 - V2不再需要 */}
  869. </div>
  870. )}
  871. {/* 描述 */}
  872. {expanded && data.description && (
  873. <div style={{
  874. fontSize: '22px',
  875. color: '#9f1239',
  876. lineHeight: '1.5',
  877. paddingTop: '16px',
  878. borderTop: '2px solid #fbcfe8',
  879. }}>
  880. {data.description}
  881. </div>
  882. )}
  883. {/* 评估理由 */}
  884. {expanded && data.evaluationReason && (
  885. <div style={{
  886. fontSize: '20px',
  887. color: '#831843',
  888. lineHeight: '1.5',
  889. paddingTop: '16px',
  890. marginTop: '16px',
  891. borderTop: '2px solid #fbcfe8',
  892. }}>
  893. <strong style={{ color: '#9f1239' }}>评估:</strong>
  894. <div style={{ marginTop: '4px' }}>{data.evaluationReason}</div>
  895. </div>
  896. )}
  897. </div>
  898. <Handle
  899. type="source"
  900. position={sourcePosition || Position.Right}
  901. style={{ background: '#ec4899', width: 8, height: 8 }}
  902. />
  903. </div>
  904. );
  905. }
  906. // AnalysisNode 组件:展示AI分析(左侧OCR文字,右侧缩略图+描述)
  907. function AnalysisNode({ data }) {
  908. const nodeStyle = {
  909. background: '#fffbeb',
  910. border: '2px solid #fbbf24',
  911. borderRadius: '8px',
  912. padding: '12px',
  913. minWidth: '700px',
  914. maxWidth: '900px',
  915. fontSize: '12px',
  916. boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
  917. cursor: 'pointer',
  918. };
  919. const handleCardClick = (e) => {
  920. // 如果点击的是链接或按钮(或其子元素),不处理(避免双重触发)
  921. if (e.target.closest('a') || e.target.closest('button')) {
  922. return;
  923. }
  924. // 打开原帖链接
  925. if (data.note_url) {
  926. window.open(data.note_url, '_blank', 'noopener,noreferrer');
  927. }
  928. };
  929. return (
  930. <div style={nodeStyle} onClick={handleCardClick}>
  931. <Handle
  932. type="target"
  933. position={Position.Left}
  934. style={{ background: '#fbbf24', width: 8, height: 8 }}
  935. />
  936. {/* 标题 */}
  937. <div style={{
  938. fontSize: '14px',
  939. fontWeight: 'bold',
  940. marginBottom: '8px',
  941. color: '#92400e',
  942. }}>
  943. 🖼️ {data.query}
  944. </div>
  945. {/* 评分和互动数据 */}
  946. <div style={{
  947. display: 'flex',
  948. justifyContent: 'space-between',
  949. marginBottom: '8px',
  950. padding: '6px',
  951. background: '#fef3c7',
  952. borderRadius: '4px',
  953. }}>
  954. <div style={{ fontSize: '11px', fontWeight: 'bold' }}>
  955. Score: {data.interact_info?.relevance_score || 0}
  956. </div>
  957. <div style={{ display: 'flex', gap: '12px', fontSize: '11px' }}>
  958. {data.interact_info?.liked_count > 0 && (
  959. <span>❤️ {data.interact_info.liked_count}</span>
  960. )}
  961. {data.interact_info?.collected_count > 0 && (
  962. <span>⭐ {data.interact_info.collected_count}</span>
  963. )}
  964. {data.interact_info?.comment_count > 0 && (
  965. <span>💬 {data.interact_info.comment_count}</span>
  966. )}
  967. </div>
  968. </div>
  969. {/* 完整正文内容 */}
  970. {data.body_text && (
  971. <div style={{
  972. padding: '8px',
  973. background: 'white',
  974. borderRadius: '4px',
  975. marginBottom: '12px',
  976. fontSize: '11px',
  977. lineHeight: '1.5',
  978. border: '1px solid #fbbf24',
  979. whiteSpace: 'pre-wrap',
  980. wordBreak: 'break-word',
  981. }}>
  982. {data.body_text}
  983. </div>
  984. )}
  985. {/* AI分析 - 左右分栏 */}
  986. {data.extraction && data.extraction.images && (
  987. <div style={{
  988. display: 'flex',
  989. flexDirection: 'column',
  990. gap: '12px',
  991. }}>
  992. {data.extraction.images.map((img, idx) => (
  993. <div
  994. key={idx}
  995. style={{
  996. display: 'flex',
  997. flexDirection: 'row',
  998. gap: '16px',
  999. padding: '10px',
  1000. background: 'white',
  1001. borderRadius: '4px',
  1002. border: '1px solid #d97706',
  1003. alignItems: 'flex-start',
  1004. }}
  1005. >
  1006. {/* 左侧:OCR提取文字 */}
  1007. <div style={{
  1008. flex: '1', // 1/3宽度
  1009. minWidth: '0',
  1010. }}>
  1011. <div style={{
  1012. fontSize: '11px',
  1013. fontWeight: 'bold',
  1014. color: '#92400e',
  1015. marginBottom: '6px',
  1016. }}>
  1017. 📝 图片 {idx + 1}/{data.extraction.images.length}
  1018. </div>
  1019. {img.extract_text && (
  1020. <div style={{
  1021. fontSize: '11px',
  1022. color: '#1f2937',
  1023. lineHeight: '1.6',
  1024. padding: '8px',
  1025. background: '#fef9e7',
  1026. borderRadius: '3px',
  1027. borderLeft: '3px solid #f39c12',
  1028. wordBreak: 'break-word',
  1029. }}>
  1030. <div style={{
  1031. fontSize: '10px',
  1032. fontWeight: 'bold',
  1033. color: '#d97706',
  1034. marginBottom: '4px',
  1035. }}>
  1036. 【提取文字】
  1037. </div>
  1038. {img.extract_text}
  1039. </div>
  1040. )}
  1041. </div>
  1042. {/* 右侧:缩略图 + 描述 */}
  1043. <div style={{
  1044. flex: '2', // 2/3宽度
  1045. display: 'flex',
  1046. flexDirection: 'column',
  1047. gap: '8px',
  1048. minWidth: '200px',
  1049. }}>
  1050. {/* 缩略图 */}
  1051. {data.image_list && data.image_list[idx] && (
  1052. <img
  1053. src={(data.image_list[idx].image_url || data.image_list[idx])}
  1054. alt={'图片' + (idx + 1)}
  1055. style={{
  1056. width: '100%',
  1057. height: 'auto',
  1058. maxHeight: '180px',
  1059. objectFit: 'contain',
  1060. borderRadius: '4px',
  1061. border: '1px solid #d97706',
  1062. cursor: 'pointer',
  1063. }}
  1064. onError={(e) => {
  1065. e.target.style.display = 'none';
  1066. }}
  1067. />
  1068. )}
  1069. {/* 描述文字(完整展示) */}
  1070. {img.description && (
  1071. <div
  1072. style={{
  1073. fontSize: '10px',
  1074. color: '#78350f',
  1075. lineHeight: '1.5',
  1076. wordBreak: 'break-word',
  1077. padding: '8px',
  1078. background: '#fef9e7',
  1079. borderRadius: '3px',
  1080. border: '1px solid #f39c12',
  1081. }}
  1082. >
  1083. <div style={{
  1084. fontSize: '9px',
  1085. fontWeight: 'bold',
  1086. color: '#d97706',
  1087. marginBottom: '4px',
  1088. }}>
  1089. 【图片描述】
  1090. </div>
  1091. {img.description}
  1092. </div>
  1093. )}
  1094. </div>
  1095. </div>
  1096. ))}
  1097. </div>
  1098. )}
  1099. {/* 查看原帖链接 */}
  1100. {data.note_url && (
  1101. <div style={{ marginTop: '8px', fontSize: '10px' }}>
  1102. <a
  1103. href={data.note_url}
  1104. target="_blank"
  1105. rel="noopener noreferrer"
  1106. style={{ color: '#92400e', textDecoration: 'underline' }}
  1107. >
  1108. 🔗 查看原帖
  1109. </a>
  1110. </div>
  1111. )}
  1112. <Handle
  1113. type="source"
  1114. position={Position.Right}
  1115. style={{ background: '#fbbf24', width: 8, height: 8 }}
  1116. />
  1117. </div>
  1118. );
  1119. }
  1120. const nodeTypes = {
  1121. query: QueryNode,
  1122. note: NoteNode,
  1123. post: NoteNode, // 帖子节点使用 NoteNode 组件渲染
  1124. analysis: AnalysisNode,
  1125. };
  1126. // 根据 score 获取颜色
  1127. function getScoreColor(score) {
  1128. if (score >= 0.7) return '#10b981'; // 绿色 - 高分
  1129. if (score >= 0.4) return '#f59e0b'; // 橙色 - 中分
  1130. return '#ef4444'; // 红色 - 低分
  1131. }
  1132. // 截断文本,保留头尾,中间显示省略号
  1133. function truncateMiddle(text, maxLength = 20) {
  1134. if (!text || text.length <= maxLength) return text;
  1135. const headLength = Math.ceil(maxLength * 0.4);
  1136. const tailLength = Math.floor(maxLength * 0.4);
  1137. const head = text.substring(0, headLength);
  1138. const tail = text.substring(text.length - tailLength);
  1139. return \`\${head}...\${tail}\`;
  1140. }
  1141. // 根据策略获取颜色
  1142. // 智能提取主要策略的辅助函数
  1143. function getPrimaryStrategy(nodeData) {
  1144. // 优先级1: 使用 primaryStrategy 字段
  1145. if (nodeData.primaryStrategy) {
  1146. return nodeData.primaryStrategy;
  1147. }
  1148. // 优先级2: 从 occurrences 数组中获取最新的策略
  1149. if (nodeData.occurrences && Array.isArray(nodeData.occurrences) && nodeData.occurrences.length > 0) {
  1150. const latestOccurrence = nodeData.occurrences[nodeData.occurrences.length - 1];
  1151. if (latestOccurrence && latestOccurrence.strategy) {
  1152. return latestOccurrence.strategy;
  1153. }
  1154. }
  1155. // 优先级3: 拆分组合策略字符串,取第一个
  1156. if (nodeData.strategy && typeof nodeData.strategy === 'string') {
  1157. const strategies = nodeData.strategy.split(' + ');
  1158. if (strategies.length > 0 && strategies[0]) {
  1159. return strategies[0].trim();
  1160. }
  1161. }
  1162. // 默认返回原始strategy或未知
  1163. return nodeData.strategy || '未知';
  1164. }
  1165. function getStrategyColor(strategy) {
  1166. const strategyColors = {
  1167. '初始分词': '#10b981',
  1168. '调用sug': '#06b6d4',
  1169. '同义改写': '#f59e0b',
  1170. '加词': '#3b82f6',
  1171. '抽象改写': '#8b5cf6',
  1172. '基于部分匹配改进': '#ec4899',
  1173. '结果分支-抽象改写': '#a855f7',
  1174. '结果分支-同义改写': '#fb923c',
  1175. // v6.1.2.8 新增策略
  1176. '原始问题': '#6b21a8',
  1177. '来自分词': '#10b981',
  1178. '加词生成': '#ef4444',
  1179. '建议词': '#06b6d4',
  1180. '执行搜索': '#8b5cf6',
  1181. // 添加简化版本的策略映射
  1182. '分词': '#10b981',
  1183. '推荐词': '#06b6d4',
  1184. };
  1185. return strategyColors[strategy] || '#9ca3af';
  1186. }
  1187. // 树节点组件
  1188. function TreeNode({ node, level, children, isCollapsed, onToggle, isSelected, onSelect }) {
  1189. const hasChildren = children && children.length > 0;
  1190. const score = node.data.score ? parseFloat(node.data.score) : 0;
  1191. const strategy = getPrimaryStrategy(node.data); // 使用智能提取函数
  1192. const strategyColor = getStrategyColor(strategy);
  1193. const nodeActualType = node.data.nodeType || node.type; // 获取实际节点类型
  1194. const isDomainCombination = nodeActualType === 'domain_combination';
  1195. let sourceSummary = '';
  1196. if (isDomainCombination && Array.isArray(node.data.source_word_details) && node.data.source_word_details.length > 0) {
  1197. const summaryParts = [];
  1198. node.data.source_word_details.forEach((detail) => {
  1199. const words = Array.isArray(detail.words) ? detail.words : [];
  1200. const wordTexts = [];
  1201. words.forEach((w) => {
  1202. const numericScore = typeof w.score === 'number' ? w.score : parseFloat(w.score || '0');
  1203. const formattedScore = Number.isFinite(numericScore) ? numericScore.toFixed(2) : '0.00';
  1204. wordTexts.push(w.text + ' (' + formattedScore + ')');
  1205. });
  1206. if (wordTexts.length > 0) {
  1207. const segmentLabel = detail.segment_type ? '[' + detail.segment_type + '] ' : '';
  1208. summaryParts.push(segmentLabel + wordTexts.join(' + '));
  1209. }
  1210. });
  1211. sourceSummary = summaryParts.join(' | ');
  1212. }
  1213. // 计算字体颜色:根据分数提升幅度判断
  1214. let fontColor = '#374151'; // 默认颜色
  1215. if (node.type === 'note') {
  1216. // V2评估:基于知识得分和相关性得分判断颜色
  1217. const knowledgeScore = node.data.knowledge_score;
  1218. const relevanceScore = node.data.post_relevance_score;
  1219. if (knowledgeScore != null && relevanceScore != null) {
  1220. if (knowledgeScore <= 40) {
  1221. fontColor = '#ef4444'; // 红色 - 知识得分低
  1222. } else if (knowledgeScore > 40 && relevanceScore > 40) {
  1223. fontColor = '#22c55e'; // 绿色 - 知识和相关性都高
  1224. } else {
  1225. fontColor = '#eab308'; // 黄色 - 知识得分高但相关性低
  1226. }
  1227. } else {
  1228. // V1兼容:如果没有V2评估数据,使用matchLevel判断
  1229. fontColor = node.data.matchLevel === 'unsatisfied' ? '#ef4444' : '#374151';
  1230. }
  1231. } else if (node.data.seed_score !== undefined) {
  1232. const parentScore = parseFloat(node.data.seed_score);
  1233. const gain = score - parentScore;
  1234. fontColor = gain >= 0.05 ? '#16a34a' : '#ef4444';
  1235. } else if (node.data.isSelected === false) {
  1236. fontColor = '#ef4444';
  1237. }
  1238. return (
  1239. <div style={{ marginLeft: level * 12 + 'px', marginBottom: '8px' }}>
  1240. <div
  1241. style={{
  1242. padding: '6px 8px',
  1243. borderRadius: '4px',
  1244. cursor: 'pointer',
  1245. background: 'transparent',
  1246. border: isSelected ? '1px solid #3b82f6' : '1px solid transparent',
  1247. display: 'flex',
  1248. alignItems: 'center',
  1249. gap: '6px',
  1250. transition: 'all 0.2s ease',
  1251. position: 'relative',
  1252. overflow: 'visible',
  1253. }}
  1254. onMouseEnter={(e) => {
  1255. if (!isSelected) e.currentTarget.style.background = '#f9fafb';
  1256. }}
  1257. onMouseLeave={(e) => {
  1258. if (!isSelected) e.currentTarget.style.background = 'transparent';
  1259. }}
  1260. >
  1261. {/* 策略类型竖线 */}
  1262. <div style={{
  1263. width: '3px',
  1264. height: '20px',
  1265. background: strategyColor,
  1266. borderRadius: '2px',
  1267. flexShrink: 0,
  1268. position: 'relative',
  1269. zIndex: 1,
  1270. }} />
  1271. {hasChildren && (
  1272. <span
  1273. style={{
  1274. fontSize: '10px',
  1275. color: '#6b7280',
  1276. cursor: 'pointer',
  1277. width: '16px',
  1278. textAlign: 'center',
  1279. position: 'relative',
  1280. zIndex: 1,
  1281. }}
  1282. onClick={(e) => {
  1283. e.stopPropagation();
  1284. onToggle();
  1285. }}
  1286. >
  1287. {isCollapsed ? '▶' : '▼'}
  1288. </span>
  1289. )}
  1290. {!hasChildren && <span style={{ width: '16px', position: 'relative', zIndex: 1 }}></span>}
  1291. <div
  1292. style={{
  1293. flex: 1,
  1294. fontSize: '12px',
  1295. color: '#374151',
  1296. position: 'relative',
  1297. zIndex: 1,
  1298. minWidth: 0,
  1299. display: 'flex',
  1300. flexDirection: 'column',
  1301. gap: '4px',
  1302. }}
  1303. onClick={onSelect}
  1304. >
  1305. <div style={{
  1306. display: 'flex',
  1307. alignItems: 'center',
  1308. gap: '8px',
  1309. }}>
  1310. {/* 文本标题 - 左侧 */}
  1311. <div style={{
  1312. fontWeight: level === 0 ? '600' : '400',
  1313. flex: 1,
  1314. minWidth: 0,
  1315. color: node.data.scoreColor || fontColor,
  1316. overflow: 'hidden',
  1317. textOverflow: 'ellipsis',
  1318. whiteSpace: 'nowrap',
  1319. }}
  1320. title={node.data.title || node.id}
  1321. >
  1322. {node.data.title || node.id}
  1323. </div>
  1324. {/* 域标识 - 右侧,挨着分数,优先显示域类型,否则显示域索引或域字符串,但domain_combination节点不显示 */}
  1325. {(node.data.domain_type || node.data.domains_str || (node.data.domain_index !== null && node.data.domain_index !== undefined)) && nodeActualType !== 'domain_combination' && (
  1326. <span style={{
  1327. fontSize: '12px',
  1328. color: '#fff',
  1329. background: '#6366f1',
  1330. padding: '2px 5px',
  1331. borderRadius: '3px',
  1332. flexShrink: 0,
  1333. fontWeight: '600',
  1334. marginLeft: '4px',
  1335. }}
  1336. title={
  1337. node.data.domain_type ? '域: ' + node.data.domain_type + ' (D' + node.data.domain_index + ')' :
  1338. node.data.domains_str ? '域: ' + node.data.domains_str :
  1339. '域 D' + node.data.domain_index
  1340. }
  1341. >
  1342. {node.data.domain_type || node.data.domains_str || ('D' + node.data.domain_index)}
  1343. </span>
  1344. )}
  1345. {node.data.is_suggestion && node.data.suggestion_label && (
  1346. <span style={{
  1347. fontSize: '12px',
  1348. color: '#fff',
  1349. background: '#8b5cf6',
  1350. padding: '2px 5px',
  1351. borderRadius: '3px',
  1352. flexShrink: 0,
  1353. fontWeight: '600',
  1354. }}
  1355. >
  1356. {node.data.suggestion_label}
  1357. </span>
  1358. )}
  1359. {/* 类型标签 - 显示在右侧靠近分数,蓝色背景 */}
  1360. {node.data.type_label && (
  1361. <span style={{
  1362. fontSize: '12px',
  1363. color: '#fff',
  1364. background: '#6366f1',
  1365. padding: '2px 5px',
  1366. borderRadius: '3px',
  1367. flexShrink: 0,
  1368. fontWeight: '600',
  1369. }}
  1370. title={'类型: ' + node.data.type_label}
  1371. >
  1372. {node.data.type_label}
  1373. </span>
  1374. )}
  1375. {/* 分数显示 - 步骤和轮次节点不显示分数 */}
  1376. {nodeActualType !== 'step' && nodeActualType !== 'round' && (
  1377. <span style={{
  1378. fontSize: '11px',
  1379. color: '#6b7280',
  1380. fontWeight: '500',
  1381. flexShrink: 0,
  1382. minWidth: '35px',
  1383. textAlign: 'right',
  1384. }}>
  1385. {score.toFixed(2)}
  1386. </span>
  1387. )}
  1388. </div>
  1389. {/* 域组合的来源词得分(树状视图,右对齐) */}
  1390. {isDomainCombination && sourceSummary && (
  1391. <div style={{
  1392. fontSize: '10px',
  1393. color: '#2563eb',
  1394. lineHeight: '1.4',
  1395. display: 'flex',
  1396. flexDirection: 'column',
  1397. alignItems: 'flex-end',
  1398. gap: '2px',
  1399. textAlign: 'right',
  1400. }}>
  1401. {node.data.source_word_details.map((detail, idx) => {
  1402. const words = Array.isArray(detail.words) ? detail.words : [];
  1403. const summary = words.map((w) => {
  1404. const numericScore = typeof w.score === 'number' ? w.score : parseFloat(w.score || '0');
  1405. const formattedScore = Number.isFinite(numericScore) ? numericScore.toFixed(2) : '0.00';
  1406. return w.text + ' (' + formattedScore + ')';
  1407. }).join(' + ');
  1408. return (
  1409. <span key={idx} title={summary}>
  1410. {summary}
  1411. </span>
  1412. );
  1413. })}
  1414. </div>
  1415. )}
  1416. {/* 分数下划线 - 步骤和轮次节点不显示 */}
  1417. {nodeActualType !== 'step' && nodeActualType !== 'round' && (
  1418. <div style={{
  1419. width: (score * 100) + '%',
  1420. height: '2px',
  1421. background: getScoreColor(score),
  1422. borderRadius: '1px',
  1423. }} />
  1424. )}
  1425. </div>
  1426. </div>
  1427. {hasChildren && !isCollapsed && (
  1428. <div>
  1429. {children}
  1430. </div>
  1431. )}
  1432. </div>
  1433. );
  1434. }
  1435. // 使用 dagre 自动布局
  1436. function getLayoutedElements(nodes, edges, direction = 'LR') {
  1437. console.log('🎯 Starting layout with dagre...');
  1438. console.log('Input:', nodes.length, 'nodes,', edges.length, 'edges');
  1439. // 检查 dagre 是否加载
  1440. if (typeof window === 'undefined' || typeof window.dagre === 'undefined') {
  1441. console.warn('⚠️ Dagre not loaded, using fallback layout');
  1442. // 降级到简单布局
  1443. const levelGroups = {};
  1444. nodes.forEach(node => {
  1445. const level = node.data.level || 0;
  1446. if (!levelGroups[level]) levelGroups[level] = [];
  1447. levelGroups[level].push(node);
  1448. });
  1449. Object.entries(levelGroups).forEach(([level, nodeList]) => {
  1450. const x = parseInt(level) * 480;
  1451. nodeList.forEach((node, index) => {
  1452. node.position = { x, y: index * 260 };
  1453. node.targetPosition = 'left';
  1454. node.sourcePosition = 'right';
  1455. });
  1456. });
  1457. return { nodes, edges };
  1458. }
  1459. try {
  1460. const dagreGraph = new window.dagre.graphlib.Graph();
  1461. dagreGraph.setDefaultEdgeLabel(() => ({}));
  1462. const isHorizontal = direction === 'LR';
  1463. dagreGraph.setGraph({
  1464. rankdir: direction,
  1465. nodesep: 800, // 垂直间距 - 增加以适应更高的note节点(卡片高度2600px + 800px间距)
  1466. ranksep: 400, // 水平间距 - 增加以容纳更宽的节点
  1467. });
  1468. // 添加节点 - 根据节点类型设置不同的尺寸
  1469. nodes.forEach((node) => {
  1470. let nodeWidth = 320;
  1471. let nodeHeight = 220;
  1472. // note 节点有轮播图,需要更大的空间
  1473. if (node.type === 'note') {
  1474. nodeWidth = 360;
  1475. nodeHeight = 2600; // 更新以适应完整内容:1:1图片(880px) + 标题/原始问题/评估(500px) + 正文/AI提取(最多1200px)
  1476. }
  1477. // analysis 节点内容很多,需要更大的空间
  1478. else if (node.type === 'analysis') {
  1479. nodeWidth = 900; // 宽度足够容纳左右分栏
  1480. nodeHeight = 600; // 高度足够容纳多张图片
  1481. }
  1482. dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
  1483. });
  1484. // 添加边
  1485. edges.forEach((edge) => {
  1486. dagreGraph.setEdge(edge.source, edge.target);
  1487. });
  1488. // 计算布局
  1489. window.dagre.layout(dagreGraph);
  1490. console.log('✅ Dagre layout completed');
  1491. // 更新节点位置和 handle 位置
  1492. nodes.forEach((node) => {
  1493. const nodeWithPosition = dagreGraph.node(node.id);
  1494. if (!nodeWithPosition) {
  1495. console.warn('Node position not found for:', node.id);
  1496. return;
  1497. }
  1498. node.targetPosition = isHorizontal ? 'left' : 'top';
  1499. node.sourcePosition = isHorizontal ? 'right' : 'bottom';
  1500. // 根据节点类型获取尺寸
  1501. let nodeWidth = 320;
  1502. let nodeHeight = 220;
  1503. if (node.type === 'note') {
  1504. nodeWidth = 360;
  1505. nodeHeight = 2600; // 与dagre布局参数保持一致
  1506. }
  1507. // 将 dagre 的中心点位置转换为 React Flow 的左上角位置
  1508. node.position = {
  1509. x: nodeWithPosition.x - nodeWidth / 2,
  1510. y: nodeWithPosition.y - nodeHeight / 2,
  1511. };
  1512. });
  1513. // 为同层级的 note 节点添加交错偏移,避免视觉重叠
  1514. console.log('=== 开始交错偏移逻辑 ===');
  1515. console.log('总节点数:', nodes.length);
  1516. const noteNodes = nodes.filter(n => n.type === 'note');
  1517. console.log('过滤后的 note 节点数:', noteNodes.length);
  1518. if (noteNodes.length > 1) {
  1519. // 输出排序前的位置
  1520. console.log('排序前的 note 节点位置:');
  1521. noteNodes.forEach((n, i) => {
  1522. console.log(' [' + i + '] ' + n.id.substring(0, 40) + '... | type=' + n.type + ' | pos=(' + n.position.x.toFixed(0) + ', ' + n.position.y.toFixed(0) + ')');
  1523. });
  1524. // 按 Y 坐标排序
  1525. noteNodes.sort((a, b) => a.position.y - b.position.y);
  1526. console.log('排序后的 note 节点位置:');
  1527. noteNodes.forEach((n, i) => {
  1528. console.log(' [' + i + '] ' + n.id.substring(0, 40) + '... | pos=(' + n.position.x.toFixed(0) + ', ' + n.position.y.toFixed(0) + ')');
  1529. });
  1530. // 为相邻的 note 节点添加 X 方向的交错(3个位置:左、中、右)
  1531. const baseX = noteNodes.length > 0 ? noteNodes[0].position.x : 0;
  1532. const leftX = baseX - 1500;
  1533. const centerX = baseX;
  1534. const rightX = baseX + 1500;
  1535. let appliedCount = 0;
  1536. noteNodes.forEach((node, index) => {
  1537. const oldX = node.position.x;
  1538. const position = index % 3;
  1539. if (position === 0) {
  1540. node.position.x = leftX;
  1541. console.log(' [' + index + '] 设置为左侧: X ' + oldX.toFixed(0) + ' → ' + node.position.x.toFixed(0));
  1542. } else if (position === 1) {
  1543. node.position.x = centerX;
  1544. console.log(' [' + index + '] 设置为中间: X ' + oldX.toFixed(0) + ' → ' + node.position.x.toFixed(0));
  1545. } else {
  1546. node.position.x = rightX;
  1547. console.log(' [' + index + '] 设置为右侧: X ' + oldX.toFixed(0) + ' → ' + node.position.x.toFixed(0));
  1548. }
  1549. appliedCount++;
  1550. });
  1551. console.log('总共应用了 ' + appliedCount + ' 次偏移');
  1552. } else {
  1553. console.log('note 节点数量 <= 1,不需要交错');
  1554. }
  1555. console.log('=== 交错偏移逻辑结束 ===');
  1556. console.log('✅ Layout completed, sample node:', nodes[0]);
  1557. return { nodes, edges };
  1558. } catch (error) {
  1559. console.error('❌ Error in dagre layout:', error);
  1560. console.error('Error details:', error.message, error.stack);
  1561. // 降级处理
  1562. console.log('Using fallback layout...');
  1563. const levelGroups = {};
  1564. nodes.forEach(node => {
  1565. const level = node.data.level || 0;
  1566. if (!levelGroups[level]) levelGroups[level] = [];
  1567. levelGroups[level].push(node);
  1568. });
  1569. Object.entries(levelGroups).forEach(([level, nodeList]) => {
  1570. const x = parseInt(level) * 480;
  1571. nodeList.forEach((node, index) => {
  1572. node.position = { x, y: index * 260 };
  1573. node.targetPosition = 'left';
  1574. node.sourcePosition = 'right';
  1575. });
  1576. });
  1577. return { nodes, edges };
  1578. }
  1579. }
  1580. function transformData(data) {
  1581. const nodes = [];
  1582. const edges = [];
  1583. const originalIdToCanvasId = {}; // 原始ID -> 画布ID的映射
  1584. const canvasIdToNodeData = {}; // 避免重复创建相同的节点
  1585. let analysisNodeCount = 0; // 用于给analysis节点添加X偏移
  1586. // 🆕 获取原始问题(从root节点)
  1587. const originalQuestion = data.nodes['root_o']?.query || '';
  1588. // 创建节点
  1589. Object.entries(data.nodes).forEach(([originalId, node]) => {
  1590. // 统一处理所有类型的节点
  1591. const nodeType = node.type || 'query';
  1592. // 直接使用originalId作为canvasId,避免冲突
  1593. const canvasId = originalId;
  1594. originalIdToCanvasId[originalId] = canvasId;
  1595. // 如果这个 canvasId 还没有创建过节点,则创建
  1596. if (!canvasIdToNodeData[canvasId]) {
  1597. canvasIdToNodeData[canvasId] = true;
  1598. // 根据节点类型创建不同的数据结构
  1599. if (nodeType === 'note' || nodeType === 'post') {
  1600. nodes.push({
  1601. id: canvasId,
  1602. originalId: originalId,
  1603. type: 'note',
  1604. data: {
  1605. title: node.query || node.title || '帖子',
  1606. matchLevel: node.match_level,
  1607. score: node.relevance_score ? node.relevance_score.toFixed(2) : '0.00',
  1608. description: node.body_text || node.desc || '',
  1609. isSelected: node.is_selected !== undefined ? node.is_selected : true,
  1610. imageList: node.image_list || [],
  1611. note_url: node.note_url || '',
  1612. evaluationReason: node.evaluationReason || node.evaluation_reason || '',
  1613. interact_info: node.interact_info || {},
  1614. nodeType: nodeType,
  1615. // 🆕 评估字段 (V2)
  1616. // 知识评估
  1617. is_knowledge: node.is_knowledge !== undefined ? node.is_knowledge : null,
  1618. knowledge_reason: node.knowledge_reason || '',
  1619. knowledge_score: node.knowledge_score !== undefined ? node.knowledge_score : null,
  1620. knowledge_level: node.knowledge_level !== undefined ? node.knowledge_level : null,
  1621. knowledge_evaluation: node.knowledge_evaluation || null,
  1622. // 相关性评估
  1623. post_relevance_score: node.post_relevance_score !== undefined ? node.post_relevance_score : null,
  1624. relevance_level: node.relevance_level || '',
  1625. relevance_reason: node.relevance_reason || '',
  1626. relevance_conclusion: node.relevance_conclusion || '',
  1627. relevance_evaluation: node.relevance_evaluation || null,
  1628. // 🆕 原始问题
  1629. originalQuestion: originalQuestion
  1630. },
  1631. position: { x: 0, y: 0 },
  1632. });
  1633. } else if (nodeType === 'analysis') {
  1634. // AI分析节点 - 添加X偏移避免叠加
  1635. const xOffset = analysisNodeCount * 150; // 每个节点偏移150px
  1636. analysisNodeCount++;
  1637. nodes.push({
  1638. id: canvasId,
  1639. originalId: originalId,
  1640. type: 'analysis',
  1641. data: {
  1642. query: node.query || '[AI分析]',
  1643. note_id: node.note_id,
  1644. note_url: node.note_url,
  1645. title: node.title || '',
  1646. body_text: node.body_text || '',
  1647. interact_info: node.interact_info || {},
  1648. extraction: node.extraction || null,
  1649. image_list: node.image_list || [],
  1650. },
  1651. position: { x: xOffset, y: 0 },
  1652. });
  1653. } else {
  1654. // query, seg, q, search, root 等节点
  1655. let displayTitle = node.query || originalId;
  1656. nodes.push({
  1657. id: canvasId,
  1658. originalId: originalId,
  1659. type: 'query', // 使用 query 组件渲染所有非note节点
  1660. data: {
  1661. title: displayTitle,
  1662. level: node.level || 0,
  1663. score: node.relevance_score ? node.relevance_score.toFixed(2) : '0.00',
  1664. strategy: node.strategy || '',
  1665. parent: node.parent_query || '',
  1666. isSelected: node.is_selected !== undefined ? node.is_selected : true,
  1667. evaluationReason: node.evaluationReason || node.evaluation_reason || '',
  1668. nodeType: nodeType, // 传递实际节点类型用于样式
  1669. searchCount: node.search_count, // search 节点特有
  1670. totalPosts: node.total_posts, // search 节点特有
  1671. selectedWord: node.selected_word || '', // 加词节点特有 - 显示选择的词
  1672. scoreColor: node.scoreColor || null, // SUG节点的颜色标识
  1673. parentQScore: node.parentQScore || 0, // 父Q得分(用于调试)
  1674. domain_index: node.domain_index !== undefined ? node.domain_index : null, // 域索引
  1675. domain_type: node.domain_type || '', // 域类型(如"中心名词"、"核心动作"),只有Q节点有,segment节点不显示
  1676. segment_type: node.segment_type || '', // segment类型(只有segment节点才有)
  1677. type_label: node.type_label || '', // 类型标签
  1678. domains: node.domains || [], // 域索引数组(domain_combination节点特有)
  1679. domains_str: node.domains_str || '', // 域标识字符串(如"D0,D1")
  1680. from_segments: node.from_segments || [], // 来源segments(domain_combination节点特有)
  1681. source_word_details: node.source_word_details || [], // 组合来源词及其得分
  1682. source_scores: node.source_scores || [], // 扁平来源得分
  1683. is_above_sources: node.is_above_sources || false, // 组合是否高于来源得分
  1684. max_source_score: node.max_source_score !== undefined ? node.max_source_score : null, // 来源最高分
  1685. item_type: node.item_type || '', // 构建下一轮节点来源类型
  1686. is_suggestion: node.is_suggestion || false,
  1687. suggestion_label: node.suggestion_label || '',
  1688. },
  1689. position: { x: 0, y: 0 },
  1690. });
  1691. }
  1692. }
  1693. });
  1694. // 创建边 - 使用虚线样式,映射到画布ID
  1695. data.edges.forEach((edge, index) => {
  1696. const edgeColors = {
  1697. '初始分词': '#10b981',
  1698. '调用sug': '#06b6d4',
  1699. '同义改写': '#f59e0b',
  1700. '加词': '#3b82f6',
  1701. '抽象改写': '#8b5cf6',
  1702. '基于部分匹配改进': '#ec4899',
  1703. '结果分支-抽象改写': '#a855f7',
  1704. '结果分支-同义改写': '#fb923c',
  1705. 'query_to_note': '#ec4899',
  1706. };
  1707. const color = edgeColors[edge.strategy] || edgeColors[edge.edge_type] || '#d1d5db';
  1708. const isNoteEdge = edge.edge_type === 'query_to_note';
  1709. edges.push({
  1710. id: \`edge-\${index}\`,
  1711. source: originalIdToCanvasId[edge.from], // 使用画布ID
  1712. target: originalIdToCanvasId[edge.to], // 使用画布ID
  1713. type: 'simplebezier', // 使用简单贝塞尔曲线
  1714. animated: isNoteEdge,
  1715. style: {
  1716. stroke: color,
  1717. strokeWidth: isNoteEdge ? 2.5 : 2,
  1718. strokeDasharray: isNoteEdge ? '5,5' : '8,4',
  1719. },
  1720. markerEnd: {
  1721. type: 'arrowclosed',
  1722. color: color,
  1723. width: 20,
  1724. height: 20,
  1725. },
  1726. });
  1727. });
  1728. // 使用 dagre 自动计算布局 - 从左到右
  1729. return getLayoutedElements(nodes, edges, 'LR');
  1730. }
  1731. function FlowContent() {
  1732. // 画布使用简化数据
  1733. const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
  1734. console.log('🔍 Transforming data for canvas...');
  1735. const result = transformData(data);
  1736. console.log('✅ Canvas data:', result.nodes.length, 'nodes,', result.edges.length, 'edges');
  1737. return result;
  1738. }, []);
  1739. // 目录使用完整数据(如果存在)
  1740. const { nodes: fullNodes, edges: fullEdges } = useMemo(() => {
  1741. if (data.fullData) {
  1742. console.log('🔍 Transforming full data for tree directory...');
  1743. const result = transformData(data.fullData);
  1744. console.log('✅ Directory data:', result.nodes.length, 'nodes,', result.edges.length, 'edges');
  1745. return result;
  1746. }
  1747. // 如果没有 fullData,使用简化数据
  1748. return { nodes: initialNodes, edges: initialEdges };
  1749. }, [initialNodes, initialEdges]);
  1750. // 初始化:找出所有有子节点的节点,默认折叠(画布节点)
  1751. const initialCollapsedNodes = useMemo(() => {
  1752. const nodesWithChildren = new Set();
  1753. initialEdges.forEach(edge => {
  1754. nodesWithChildren.add(edge.source);
  1755. });
  1756. // 排除根节点(level 0),让根节点默认展开
  1757. const rootNode = initialNodes.find(n => n.data.level === 0);
  1758. if (rootNode) {
  1759. nodesWithChildren.delete(rootNode.id);
  1760. }
  1761. return nodesWithChildren;
  1762. }, [initialNodes, initialEdges]);
  1763. // 树节点的折叠状态需要在树构建后初始化
  1764. const [collapsedNodes, setCollapsedNodes] = useState(() => initialCollapsedNodes);
  1765. const [collapsedTreeNodes, setCollapsedTreeNodes] = useState(new Set());
  1766. const [selectedNodeId, setSelectedNodeId] = useState(null);
  1767. const [hiddenNodes, setHiddenNodes] = useState(new Set()); // 用户手动隐藏的节点
  1768. const [focusMode, setFocusMode] = useState(false); // 全局聚焦模式,默认关闭
  1769. const [focusedNodeId, setFocusedNodeId] = useState(null); // 单独聚焦的节点ID
  1770. const [sidebarWidth, setSidebarWidth] = useState(400); // 左侧目录宽度
  1771. const [isResizing, setIsResizing] = useState(false); // 是否正在拖拽调整宽度
  1772. // 拖拽调整侧边栏宽度的处理逻辑
  1773. const handleMouseDown = useCallback(() => {
  1774. setIsResizing(true);
  1775. }, []);
  1776. useEffect(() => {
  1777. if (!isResizing) return;
  1778. const handleMouseMove = (e) => {
  1779. const newWidth = e.clientX;
  1780. // 限制宽度范围:300px - 700px
  1781. if (newWidth >= 300 && newWidth <= 700) {
  1782. setSidebarWidth(newWidth);
  1783. }
  1784. };
  1785. const handleMouseUp = () => {
  1786. setIsResizing(false);
  1787. };
  1788. document.addEventListener('mousemove', handleMouseMove);
  1789. document.addEventListener('mouseup', handleMouseUp);
  1790. return () => {
  1791. document.removeEventListener('mousemove', handleMouseMove);
  1792. document.removeEventListener('mouseup', handleMouseUp);
  1793. };
  1794. }, [isResizing]);
  1795. // 获取 React Flow 实例以控制画布
  1796. const { setCenter, fitView } = useReactFlow();
  1797. // 获取某个节点的所有后代节点ID
  1798. const getDescendants = useCallback((nodeId) => {
  1799. const descendants = new Set();
  1800. const queue = [nodeId];
  1801. while (queue.length > 0) {
  1802. const current = queue.shift();
  1803. initialEdges.forEach(edge => {
  1804. if (edge.source === current && !descendants.has(edge.target)) {
  1805. descendants.add(edge.target);
  1806. queue.push(edge.target);
  1807. }
  1808. });
  1809. }
  1810. return descendants;
  1811. }, [initialEdges]);
  1812. // 获取直接父节点
  1813. const getDirectParents = useCallback((nodeId) => {
  1814. const parents = [];
  1815. initialEdges.forEach(edge => {
  1816. if (edge.target === nodeId) {
  1817. parents.push(edge.source);
  1818. }
  1819. });
  1820. return parents;
  1821. }, [initialEdges]);
  1822. // 获取直接子节点
  1823. const getDirectChildren = useCallback((nodeId) => {
  1824. const children = [];
  1825. initialEdges.forEach(edge => {
  1826. if (edge.source === nodeId) {
  1827. children.push(edge.target);
  1828. }
  1829. });
  1830. return children;
  1831. }, [initialEdges]);
  1832. // 切换节点折叠状态
  1833. const toggleNodeCollapse = useCallback((nodeId) => {
  1834. setCollapsedNodes(prev => {
  1835. const newSet = new Set(prev);
  1836. const descendants = getDescendants(nodeId);
  1837. if (newSet.has(nodeId)) {
  1838. // 展开:移除此节点,但保持其他折叠的节点
  1839. newSet.delete(nodeId);
  1840. } else {
  1841. // 折叠:添加此节点
  1842. newSet.add(nodeId);
  1843. }
  1844. return newSet;
  1845. });
  1846. }, [getDescendants]);
  1847. // 过滤可见的节点和边,并重新计算布局
  1848. const { nodes, edges } = useMemo(() => {
  1849. const nodesToHide = new Set();
  1850. // 判断使用哪个节点ID进行聚焦:优先使用单独聚焦的节点,否则使用全局聚焦模式的选中节点
  1851. const effectiveFocusNodeId = focusedNodeId || (focusMode ? selectedNodeId : null);
  1852. // 聚焦模式:只显示聚焦节点、其父节点和直接子节点
  1853. if (effectiveFocusNodeId) {
  1854. const visibleInFocus = new Set([effectiveFocusNodeId]);
  1855. // 添加所有父节点
  1856. initialEdges.forEach(edge => {
  1857. if (edge.target === effectiveFocusNodeId) {
  1858. visibleInFocus.add(edge.source);
  1859. }
  1860. });
  1861. // 添加所有直接子节点
  1862. initialEdges.forEach(edge => {
  1863. if (edge.source === effectiveFocusNodeId) {
  1864. visibleInFocus.add(edge.target);
  1865. }
  1866. });
  1867. // 隐藏不在聚焦范围内的节点
  1868. initialNodes.forEach(node => {
  1869. if (!visibleInFocus.has(node.id)) {
  1870. nodesToHide.add(node.id);
  1871. }
  1872. });
  1873. } else {
  1874. // 非聚焦模式:使用原有的折叠逻辑
  1875. // 收集所有被折叠节点的后代
  1876. collapsedNodes.forEach(collapsedId => {
  1877. const descendants = getDescendants(collapsedId);
  1878. descendants.forEach(id => nodesToHide.add(id));
  1879. });
  1880. }
  1881. // 添加用户手动隐藏的节点
  1882. hiddenNodes.forEach(id => nodesToHide.add(id));
  1883. const visibleNodes = initialNodes
  1884. .filter(node => !nodesToHide.has(node.id))
  1885. .map(node => ({
  1886. ...node,
  1887. data: {
  1888. ...node.data,
  1889. isCollapsed: collapsedNodes.has(node.id),
  1890. hasChildren: initialEdges.some(e => e.source === node.id),
  1891. onToggleCollapse: () => toggleNodeCollapse(node.id),
  1892. onHideSelf: () => {
  1893. setHiddenNodes(prev => {
  1894. const newSet = new Set(prev);
  1895. newSet.add(node.id);
  1896. return newSet;
  1897. });
  1898. },
  1899. onFocus: () => {
  1900. // 切换聚焦状态
  1901. if (focusedNodeId === node.id) {
  1902. setFocusedNodeId(null); // 如果已经聚焦,则取消聚焦
  1903. } else {
  1904. // 先取消之前的聚焦,然后聚焦到当前节点
  1905. setFocusedNodeId(node.id);
  1906. // 延迟聚焦视图到该节点
  1907. setTimeout(() => {
  1908. fitView({
  1909. nodes: [{ id: node.id }],
  1910. duration: 800,
  1911. padding: 0.3,
  1912. });
  1913. }, 100);
  1914. }
  1915. },
  1916. isFocused: focusedNodeId === node.id,
  1917. isHighlighted: selectedNodeId === node.id,
  1918. }
  1919. }));
  1920. const visibleEdges = initialEdges.filter(
  1921. edge => !nodesToHide.has(edge.source) && !nodesToHide.has(edge.target)
  1922. );
  1923. // 重新计算布局 - 只对可见节点
  1924. if (typeof window !== 'undefined' && typeof window.dagre !== 'undefined') {
  1925. try {
  1926. const dagreGraph = new window.dagre.graphlib.Graph();
  1927. dagreGraph.setDefaultEdgeLabel(() => ({}));
  1928. dagreGraph.setGraph({
  1929. rankdir: 'LR',
  1930. nodesep: 800, // 与static layout保持一致,确保不重叠
  1931. ranksep: 400, // 增加水平间距
  1932. });
  1933. visibleNodes.forEach((node) => {
  1934. let nodeWidth = 320;
  1935. let nodeHeight = 220;
  1936. // note 节点有轮播图,需要更大的空间
  1937. if (node.type === 'note') {
  1938. nodeWidth = 360;
  1939. nodeHeight = 2600; // 与static layout保持一致
  1940. }
  1941. dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
  1942. });
  1943. visibleEdges.forEach((edge) => {
  1944. dagreGraph.setEdge(edge.source, edge.target);
  1945. });
  1946. window.dagre.layout(dagreGraph);
  1947. visibleNodes.forEach((node) => {
  1948. const nodeWithPosition = dagreGraph.node(node.id);
  1949. if (nodeWithPosition) {
  1950. // 根据节点类型获取对应的尺寸
  1951. let nodeWidth = 320;
  1952. let nodeHeight = 220;
  1953. if (node.type === 'note') {
  1954. nodeWidth = 360;
  1955. nodeHeight = 2600; // 与static layout保持一致
  1956. }
  1957. node.position = {
  1958. x: nodeWithPosition.x - nodeWidth / 2,
  1959. y: nodeWithPosition.y - nodeHeight / 2,
  1960. };
  1961. node.targetPosition = 'left';
  1962. node.sourcePosition = 'right';
  1963. }
  1964. });
  1965. // 为同层级的 note 节点添加交错偏移,避免视觉重叠
  1966. console.log('[DYNAMIC LAYOUT] 开始应用交错偏移');
  1967. const noteNodesToStagger = visibleNodes.filter(n => n.type === 'note');
  1968. console.log('[DYNAMIC LAYOUT] note 节点数:', noteNodesToStagger.length);
  1969. if (noteNodesToStagger.length > 1) {
  1970. // 按 Y 坐标排序
  1971. noteNodesToStagger.sort((a, b) => a.position.y - b.position.y);
  1972. console.log('[DYNAMIC LAYOUT] 排序后准备应用偏移:');
  1973. noteNodesToStagger.forEach((n, i) => {
  1974. console.log(' [' + i + '] ' + n.id.substring(0, 40) + '... | pos=(' + n.position.x.toFixed(0) + ', ' + n.position.y.toFixed(0) + ')');
  1975. });
  1976. // 为相邻的 note 节点添加 X 方向的交错(3个位置:左、中、右)
  1977. const baseX = noteNodesToStagger.length > 0 ? noteNodesToStagger[0].position.x : 0;
  1978. const leftX = baseX - 1500;
  1979. const centerX = baseX;
  1980. const rightX = baseX + 1500;
  1981. let appliedCount = 0;
  1982. noteNodesToStagger.forEach((node, index) => {
  1983. const oldX = node.position.x;
  1984. const position = index % 3;
  1985. if (position === 0) {
  1986. node.position.x = leftX;
  1987. console.log('[DYNAMIC LAYOUT] [' + index + '] 设置为左侧: X ' + oldX.toFixed(0) + ' → ' + node.position.x.toFixed(0));
  1988. } else if (position === 1) {
  1989. node.position.x = centerX;
  1990. console.log('[DYNAMIC LAYOUT] [' + index + '] 设置为中间: X ' + oldX.toFixed(0) + ' → ' + node.position.x.toFixed(0));
  1991. } else {
  1992. node.position.x = rightX;
  1993. console.log('[DYNAMIC LAYOUT] [' + index + '] 设置为右侧: X ' + oldX.toFixed(0) + ' → ' + node.position.x.toFixed(0));
  1994. }
  1995. appliedCount++;
  1996. });
  1997. console.log('[DYNAMIC LAYOUT] 总共应用了 ' + appliedCount + ' 次偏移');
  1998. }
  1999. console.log('✅ Dynamic layout recalculated for', visibleNodes.length, 'visible nodes');
  2000. } catch (error) {
  2001. console.error('❌ Error in dynamic layout:', error);
  2002. }
  2003. }
  2004. return { nodes: visibleNodes, edges: visibleEdges };
  2005. }, [initialNodes, initialEdges, collapsedNodes, hiddenNodes, focusMode, focusedNodeId, getDescendants, toggleNodeCollapse, selectedNodeId]);
  2006. // 构建树形结构 - 允许一个节点有多个父节点
  2007. // 为目录构建树(使用完整数据)
  2008. const buildTree = useCallback(() => {
  2009. // 使用完整数据构建目录树
  2010. const nodeMap = new Map();
  2011. fullNodes.forEach(node => {
  2012. nodeMap.set(node.id, node);
  2013. });
  2014. // 为每个节点创建树节点的副本(允许多次出现)
  2015. const createTreeNode = (nodeId, pathKey) => {
  2016. const node = nodeMap.get(nodeId);
  2017. if (!node) return null;
  2018. return {
  2019. ...node,
  2020. treeKey: pathKey, // 唯一的树路径key,用于React key
  2021. children: []
  2022. };
  2023. };
  2024. // 构建父子关系映射:记录每个节点的所有父节点,去重边
  2025. const parentToChildren = new Map();
  2026. const childToParents = new Map();
  2027. fullEdges.forEach(edge => {
  2028. // 记录父->子关系(去重:同一个父节点到同一个子节点只记录一次)
  2029. if (!parentToChildren.has(edge.source)) {
  2030. parentToChildren.set(edge.source, []);
  2031. }
  2032. const children = parentToChildren.get(edge.source);
  2033. if (!children.includes(edge.target)) {
  2034. children.push(edge.target);
  2035. }
  2036. // 记录子->父关系(用于判断是否有多个父节点,也去重)
  2037. if (!childToParents.has(edge.target)) {
  2038. childToParents.set(edge.target, []);
  2039. }
  2040. const parents = childToParents.get(edge.target);
  2041. if (!parents.includes(edge.source)) {
  2042. parents.push(edge.source);
  2043. }
  2044. });
  2045. // 递归构建树
  2046. const buildSubtree = (nodeId, pathKey, visitedInPath) => {
  2047. // 避免循环引用:如果当前路径中已经访问过这个节点,跳过
  2048. if (visitedInPath.has(nodeId)) {
  2049. return null;
  2050. }
  2051. const treeNode = createTreeNode(nodeId, pathKey);
  2052. if (!treeNode) return null;
  2053. const newVisitedInPath = new Set(visitedInPath);
  2054. newVisitedInPath.add(nodeId);
  2055. const children = parentToChildren.get(nodeId) || [];
  2056. treeNode.children = children
  2057. .map((childId, index) => buildSubtree(childId, pathKey + '-' + childId + '-' + index, newVisitedInPath))
  2058. .filter(child => child !== null);
  2059. return treeNode;
  2060. };
  2061. // 找出所有根节点(没有入边的节点)
  2062. const hasParent = new Set();
  2063. fullEdges.forEach(edge => {
  2064. hasParent.add(edge.target);
  2065. });
  2066. const roots = [];
  2067. fullNodes.forEach((node, index) => {
  2068. if (!hasParent.has(node.id)) {
  2069. const treeNode = buildSubtree(node.id, 'root-' + node.id + '-' + index, new Set());
  2070. if (treeNode) roots.push(treeNode);
  2071. }
  2072. });
  2073. return roots;
  2074. }, [fullNodes, fullEdges]);
  2075. const treeRoots = useMemo(() => buildTree(), [buildTree]);
  2076. // 生成树形文本结构(使用完整数据)
  2077. const generateTreeText = useCallback(() => {
  2078. const lines = [];
  2079. // 递归生成树形文本
  2080. const traverse = (nodes, prefix = '', isLast = true, depth = 0) => {
  2081. nodes.forEach((node, index) => {
  2082. const isLastNode = index === nodes.length - 1;
  2083. const nodeData = fullNodes.find(n => n.id === node.id)?.data || {};
  2084. const nodeType = nodeData.nodeType || node.data?.nodeType || 'unknown';
  2085. const title = nodeData.title || node.data?.title || node.id;
  2086. // 优先从node.data获取score,然后从nodeData获取
  2087. let score = null;
  2088. if (node.data?.score !== undefined && node.data?.score !== null) {
  2089. score = node.data.score;
  2090. } else if (node.data?.relevance_score !== undefined && node.data?.relevance_score !== null) {
  2091. score = node.data.relevance_score;
  2092. } else if (nodeData.score !== undefined && nodeData.score !== null) {
  2093. score = nodeData.score;
  2094. } else if (nodeData.relevance_score !== undefined && nodeData.relevance_score !== null) {
  2095. score = nodeData.relevance_score;
  2096. }
  2097. const strategy = nodeData.strategy || node.data?.strategy || '';
  2098. // 构建当前行 - score可能是数字或字符串,step/round节点不显示分数
  2099. const connector = isLastNode ? '└─' : '├─';
  2100. let scoreText = '';
  2101. if (nodeType !== 'step' && nodeType !== 'round' && score !== null && score !== undefined) {
  2102. // score可能已经是字符串格式(如 "0.05"),也可能是数字
  2103. const scoreStr = typeof score === 'number' ? score.toFixed(2) : score;
  2104. scoreText = \` (分数: \${scoreStr})\`;
  2105. }
  2106. const strategyText = strategy ? \` [\${strategy}]\` : '';
  2107. lines.push(\`\${prefix}\${connector} \${title}\${scoreText}\${strategyText}\`);
  2108. // 递归处理子节点
  2109. if (node.children && node.children.length > 0) {
  2110. const childPrefix = prefix + (isLastNode ? ' ' : '│ ');
  2111. traverse(node.children, childPrefix, isLastNode, depth + 1);
  2112. }
  2113. });
  2114. };
  2115. // 添加标题
  2116. const rootNode = fullNodes.find(n => n.data?.level === 0);
  2117. if (rootNode) {
  2118. lines.push(\`📊 查询扩展树形结构\`);
  2119. lines.push(\`原始问题: \${rootNode.data.title || rootNode.data.query}\`);
  2120. lines.push('');
  2121. }
  2122. traverse(treeRoots);
  2123. return lines.join('\\n');
  2124. }, [treeRoots, fullNodes]);
  2125. // 复制树形结构到剪贴板
  2126. const copyTreeToClipboard = useCallback(async () => {
  2127. try {
  2128. const treeText = generateTreeText();
  2129. await navigator.clipboard.writeText(treeText);
  2130. alert('✅ 树形结构已复制到剪贴板!');
  2131. } catch (err) {
  2132. console.error('复制失败:', err);
  2133. alert('❌ 复制失败,请手动复制');
  2134. }
  2135. }, [generateTreeText]);
  2136. // 初始化树节点折叠状态
  2137. useEffect(() => {
  2138. const getAllTreeKeys = (nodes) => {
  2139. const keys = new Set();
  2140. const traverse = (node) => {
  2141. if (node.children && node.children.length > 0) {
  2142. // 排除根节点
  2143. if (node.data.level !== 0) {
  2144. keys.add(node.treeKey);
  2145. }
  2146. node.children.forEach(traverse);
  2147. }
  2148. };
  2149. nodes.forEach(traverse);
  2150. return keys;
  2151. };
  2152. setCollapsedTreeNodes(getAllTreeKeys(treeRoots));
  2153. }, [treeRoots]);
  2154. // 映射完整节点ID到画布简化节点ID
  2155. const mapTreeNodeToCanvasNode = useCallback((treeNodeId) => {
  2156. // 如果是简化模式,需要映射
  2157. if (data.fullData) {
  2158. // 从完整数据中找到节点
  2159. const fullNode = fullNodes.find(n => n.id === treeNodeId);
  2160. if (!fullNode) return treeNodeId;
  2161. // 根据节点类型和文本找到画布上的简化节点
  2162. const nodeText = fullNode.data.title || fullNode.data.query;
  2163. const nodeType = fullNode.data.nodeType || fullNode.type;
  2164. // Query类节点:找 query_xxx
  2165. if (['q', 'seg', 'sug', 'add_word', 'query'].includes(nodeType)) {
  2166. const canvasNode = initialNodes.find(n =>
  2167. (n.data.title === nodeText || n.data.query === nodeText) &&
  2168. ['query'].includes(n.type)
  2169. );
  2170. return canvasNode ? canvasNode.id : treeNodeId;
  2171. }
  2172. // Post节点:按note_id查找
  2173. if (nodeType === 'post' || nodeType === 'note') {
  2174. const noteId = fullNode.data.note_id;
  2175. if (noteId) {
  2176. const canvasNode = initialNodes.find(n => n.data.note_id === noteId);
  2177. return canvasNode ? canvasNode.id : treeNodeId;
  2178. }
  2179. }
  2180. // 其他节点类型(Round/Step等):直接返回
  2181. return treeNodeId;
  2182. }
  2183. // 非简化模式,直接返回
  2184. return treeNodeId;
  2185. }, [data.fullData, fullNodes, initialNodes]);
  2186. const renderTree = useCallback((treeNodes, level = 0) => {
  2187. return treeNodes.map(node => {
  2188. // 使用 treeKey 来区分树中的不同实例
  2189. const isCollapsed = collapsedTreeNodes.has(node.treeKey);
  2190. const isSelected = selectedNodeId === node.id;
  2191. return (
  2192. <TreeNode
  2193. key={node.treeKey}
  2194. node={node}
  2195. level={level}
  2196. isCollapsed={isCollapsed}
  2197. isSelected={isSelected}
  2198. onToggle={() => {
  2199. setCollapsedTreeNodes(prev => {
  2200. const newSet = new Set(prev);
  2201. if (newSet.has(node.treeKey)) {
  2202. newSet.delete(node.treeKey);
  2203. } else {
  2204. newSet.add(node.treeKey);
  2205. }
  2206. return newSet;
  2207. });
  2208. }}
  2209. onSelect={() => {
  2210. // 将目录节点ID映射到画布节点ID
  2211. const treeNodeId = node.id;
  2212. const canvasNodeId = mapTreeNodeToCanvasNode(treeNodeId);
  2213. // 检查画布上是否存在这个节点
  2214. const canvasNodeExists = initialNodes.some(n => n.id === canvasNodeId);
  2215. if (!canvasNodeExists) {
  2216. console.warn(\`节点 \${canvasNodeId} 在画布上不存在(可能被简化了)\`);
  2217. return;
  2218. }
  2219. const nodeId = canvasNodeId;
  2220. // 展开所有祖先节点
  2221. const ancestorIds = [nodeId];
  2222. const findAncestors = (id) => {
  2223. initialEdges.forEach(edge => {
  2224. if (edge.target === id && !ancestorIds.includes(edge.source)) {
  2225. ancestorIds.push(edge.source);
  2226. findAncestors(edge.source);
  2227. }
  2228. });
  2229. };
  2230. findAncestors(nodeId);
  2231. // 如果节点或其祖先被隐藏,先恢复它们
  2232. setHiddenNodes(prev => {
  2233. const newSet = new Set(prev);
  2234. ancestorIds.forEach(id => newSet.delete(id));
  2235. return newSet;
  2236. });
  2237. setSelectedNodeId(nodeId);
  2238. // 获取选中节点的直接子节点
  2239. const childrenIds = [];
  2240. initialEdges.forEach(edge => {
  2241. if (edge.source === nodeId) {
  2242. childrenIds.push(edge.target);
  2243. }
  2244. });
  2245. setCollapsedNodes(prev => {
  2246. const newSet = new Set(prev);
  2247. // 展开所有祖先节点
  2248. ancestorIds.forEach(id => newSet.delete(id));
  2249. // 展开选中节点本身
  2250. newSet.delete(nodeId);
  2251. // 展开选中节点的直接子节点
  2252. childrenIds.forEach(id => newSet.delete(id));
  2253. return newSet;
  2254. });
  2255. // 延迟聚焦,等待节点展开和布局重新计算
  2256. setTimeout(() => {
  2257. fitView({
  2258. nodes: [{ id: nodeId }],
  2259. duration: 800,
  2260. padding: 0.3,
  2261. });
  2262. }, 300);
  2263. }}
  2264. >
  2265. {node.children && node.children.length > 0 && renderTree(node.children, level + 1)}
  2266. </TreeNode>
  2267. );
  2268. });
  2269. }, [collapsedTreeNodes, selectedNodeId, nodes, setCenter, initialEdges, setCollapsedNodes, fitView, mapTreeNodeToCanvasNode, initialNodes, setHiddenNodes]);
  2270. console.log('📊 Rendering with', nodes.length, 'visible nodes and', edges.length, 'visible edges');
  2271. if (nodes.length === 0) {
  2272. return (
  2273. <div style={{ padding: 50, color: 'red', fontSize: 20 }}>
  2274. ERROR: No nodes to display!
  2275. </div>
  2276. );
  2277. }
  2278. return (
  2279. <div style={{ width: '100vw', height: '100vh', background: '#f9fafb', display: 'flex', flexDirection: 'column' }}>
  2280. {/* 顶部面包屑导航栏 */}
  2281. <div style={{
  2282. minHeight: '48px',
  2283. maxHeight: '120px',
  2284. background: 'white',
  2285. borderBottom: '1px solid #e5e7eb',
  2286. display: 'flex',
  2287. alignItems: 'flex-start',
  2288. padding: '12px 24px',
  2289. zIndex: 1000,
  2290. boxShadow: '0 1px 3px rgba(0, 0, 0, 0.05)',
  2291. flexShrink: 0,
  2292. overflowY: 'auto',
  2293. }}>
  2294. <div style={{ width: '100%' }}>
  2295. {selectedNodeId ? (
  2296. <div style={{ fontSize: '12px', color: '#6b7280' }}>
  2297. {/* 面包屑导航 - 显示所有路径 */}
  2298. {(() => {
  2299. const selectedNode = nodes.find(n => n.id === selectedNodeId);
  2300. if (!selectedNode) return null;
  2301. // 找到所有从根节点到当前节点的路径
  2302. const findAllPaths = (targetId) => {
  2303. const paths = [];
  2304. const buildPath = (nodeId, currentPath) => {
  2305. const node = initialNodes.find(n => n.id === nodeId);
  2306. if (!node) return;
  2307. const newPath = [node, ...currentPath];
  2308. // 找到所有父节点
  2309. const parents = initialEdges.filter(e => e.target === nodeId).map(e => e.source);
  2310. if (parents.length === 0) {
  2311. // 到达根节点
  2312. paths.push(newPath);
  2313. } else {
  2314. // 递归处理所有父节点
  2315. parents.forEach(parentId => {
  2316. buildPath(parentId, newPath);
  2317. });
  2318. }
  2319. };
  2320. buildPath(targetId, []);
  2321. return paths;
  2322. };
  2323. const allPaths = findAllPaths(selectedNodeId);
  2324. // 去重:将路径转换为字符串进行比较
  2325. const uniquePaths = [];
  2326. const pathStrings = new Set();
  2327. allPaths.forEach(path => {
  2328. const pathString = path.map(n => n.id).join('->');
  2329. if (!pathStrings.has(pathString)) {
  2330. pathStrings.add(pathString);
  2331. uniquePaths.push(path);
  2332. }
  2333. });
  2334. return (
  2335. <div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
  2336. {uniquePaths.map((path, pathIndex) => (
  2337. <div key={pathIndex} style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
  2338. {pathIndex > 0 && <span style={{ color: '#d1d5db', marginRight: '4px' }}>或</span>}
  2339. {path.map((node, index) => {
  2340. // 获取节点的 score、strategy 和 isSelected
  2341. const nodeScore = node.data.score ? parseFloat(node.data.score) : 0;
  2342. const nodeStrategy = getPrimaryStrategy(node.data); // 使用智能提取函数
  2343. const strategyColor = getStrategyColor(nodeStrategy);
  2344. const nodeIsSelected = node.type === 'note' ? node.data.matchLevel !== 'unsatisfied' : node.data.isSelected !== false;
  2345. const nodeActualType = node.data.nodeType || node.type; // 获取实际节点类型
  2346. // 计算路径节点字体颜色:根据分数提升幅度判断
  2347. let pathFontColor = '#374151'; // 默认颜色
  2348. if (node.type === 'note') {
  2349. pathFontColor = node.data.matchLevel === 'unsatisfied' ? '#ef4444' : '#374151';
  2350. } else if (node.data.seed_score !== undefined) {
  2351. const parentScore = parseFloat(node.data.seed_score);
  2352. const gain = nodeScore - parentScore;
  2353. pathFontColor = gain >= 0.05 ? '#16a34a' : '#ef4444';
  2354. } else if (index > 0) {
  2355. const prevNode = path[index - 1];
  2356. const prevScore = prevNode.data.score ? parseFloat(prevNode.data.score) : 0;
  2357. const gain = nodeScore - prevScore;
  2358. pathFontColor = gain >= 0.05 ? '#16a34a' : '#ef4444';
  2359. } else if (node.data.isSelected === false) {
  2360. pathFontColor = '#ef4444';
  2361. }
  2362. return (
  2363. <React.Fragment key={node.id + '-' + index}>
  2364. <span
  2365. onClick={() => {
  2366. const nodeId = node.id;
  2367. // 找到所有祖先节点
  2368. const ancestorIds = [nodeId];
  2369. const findAncestors = (id) => {
  2370. initialEdges.forEach(edge => {
  2371. if (edge.target === id && !ancestorIds.includes(edge.source)) {
  2372. ancestorIds.push(edge.source);
  2373. findAncestors(edge.source);
  2374. }
  2375. });
  2376. };
  2377. findAncestors(nodeId);
  2378. // 如果节点或其祖先被隐藏,先恢复它们
  2379. setHiddenNodes(prev => {
  2380. const newSet = new Set(prev);
  2381. ancestorIds.forEach(id => newSet.delete(id));
  2382. return newSet;
  2383. });
  2384. // 展开目录树中到达该节点的路径
  2385. // 需要找到所有包含该节点的树路径的 treeKey,并展开它们的父节点
  2386. setCollapsedTreeNodes(prev => {
  2387. const newSet = new Set(prev);
  2388. // 清空所有折叠状态,让目录树完全展开到选中节点
  2389. // 这样可以确保选中节点在目录中可见
  2390. return new Set();
  2391. });
  2392. setSelectedNodeId(nodeId);
  2393. setTimeout(() => {
  2394. fitView({
  2395. nodes: [{ id: nodeId }],
  2396. duration: 800,
  2397. padding: 0.3,
  2398. });
  2399. }, 100);
  2400. }}
  2401. style={{
  2402. padding: '6px 8px',
  2403. borderRadius: '4px',
  2404. background: 'white',
  2405. border: index === path.length - 1 ? '2px solid #3b82f6' : '1px solid #d1d5db',
  2406. color: '#374151',
  2407. fontWeight: index === path.length - 1 ? '600' : '400',
  2408. width: '180px',
  2409. cursor: 'pointer',
  2410. transition: 'all 0.2s ease',
  2411. position: 'relative',
  2412. display: 'inline-flex',
  2413. flexDirection: 'column',
  2414. gap: '4px',
  2415. }}
  2416. onMouseEnter={(e) => {
  2417. e.currentTarget.style.opacity = '0.8';
  2418. }}
  2419. onMouseLeave={(e) => {
  2420. e.currentTarget.style.opacity = '1';
  2421. }}
  2422. title={\`\${node.data.title || node.id} (Score: \${nodeScore.toFixed(2)}, Strategy: \${nodeStrategy}, Selected: \${nodeIsSelected})\`}
  2423. >
  2424. {/* 上半部分:竖线 + 图标 + 文字 + 分数 */}
  2425. <div style={{
  2426. display: 'flex',
  2427. alignItems: 'center',
  2428. gap: '6px',
  2429. }}>
  2430. {/* 策略类型竖线 */}
  2431. <div style={{
  2432. width: '3px',
  2433. height: '16px',
  2434. background: strategyColor,
  2435. borderRadius: '2px',
  2436. flexShrink: 0,
  2437. }} />
  2438. {/* 节点文字 - 左侧 */}
  2439. <span style={{
  2440. flex: 1,
  2441. fontSize: '12px',
  2442. color: pathFontColor,
  2443. overflow: 'hidden',
  2444. textOverflow: 'ellipsis',
  2445. whiteSpace: 'nowrap',
  2446. }}>
  2447. {node.data.title || node.id}
  2448. </span>
  2449. {/* 域标识 - 右侧,挨着分数 */}
  2450. {(node.data.domain_type || node.data.domains_str || (node.data.domain_index !== null && node.data.domain_index !== undefined)) && (
  2451. <span style={{
  2452. fontSize: '12px',
  2453. color: '#fff',
  2454. background: '#6366f1',
  2455. padding: '2px 5px',
  2456. borderRadius: '3px',
  2457. flexShrink: 0,
  2458. fontWeight: '600',
  2459. marginLeft: '4px',
  2460. }}
  2461. title={
  2462. node.data.domain_type ? '域: ' + node.data.domain_type + ' (D' + node.data.domain_index + ')' :
  2463. node.data.domains_str ? '域: ' + node.data.domains_str :
  2464. '域 D' + node.data.domain_index
  2465. }
  2466. >
  2467. {node.data.domain_type || node.data.domains_str || ('D' + node.data.domain_index)}
  2468. </span>
  2469. )}
  2470. {/* 分数显示 - 步骤和轮次节点不显示分数 */}
  2471. {nodeActualType !== 'step' && nodeActualType !== 'round' && (
  2472. <span style={{
  2473. fontSize: '10px',
  2474. color: '#6b7280',
  2475. fontWeight: '500',
  2476. flexShrink: 0,
  2477. minWidth: '35px',
  2478. textAlign: 'right',
  2479. marginLeft: '4px',
  2480. }}>
  2481. {nodeScore.toFixed(2)}
  2482. </span>
  2483. )}
  2484. </div>
  2485. {/* 分数下划线 - 步骤和轮次节点不显示 */}
  2486. {nodeActualType !== 'step' && nodeActualType !== 'round' && (
  2487. <div style={{
  2488. width: (nodeScore * 100) + '%',
  2489. height: '2px',
  2490. background: getScoreColor(nodeScore),
  2491. borderRadius: '1px',
  2492. marginLeft: '9px',
  2493. }} />
  2494. )}
  2495. </span>
  2496. {index < path.length - 1 && <span style={{ color: '#9ca3af' }}>›</span>}
  2497. </React.Fragment>
  2498. )})}
  2499. </div>
  2500. ))}
  2501. </div>
  2502. );
  2503. })()}
  2504. </div>
  2505. ) : (
  2506. <div style={{ fontSize: '13px', color: '#9ca3af', textAlign: 'center' }}>
  2507. 选择一个节点查看路径
  2508. </div>
  2509. )}
  2510. </div>
  2511. </div>
  2512. {/* 主内容区:目录 + 画布 */}
  2513. <div style={{
  2514. display: 'flex',
  2515. flex: 1,
  2516. overflow: 'hidden',
  2517. cursor: isResizing ? 'col-resize' : 'default',
  2518. userSelect: isResizing ? 'none' : 'auto',
  2519. }}>
  2520. {/* 左侧目录树 */}
  2521. <div style={{
  2522. width: \`\${sidebarWidth}px\`,
  2523. background: 'white',
  2524. borderRight: '1px solid #e5e7eb',
  2525. display: 'flex',
  2526. flexDirection: 'column',
  2527. flexShrink: 0,
  2528. }}>
  2529. <div style={{
  2530. padding: '12px 16px',
  2531. borderBottom: '1px solid #e5e7eb',
  2532. display: 'flex',
  2533. justifyContent: 'space-between',
  2534. alignItems: 'center',
  2535. }}>
  2536. <span style={{
  2537. fontWeight: '600',
  2538. fontSize: '14px',
  2539. color: '#111827',
  2540. }}>
  2541. 节点目录
  2542. </span>
  2543. <div style={{ display: 'flex', gap: '6px' }}>
  2544. <button
  2545. onClick={() => {
  2546. setCollapsedTreeNodes(new Set());
  2547. }}
  2548. style={{
  2549. fontSize: '11px',
  2550. padding: '4px 8px',
  2551. borderRadius: '4px',
  2552. border: '1px solid #d1d5db',
  2553. background: 'white',
  2554. color: '#6b7280',
  2555. cursor: 'pointer',
  2556. fontWeight: '500',
  2557. }}
  2558. title="展开全部节点"
  2559. >
  2560. 全部展开
  2561. </button>
  2562. <button
  2563. onClick={() => {
  2564. const getAllTreeKeys = (nodes) => {
  2565. const keys = new Set();
  2566. const traverse = (node) => {
  2567. if (node.children && node.children.length > 0) {
  2568. keys.add(node.treeKey);
  2569. node.children.forEach(traverse);
  2570. }
  2571. };
  2572. nodes.forEach(traverse);
  2573. return keys;
  2574. };
  2575. setCollapsedTreeNodes(getAllTreeKeys(treeRoots));
  2576. }}
  2577. style={{
  2578. fontSize: '11px',
  2579. padding: '4px 8px',
  2580. borderRadius: '4px',
  2581. border: '1px solid #d1d5db',
  2582. background: 'white',
  2583. color: '#6b7280',
  2584. cursor: 'pointer',
  2585. fontWeight: '500',
  2586. }}
  2587. title="折叠全部节点"
  2588. >
  2589. 全部折叠
  2590. </button>
  2591. <button
  2592. onClick={copyTreeToClipboard}
  2593. style={{
  2594. fontSize: '11px',
  2595. padding: '4px 8px',
  2596. borderRadius: '4px',
  2597. border: '1px solid #3b82f6',
  2598. background: '#3b82f6',
  2599. color: 'white',
  2600. cursor: 'pointer',
  2601. fontWeight: '500',
  2602. transition: 'all 0.2s',
  2603. }}
  2604. onMouseEnter={(e) => e.currentTarget.style.background = '#2563eb'}
  2605. onMouseLeave={(e) => e.currentTarget.style.background = '#3b82f6'}
  2606. title="复制树形结构为文本格式"
  2607. >
  2608. 📋 复制树形结构
  2609. </button>
  2610. </div>
  2611. </div>
  2612. <div style={{
  2613. flex: 1,
  2614. overflowX: 'auto',
  2615. overflowY: 'auto',
  2616. padding: '8px',
  2617. }}>
  2618. <div style={{ minWidth: 'fit-content' }}>
  2619. {renderTree(treeRoots)}
  2620. </div>
  2621. </div>
  2622. </div>
  2623. {/* 可拖拽的分隔条 */}
  2624. <div
  2625. onMouseDown={handleMouseDown}
  2626. style={{
  2627. width: '4px',
  2628. cursor: 'col-resize',
  2629. background: isResizing ? '#3b82f6' : 'transparent',
  2630. transition: isResizing ? 'none' : 'background 0.2s',
  2631. flexShrink: 0,
  2632. position: 'relative',
  2633. }}
  2634. onMouseEnter={(e) => e.currentTarget.style.background = '#e5e7eb'}
  2635. onMouseLeave={(e) => {
  2636. if (!isResizing) e.currentTarget.style.background = 'transparent';
  2637. }}
  2638. >
  2639. {/* 拖拽提示线 */}
  2640. <div style={{
  2641. position: 'absolute',
  2642. top: '50%',
  2643. left: '50%',
  2644. transform: 'translate(-50%, -50%)',
  2645. width: '1px',
  2646. height: '40px',
  2647. background: '#9ca3af',
  2648. opacity: isResizing ? 1 : 0.3,
  2649. }} />
  2650. </div>
  2651. {/* 画布区域 */}
  2652. <div style={{ flex: 1, position: 'relative' }}>
  2653. {/* 右侧图例 */}
  2654. <div style={{
  2655. position: 'absolute',
  2656. top: '20px',
  2657. right: '20px',
  2658. background: 'white',
  2659. padding: '16px',
  2660. borderRadius: '12px',
  2661. boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
  2662. zIndex: 1000,
  2663. maxWidth: '260px',
  2664. border: '1px solid #e5e7eb',
  2665. }}>
  2666. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
  2667. <h3 style={{ fontSize: '14px', fontWeight: '600', color: '#111827', margin: 0 }}>图例</h3>
  2668. <button
  2669. onClick={() => setFocusMode(!focusMode)}
  2670. style={{
  2671. fontSize: '11px',
  2672. padding: '4px 8px',
  2673. borderRadius: '4px',
  2674. border: '1px solid',
  2675. borderColor: focusMode ? '#3b82f6' : '#d1d5db',
  2676. background: focusMode ? '#3b82f6' : 'white',
  2677. color: focusMode ? 'white' : '#6b7280',
  2678. cursor: 'pointer',
  2679. fontWeight: '500',
  2680. }}
  2681. title={focusMode ? '关闭聚焦模式' : '开启聚焦模式'}
  2682. >
  2683. {focusMode ? '🎯 聚焦' : '📊 全图'}
  2684. </button>
  2685. </div>
  2686. <div style={{ fontSize: '12px' }}>
  2687. {/* 画布节点展开/折叠控制 */}
  2688. <div style={{ marginBottom: '12px', paddingBottom: '12px', borderBottom: '1px solid #f3f4f6' }}>
  2689. <div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>节点控制</div>
  2690. <div style={{ display: 'flex', gap: '6px' }}>
  2691. <button
  2692. onClick={() => {
  2693. setCollapsedNodes(new Set());
  2694. }}
  2695. style={{
  2696. fontSize: '11px',
  2697. padding: '4px 8px',
  2698. borderRadius: '4px',
  2699. border: '1px solid #d1d5db',
  2700. background: 'white',
  2701. color: '#6b7280',
  2702. cursor: 'pointer',
  2703. fontWeight: '500',
  2704. flex: 1,
  2705. }}
  2706. title="展开画布中所有节点的子节点"
  2707. >
  2708. 全部展开
  2709. </button>
  2710. <button
  2711. onClick={() => {
  2712. const allNodeIds = new Set(initialNodes.map(n => n.id));
  2713. setCollapsedNodes(allNodeIds);
  2714. }}
  2715. style={{
  2716. fontSize: '11px',
  2717. padding: '4px 8px',
  2718. borderRadius: '4px',
  2719. border: '1px solid #d1d5db',
  2720. background: 'white',
  2721. color: '#6b7280',
  2722. cursor: 'pointer',
  2723. fontWeight: '500',
  2724. flex: 1,
  2725. }}
  2726. title="折叠画布中所有节点的子节点"
  2727. >
  2728. 全部折叠
  2729. </button>
  2730. </div>
  2731. </div>
  2732. <div style={{ paddingTop: '12px', borderTop: '1px solid #f3f4f6' }}>
  2733. <div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>策略类型</div>
  2734. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  2735. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#10b981', opacity: 0.7 }}></div>
  2736. <span style={{ color: '#6b7280', fontSize: '11px' }}>初始分词</span>
  2737. </div>
  2738. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  2739. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#06b6d4', opacity: 0.7 }}></div>
  2740. <span style={{ color: '#6b7280', fontSize: '11px' }}>调用sug</span>
  2741. </div>
  2742. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  2743. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#f59e0b', opacity: 0.7 }}></div>
  2744. <span style={{ color: '#6b7280', fontSize: '11px' }}>同义改写</span>
  2745. </div>
  2746. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  2747. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#3b82f6', opacity: 0.7 }}></div>
  2748. <span style={{ color: '#6b7280', fontSize: '11px' }}>加词</span>
  2749. </div>
  2750. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  2751. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#8b5cf6', opacity: 0.7 }}></div>
  2752. <span style={{ color: '#6b7280', fontSize: '11px' }}>抽象改写</span>
  2753. </div>
  2754. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  2755. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#ec4899', opacity: 0.7 }}></div>
  2756. <span style={{ color: '#6b7280', fontSize: '11px' }}>基于部分匹配改进</span>
  2757. </div>
  2758. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  2759. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#a855f7', opacity: 0.7 }}></div>
  2760. <span style={{ color: '#6b7280', fontSize: '11px' }}>结果分支-抽象改写</span>
  2761. </div>
  2762. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  2763. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#fb923c', opacity: 0.7 }}></div>
  2764. <span style={{ color: '#6b7280', fontSize: '11px' }}>结果分支-同义改写</span>
  2765. </div>
  2766. </div>
  2767. <div style={{
  2768. marginTop: '12px',
  2769. paddingTop: '12px',
  2770. borderTop: '1px solid #f3f4f6',
  2771. fontSize: '11px',
  2772. color: '#9ca3af',
  2773. lineHeight: '1.5',
  2774. }}>
  2775. 💡 点击节点左上角 × 隐藏节点
  2776. </div>
  2777. {/* 隐藏节点列表 - 在图例内部 */}
  2778. {hiddenNodes.size > 0 && (
  2779. <div style={{
  2780. marginTop: '12px',
  2781. paddingTop: '12px',
  2782. borderTop: '1px solid #f3f4f6',
  2783. }}>
  2784. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
  2785. <h4 style={{ fontSize: '12px', fontWeight: '600', color: '#111827' }}>已隐藏节点</h4>
  2786. <button
  2787. onClick={() => setHiddenNodes(new Set())}
  2788. style={{
  2789. fontSize: '10px',
  2790. color: '#3b82f6',
  2791. background: 'none',
  2792. border: 'none',
  2793. cursor: 'pointer',
  2794. textDecoration: 'underline',
  2795. }}
  2796. >
  2797. 全部恢复
  2798. </button>
  2799. </div>
  2800. <div style={{ fontSize: '12px', maxHeight: '200px', overflow: 'auto' }}>
  2801. {Array.from(hiddenNodes).map(nodeId => {
  2802. const node = initialNodes.find(n => n.id === nodeId);
  2803. if (!node) return null;
  2804. return (
  2805. <div
  2806. key={nodeId}
  2807. style={{
  2808. display: 'flex',
  2809. justifyContent: 'space-between',
  2810. alignItems: 'center',
  2811. padding: '6px 8px',
  2812. margin: '4px 0',
  2813. background: '#f9fafb',
  2814. borderRadius: '6px',
  2815. fontSize: '11px',
  2816. }}
  2817. >
  2818. <span
  2819. style={{
  2820. flex: 1,
  2821. overflow: 'hidden',
  2822. textOverflow: 'ellipsis',
  2823. whiteSpace: 'nowrap',
  2824. color: '#374151',
  2825. }}
  2826. title={node.data.title || nodeId}
  2827. >
  2828. {node.data.title || nodeId}
  2829. </span>
  2830. <button
  2831. onClick={() => {
  2832. setHiddenNodes(prev => {
  2833. const newSet = new Set(prev);
  2834. newSet.delete(nodeId);
  2835. return newSet;
  2836. });
  2837. }}
  2838. style={{
  2839. marginLeft: '8px',
  2840. fontSize: '10px',
  2841. color: '#10b981',
  2842. background: 'none',
  2843. border: 'none',
  2844. cursor: 'pointer',
  2845. flexShrink: 0,
  2846. }}
  2847. >
  2848. 恢复
  2849. </button>
  2850. </div>
  2851. );
  2852. })}
  2853. </div>
  2854. </div>
  2855. )}
  2856. </div>
  2857. </div>
  2858. {/* React Flow 画布 */}
  2859. <ReactFlow
  2860. nodes={nodes}
  2861. edges={edges}
  2862. nodeTypes={nodeTypes}
  2863. fitView
  2864. fitViewOptions={{ padding: 0.2, duration: 500 }}
  2865. minZoom={0.4}
  2866. maxZoom={1.5}
  2867. nodesDraggable={true}
  2868. nodesConnectable={false}
  2869. elementsSelectable={true}
  2870. defaultEdgeOptions={{
  2871. type: 'smoothstep',
  2872. }}
  2873. proOptions={{ hideAttribution: true }}
  2874. onNodeClick={(event, clickedNode) => {
  2875. setSelectedNodeId(clickedNode.id);
  2876. }}
  2877. >
  2878. <Controls style={{ bottom: '20px', left: 'auto', right: '20px' }} />
  2879. <Background variant="dots" gap={20} size={1} color="#e5e7eb" />
  2880. </ReactFlow>
  2881. </div>
  2882. </div>
  2883. </div>
  2884. );
  2885. }
  2886. function App() {
  2887. return (
  2888. <ReactFlowProvider>
  2889. <FlowContent />
  2890. </ReactFlowProvider>
  2891. );
  2892. }
  2893. const root = createRoot(document.getElementById('root'));
  2894. root.render(<App />);
  2895. `;
  2896. fs.writeFileSync(reactComponentPath, reactComponent);
  2897. // 调试:保存临时组件副本用于检查
  2898. fs.writeFileSync(path.join(__dirname, 'debug_component.jsx'), reactComponent);
  2899. console.log('📝 已保存临时组件副本: debug_component.jsx');
  2900. // 使用 esbuild 打包
  2901. console.log('🎨 Building modern visualization...');
  2902. build({
  2903. entryPoints: [reactComponentPath],
  2904. bundle: true,
  2905. outfile: path.join(__dirname, 'bundle_v2.js'),
  2906. format: 'iife',
  2907. loader: {
  2908. '.css': 'css',
  2909. },
  2910. minify: false,
  2911. treeShaking: false, // 禁用tree shaking
  2912. ignoreAnnotations: true, // 忽略所有注解,防止纯函数优化
  2913. keepNames: true, // 保留函数和变量名
  2914. sourcemap: 'inline',
  2915. // 强制所有 React 引用指向同一个位置,避免多副本
  2916. alias: {
  2917. 'react': path.join(__dirname, 'node_modules/react'),
  2918. 'react-dom': path.join(__dirname, 'node_modules/react-dom'),
  2919. 'react/jsx-runtime': path.join(__dirname, 'node_modules/react/jsx-runtime'),
  2920. 'react/jsx-dev-runtime': path.join(__dirname, 'node_modules/react/jsx-dev-runtime'),
  2921. },
  2922. define: {
  2923. 'process.env.NODE_ENV': '"development"' // 使用开发模式,减少优化
  2924. },
  2925. }).then(() => {
  2926. // 读取打包后的 JS
  2927. const bundleJs = fs.readFileSync(path.join(__dirname, 'bundle_v2.js'), 'utf-8');
  2928. // 调试:检查bundle中是否包含评估UI代码
  2929. const hasEvalCode = bundleJs.includes('知识内容') || bundleJs.includes('is_knowledge');
  2930. console.log('📝 Bundle调试: 包含评估代码 =', hasEvalCode);
  2931. if (hasEvalCode) {
  2932. console.log(' ✓ 评估UI代码在bundle中');
  2933. } else {
  2934. console.log(' ⚠️ 评估UI代码不在bundle中,检查临时组件文件...');
  2935. const tempContent = fs.readFileSync(reactComponentPath, 'utf-8');
  2936. const hasTempEvalCode = tempContent.includes('知识内容');
  2937. console.log(' 临时组件文件包含评估代码 =', hasTempEvalCode);
  2938. }
  2939. // 读取 CSS
  2940. const cssPath = path.join(__dirname, 'node_modules/@xyflow/react/dist/style.css');
  2941. const css = fs.readFileSync(cssPath, 'utf-8');
  2942. // 生成最终 HTML
  2943. const html = `<!DOCTYPE html>
  2944. <html lang="zh-CN">
  2945. <head>
  2946. <meta charset="UTF-8">
  2947. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  2948. <title>查询图可视化</title>
  2949. <link rel="preconnect" href="https://fonts.googleapis.com">
  2950. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  2951. <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
  2952. <script src="https://unpkg.com/dagre@0.8.5/dist/dagre.min.js"></script>
  2953. <script>
  2954. // 过滤特定的 React 警告
  2955. const originalError = console.error;
  2956. console.error = (...args) => {
  2957. if (typeof args[0] === 'string' && args[0].includes('Each child in a list should have a unique "key" prop')) {
  2958. return;
  2959. }
  2960. originalError.apply(console, args);
  2961. };
  2962. </script>
  2963. <style>
  2964. * {
  2965. margin: 0;
  2966. padding: 0;
  2967. box-sizing: border-box;
  2968. }
  2969. body {
  2970. font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  2971. overflow: hidden;
  2972. -webkit-font-smoothing: antialiased;
  2973. -moz-osx-font-smoothing: grayscale;
  2974. }
  2975. #root {
  2976. width: 100vw;
  2977. height: 100vh;
  2978. }
  2979. ${css}
  2980. /* 自定义样式覆盖 */
  2981. .react-flow__edge-path {
  2982. stroke-linecap: round;
  2983. }
  2984. .react-flow__controls {
  2985. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  2986. border: 1px solid #e5e7eb;
  2987. border-radius: 8px;
  2988. }
  2989. .react-flow__controls-button {
  2990. border: none;
  2991. border-bottom: 1px solid #e5e7eb;
  2992. }
  2993. .react-flow__controls-button:hover {
  2994. background: #f9fafb;
  2995. }
  2996. </style>
  2997. </head>
  2998. <body>
  2999. <div id="root"></div>
  3000. <script>${bundleJs}</script>
  3001. </body>
  3002. </html>`;
  3003. // 调试:详细检查bundle和HTML内容
  3004. const bundleHas知识 = bundleJs.includes('知识内容');
  3005. const bundleHasIsKnowledge = bundleJs.includes('is_knowledge');
  3006. const bundleHasDataIsKnowledge = bundleJs.includes('data.is_knowledge');
  3007. console.log('📝 Bundle内容检查:');
  3008. console.log(' 包含 "知识内容":', bundleHas知识);
  3009. console.log(' 包含 "is_knowledge":', bundleHasIsKnowledge);
  3010. console.log(' 包含 "data.is_knowledge":', bundleHasDataIsKnowledge);
  3011. console.log(' Bundle长度:', bundleJs.length);
  3012. const htmlHas知识 = html.includes('知识内容');
  3013. const htmlHasIsKnowledge = html.includes('is_knowledge');
  3014. const htmlHasDataIsKnowledge = html.includes('data.is_knowledge');
  3015. console.log('📝 HTML内容检查:');
  3016. console.log(' 包含 "知识内容":', htmlHas知识);
  3017. console.log(' 包含 "is_knowledge":', htmlHasIsKnowledge);
  3018. console.log(' 包含 "data.is_knowledge":', htmlHasDataIsKnowledge);
  3019. console.log(' HTML长度:', html.length);
  3020. // 如果bundle有但HTML没有,保存用于调试
  3021. if ((bundleHas知识 || bundleHasDataIsKnowledge) && !htmlHas知识 && !htmlHasDataIsKnowledge) {
  3022. console.log(' ⚠️ Bundle中有评估代码但HTML中没有!');
  3023. fs.writeFileSync(path.join(__dirname, 'debug_bundle.js'), bundleJs);
  3024. console.log(' 已保存 debug_bundle.js 用于调试');
  3025. }
  3026. // 写入输出文件
  3027. fs.writeFileSync(outputFile, html);
  3028. // 调试:暂时保留bundle文件用于分析
  3029. console.log('📝 保留 bundle_v2.js 和 temp_flow_component_v2.jsx 用于调试');
  3030. // 清理临时文件(调试期间注释掉)
  3031. // fs.unlinkSync(reactComponentPath);
  3032. // fs.unlinkSync(path.join(__dirname, 'bundle_v2.js'));
  3033. console.log('✅ Visualization generated: ' + outputFile);
  3034. console.log('📊 Nodes: ' + Object.keys(data.nodes).length);
  3035. console.log('🔗 Edges: ' + data.edges.length);
  3036. }).catch(error => {
  3037. console.error('❌ Build error:', error);
  3038. process.exit(1);
  3039. });