瀏覽代碼

Add visualization tool for sug_v6_1_2_5

- Add visualization/sug_v6_1_2_5/ folder with React Flow based query graph visualization
- Features:
  - Interactive node dragging and filtering
  - Directory tree and breadcrumb navigation
  - "Worth searching" query panel
  - Type icons for query (🔍) and note (📝) nodes
  - Red text for unsatisfied nodes
  - Support for large datasets (1000+ nodes)
- Fix React multiple copies issue with esbuild alias configuration
- Fix nodeWidth undefined error in dagre layout
- Update sug_v6_1_2_5.py to integrate visualization:
  - Add --visualize flag for auto-generation after run
  - Add --visualize-only flag for standalone visualization
  - Execute in visualization subfolder to use local dependencies
- Add .gitignore rules for build artifacts and temp files

🤖 Generated with Claude Code
yangxiaohui 1 月之前
父節點
當前提交
7d9f61f46f
共有 4 個文件被更改,包括 2138 次插入8 次删除
  1. 21 8
      sug_v6_1_2_5.py
  2. 56 0
      visualization/README.md
  3. 95 0
      visualization/sug_v6_1_2_5/README.md
  4. 1966 0
      visualization/sug_v6_1_2_5/index.js

+ 21 - 8
sug_v6_1_2_5.py

@@ -1473,11 +1473,19 @@ async def main(input_dir: str, max_iterations: int = 20, visualize: bool = False
         import subprocess
         output_html = os.path.join(run_context.log_dir, "visualization.html")
         print(f"\n🎨 生成可视化HTML...")
+
+        # 获取绝对路径
+        vis_script = os.path.abspath("visualization/sug_v6_1_2_5/index.js")
+        abs_query_graph = os.path.abspath(query_graph_file_path)
+        abs_output_html = os.path.abspath(output_html)
+
+        # 在可视化脚本目录中执行,确保使用本地 node_modules
         result = subprocess.run([
-            "node", "visualize_v2.js",
-            query_graph_file_path,
-            output_html
-        ])
+            "node", "index.js",
+            abs_query_graph,
+            abs_output_html
+        ], cwd="visualization/sug_v6_1_2_5")
+
         if result.returncode == 0:
             print(f"✅ 可视化已生成: {output_html}")
         else:
@@ -1523,11 +1531,16 @@ if __name__ == "__main__":
         print(f"输入: {query_graph_path}")
         print(f"输出: {output_html}")
 
+        # 获取绝对路径
+        abs_query_graph = os.path.abspath(query_graph_path)
+        abs_output_html = os.path.abspath(output_html)
+
+        # 在可视化脚本目录中执行,确保使用本地 node_modules
         result = subprocess.run([
-            "node", "visualize_v2.js",
-            query_graph_path,
-            output_html
-        ])
+            "node", "index.js",
+            abs_query_graph,
+            abs_output_html
+        ], cwd="visualization/sug_v6_1_2_5")
 
         if result.returncode == 0:
             print(f"✅ 可视化已生成: {output_html}")

+ 56 - 0
visualization/README.md

@@ -0,0 +1,56 @@
+# 可视化工具集
+
+这个文件夹包含所有脚本的可视化工具。每个子文件夹对应一个主脚本,包含独立的可视化实现。
+
+## 📁 文件夹结构
+
+```
+visualization/
+├── sug_v6_1_2_5/          # sug_v6_1_2_5.py 的可视化工具
+│   ├── index.js           # 统一的入口文件
+│   ├── node_modules/      # 独立的依赖包
+│   └── README.md          # 详细使用说明
+├── (其他脚本的可视化工具)/
+└── README.md              # 本文件
+```
+
+## 🎯 设计原则
+
+1. **目录对应**:每个子文件夹名称与主脚本名称对应
+   - `sug_v6_1_2_5.py` → `visualization/sug_v6_1_2_5/`
+
+2. **统一命名**:所有可视化工具的入口文件统一命名为 `index.js`
+
+3. **独立依赖**:每个子文件夹包含自己的 `node_modules`,不依赖外部资源
+
+4. **自包含**:可以单独复制任何子文件夹到其他位置使用
+
+## 🚀 使用方式
+
+每个子文件夹都可以独立使用:
+
+```bash
+# 方式1:通过主脚本调用
+python sug_v6_1_2_5.py --visualize-only "path/to/query_graph.json"
+
+# 方式2:直接调用可视化工具
+node visualization/sug_v6_1_2_5/index.js "path/to/query_graph.json" "output.html"
+```
+
+## 📦 技术栈
+
+- Node.js
+- esbuild(打包)
+- React + React DOM
+- @xyflow/react(流程图)
+- dagre(布局算法)
+
+## 💡 添加新的可视化工具
+
+如果需要为其他脚本添加可视化工具:
+
+1. 创建对应的子文件夹:`visualization/script_name/`
+2. 添加 `index.js` 作为入口文件
+3. 复制必要的 `node_modules` 依赖
+4. 添加 `README.md` 说明文档
+5. 在主脚本中添加调用逻辑

+ 95 - 0
visualization/sug_v6_1_2_5/README.md

@@ -0,0 +1,95 @@
+# sug_v6_1_2_5 可视化工具
+
+这是 `sug_v6_1_2_5.py` 的配套可视化工具,用于将 `query_graph.json` 转换为交互式 HTML 可视化页面。
+
+## 📁 文件结构
+
+```
+visualization/
+└── sug_v6_1_2_5/           # 与主脚本名称对应
+    ├── index.js            # 主可视化脚本(统一命名)
+    ├── node_modules/       # 所有依赖包(独立,不依赖外部)
+    │   ├── esbuild/
+    │   ├── @xyflow/
+    │   ├── react/
+    │   ├── react-dom/
+    │   ├── dagre/
+    │   └── ...
+    └── README.md           # 本文件
+```
+
+## ✨ 特性
+
+- **独立运行**:所有依赖都包含在文件夹内,无需外部 node_modules
+- **React Flow 可视化**:基于 @xyflow/react 的现代化图形展示
+- **交互功能**:
+  - 节点拖拽
+  - 层级筛选
+  - 节点搜索
+  - 路径高亮
+  - 目录树导航
+  - 面包屑路径
+  - 值得搜索的查询列表
+- **节点类型区分**:
+  - 🔍 查询节点(query)
+  - 📝 帖子节点(note)
+- **状态标识**:
+  - 未选中查询:红色文字
+  - 不满足的帖子(match_level: unsatisfied):红色文字
+
+## 🚀 使用方式
+
+### 方式1:通过 sug_v6_1_2_5.py 调用
+
+```bash
+# 运行主程序并自动生成可视化
+python sug_v6_1_2_5.py --input-dir "input/某目录" --visualize
+
+# 仅生成可视化(不运行主程序)
+python sug_v6_1_2_5.py --visualize-only "path/to/query_graph.json"
+```
+
+### 方式2:直接调用脚本
+
+```bash
+node visualization/sug_v6_1_2_5/index.js <query_graph.json> [output.html]
+```
+
+## 📊 输入/输出
+
+**输入**:`query_graph.json` - 包含节点和边信息的 JSON 文件
+```json
+{
+  "nodes": {
+    "node_id": {
+      "type": "query" | "note",
+      "query": "...",
+      "level": 1,
+      "relevance_score": 0.95,
+      ...
+    }
+  },
+  "edges": [
+    {
+      "from": "node_id_1",
+      "to": "node_id_2",
+      "edge_type": "..."
+    }
+  ]
+}
+```
+
+**输出**:`visualization.html` - 独立的 HTML 文件,包含所有 JavaScript 和 CSS
+
+## 📦 依赖大小
+
+- 总大小:约 33MB
+- 所有依赖已打包,无需额外安装
+
+## 🔧 技术栈
+
+- Node.js 运行环境
+- esbuild(打包工具)
+- React + React DOM
+- @xyflow/react(流程图库)
+- dagre(图布局算法)

+ 1966 - 0
visualization/sug_v6_1_2_5/index.js

@@ -0,0 +1,1966 @@
+#!/usr/bin/env node
+
+const fs = require('fs');
+const path = require('path');
+const { build } = require('esbuild');
+
+// 读取命令行参数
+const args = process.argv.slice(2);
+if (args.length === 0) {
+  console.error('Usage: node index.js <path-to-query_graph.json> [output.html]');
+  process.exit(1);
+}
+
+const inputFile = args[0];
+const outputFile = args[1] || 'query_graph_output.html';
+
+// 读取输入数据
+const data = JSON.parse(fs.readFileSync(inputFile, 'utf-8'));
+
+// 创建临时 React 组件文件
+const reactComponentPath = path.join(__dirname, 'temp_flow_component_v2.jsx');
+const reactComponent = `
+import React, { useState, useCallback, useMemo, useEffect } from 'react';
+import { createRoot } from 'react-dom/client';
+import {
+  ReactFlow,
+  Controls,
+  Background,
+  useNodesState,
+  useEdgesState,
+  Handle,
+  Position,
+  useReactFlow,
+  ReactFlowProvider,
+} from '@xyflow/react';
+import '@xyflow/react/dist/style.css';
+
+const data = ${JSON.stringify(data, null, 2)};
+
+// 查询节点组件 - 卡片样式
+function QueryNode({ id, data, sourcePosition, targetPosition }) {
+  // 所有节点默认展开
+  const expanded = true;
+
+  return (
+    <div>
+      <Handle
+        type="target"
+        position={targetPosition || Position.Left}
+        style={{ background: '#667eea', width: 8, height: 8 }}
+      />
+      <div
+        style={{
+          padding: '12px',
+          borderRadius: '8px',
+          border: data.isHighlighted ? '3px solid #667eea' :
+                  data.isCollapsed ? '2px solid #667eea' :
+                  data.isSelected === false ? '2px dashed #d1d5db' :
+                  data.level === 0 ? '2px solid #8b5cf6' : '1px solid #e5e7eb',
+          background: data.isHighlighted ? '#eef2ff' :
+                      data.isSelected === false ? '#f9fafb' : 'white',
+          minWidth: '200px',
+          maxWidth: '280px',
+          boxShadow: data.isHighlighted ? '0 0 0 4px rgba(102, 126, 234, 0.25), 0 4px 16px rgba(102, 126, 234, 0.4)' :
+                     data.isCollapsed ? '0 4px 12px rgba(102, 126, 234, 0.15)' :
+                     data.level === 0 ? '0 4px 12px rgba(139, 92, 246, 0.15)' : '0 2px 6px rgba(0, 0, 0, 0.06)',
+          transition: 'all 0.3s ease',
+          cursor: 'pointer',
+          position: 'relative',
+          opacity: data.isSelected === false ? 0.6 : 1,
+        }}
+      >
+        {/* 折叠当前节点按钮 - 左边 */}
+        <div
+          style={{
+            position: 'absolute',
+            top: '6px',
+            left: '6px',
+            width: '20px',
+            height: '20px',
+            borderRadius: '50%',
+            background: '#f59e0b',
+            color: 'white',
+            display: 'flex',
+            alignItems: 'center',
+            justifyContent: 'center',
+            fontSize: '11px',
+            fontWeight: 'bold',
+            cursor: 'pointer',
+            transition: 'all 0.2s ease',
+            zIndex: 10,
+          }}
+          onClick={(e) => {
+            e.stopPropagation();
+            if (data.onHideSelf) {
+              data.onHideSelf();
+            }
+          }}
+          onMouseEnter={(e) => {
+            e.currentTarget.style.background = '#d97706';
+          }}
+          onMouseLeave={(e) => {
+            e.currentTarget.style.background = '#f59e0b';
+          }}
+          title="隐藏当前节点"
+        >
+          ×
+        </div>
+
+        {/* 聚焦按钮 - 右上角 */}
+        <div
+          style={{
+            position: 'absolute',
+            top: '6px',
+            right: '6px',
+            width: '20px',
+            height: '20px',
+            borderRadius: '50%',
+            background: data.isFocused ? '#10b981' : '#e5e7eb',
+            color: data.isFocused ? 'white' : '#6b7280',
+            display: 'flex',
+            alignItems: 'center',
+            justifyContent: 'center',
+            fontSize: '11px',
+            fontWeight: 'bold',
+            cursor: 'pointer',
+            transition: 'all 0.2s ease',
+            zIndex: 10,
+          }}
+          onClick={(e) => {
+            e.stopPropagation();
+            if (data.onFocus) {
+              data.onFocus();
+            }
+          }}
+          onMouseEnter={(e) => {
+            if (!data.isFocused) {
+              e.currentTarget.style.background = '#d1d5db';
+            }
+          }}
+          onMouseLeave={(e) => {
+            if (!data.isFocused) {
+              e.currentTarget.style.background = '#e5e7eb';
+            }
+          }}
+          title={data.isFocused ? '取消聚焦' : '聚焦到此节点'}
+        >
+          🎯
+        </div>
+
+        {/* 折叠/展开子节点按钮 - 右边第二个位置 */}
+        {data.hasChildren && (
+          <div
+            style={{
+              position: 'absolute',
+              top: '6px',
+              right: '30px',
+              width: '20px',
+              height: '20px',
+              borderRadius: '50%',
+              background: data.isCollapsed ? '#667eea' : '#e5e7eb',
+              color: data.isCollapsed ? 'white' : '#6b7280',
+              display: 'flex',
+              alignItems: 'center',
+              justifyContent: 'center',
+              fontSize: '11px',
+              fontWeight: 'bold',
+              cursor: 'pointer',
+              transition: 'all 0.2s ease',
+              zIndex: 10,
+            }}
+            onClick={(e) => {
+              e.stopPropagation();
+              data.onToggleCollapse();
+            }}
+            title={data.isCollapsed ? '展开子节点' : '折叠子节点'}
+          >
+            {data.isCollapsed ? '+' : '−'}
+          </div>
+        )}
+
+        {/* 卡片内容 */}
+        <div>
+          {/* 标题行 */}
+          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px', paddingLeft: '24px', paddingRight: data.hasChildren ? '54px' : '28px' }}>
+            <div style={{ flex: 1 }}>
+              <div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '3px' }}>
+                <div style={{
+                  fontSize: '13px',
+                  fontWeight: data.level === 0 ? '700' : '600',
+                  color: data.level === 0 ? '#6b21a8' : '#1f2937',
+                  lineHeight: '1.3',
+                  flex: 1,
+                }}>
+                  {data.title}
+                </div>
+                {data.isSelected === false && (
+                  <div style={{
+                    fontSize: '9px',
+                    padding: '1px 4px',
+                    borderRadius: '3px',
+                    background: '#fee2e2',
+                    color: '#991b1b',
+                    fontWeight: '500',
+                    flexShrink: 0,
+                  }}>
+                    未选中
+                  </div>
+                )}
+              </div>
+            </div>
+          </div>
+
+        {/* 展开的详细信息 - 始终显示 */}
+        <div style={{ fontSize: '11px', lineHeight: 1.4 }}>
+            <div style={{ display: 'flex', gap: '4px', marginBottom: '6px', flexWrap: 'wrap' }}>
+              <span style={{
+                display: 'inline-block',
+                padding: '1px 6px',
+                borderRadius: '10px',
+                background: '#eff6ff',
+                color: '#3b82f6',
+                fontSize: '10px',
+                fontWeight: '500',
+              }}>
+                Lv.{data.level}
+              </span>
+              <span style={{
+                display: 'inline-block',
+                padding: '1px 6px',
+                borderRadius: '10px',
+                background: '#f0fdf4',
+                color: '#16a34a',
+                fontSize: '10px',
+                fontWeight: '500',
+              }}>
+                {data.score}
+              </span>
+              {data.strategy && data.strategy !== 'root' && (
+                <span style={{
+                  display: 'inline-block',
+                  padding: '1px 6px',
+                  borderRadius: '10px',
+                  background: '#fef3c7',
+                  color: '#92400e',
+                  fontSize: '10px',
+                  fontWeight: '500',
+                }}>
+                  {data.strategy}
+                </span>
+              )}
+            </div>
+
+            {data.parent && (
+              <div style={{ color: '#6b7280', fontSize: '10px', marginTop: '4px', paddingTop: '4px', borderTop: '1px solid #f3f4f6' }}>
+                <strong>Parent:</strong> {data.parent}
+              </div>
+            )}
+            {data.evaluationReason && (
+              <div style={{
+                marginTop: '6px',
+                paddingTop: '6px',
+                borderTop: '1px solid #f3f4f6',
+                fontSize: '10px',
+                color: '#6b7280',
+                lineHeight: '1.5',
+              }}>
+                <strong style={{ color: '#4b5563' }}>评估:</strong>
+                <div style={{ marginTop: '2px' }}>{data.evaluationReason}</div>
+              </div>
+            )}
+          </div>
+        </div>
+      </div>
+      <Handle
+        type="source"
+        position={sourcePosition || Position.Right}
+        style={{ background: '#667eea', width: 8, height: 8 }}
+      />
+    </div>
+  );
+}
+
+// 笔记节点组件 - 卡片样式,带轮播图
+function NoteNode({ id, data, sourcePosition, targetPosition }) {
+  const [currentImageIndex, setCurrentImageIndex] = useState(0);
+  const expanded = true;
+  const hasImages = data.imageList && data.imageList.length > 0;
+
+  const nextImage = (e) => {
+    e.stopPropagation();
+    if (hasImages) {
+      setCurrentImageIndex((prev) => (prev + 1) % data.imageList.length);
+    }
+  };
+
+  const prevImage = (e) => {
+    e.stopPropagation();
+    if (hasImages) {
+      setCurrentImageIndex((prev) => (prev - 1 + data.imageList.length) % data.imageList.length);
+    }
+  };
+
+  return (
+    <div>
+      <Handle
+        type="target"
+        position={targetPosition || Position.Left}
+        style={{ background: '#ec4899', width: 8, height: 8 }}
+      />
+      <div
+        style={{
+          padding: '14px',
+          borderRadius: '20px',
+          border: data.isHighlighted ? '3px solid #ec4899' : '2px solid #fce7f3',
+          background: data.isHighlighted ? '#eef2ff' : 'white',
+          minWidth: '220px',
+          maxWidth: '300px',
+          boxShadow: data.isHighlighted ? '0 0 0 4px rgba(236, 72, 153, 0.25), 0 4px 16px rgba(236, 72, 153, 0.4)' : '0 4px 12px rgba(236, 72, 153, 0.15)',
+          transition: 'all 0.3s ease',
+          cursor: 'pointer',
+        }}
+      >
+        {/* 笔记图标和标题 */}
+        <div style={{ display: 'flex', alignItems: 'flex-start', marginBottom: '8px' }}>
+          <span style={{ fontSize: '16px', marginRight: '8px' }}>📝</span>
+          <div style={{ flex: 1 }}>
+            <div style={{
+              fontSize: '13px',
+              fontWeight: '600',
+              color: '#831843',
+              lineHeight: '1.4',
+              marginBottom: '4px',
+            }}>
+              {data.title}
+            </div>
+          </div>
+        </div>
+
+        {/* 轮播图 */}
+        {hasImages && (
+          <div style={{
+            position: 'relative',
+            marginBottom: '8px',
+            borderRadius: '12px',
+            overflow: 'hidden',
+          }}>
+            <img
+              src={data.imageList[currentImageIndex].image_url}
+              alt={\`Image \${currentImageIndex + 1}\`}
+              style={{
+                width: '100%',
+                height: '160px',
+                objectFit: 'cover',
+                display: 'block',
+              }}
+              onError={(e) => {
+                e.target.style.display = 'none';
+              }}
+            />
+            {data.imageList.length > 1 && (
+              <>
+                {/* 左右切换按钮 */}
+                <button
+                  onClick={prevImage}
+                  style={{
+                    position: 'absolute',
+                    left: '4px',
+                    top: '50%',
+                    transform: 'translateY(-50%)',
+                    background: 'rgba(0, 0, 0, 0.5)',
+                    color: 'white',
+                    border: 'none',
+                    borderRadius: '50%',
+                    width: '24px',
+                    height: '24px',
+                    cursor: 'pointer',
+                    display: 'flex',
+                    alignItems: 'center',
+                    justifyContent: 'center',
+                    fontSize: '14px',
+                  }}
+                >
+                  ‹
+                </button>
+                <button
+                  onClick={nextImage}
+                  style={{
+                    position: 'absolute',
+                    right: '4px',
+                    top: '50%',
+                    transform: 'translateY(-50%)',
+                    background: 'rgba(0, 0, 0, 0.5)',
+                    color: 'white',
+                    border: 'none',
+                    borderRadius: '50%',
+                    width: '24px',
+                    height: '24px',
+                    cursor: 'pointer',
+                    display: 'flex',
+                    alignItems: 'center',
+                    justifyContent: 'center',
+                    fontSize: '14px',
+                  }}
+                >
+                  ›
+                </button>
+                {/* 图片计数 */}
+                <div style={{
+                  position: 'absolute',
+                  bottom: '4px',
+                  right: '4px',
+                  background: 'rgba(0, 0, 0, 0.6)',
+                  color: 'white',
+                  padding: '2px 6px',
+                  borderRadius: '10px',
+                  fontSize: '10px',
+                }}>
+                  {currentImageIndex + 1}/{data.imageList.length}
+                </div>
+              </>
+            )}
+          </div>
+        )}
+
+        {/* 标签 */}
+        <div style={{ display: 'flex', gap: '6px', marginBottom: '8px', flexWrap: 'wrap' }}>
+          <span style={{
+            display: 'inline-block',
+            padding: '2px 8px',
+            borderRadius: '12px',
+            background: '#fff1f2',
+            color: '#be123c',
+            fontSize: '10px',
+            fontWeight: '500',
+          }}>
+            {data.matchLevel}
+          </span>
+          <span style={{
+            display: 'inline-block',
+            padding: '2px 8px',
+            borderRadius: '12px',
+            background: '#fff7ed',
+            color: '#c2410c',
+            fontSize: '10px',
+            fontWeight: '500',
+          }}>
+            Score: {data.score}
+          </span>
+        </div>
+
+        {/* 描述 */}
+        {expanded && data.description && (
+          <div style={{
+            fontSize: '11px',
+            color: '#9f1239',
+            lineHeight: '1.5',
+            paddingTop: '8px',
+            borderTop: '1px solid #fbcfe8',
+          }}>
+            {data.description}
+          </div>
+        )}
+
+        {/* 评估理由 */}
+        {expanded && data.evaluationReason && (
+          <div style={{
+            fontSize: '10px',
+            color: '#831843',
+            lineHeight: '1.5',
+            paddingTop: '8px',
+            marginTop: '8px',
+            borderTop: '1px solid #fbcfe8',
+          }}>
+            <strong style={{ color: '#9f1239' }}>评估:</strong>
+            <div style={{ marginTop: '2px' }}>{data.evaluationReason}</div>
+          </div>
+        )}
+      </div>
+      <Handle
+        type="source"
+        position={sourcePosition || Position.Right}
+        style={{ background: '#ec4899', width: 8, height: 8 }}
+      />
+    </div>
+  );
+}
+
+const nodeTypes = {
+  query: QueryNode,
+  note: NoteNode,
+};
+
+// 根据 score 获取颜色
+function getScoreColor(score) {
+  if (score >= 0.7) return '#10b981'; // 绿色 - 高分
+  if (score >= 0.4) return '#f59e0b'; // 橙色 - 中分
+  return '#ef4444'; // 红色 - 低分
+}
+
+// 截断文本,保留头尾,中间显示省略号
+function truncateMiddle(text, maxLength = 20) {
+  if (!text || text.length <= maxLength) return text;
+  const headLength = Math.ceil(maxLength * 0.4);
+  const tailLength = Math.floor(maxLength * 0.4);
+  const head = text.substring(0, headLength);
+  const tail = text.substring(text.length - tailLength);
+  return \`\${head}...\${tail}\`;
+}
+
+// 根据策略获取颜色
+function getStrategyColor(strategy) {
+  const strategyColors = {
+    '初始分词': '#10b981',
+    '调用sug': '#06b6d4',
+    '同义改写': '#f59e0b',
+    '加词': '#3b82f6',
+    '抽象改写': '#8b5cf6',
+    '基于部分匹配改进': '#ec4899',
+    '结果分支-抽象改写': '#a855f7',
+    '结果分支-同义改写': '#fb923c',
+  };
+  return strategyColors[strategy] || '#9ca3af';
+}
+
+// 树节点组件
+function TreeNode({ node, level, children, isCollapsed, onToggle, isSelected, onSelect }) {
+  const hasChildren = children && children.length > 0;
+  const score = node.data.score ? parseFloat(node.data.score) : 0;
+  const strategy = node.data.strategy || '';
+  const strategyColor = getStrategyColor(strategy);
+
+  return (
+    <div style={{ marginLeft: level * 12 + 'px' }}>
+      <div
+        style={{
+          padding: '6px 8px',
+          borderRadius: '4px',
+          cursor: 'pointer',
+          background: 'transparent',
+          border: isSelected ? '1px solid #3b82f6' : '1px solid transparent',
+          display: 'flex',
+          alignItems: 'center',
+          gap: '6px',
+          transition: 'all 0.2s ease',
+          position: 'relative',
+          overflow: 'visible',
+        }}
+        onMouseEnter={(e) => {
+          if (!isSelected) e.currentTarget.style.background = '#f9fafb';
+        }}
+        onMouseLeave={(e) => {
+          if (!isSelected) e.currentTarget.style.background = 'transparent';
+        }}
+      >
+        {/* 策略类型竖线 */}
+        <div style={{
+          width: '3px',
+          height: '20px',
+          background: strategyColor,
+          borderRadius: '2px',
+          flexShrink: 0,
+          position: 'relative',
+          zIndex: 1,
+        }} />
+
+        {hasChildren && (
+          <span
+            style={{
+              fontSize: '10px',
+              color: '#6b7280',
+              cursor: 'pointer',
+              width: '16px',
+              textAlign: 'center',
+              position: 'relative',
+              zIndex: 1,
+            }}
+            onClick={(e) => {
+              e.stopPropagation();
+              onToggle();
+            }}
+          >
+            {isCollapsed ? '▶' : '▼'}
+          </span>
+        )}
+        {!hasChildren && <span style={{ width: '16px', position: 'relative', zIndex: 1 }}></span>}
+
+        <div
+          style={{
+            flex: 1,
+            fontSize: '12px',
+            color: '#374151',
+            position: 'relative',
+            zIndex: 1,
+            minWidth: 0,
+            display: 'flex',
+            flexDirection: 'column',
+            gap: '4px',
+          }}
+          onClick={onSelect}
+        >
+          <div style={{
+            display: 'flex',
+            alignItems: 'center',
+            gap: '8px',
+          }}>
+            {/* 节点类型图标 */}
+            <span style={{
+              fontSize: '12px',
+              flexShrink: 0,
+            }}>
+              {node.type === 'note' ? '📝' : '🔍'}
+            </span>
+
+            <div style={{
+              fontWeight: level === 0 ? '600' : '400',
+              maxWidth: '180px',
+              flex: 1,
+              minWidth: 0,
+              color: (node.type === 'note' ? node.data.matchLevel === 'unsatisfied' : node.data.isSelected === false) ? '#ef4444' : '#374151',
+            }}
+            title={node.data.title || node.id}
+            >
+              {truncateMiddle(node.data.title || node.id, 18)}
+            </div>
+
+            {/* 分数显示 */}
+            <span style={{
+              fontSize: '11px',
+              color: '#6b7280',
+              fontWeight: '500',
+              flexShrink: 0,
+            }}>
+              {score.toFixed(2)}
+            </span>
+          </div>
+
+          {/* 分数下划线 */}
+          <div style={{
+            width: (score * 100) + '%',
+            height: '2px',
+            background: getScoreColor(score),
+            borderRadius: '1px',
+          }} />
+        </div>
+      </div>
+
+      {hasChildren && !isCollapsed && (
+        <div>
+          {children}
+        </div>
+      )}
+    </div>
+  );
+}
+
+// 使用 dagre 自动布局
+function getLayoutedElements(nodes, edges, direction = 'LR') {
+  console.log('🎯 Starting layout with dagre...');
+  console.log('Input:', nodes.length, 'nodes,', edges.length, 'edges');
+
+  // 检查 dagre 是否加载
+  if (typeof window === 'undefined' || typeof window.dagre === 'undefined') {
+    console.warn('⚠️ Dagre not loaded, using fallback layout');
+    // 降级到简单布局
+    const levelGroups = {};
+    nodes.forEach(node => {
+      const level = node.data.level || 0;
+      if (!levelGroups[level]) levelGroups[level] = [];
+      levelGroups[level].push(node);
+    });
+
+    Object.entries(levelGroups).forEach(([level, nodeList]) => {
+      const x = parseInt(level) * 350;
+      nodeList.forEach((node, index) => {
+        node.position = { x, y: index * 150 };
+        node.targetPosition = 'left';
+        node.sourcePosition = 'right';
+      });
+    });
+
+    return { nodes, edges };
+  }
+
+  try {
+    const dagreGraph = new window.dagre.graphlib.Graph();
+    dagreGraph.setDefaultEdgeLabel(() => ({}));
+
+    const isHorizontal = direction === 'LR';
+    dagreGraph.setGraph({
+      rankdir: direction,
+      nodesep: 120,   // 垂直间距 - 增加以避免节点重叠
+      ranksep: 280,  // 水平间距 - 增加以容纳更宽的节点
+    });
+
+    // 添加节点 - 根据节点类型设置不同的尺寸
+    nodes.forEach((node) => {
+      let nodeWidth = 280;
+      let nodeHeight = 180;
+
+      // note 节点有轮播图,需要更大的空间
+      if (node.type === 'note') {
+        nodeWidth = 320;
+        nodeHeight = 350;  // 增加高度以容纳轮播图
+      }
+
+      dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
+    });
+
+    // 添加边
+    edges.forEach((edge) => {
+      dagreGraph.setEdge(edge.source, edge.target);
+    });
+
+    // 计算布局
+    window.dagre.layout(dagreGraph);
+    console.log('✅ Dagre layout completed');
+
+    // 更新节点位置和 handle 位置
+    nodes.forEach((node) => {
+      const nodeWithPosition = dagreGraph.node(node.id);
+
+      if (!nodeWithPosition) {
+        console.warn('Node position not found for:', node.id);
+        return;
+      }
+
+      node.targetPosition = isHorizontal ? 'left' : 'top';
+      node.sourcePosition = isHorizontal ? 'right' : 'bottom';
+
+      // 根据节点类型获取尺寸
+      let nodeWidth = 280;
+      let nodeHeight = 180;
+      if (node.type === 'note') {
+        nodeWidth = 320;
+        nodeHeight = 350;
+      }
+
+      // 将 dagre 的中心点位置转换为 React Flow 的左上角位置
+      node.position = {
+        x: nodeWithPosition.x - nodeWidth / 2,
+        y: nodeWithPosition.y - nodeHeight / 2,
+      };
+    });
+
+    console.log('✅ Layout completed, sample node:', nodes[0]);
+    return { nodes, edges };
+  } catch (error) {
+    console.error('❌ Error in dagre layout:', error);
+    console.error('Error details:', error.message, error.stack);
+
+    // 降级处理
+    console.log('Using fallback layout...');
+    const levelGroups = {};
+    nodes.forEach(node => {
+      const level = node.data.level || 0;
+      if (!levelGroups[level]) levelGroups[level] = [];
+      levelGroups[level].push(node);
+    });
+
+    Object.entries(levelGroups).forEach(([level, nodeList]) => {
+      const x = parseInt(level) * 350;
+      nodeList.forEach((node, index) => {
+        node.position = { x, y: index * 150 };
+        node.targetPosition = 'left';
+        node.sourcePosition = 'right';
+      });
+    });
+
+    return { nodes, edges };
+  }
+}
+
+function transformData(data) {
+  const nodes = [];
+  const edges = [];
+
+  const originalIdToCanvasId = {}; // 原始ID -> 画布ID的映射
+  const canvasIdToNodeData = {}; // 避免重复创建相同的节点
+
+  // 创建节点
+  Object.entries(data.nodes).forEach(([originalId, node]) => {
+    if (node.type === 'query') {
+      // 使用 query_level 作为唯一ID
+      const canvasId = node.query + '_' + node.level;
+      originalIdToCanvasId[originalId] = canvasId;
+
+      // 如果这个 canvasId 还没有创建过节点,则创建
+      if (!canvasIdToNodeData[canvasId]) {
+        canvasIdToNodeData[canvasId] = true;
+        nodes.push({
+          id: canvasId, // 使用 query_level 格式
+          originalId: originalId, // 保留原始ID用于调试
+          type: 'query',
+          data: {
+            title: node.query,
+            level: node.level,
+            score: node.relevance_score.toFixed(2),
+            strategy: node.strategy,
+            parent: node.parent_query,
+            isSelected: node.is_selected,
+            evaluationReason: node.evaluation_reason || '',
+          },
+          position: { x: 0, y: 0 }, // 初始位置,会被 dagre 覆盖
+        });
+      }
+    } else if (node.type === 'note') {
+      // note节点直接使用原始ID
+      originalIdToCanvasId[originalId] = originalId;
+
+      if (!canvasIdToNodeData[originalId]) {
+        canvasIdToNodeData[originalId] = true;
+        nodes.push({
+          id: originalId,
+          originalId: originalId,
+          type: 'note',
+          data: {
+            title: node.title,
+            matchLevel: node.match_level,
+            score: node.relevance_score.toFixed(2),
+            description: node.desc,
+            isSelected: node.is_selected !== undefined ? node.is_selected : true,
+            imageList: node.image_list || [], // 添加图片列表
+            noteUrl: node.note_url || '', // 添加帖子链接
+            evaluationReason: node.evaluation_reason || '', // 添加评估理由
+          },
+          position: { x: 0, y: 0 },
+        });
+      }
+    }
+  });
+
+  // 创建边 - 使用虚线样式,映射到画布ID
+  data.edges.forEach((edge, index) => {
+    const edgeColors = {
+      '初始分词': '#10b981',
+      '调用sug': '#06b6d4',
+      '同义改写': '#f59e0b',
+      '加词': '#3b82f6',
+      '抽象改写': '#8b5cf6',
+      '基于部分匹配改进': '#ec4899',
+      '结果分支-抽象改写': '#a855f7',
+      '结果分支-同义改写': '#fb923c',
+      'query_to_note': '#ec4899',
+    };
+
+    const color = edgeColors[edge.strategy] || edgeColors[edge.edge_type] || '#d1d5db';
+    const isNoteEdge = edge.edge_type === 'query_to_note';
+
+    edges.push({
+      id: \`edge-\${index}\`,
+      source: originalIdToCanvasId[edge.from], // 使用画布ID
+      target: originalIdToCanvasId[edge.to],   // 使用画布ID
+      type: 'simplebezier', // 使用简单贝塞尔曲线
+      animated: isNoteEdge,
+      style: {
+        stroke: color,
+        strokeWidth: isNoteEdge ? 2.5 : 2,
+        strokeDasharray: isNoteEdge ? '5,5' : '8,4',
+      },
+      markerEnd: {
+        type: 'arrowclosed',
+        color: color,
+        width: 20,
+        height: 20,
+      },
+    });
+  });
+
+  // 使用 dagre 自动计算布局 - 从左到右
+  return getLayoutedElements(nodes, edges, 'LR');
+}
+
+function FlowContent() {
+  const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
+    console.log('🔍 Transforming data...');
+    const result = transformData(data);
+    console.log('✅ Transformed:', result.nodes.length, 'nodes,', result.edges.length, 'edges');
+    return result;
+  }, []);
+
+  // 初始化:找出所有有子节点的节点,默认折叠(画布节点)
+  const initialCollapsedNodes = useMemo(() => {
+    const nodesWithChildren = new Set();
+    initialEdges.forEach(edge => {
+      nodesWithChildren.add(edge.source);
+    });
+    // 排除根节点(level 0),让根节点默认展开
+    const rootNode = initialNodes.find(n => n.data.level === 0);
+    if (rootNode) {
+      nodesWithChildren.delete(rootNode.id);
+    }
+    return nodesWithChildren;
+  }, [initialNodes, initialEdges]);
+
+  // 树节点的折叠状态需要在树构建后初始化
+  const [collapsedNodes, setCollapsedNodes] = useState(() => initialCollapsedNodes);
+  const [collapsedTreeNodes, setCollapsedTreeNodes] = useState(new Set());
+  const [selectedNodeId, setSelectedNodeId] = useState(null);
+  const [hiddenNodes, setHiddenNodes] = useState(new Set()); // 用户手动隐藏的节点
+  const [focusMode, setFocusMode] = useState(false); // 全局聚焦模式,默认关闭
+  const [focusedNodeId, setFocusedNodeId] = useState(null); // 单独聚焦的节点ID
+
+  // 获取 React Flow 实例以控制画布
+  const { setCenter, fitView } = useReactFlow();
+
+  // 获取某个节点的所有后代节点ID
+  const getDescendants = useCallback((nodeId) => {
+    const descendants = new Set();
+    const queue = [nodeId];
+
+    while (queue.length > 0) {
+      const current = queue.shift();
+      initialEdges.forEach(edge => {
+        if (edge.source === current && !descendants.has(edge.target)) {
+          descendants.add(edge.target);
+          queue.push(edge.target);
+        }
+      });
+    }
+
+    return descendants;
+  }, [initialEdges]);
+
+  // 获取直接父节点
+  const getDirectParents = useCallback((nodeId) => {
+    const parents = [];
+    initialEdges.forEach(edge => {
+      if (edge.target === nodeId) {
+        parents.push(edge.source);
+      }
+    });
+    return parents;
+  }, [initialEdges]);
+
+  // 获取直接子节点
+  const getDirectChildren = useCallback((nodeId) => {
+    const children = [];
+    initialEdges.forEach(edge => {
+      if (edge.source === nodeId) {
+        children.push(edge.target);
+      }
+    });
+    return children;
+  }, [initialEdges]);
+
+  // 切换节点折叠状态
+  const toggleNodeCollapse = useCallback((nodeId) => {
+    setCollapsedNodes(prev => {
+      const newSet = new Set(prev);
+      const descendants = getDescendants(nodeId);
+
+      if (newSet.has(nodeId)) {
+        // 展开:移除此节点,但保持其他折叠的节点
+        newSet.delete(nodeId);
+      } else {
+        // 折叠:添加此节点
+        newSet.add(nodeId);
+      }
+
+      return newSet;
+    });
+  }, [getDescendants]);
+
+  // 过滤可见的节点和边,并重新计算布局
+  const { nodes, edges } = useMemo(() => {
+    const nodesToHide = new Set();
+
+    // 判断使用哪个节点ID进行聚焦:优先使用单独聚焦的节点,否则使用全局聚焦模式的选中节点
+    const effectiveFocusNodeId = focusedNodeId || (focusMode ? selectedNodeId : null);
+
+    // 聚焦模式:只显示聚焦节点、其父节点和直接子节点
+    if (effectiveFocusNodeId) {
+      const visibleInFocus = new Set([effectiveFocusNodeId]);
+
+      // 添加所有父节点
+      initialEdges.forEach(edge => {
+        if (edge.target === effectiveFocusNodeId) {
+          visibleInFocus.add(edge.source);
+        }
+      });
+
+      // 添加所有直接子节点
+      initialEdges.forEach(edge => {
+        if (edge.source === effectiveFocusNodeId) {
+          visibleInFocus.add(edge.target);
+        }
+      });
+
+      // 隐藏不在聚焦范围内的节点
+      initialNodes.forEach(node => {
+        if (!visibleInFocus.has(node.id)) {
+          nodesToHide.add(node.id);
+        }
+      });
+    } else {
+      // 非聚焦模式:使用原有的折叠逻辑
+      // 收集所有被折叠节点的后代
+      collapsedNodes.forEach(collapsedId => {
+        const descendants = getDescendants(collapsedId);
+        descendants.forEach(id => nodesToHide.add(id));
+      });
+    }
+
+    // 添加用户手动隐藏的节点
+    hiddenNodes.forEach(id => nodesToHide.add(id));
+
+    const visibleNodes = initialNodes
+      .filter(node => !nodesToHide.has(node.id))
+      .map(node => ({
+        ...node,
+        data: {
+          ...node.data,
+          isCollapsed: collapsedNodes.has(node.id),
+          hasChildren: initialEdges.some(e => e.source === node.id),
+          onToggleCollapse: () => toggleNodeCollapse(node.id),
+          onHideSelf: () => {
+            setHiddenNodes(prev => {
+              const newSet = new Set(prev);
+              newSet.add(node.id);
+              return newSet;
+            });
+          },
+          onFocus: () => {
+            // 切换聚焦状态
+            if (focusedNodeId === node.id) {
+              setFocusedNodeId(null); // 如果已经聚焦,则取消聚焦
+            } else {
+              // 先取消之前的聚焦,然后聚焦到当前节点
+              setFocusedNodeId(node.id);
+
+              // 延迟聚焦视图到该节点
+              setTimeout(() => {
+                fitView({
+                  nodes: [{ id: node.id }],
+                  duration: 800,
+                  padding: 0.3,
+                });
+              }, 100);
+            }
+          },
+          isFocused: focusedNodeId === node.id,
+          isHighlighted: selectedNodeId === node.id,
+        }
+      }));
+
+    const visibleEdges = initialEdges.filter(
+      edge => !nodesToHide.has(edge.source) && !nodesToHide.has(edge.target)
+    );
+
+    // 重新计算布局 - 只对可见节点
+    if (typeof window !== 'undefined' && typeof window.dagre !== 'undefined') {
+      try {
+        const dagreGraph = new window.dagre.graphlib.Graph();
+        dagreGraph.setDefaultEdgeLabel(() => ({}));
+
+        dagreGraph.setGraph({
+          rankdir: 'LR',
+          nodesep: 120,   // 垂直间距 - 增加以避免节点重叠
+          ranksep: 280,  // 水平间距 - 增加以容纳更宽的节点
+        });
+
+        visibleNodes.forEach((node) => {
+          let nodeWidth = 280;
+          let nodeHeight = 180;
+
+          // note 节点有轮播图,需要更大的空间
+          if (node.type === 'note') {
+            nodeWidth = 320;
+            nodeHeight = 350;
+          }
+
+          dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
+        });
+
+        visibleEdges.forEach((edge) => {
+          dagreGraph.setEdge(edge.source, edge.target);
+        });
+
+        window.dagre.layout(dagreGraph);
+
+        visibleNodes.forEach((node) => {
+          const nodeWithPosition = dagreGraph.node(node.id);
+          if (nodeWithPosition) {
+            // 根据节点类型获取对应的尺寸
+            let nodeWidth = 280;
+            let nodeHeight = 180;
+            if (node.type === 'note') {
+              nodeWidth = 320;
+              nodeHeight = 350;
+            }
+
+            node.position = {
+              x: nodeWithPosition.x - nodeWidth / 2,
+              y: nodeWithPosition.y - nodeHeight / 2,
+            };
+            node.targetPosition = 'left';
+            node.sourcePosition = 'right';
+          }
+        });
+
+        console.log('✅ Dynamic layout recalculated for', visibleNodes.length, 'visible nodes');
+      } catch (error) {
+        console.error('❌ Error in dynamic layout:', error);
+      }
+    }
+
+    return { nodes: visibleNodes, edges: visibleEdges };
+  }, [initialNodes, initialEdges, collapsedNodes, hiddenNodes, focusMode, focusedNodeId, getDescendants, toggleNodeCollapse, selectedNodeId]);
+
+  // 构建树形结构 - 允许一个节点有多个父节点
+  const buildTree = useCallback(() => {
+    const nodeMap = new Map();
+    initialNodes.forEach(node => {
+      nodeMap.set(node.id, node);
+    });
+
+    // 为每个节点创建树节点的副本(允许多次出现)
+    const createTreeNode = (nodeId, pathKey) => {
+      const node = nodeMap.get(nodeId);
+      if (!node) return null;
+
+      return {
+        ...node,
+        treeKey: pathKey, // 唯一的树路径key,用于React key
+        children: []
+      };
+    };
+
+    // 构建父子关系映射:记录每个节点的所有父节点,去重边
+    const parentToChildren = new Map();
+    const childToParents = new Map();
+
+    initialEdges.forEach(edge => {
+      // 记录父->子关系(去重:同一个父节点到同一个子节点只记录一次)
+      if (!parentToChildren.has(edge.source)) {
+        parentToChildren.set(edge.source, []);
+      }
+      const children = parentToChildren.get(edge.source);
+      if (!children.includes(edge.target)) {
+        children.push(edge.target);
+      }
+
+      // 记录子->父关系(用于判断是否有多个父节点,也去重)
+      if (!childToParents.has(edge.target)) {
+        childToParents.set(edge.target, []);
+      }
+      const parents = childToParents.get(edge.target);
+      if (!parents.includes(edge.source)) {
+        parents.push(edge.source);
+      }
+    });
+
+    // 递归构建树
+    const buildSubtree = (nodeId, pathKey, visitedInPath) => {
+      // 避免循环引用:如果当前路径中已经访问过这个节点,跳过
+      if (visitedInPath.has(nodeId)) {
+        return null;
+      }
+
+      const treeNode = createTreeNode(nodeId, pathKey);
+      if (!treeNode) return null;
+
+      const newVisitedInPath = new Set(visitedInPath);
+      newVisitedInPath.add(nodeId);
+
+      const children = parentToChildren.get(nodeId) || [];
+      treeNode.children = children
+        .map((childId, index) => buildSubtree(childId, pathKey + '-' + childId + '-' + index, newVisitedInPath))
+        .filter(child => child !== null);
+
+      return treeNode;
+    };
+
+    // 找出所有根节点(没有入边的节点)
+    const hasParent = new Set();
+    initialEdges.forEach(edge => {
+      hasParent.add(edge.target);
+    });
+
+    const roots = [];
+    initialNodes.forEach((node, index) => {
+      if (!hasParent.has(node.id)) {
+        const treeNode = buildSubtree(node.id, 'root-' + node.id + '-' + index, new Set());
+        if (treeNode) roots.push(treeNode);
+      }
+    });
+
+    return roots;
+  }, [initialNodes, initialEdges]);
+
+  const treeRoots = useMemo(() => buildTree(), [buildTree]);
+
+  // 初始化树节点折叠状态
+  useEffect(() => {
+    const getAllTreeKeys = (nodes) => {
+      const keys = new Set();
+      const traverse = (node) => {
+        if (node.children && node.children.length > 0) {
+          // 排除根节点
+          if (node.data.level !== 0) {
+            keys.add(node.treeKey);
+          }
+          node.children.forEach(traverse);
+        }
+      };
+      nodes.forEach(traverse);
+      return keys;
+    };
+
+    setCollapsedTreeNodes(getAllTreeKeys(treeRoots));
+  }, [treeRoots]);
+
+  const renderTree = useCallback((treeNodes, level = 0) => {
+    return treeNodes.map(node => {
+      // 使用 treeKey 来区分树中的不同实例
+      const isCollapsed = collapsedTreeNodes.has(node.treeKey);
+      const isSelected = selectedNodeId === node.id;
+
+      return (
+        <TreeNode
+          key={node.treeKey}
+          node={node}
+          level={level}
+          isCollapsed={isCollapsed}
+          isSelected={isSelected}
+          onToggle={() => {
+            setCollapsedTreeNodes(prev => {
+              const newSet = new Set(prev);
+              if (newSet.has(node.treeKey)) {
+                newSet.delete(node.treeKey);
+              } else {
+                newSet.add(node.treeKey);
+              }
+              return newSet;
+            });
+          }}
+          onSelect={() => {
+            const nodeId = node.id;
+
+            // 展开所有祖先节点
+            const ancestorIds = [nodeId];
+            const findAncestors = (id) => {
+              initialEdges.forEach(edge => {
+                if (edge.target === id && !ancestorIds.includes(edge.source)) {
+                  ancestorIds.push(edge.source);
+                  findAncestors(edge.source);
+                }
+              });
+            };
+            findAncestors(nodeId);
+
+            // 如果节点或其祖先被隐藏,先恢复它们
+            setHiddenNodes(prev => {
+              const newSet = new Set(prev);
+              ancestorIds.forEach(id => newSet.delete(id));
+              return newSet;
+            });
+
+            setSelectedNodeId(nodeId);
+
+            // 获取选中节点的直接子节点
+            const childrenIds = [];
+            initialEdges.forEach(edge => {
+              if (edge.source === nodeId) {
+                childrenIds.push(edge.target);
+              }
+            });
+
+            setCollapsedNodes(prev => {
+              const newSet = new Set(prev);
+              // 展开所有祖先节点
+              ancestorIds.forEach(id => newSet.delete(id));
+              // 展开选中节点本身
+              newSet.delete(nodeId);
+              // 展开选中节点的直接子节点
+              childrenIds.forEach(id => newSet.delete(id));
+              return newSet;
+            });
+
+            // 延迟聚焦,等待节点展开和布局重新计算
+            setTimeout(() => {
+              fitView({
+                nodes: [{ id: nodeId }],
+                duration: 800,
+                padding: 0.3,
+              });
+            }, 300);
+          }}
+        >
+          {node.children && node.children.length > 0 && renderTree(node.children, level + 1)}
+        </TreeNode>
+      );
+    });
+  }, [collapsedTreeNodes, selectedNodeId, nodes, setCenter, initialEdges, setCollapsedNodes, fitView]);
+
+  console.log('📊 Rendering with', nodes.length, 'visible nodes and', edges.length, 'visible edges');
+
+  if (nodes.length === 0) {
+    return (
+      <div style={{ padding: 50, color: 'red', fontSize: 20 }}>
+        ERROR: No nodes to display!
+      </div>
+    );
+  }
+
+  return (
+    <div style={{ width: '100vw', height: '100vh', background: '#f9fafb', display: 'flex', flexDirection: 'column' }}>
+      {/* 顶部面包屑导航栏 */}
+      <div style={{
+        minHeight: '48px',
+        maxHeight: '120px',
+        background: 'white',
+        borderBottom: '1px solid #e5e7eb',
+        display: 'flex',
+        alignItems: 'flex-start',
+        padding: '12px 24px',
+        zIndex: 1000,
+        boxShadow: '0 1px 3px rgba(0, 0, 0, 0.05)',
+        flexShrink: 0,
+        overflowY: 'auto',
+      }}>
+        <div style={{ width: '100%' }}>
+          {selectedNodeId ? (
+            <div style={{ fontSize: '12px', color: '#6b7280' }}>
+              {/* 面包屑导航 - 显示所有路径 */}
+              {(() => {
+                const selectedNode = nodes.find(n => n.id === selectedNodeId);
+                if (!selectedNode) return null;
+
+                // 找到所有从根节点到当前节点的路径
+                const findAllPaths = (targetId) => {
+                  const paths = [];
+
+                  const buildPath = (nodeId, currentPath) => {
+                    const node = initialNodes.find(n => n.id === nodeId);
+                    if (!node) return;
+
+                    const newPath = [node, ...currentPath];
+
+                    // 找到所有父节点
+                    const parents = initialEdges.filter(e => e.target === nodeId).map(e => e.source);
+
+                    if (parents.length === 0) {
+                      // 到达根节点
+                      paths.push(newPath);
+                    } else {
+                      // 递归处理所有父节点
+                      parents.forEach(parentId => {
+                        buildPath(parentId, newPath);
+                      });
+                    }
+                  };
+
+                  buildPath(targetId, []);
+                  return paths;
+                };
+
+                const allPaths = findAllPaths(selectedNodeId);
+
+                // 去重:将路径转换为字符串进行比较
+                const uniquePaths = [];
+                const pathStrings = new Set();
+                allPaths.forEach(path => {
+                  const pathString = path.map(n => n.id).join('->');
+                  if (!pathStrings.has(pathString)) {
+                    pathStrings.add(pathString);
+                    uniquePaths.push(path);
+                  }
+                });
+
+                return (
+                  <div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
+                    {uniquePaths.map((path, pathIndex) => (
+                      <div key={pathIndex} style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
+                        {pathIndex > 0 && <span style={{ color: '#d1d5db', marginRight: '4px' }}>或</span>}
+                        {path.map((node, index) => {
+                          // 获取节点的 score、strategy 和 isSelected
+                          const nodeScore = node.data.score ? parseFloat(node.data.score) : 0;
+                          const nodeStrategy = node.data.strategy || '';
+                          const strategyColor = getStrategyColor(nodeStrategy);
+                          const nodeIsSelected = node.type === 'note' ? node.data.matchLevel !== 'unsatisfied' : node.data.isSelected !== false;
+
+                          return (
+                          <React.Fragment key={node.id + '-' + index}>
+                            <span
+                              onClick={() => {
+                                const nodeId = node.id;
+
+                                // 找到所有祖先节点
+                                const ancestorIds = [nodeId];
+                                const findAncestors = (id) => {
+                                  initialEdges.forEach(edge => {
+                                    if (edge.target === id && !ancestorIds.includes(edge.source)) {
+                                      ancestorIds.push(edge.source);
+                                      findAncestors(edge.source);
+                                    }
+                                  });
+                                };
+                                findAncestors(nodeId);
+
+                                // 如果节点或其祖先被隐藏,先恢复它们
+                                setHiddenNodes(prev => {
+                                  const newSet = new Set(prev);
+                                  ancestorIds.forEach(id => newSet.delete(id));
+                                  return newSet;
+                                });
+
+                                // 展开目录树中到达该节点的路径
+                                // 需要找到所有包含该节点的树路径的 treeKey,并展开它们的父节点
+                                setCollapsedTreeNodes(prev => {
+                                  const newSet = new Set(prev);
+                                  // 清空所有折叠状态,让目录树完全展开到选中节点
+                                  // 这样可以确保选中节点在目录中可见
+                                  return new Set();
+                                });
+
+                                setSelectedNodeId(nodeId);
+                                setTimeout(() => {
+                                  fitView({
+                                    nodes: [{ id: nodeId }],
+                                    duration: 800,
+                                    padding: 0.3,
+                                  });
+                                }, 100);
+                              }}
+                              style={{
+                                padding: '6px 8px',
+                                borderRadius: '4px',
+                                background: 'white',
+                                border: index === path.length - 1 ? '2px solid #3b82f6' : '1px solid #d1d5db',
+                                color: '#374151',
+                                fontWeight: index === path.length - 1 ? '600' : '400',
+                                width: '180px',
+                                cursor: 'pointer',
+                                transition: 'all 0.2s ease',
+                                position: 'relative',
+                                display: 'inline-flex',
+                                flexDirection: 'column',
+                                gap: '4px',
+                              }}
+                              onMouseEnter={(e) => {
+                                e.currentTarget.style.opacity = '0.8';
+                              }}
+                              onMouseLeave={(e) => {
+                                e.currentTarget.style.opacity = '1';
+                              }}
+                              title={\`\${node.data.title || node.id} (Score: \${nodeScore.toFixed(2)}, Strategy: \${nodeStrategy}, Selected: \${nodeIsSelected})\`}
+                            >
+                              {/* 上半部分:竖线 + 图标 + 文字 + 分数 */}
+                              <div style={{
+                                display: 'flex',
+                                alignItems: 'center',
+                                gap: '6px',
+                              }}>
+                                {/* 策略类型竖线 */}
+                                <div style={{
+                                  width: '3px',
+                                  height: '16px',
+                                  background: strategyColor,
+                                  borderRadius: '2px',
+                                  flexShrink: 0,
+                                }} />
+
+                                {/* 节点类型图标 */}
+                                <span style={{
+                                  fontSize: '11px',
+                                  flexShrink: 0,
+                                }}>
+                                  {node.type === 'note' ? '📝' : '🔍'}
+                                </span>
+
+                                {/* 节点文字 */}
+                                <span style={{
+                                  flex: 1,
+                                  fontSize: '12px',
+                                  color: nodeIsSelected ? '#374151' : '#ef4444',
+                                }}>
+                                  {truncateMiddle(node.data.title || node.id, 18)}
+                                </span>
+
+                                {/* 分数显示 */}
+                                <span style={{
+                                  fontSize: '10px',
+                                  color: '#6b7280',
+                                  fontWeight: '500',
+                                  flexShrink: 0,
+                                }}>
+                                  {nodeScore.toFixed(2)}
+                                </span>
+                              </div>
+
+                              {/* 分数下划线 */}
+                              <div style={{
+                                width: (nodeScore * 100) + '%',
+                                height: '2px',
+                                background: getScoreColor(nodeScore),
+                                borderRadius: '1px',
+                                marginLeft: '9px',
+                              }} />
+                            </span>
+                            {index < path.length - 1 && <span style={{ color: '#9ca3af' }}>›</span>}
+                          </React.Fragment>
+                        )})}
+                      </div>
+                    ))}
+                  </div>
+                );
+              })()}
+            </div>
+          ) : (
+            <div style={{ fontSize: '13px', color: '#9ca3af', textAlign: 'center' }}>
+              选择一个节点查看路径
+            </div>
+          )}
+        </div>
+      </div>
+
+      {/* 主内容区:目录 + 画布 */}
+      <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
+        {/* 左侧目录树 */}
+        <div style={{
+          width: '320px',
+          background: 'white',
+          borderRight: '1px solid #e5e7eb',
+          display: 'flex',
+          flexDirection: 'column',
+          flexShrink: 0,
+        }}>
+          <div style={{
+            padding: '12px 16px',
+            borderBottom: '1px solid #e5e7eb',
+            display: 'flex',
+            justifyContent: 'space-between',
+            alignItems: 'center',
+          }}>
+            <span style={{
+              fontWeight: '600',
+              fontSize: '14px',
+              color: '#111827',
+            }}>
+              节点目录
+            </span>
+            <div style={{ display: 'flex', gap: '6px' }}>
+              <button
+                onClick={() => {
+                  setCollapsedTreeNodes(new Set());
+                }}
+                style={{
+                  fontSize: '11px',
+                  padding: '4px 8px',
+                  borderRadius: '4px',
+                  border: '1px solid #d1d5db',
+                  background: 'white',
+                  color: '#6b7280',
+                  cursor: 'pointer',
+                  fontWeight: '500',
+                }}
+                title="展开全部节点"
+              >
+                全部展开
+              </button>
+              <button
+                onClick={() => {
+                  const getAllTreeKeys = (nodes) => {
+                    const keys = new Set();
+                    const traverse = (node) => {
+                      if (node.children && node.children.length > 0) {
+                        keys.add(node.treeKey);
+                        node.children.forEach(traverse);
+                      }
+                    };
+                    nodes.forEach(traverse);
+                    return keys;
+                  };
+                  setCollapsedTreeNodes(getAllTreeKeys(treeRoots));
+                }}
+                style={{
+                  fontSize: '11px',
+                  padding: '4px 8px',
+                  borderRadius: '4px',
+                  border: '1px solid #d1d5db',
+                  background: 'white',
+                  color: '#6b7280',
+                  cursor: 'pointer',
+                  fontWeight: '500',
+                }}
+                title="折叠全部节点"
+              >
+                全部折叠
+              </button>
+            </div>
+          </div>
+          <div style={{
+            flex: 1,
+            overflowX: 'auto',
+            overflowY: 'auto',
+            padding: '8px',
+          }}>
+            <div style={{ minWidth: 'fit-content' }}>
+              {renderTree(treeRoots)}
+            </div>
+          </div>
+        </div>
+
+        {/* 画布区域 */}
+        <div style={{ flex: 1, position: 'relative' }}>
+
+          {/* 右侧图例 */}
+          <div style={{
+            position: 'absolute',
+            top: '20px',
+            right: '20px',
+            background: 'white',
+            padding: '16px',
+            borderRadius: '12px',
+            boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
+            zIndex: 1000,
+            maxWidth: '260px',
+            border: '1px solid #e5e7eb',
+          }}>
+        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
+          <h3 style={{ fontSize: '14px', fontWeight: '600', color: '#111827', margin: 0 }}>图例</h3>
+          <button
+            onClick={() => setFocusMode(!focusMode)}
+            style={{
+              fontSize: '11px',
+              padding: '4px 8px',
+              borderRadius: '4px',
+              border: '1px solid',
+              borderColor: focusMode ? '#3b82f6' : '#d1d5db',
+              background: focusMode ? '#3b82f6' : 'white',
+              color: focusMode ? 'white' : '#6b7280',
+              cursor: 'pointer',
+              fontWeight: '500',
+            }}
+            title={focusMode ? '关闭聚焦模式' : '开启聚焦模式'}
+          >
+            {focusMode ? '🎯 聚焦' : '📊 全图'}
+          </button>
+        </div>
+
+        <div style={{ fontSize: '12px' }}>
+          {/* 画布节点展开/折叠控制 */}
+          <div style={{ marginBottom: '12px', paddingBottom: '12px', borderBottom: '1px solid #f3f4f6' }}>
+            <div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>节点控制</div>
+            <div style={{ display: 'flex', gap: '6px' }}>
+              <button
+                onClick={() => {
+                  setCollapsedNodes(new Set());
+                }}
+                style={{
+                  fontSize: '11px',
+                  padding: '4px 8px',
+                  borderRadius: '4px',
+                  border: '1px solid #d1d5db',
+                  background: 'white',
+                  color: '#6b7280',
+                  cursor: 'pointer',
+                  fontWeight: '500',
+                  flex: 1,
+                }}
+                title="展开画布中所有节点的子节点"
+              >
+                全部展开
+              </button>
+              <button
+                onClick={() => {
+                  const allNodeIds = new Set(initialNodes.map(n => n.id));
+                  setCollapsedNodes(allNodeIds);
+                }}
+                style={{
+                  fontSize: '11px',
+                  padding: '4px 8px',
+                  borderRadius: '4px',
+                  border: '1px solid #d1d5db',
+                  background: 'white',
+                  color: '#6b7280',
+                  cursor: 'pointer',
+                  fontWeight: '500',
+                  flex: 1,
+                }}
+                title="折叠画布中所有节点的子节点"
+              >
+                全部折叠
+              </button>
+            </div>
+          </div>
+
+          <div style={{ paddingTop: '12px', borderTop: '1px solid #f3f4f6' }}>
+            <div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>策略类型</div>
+            <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
+              <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#10b981', opacity: 0.7 }}></div>
+              <span style={{ color: '#6b7280', fontSize: '11px' }}>初始分词</span>
+            </div>
+            <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
+              <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#06b6d4', opacity: 0.7 }}></div>
+              <span style={{ color: '#6b7280', fontSize: '11px' }}>调用sug</span>
+            </div>
+            <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
+              <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#f59e0b', opacity: 0.7 }}></div>
+              <span style={{ color: '#6b7280', fontSize: '11px' }}>同义改写</span>
+            </div>
+            <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
+              <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#3b82f6', opacity: 0.7 }}></div>
+              <span style={{ color: '#6b7280', fontSize: '11px' }}>加词</span>
+            </div>
+            <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
+              <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#8b5cf6', opacity: 0.7 }}></div>
+              <span style={{ color: '#6b7280', fontSize: '11px' }}>抽象改写</span>
+            </div>
+            <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
+              <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#ec4899', opacity: 0.7 }}></div>
+              <span style={{ color: '#6b7280', fontSize: '11px' }}>基于部分匹配改进</span>
+            </div>
+            <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
+              <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#a855f7', opacity: 0.7 }}></div>
+              <span style={{ color: '#6b7280', fontSize: '11px' }}>结果分支-抽象改写</span>
+            </div>
+            <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
+              <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#fb923c', opacity: 0.7 }}></div>
+              <span style={{ color: '#6b7280', fontSize: '11px' }}>结果分支-同义改写</span>
+            </div>
+          </div>
+
+          <div style={{
+            marginTop: '12px',
+            paddingTop: '12px',
+            borderTop: '1px solid #f3f4f6',
+            fontSize: '11px',
+            color: '#9ca3af',
+            lineHeight: '1.5',
+          }}>
+            💡 点击节点左上角 × 隐藏节点
+          </div>
+
+          {/* 隐藏节点列表 - 在图例内部 */}
+          {hiddenNodes.size > 0 && (
+            <div style={{
+              marginTop: '12px',
+              paddingTop: '12px',
+              borderTop: '1px solid #f3f4f6',
+            }}>
+              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
+                <h4 style={{ fontSize: '12px', fontWeight: '600', color: '#111827' }}>已隐藏节点</h4>
+                <button
+                  onClick={() => setHiddenNodes(new Set())}
+                  style={{
+                    fontSize: '10px',
+                    color: '#3b82f6',
+                    background: 'none',
+                    border: 'none',
+                    cursor: 'pointer',
+                    textDecoration: 'underline',
+                  }}
+                >
+                  全部恢复
+                </button>
+              </div>
+              <div style={{ fontSize: '12px', maxHeight: '200px', overflow: 'auto' }}>
+                {Array.from(hiddenNodes).map(nodeId => {
+                  const node = initialNodes.find(n => n.id === nodeId);
+                  if (!node) return null;
+                  return (
+                    <div
+                      key={nodeId}
+                      style={{
+                        display: 'flex',
+                        justifyContent: 'space-between',
+                        alignItems: 'center',
+                        padding: '6px 8px',
+                        margin: '4px 0',
+                        background: '#f9fafb',
+                        borderRadius: '6px',
+                        fontSize: '11px',
+                      }}
+                    >
+                      <span
+                        style={{
+                          flex: 1,
+                          overflow: 'hidden',
+                          textOverflow: 'ellipsis',
+                          whiteSpace: 'nowrap',
+                          color: '#374151',
+                        }}
+                        title={node.data.title || nodeId}
+                      >
+                        {node.data.title || nodeId}
+                      </span>
+                      <button
+                        onClick={() => {
+                          setHiddenNodes(prev => {
+                            const newSet = new Set(prev);
+                            newSet.delete(nodeId);
+                            return newSet;
+                          });
+                        }}
+                        style={{
+                          marginLeft: '8px',
+                          fontSize: '10px',
+                          color: '#10b981',
+                          background: 'none',
+                          border: 'none',
+                          cursor: 'pointer',
+                          flexShrink: 0,
+                        }}
+                      >
+                        恢复
+                      </button>
+                    </div>
+                  );
+                })}
+              </div>
+            </div>
+          )}
+          </div>
+          </div>
+
+          {/* React Flow 画布 */}
+          <ReactFlow
+            nodes={nodes}
+            edges={edges}
+            nodeTypes={nodeTypes}
+            fitView
+            fitViewOptions={{ padding: 0.2, duration: 500 }}
+            minZoom={0.1}
+            maxZoom={1.5}
+            nodesDraggable={true}
+            nodesConnectable={false}
+            elementsSelectable={true}
+            defaultEdgeOptions={{
+              type: 'smoothstep',
+            }}
+            proOptions={{ hideAttribution: true }}
+            onNodeClick={(event, clickedNode) => {
+              setSelectedNodeId(clickedNode.id);
+            }}
+          >
+            <Controls style={{ bottom: '20px', left: 'auto', right: '20px' }} />
+            <Background variant="dots" gap={20} size={1} color="#e5e7eb" />
+          </ReactFlow>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function App() {
+  return (
+    <ReactFlowProvider>
+      <FlowContent />
+    </ReactFlowProvider>
+  );
+}
+
+const root = createRoot(document.getElementById('root'));
+root.render(<App />);
+`;
+
+fs.writeFileSync(reactComponentPath, reactComponent);
+
+// 使用 esbuild 打包
+console.log('🎨 Building modern visualization...');
+
+build({
+  entryPoints: [reactComponentPath],
+  bundle: true,
+  outfile: path.join(__dirname, 'bundle_v2.js'),
+  format: 'iife',
+  loader: {
+    '.css': 'css',
+  },
+  minify: false,
+  sourcemap: 'inline',
+  // 强制所有 React 引用指向同一个位置,避免多副本
+  alias: {
+    'react': path.join(__dirname, 'node_modules/react'),
+    'react-dom': path.join(__dirname, 'node_modules/react-dom'),
+    'react/jsx-runtime': path.join(__dirname, 'node_modules/react/jsx-runtime'),
+    'react/jsx-dev-runtime': path.join(__dirname, 'node_modules/react/jsx-dev-runtime'),
+  },
+}).then(() => {
+  // 读取打包后的 JS
+  const bundleJs = fs.readFileSync(path.join(__dirname, 'bundle_v2.js'), 'utf-8');
+
+  // 读取 CSS
+  const cssPath = path.join(__dirname, 'node_modules/@xyflow/react/dist/style.css');
+  const css = fs.readFileSync(cssPath, 'utf-8');
+
+  // 生成最终 HTML
+  const html = `<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>查询图可视化</title>
+    <link rel="preconnect" href="https://fonts.googleapis.com">
+    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
+    <script src="https://unpkg.com/dagre@0.8.5/dist/dagre.min.js"></script>
+    <script>
+      // 过滤特定的 React 警告
+      const originalError = console.error;
+      console.error = (...args) => {
+        if (typeof args[0] === 'string' && args[0].includes('Each child in a list should have a unique "key" prop')) {
+          return;
+        }
+        originalError.apply(console, args);
+      };
+    </script>
+    <style>
+        * {
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }
+        body {
+            font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+            overflow: hidden;
+            -webkit-font-smoothing: antialiased;
+            -moz-osx-font-smoothing: grayscale;
+        }
+        #root {
+            width: 100vw;
+            height: 100vh;
+        }
+        ${css}
+
+        /* 自定义样式覆盖 */
+        .react-flow__edge-path {
+            stroke-linecap: round;
+        }
+        .react-flow__controls {
+            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+            border: 1px solid #e5e7eb;
+            border-radius: 8px;
+        }
+        .react-flow__controls-button {
+            border: none;
+            border-bottom: 1px solid #e5e7eb;
+        }
+        .react-flow__controls-button:hover {
+            background: #f9fafb;
+        }
+    </style>
+</head>
+<body>
+    <div id="root"></div>
+    <script>${bundleJs}</script>
+</body>
+</html>`;
+
+  // 写入输出文件
+  fs.writeFileSync(outputFile, html);
+
+  // 清理临时文件
+  fs.unlinkSync(reactComponentPath);
+  fs.unlinkSync(path.join(__dirname, 'bundle_v2.js'));
+
+  console.log('✅ Visualization generated: ' + outputFile);
+  console.log('📊 Nodes: ' + Object.keys(data.nodes).length);
+  console.log('🔗 Edges: ' + data.edges.length);
+}).catch(error => {
+  console.error('❌ Build error:', error);
+  process.exit(1);
+});