Explorar el Código

feat: 添加帖子标签节点的双向游走功能

- 新增帖子标签节点游走:从当前帖子标签出发,通过人设图谱找到同一帖子的其他标签
- 双向搜索算法:正向从起点经匹配边到人设节点,反向从其他标签经匹配边到人设节点
- 游走层可视化:按层级渲染新节点,已有节点保持原位置
- 统一游走配置:将人设游走和帖子游走配置都放在相关图中
- GraphView 支持显示帖子游走的路径和节点
- 修复 baseNodeElements 防止游走节点污染已有节点判断

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui hace 2 días
padre
commit
9fe39bac47

+ 45 - 8
script/visualization/src/components/GraphView.vue

@@ -24,8 +24,8 @@
       </template>
     </div>
 
-    <!-- 游走配置面板 -->
-    <div v-show="showConfig" class="px-4 py-2 bg-base-200 border-b border-base-300 text-xs space-y-3 max-h-64 overflow-y-auto relative z-50">
+    <!-- 人设节点游走配置 -->
+    <div v-show="showConfig && isPersonaWalk" class="px-4 py-2 bg-base-200 border-b border-base-300 text-xs space-y-3 max-h-64 overflow-y-auto relative z-50">
       <!-- 步数设置 -->
       <div class="flex items-center gap-2">
         <span class="text-base-content/60 w-16">游走步数:</span>
@@ -58,6 +58,30 @@
       </div>
     </div>
 
+    <!-- 帖子标签节点游走配置 -->
+    <div v-show="showConfig && isPostWalk" class="px-4 py-2 bg-base-200 border-b border-base-300 text-xs space-y-2">
+      <div class="flex items-center gap-2">
+        <span class="text-base-content/60 w-20">最大步数:</span>
+        <input type="range" :min="2" :max="6" v-model.number="store.postWalkConfig.maxSteps" class="range range-xs flex-1" />
+        <span class="w-6 text-center">{{ store.postWalkConfig.maxSteps }}</span>
+      </div>
+      <div class="flex items-center gap-2">
+        <span class="text-base-content/60 w-20">最后步分数:</span>
+        <input type="range" :min="0" :max="1" :step="0.1" v-model.number="store.postWalkConfig.lastStepMinScore" class="range range-xs flex-1" />
+        <span class="w-8 text-center">{{ store.postWalkConfig.lastStepMinScore.toFixed(1) }}</span>
+      </div>
+      <div class="flex items-center gap-2 text-base-content/50">
+        <span>路径: 帖子标签</span>
+        <span class="text-primary">--{{ store.postWalkConfig.firstEdgeType }}--></span>
+        <span>人设图谱</span>
+        <span class="text-primary">--{{ store.postWalkConfig.lastEdgeType }}--></span>
+        <span>其他标签</span>
+      </div>
+      <div v-if="store.postWalkedPaths.length > 0" class="text-success">
+        找到 {{ store.postWalkedPaths.length }} 条路径
+      </div>
+    </div>
+
     <!-- SVG 容器 -->
     <div ref="containerRef" class="flex-1 relative overflow-hidden">
       <svg ref="svgRef" class="w-full h-full transition-opacity duration-200"></svg>
@@ -108,6 +132,10 @@ const currentNodeName = computed(() => {
   return node ? node.name : store.selectedNodeId
 })
 
+// 判断当前是哪种游走类型
+const isPersonaWalk = computed(() => store.selectedNodeId && store.shouldWalk(store.selectedNodeId))
+const isPostWalk = computed(() => store.selectedNodeId && store.shouldPostWalk(store.selectedNodeId))
+
 // 渲染相关图
 function renderGraph() {
   // 停止旧的 simulation
@@ -121,8 +149,8 @@ function renderGraph() {
 
   if (!store.selectedNodeId) return
 
-  // 只有配置的节点类型才显示相关图
-  if (!store.shouldWalk(store.selectedNodeId)) return
+  // 只有配置的节点类型才显示相关图(人设节点或帖子标签节点)
+  if (!store.shouldWalk(store.selectedNodeId) && !store.shouldPostWalk(store.selectedNodeId)) return
 
   const container = containerRef.value
   if (!container) return
@@ -169,7 +197,9 @@ function renderGraph() {
   }
 
   // 使用游走时记录的边(只显示两端节点都存在的边)
-  for (const edge of store.walkedEdges) {
+  // 优先使用 postWalkedEdges(帖子游走),否则用 walkedEdges(人设游走)
+  const edges = store.postWalkedEdges.length > 0 ? store.postWalkedEdges : store.walkedEdges
+  for (const edge of edges) {
     if (nodeSet.has(edge.source) && nodeSet.has(edge.target)) {
       links.push({ ...edge })
     }
@@ -280,14 +310,21 @@ function handleSvgClick(event) {
   }
 }
 
-// 监听高亮变化(walkedEdges 变化时重新渲染)
-watch(() => store.walkedEdges.length, () => {
+// 监听高亮变化(walkedEdges 或 postWalkedEdges 变化时重新渲染)
+watch([() => store.walkedEdges.length, () => store.postWalkedEdges.length], () => {
   nextTick(renderGraph)
 })
 
 // 监听配置变化,重新选中触发游走
 watch([() => store.walkSteps, () => store.stepConfigs], () => {
-  if (store.selectedNodeId) {
+  if (store.selectedNodeId && isPersonaWalk.value) {
+    store.selectNode(store.selectedNodeId)
+  }
+}, { deep: true })
+
+// 监听帖子游走配置变化
+watch(() => store.postWalkConfig, () => {
+  if (store.selectedNodeId && isPostWalk.value) {
     store.selectNode(store.selectedNodeId)
   }
 }, { deep: true })

+ 275 - 2
script/visualization/src/components/PostTreeView.vue

@@ -93,6 +93,7 @@ function formatPostOption(post) {
 
 // 节点元素映射(统一存储所有节点位置)
 let nodeElements = {}
+let baseNodeElements = {}  // 基础节点(帖子树+匹配层),不含游走节点
 let currentRoot = null
 
 // 处理节点点击
@@ -427,6 +428,271 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
 
   // 更新总高度(用于 fitToView)
   treeHeight = matchLayerY + 50
+
+  // 保存匹配层 Y 位置,供游走层使用
+  lastMatchLayerY = matchLayerY
+
+  // 保存基础节点快照(帖子树+匹配层),供游走层判断已有节点
+  baseNodeElements = { ...nodeElements }
+}
+
+// 保存匹配层 Y 位置
+let lastMatchLayerY = 0
+
+// 绘制游走层(按层级渲染路径)
+function renderWalkedLayer() {
+  if (!mainG) return
+
+  // 移除旧的游走层
+  mainG.selectAll('.walked-layer').remove()
+
+  // 重置 nodeElements 为基础节点(清除之前的游走节点)
+  nodeElements = { ...baseNodeElements }
+
+  const paths = store.postWalkedPaths
+  if (!paths.length) return
+
+  const contentG = mainG.select('g')
+  if (contentG.empty()) return
+
+  // 创建游走层组
+  const walkedG = contentG.append('g').attr('class', 'walked-layer')
+
+  // ========== 按路径位置分层遍历,确定每个新节点的层数 ==========
+  // nodeLayer: nodeId -> layer (已有节点从 nodeElements 推断,新节点计算得出)
+  const nodeLayer = new Map()
+  const newNodes = new Set()  // 新增的节点
+
+  console.log('=== renderWalkedLayer 层数计算 ===')
+  console.log('路径数:', paths.length)
+  console.log('lastMatchLayerY:', lastMatchLayerY)
+
+  // 初始化:已有节点的层数(帖子树和匹配层)
+  // 使用 baseNodeElements(不含之前的游走节点)
+  // 匹配层的 Y 坐标是 lastMatchLayerY,作为基准层 0
+  for (const [nodeId, info] of Object.entries(baseNodeElements)) {
+    // 根据 Y 坐标推断层数(匹配层为0,帖子树在上面为负数)
+    const layer = Math.round((info.y - 25 - lastMatchLayerY) / 80)
+    nodeLayer.set(nodeId, Math.min(layer, 0))  // 已有节点层数 <= 0
+  }
+  console.log('基础节点数:', nodeLayer.size)
+
+  // 找出所有路径的最大长度
+  const maxPathLen = Math.max(...paths.map(p => p.nodes.length))
+  console.log('最大路径长度:', maxPathLen)
+
+  // 按位置分层遍历(从第2个节点开始,index=1)
+  for (let pos = 1; pos < maxPathLen; pos++) {
+    console.log(`--- 处理位置 ${pos} ---`)
+    for (const path of paths) {
+      if (pos >= path.nodes.length) continue
+
+      const nodeId = path.nodes[pos]
+      const prevNodeId = path.nodes[pos - 1]
+
+      // 如果已经在树上,跳过
+      if (nodeLayer.has(nodeId)) {
+        console.log(`  节点 ${nodeId}: 已存在,层数=${nodeLayer.get(nodeId)},跳过`)
+        continue
+      }
+
+      // 找前驱节点的层数
+      const prevLayer = nodeLayer.get(prevNodeId) ?? 0
+      const newLayer = prevLayer + 1
+
+      console.log(`  节点 ${nodeId}: 前驱=${prevNodeId}(层${prevLayer}) → 新层数=${newLayer}`)
+
+      // 新节点层数 = 前驱层数 + 1
+      nodeLayer.set(nodeId, newLayer)
+      newNodes.add(nodeId)
+    }
+  }
+
+  console.log('新增节点数:', newNodes.size)
+  console.log('新增节点:', Array.from(newNodes))
+
+  // ========== 按层分组新节点 ==========
+  const layerGroups = new Map()  // layer -> Set of nodeIds
+  for (const nodeId of newNodes) {
+    const layer = nodeLayer.get(nodeId)
+    if (!layerGroups.has(layer)) {
+      layerGroups.set(layer, new Set())
+    }
+    layerGroups.get(layer).add(nodeId)
+  }
+
+  // ========== 计算新节点位置 ==========
+  const treeNodes = Object.values(nodeElements)
+  const minX = d3.min(treeNodes, d => d.x) || 50
+  const maxX = d3.max(treeNodes, d => d.x) || 300
+  const layerSpacing = 80  // 层间距
+
+  // nodePositions 只存储新节点的位置
+  const nodePositions = {}
+
+  for (const [layer, nodeIds] of layerGroups) {
+    const nodesAtLayer = Array.from(nodeIds)
+    const layerY = lastMatchLayerY + layer * layerSpacing
+
+    // 新节点均匀分布
+    const spacing = (maxX - minX - 100) / Math.max(nodesAtLayer.length - 1, 1)
+    nodesAtLayer.forEach((nodeId, i) => {
+      nodePositions[nodeId] = {
+        x: nodesAtLayer.length === 1 ? (minX + maxX) / 2 - 50 : (minX - 50) + i * spacing,
+        y: layerY
+      }
+    })
+  }
+
+  // ========== 收集所有边 ==========
+  const allEdges = new Map()
+  for (const path of paths) {
+    for (const edge of path.edges) {
+      const edgeKey = `${edge.source}->${edge.target}`
+      if (!allEdges.has(edgeKey)) {
+        allEdges.set(edgeKey, edge)
+      }
+    }
+  }
+
+  // 获取节点位置的辅助函数(优先新节点位置,然后已有节点位置)
+  function getNodePos(nodeId) {
+    if (nodePositions[nodeId]) {
+      return nodePositions[nodeId]
+    }
+    if (nodeElements[nodeId]) {
+      return {
+        x: nodeElements[nodeId].x - 50,
+        y: nodeElements[nodeId].y - 25
+      }
+    }
+    return null
+  }
+
+  // ========== 绘制所有路径上的边 ==========
+  const edgesData = []
+  console.log('=== 绘制边 ===')
+  console.log('总边数:', allEdges.size)
+
+  for (const edge of allEdges.values()) {
+    const srcPos = getNodePos(edge.source)
+    const tgtPos = getNodePos(edge.target)
+
+    console.log(`边 ${edge.source} -> ${edge.target}:`,
+      'srcPos=', srcPos ? `(${srcPos.x.toFixed(0)},${srcPos.y.toFixed(0)})` : 'null',
+      'tgtPos=', tgtPos ? `(${tgtPos.x.toFixed(0)},${tgtPos.y.toFixed(0)})` : 'null'
+    )
+
+    if (srcPos && tgtPos) {
+      edgesData.push({
+        ...edge,
+        srcX: srcPos.x,
+        srcY: srcPos.y,
+        tgtX: tgtPos.x,
+        tgtY: tgtPos.y
+      })
+    } else {
+      console.log('  ⚠️ 跳过:位置缺失')
+    }
+  }
+  console.log('实际绘制边数:', edgesData.length)
+
+  walkedG.selectAll('.walked-link')
+    .data(edgesData)
+    .join('path')
+    .attr('class', 'walked-link')
+    .attr('fill', 'none')
+    .attr('stroke', d => getEdgeStyle({ type: d.type, score: d.score }).color)
+    .attr('stroke-opacity', d => getEdgeStyle({ type: d.type, score: d.score }).opacity)
+    .attr('stroke-width', d => getEdgeStyle({ type: d.type, score: d.score }).strokeWidth)
+    .attr('stroke-dasharray', d => getEdgeStyle({ type: d.type, score: d.score }).strokeDasharray)
+    .attr('d', d => {
+      const midY = (d.srcY + d.tgtY) / 2
+      return `M${d.srcX},${d.srcY} C${d.srcX},${midY} ${d.tgtX},${midY} ${d.tgtX},${d.tgtY}`
+    })
+
+  // 绘制分数标签
+  const scoreData = edgesData.filter(d => getEdgeStyle({ type: d.type, score: d.score }).scoreText)
+  const scoreGroups = walkedG.selectAll('.walked-score')
+    .data(scoreData)
+    .join('g')
+    .attr('class', 'walked-score')
+    .attr('transform', d => {
+      const midX = (d.srcX + d.tgtX) / 2
+      const midY = (d.srcY + d.tgtY) / 2
+      return `translate(${midX}, ${midY})`
+    })
+
+  scoreGroups.append('rect')
+    .attr('x', -14).attr('y', -6).attr('width', 28).attr('height', 12)
+    .attr('rx', 2).attr('fill', '#1d232a').attr('opacity', 0.9)
+
+  scoreGroups.append('text')
+    .attr('text-anchor', 'middle').attr('dy', '0.35em')
+    .attr('fill', d => getEdgeStyle({ type: d.type, score: d.score }).color)
+    .attr('font-size', '8px')
+    .text(d => getEdgeStyle({ type: d.type, score: d.score }).scoreText)
+
+  // ========== 绘制新节点(nodePositions 中的都是新节点) ==========
+  const newNodesData = []
+  for (const [nodeId, pos] of Object.entries(nodePositions)) {
+    // 获取节点数据
+    const nodeData = store.postWalkedNodes.find(n => n.id === nodeId)
+    if (nodeData) {
+      newNodesData.push({ ...nodeData, x: pos.x, y: pos.y })
+    }
+  }
+
+  const walkedNodeGroups = walkedG.selectAll('.walked-node')
+    .data(newNodesData)
+    .join('g')
+    .attr('class', 'walked-node')
+    .attr('transform', d => `translate(${d.x},${d.y})`)
+    .style('cursor', 'pointer')
+    .on('click', (event, d) => {
+      event.stopPropagation()
+      store.selectNode(d)
+    })
+
+  // 节点形状
+  walkedNodeGroups.each(function(d) {
+    const el = d3.select(this)
+    const style = getNodeStyle(d)
+
+    if (style.shape === 'rect') {
+      el.append('rect')
+        .attr('class', 'walked-shape')
+        .attr('x', -4).attr('y', -4).attr('width', 8).attr('height', 8)
+        .attr('rx', 1).attr('fill', style.color)
+        .attr('stroke', 'rgba(255,255,255,0.5)').attr('stroke-width', 1)
+    } else {
+      el.append('circle')
+        .attr('class', 'walked-shape')
+        .attr('r', 3).attr('fill', style.color)
+        .attr('stroke', 'rgba(255,255,255,0.5)').attr('stroke-width', 1)
+    }
+
+    // 保存节点位置
+    nodeElements[d.id] = { element: this, x: d.x + 50, y: d.y + 25 }
+  })
+
+  // 节点标签
+  walkedNodeGroups.append('text')
+    .attr('dy', 4).attr('dx', 10).attr('text-anchor', 'start')
+    .attr('fill', d => getNodeStyle(d).text.fill)
+    .attr('font-size', d => getNodeStyle(d).text.fontSize)
+    .attr('font-weight', d => getNodeStyle(d).text.fontWeight)
+    .text(d => {
+      const name = d.name
+      const maxLen = 10
+      return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
+    })
+
+  // 更新高度(如果有新节点)
+  if (Object.keys(nodePositions).length > 0) {
+    const maxY = Math.max(...Object.values(nodePositions).map(p => p.y))
+    treeHeight = Math.max(treeHeight, maxY + 50)
+  }
 }
 
 // 匹配节点点击处理
@@ -479,13 +745,15 @@ function zoomToNode(nodeId) {
 
 // 更新高亮/置灰状态
 function updateHighlight() {
-  applyHighlight(svgRef.value, store.highlightedNodeIds, store.walkedEdgeSet, store.selectedNodeId)
+  // 使用帖子游走的边集合(如果有),否则用人设游走的边集合
+  const edgeSet = store.postWalkedEdgeSet.size > 0 ? store.postWalkedEdgeSet : store.walkedEdgeSet
+  applyHighlight(svgRef.value, store.highlightedNodeIds, edgeSet, store.selectedNodeId)
 }
 
 // 点击空白取消激活
 function handleSvgClick(event) {
   const target = event.target
-  if (!target.closest('.tree-node') && !target.closest('.match-node')) {
+  if (!target.closest('.tree-node') && !target.closest('.match-node') && !target.closest('.walked-node')) {
     store.clearSelection()
   }
 }
@@ -500,6 +768,11 @@ watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
 
 watch(() => store.highlightedNodeIds.size, updateHighlight)
 
+// 监听帖子游走结果变化,渲染游走层
+watch(() => store.postWalkedNodes.length, () => {
+  nextTick(renderWalkedLayer)
+})
+
 // 监听当前帖子变化,重新渲染树
 watch(() => store.currentPostGraph, () => {
   nextTick(() => {

+ 267 - 5
script/visualization/src/stores/graph.js

@@ -42,7 +42,7 @@ export const useGraphStore = defineStore('graph', () => {
     clearSelection()
   }
 
-  // ==================== 游走配置 ====================
+  // ==================== 人设节点游走配置 ====================
   // 使用游走的节点类型前缀
   const walkNodeTypes = ref(['人设:'])
 
@@ -55,11 +55,245 @@ export const useGraphStore = defineStore('graph', () => {
     { edgeTypes: ['属于'], minScore: 0 }
   ])
 
-  // 判断节点是否使用游走
+  // 判断节点是否使用人设游走
   function shouldWalk(nodeId) {
     return walkNodeTypes.value.some(prefix => nodeId.startsWith(prefix))
   }
 
+  // ==================== 帖子标签节点游走配置 ====================
+  const postWalkConfig = reactive({
+    nodeTypes: ['帖子:'],  // 触发游走的节点类型前缀
+    maxSteps: 4,  // 最大步数
+    lastStepMinScore: 0.8,  // 最后一步最小分数
+    firstEdgeType: '匹配',  // 第一步边类型
+    lastEdgeType: '匹配',  // 最后一步边类型(反向)
+    excludeMiddleEdgeTypes: ['匹配']  // 中间步骤排除的边类型
+  })
+
+  // 判断节点是否使用帖子游走(帖子树中的标签节点)
+  function shouldPostWalk(nodeId) {
+    return postWalkConfig.nodeTypes.some(prefix => nodeId.startsWith(prefix))
+  }
+
+  // 帖子游走结果
+  const postWalkedPaths = ref([])  // 所有满足条件的路径
+  const postWalkedNodes = ref([])  // 路径中的所有节点(去重)
+  const postWalkedEdges = ref([])  // 路径中的所有边(去重)
+
+  // 获取当前帖子的所有节点ID(用于排除)
+  function getCurrentPostNodeIds() {
+    const postGraph = currentPostGraph.value
+    if (!postGraph) return new Set()
+    return new Set(Object.keys(postGraph.nodes || {}))
+  }
+
+  // 执行帖子标签节点游走:双向搜索找可达路径
+  function executePostWalk(startNodeId) {
+    console.log('=== executePostWalk 双向搜索 ===')
+    console.log('起点:', startNodeId)
+
+    const currentPostNodes = getCurrentPostNodeIds()
+    const postGraph = currentPostGraph.value
+    const personaGraph = graphData.value
+
+    if (!postGraph || !personaGraph) {
+      console.log('缺少图谱数据')
+      postWalkedPaths.value = []
+      postWalkedNodes.value = []
+      postWalkedEdges.value = []
+      return new Set([startNodeId])
+    }
+
+    // ========== 正向:起点 → 匹配边 → 人设节点 ==========
+    const forwardNodes = new Map()  // nodeId -> { prevNode, edge }
+    const forwardFrontier = new Set()
+
+    // 直接遍历边(postGraph.edges),因为 postGraph 可能没有 index 结构
+    const postEdges = Object.values(postGraph.edges || {})
+    console.log('帖子图谱边数:', postEdges.length)
+
+    for (const edge of postEdges) {
+      if (edge.source === startNodeId && edge.type === postWalkConfig.firstEdgeType) {
+        forwardNodes.set(edge.target, {
+          prevNode: startNodeId,
+          edge: { source: startNodeId, target: edge.target, type: edge.type, score: edge.score || 0 }
+        })
+        forwardFrontier.add(edge.target)
+      }
+    }
+    console.log('正向第一步到达节点数:', forwardFrontier.size)
+    console.log('正向到达节点:', Array.from(forwardFrontier))
+
+    // ========== 反向:当前帖子的其他标签 ← 匹配边 ← 人设节点 ==========
+    const backwardNodes = new Map()  // nodeId -> [{ nextNode, edge }]
+    const backwardFrontier = new Set()
+
+    // 在当前帖子图谱中,找除了起点之外的其他标签节点
+    for (const edge of postEdges) {
+      // 匹配边:帖子标签 -> 人设节点
+      if (edge.type === postWalkConfig.lastEdgeType && edge.source !== startNodeId) {
+        if ((edge.score || 0) >= postWalkConfig.lastStepMinScore) {
+          // edge.target 是人设节点,edge.source 是当前帖子的其他标签
+          if (!backwardNodes.has(edge.target)) {
+            backwardNodes.set(edge.target, [])
+          }
+          backwardNodes.get(edge.target).push({
+            nextNode: edge.source,
+            edge: { source: edge.target, target: edge.source, type: edge.type, score: edge.score || 0 }
+          })
+          backwardFrontier.add(edge.target)
+        }
+      }
+    }
+    console.log('反向第一步到达节点数:', backwardFrontier.size)
+    console.log('反向到达节点(部分):', Array.from(backwardFrontier).slice(0, 5))
+
+    // ========== 检查直接相遇(2步路径) ==========
+    const paths = []
+    const meetingNodes = new Set()
+
+    for (const nodeId of forwardFrontier) {
+      if (backwardFrontier.has(nodeId)) {
+        meetingNodes.add(nodeId)
+      }
+    }
+    console.log('直接相遇节点数:', meetingNodes.size)
+
+    // ========== 如果没有直接相遇,在人设图谱中扩展 ==========
+    const maxMiddleSteps = postWalkConfig.maxSteps - 2  // 减去首尾两步
+    let currentForward = new Set(forwardFrontier)
+    let currentBackward = new Set(backwardFrontier)
+
+    // 记录扩展路径
+    const forwardPaths = new Map()  // nodeId -> [path from start]
+    for (const nodeId of forwardFrontier) {
+      forwardPaths.set(nodeId, [forwardNodes.get(nodeId).edge])
+    }
+
+    const backwardPaths = new Map()  // nodeId -> [path to end]
+    for (const nodeId of backwardFrontier) {
+      backwardPaths.set(nodeId, backwardNodes.get(nodeId).map(b => b.edge))
+    }
+
+    for (let step = 0; step < maxMiddleSteps && meetingNodes.size === 0; step++) {
+      // 扩展正向(在人设图谱中)
+      const nextForward = new Set()
+      const newForwardPaths = new Map()
+
+      for (const nodeId of currentForward) {
+        const outEdges = personaGraph.index?.outEdges?.[nodeId] || {}
+        for (const [edgeType, targets] of Object.entries(outEdges)) {
+          if (!postWalkConfig.excludeMiddleEdgeTypes.includes(edgeType)) {
+            for (const t of targets) {
+              if (!forwardNodes.has(t.target) && t.target !== startNodeId) {
+                const newEdge = { source: nodeId, target: t.target, type: edgeType, score: t.score || 0 }
+                forwardNodes.set(t.target, { prevNode: nodeId, edge: newEdge })
+                nextForward.add(t.target)
+
+                // 记录路径
+                const prevPath = forwardPaths.get(nodeId) || []
+                newForwardPaths.set(t.target, [...prevPath, newEdge])
+
+                // 检查是否与反向相遇
+                if (backwardFrontier.has(t.target)) {
+                  meetingNodes.add(t.target)
+                }
+              }
+            }
+          }
+        }
+      }
+
+      for (const [k, v] of newForwardPaths) {
+        forwardPaths.set(k, v)
+      }
+      currentForward = nextForward
+      console.log(`正向扩展第${step + 1}步,新增节点:`, nextForward.size)
+
+      if (meetingNodes.size > 0) break
+    }
+
+    console.log('最终相遇节点数:', meetingNodes.size)
+
+    // ========== 构建完整路径 ==========
+    const allNodes = new Map()
+    const allEdges = new Map()
+
+    // 添加起点
+    const startNodeData = postGraph.nodes?.[startNodeId]
+    if (startNodeData) {
+      allNodes.set(startNodeId, { id: startNodeId, ...startNodeData })
+    }
+
+    for (const meetNode of meetingNodes) {
+      // 正向路径
+      const fPath = forwardPaths.get(meetNode) || []
+      for (const edge of fPath) {
+        const edgeKey = `${edge.source}->${edge.target}`
+        if (!allEdges.has(edgeKey)) {
+          allEdges.set(edgeKey, edge)
+        }
+        // 添加节点
+        for (const nid of [edge.source, edge.target]) {
+          if (!allNodes.has(nid)) {
+            const nodeData = postGraph.nodes?.[nid] || personaGraph.nodes?.[nid]
+            if (nodeData) {
+              allNodes.set(nid, { id: nid, ...nodeData })
+            }
+          }
+        }
+      }
+
+      // 反向路径(到终点)
+      const bEdges = backwardNodes.get(meetNode) || []
+      for (const b of bEdges) {
+        const edgeKey = `${b.edge.source}->${b.edge.target}`
+        if (!allEdges.has(edgeKey)) {
+          allEdges.set(edgeKey, b.edge)
+        }
+        // 添加终点节点(当前帖子的其他标签)
+        if (!allNodes.has(b.nextNode)) {
+          const nodeData = postGraph.nodes?.[b.nextNode]
+          if (nodeData) {
+            allNodes.set(b.nextNode, { id: b.nextNode, ...nodeData })
+          }
+        }
+      }
+
+      // 记录路径
+      for (const b of bEdges) {
+        paths.push({
+          nodes: [...fPath.map(e => e.source), meetNode, b.nextNode],
+          edges: [...fPath, b.edge]
+        })
+      }
+    }
+
+    console.log('找到路径数:', paths.length)
+    console.log('涉及节点数:', allNodes.size)
+    console.log('涉及边数:', allEdges.size)
+
+    // 打印完整路径
+    for (let i = 0; i < paths.length; i++) {
+      const p = paths[i]
+      const pathStr = p.nodes.join(' -> ')
+      const scoresStr = p.edges.map(e => `${e.type}(${e.score?.toFixed(2) || 0})`).join(' -> ')
+      console.log(`路径${i + 1}: ${pathStr}`)
+      console.log(`  边: ${scoresStr}`)
+    }
+
+    postWalkedPaths.value = paths
+    postWalkedNodes.value = Array.from(allNodes.values())
+    postWalkedEdges.value = Array.from(allEdges.values())
+
+    // 返回高亮节点集合
+    const highlightedIds = new Set([startNodeId])
+    for (const node of allNodes.keys()) {
+      highlightedIds.add(node)
+    }
+    return highlightedIds
+  }
+
   // 所有边类型
   const allEdgeTypes = computed(() => {
     const types = new Set()
@@ -99,6 +333,15 @@ export const useGraphStore = defineStore('graph', () => {
     return set
   })
 
+  // 帖子游走的边集合
+  const postWalkedEdgeSet = computed(() => {
+    const set = new Set()
+    for (const e of postWalkedEdges.value) {
+      set.add(`${e.source}->${e.target}`)
+    }
+    return set
+  })
+
   // ==================== 统一的选中/高亮状态 ====================
   const selectedNodeId = ref(null)
   const highlightedNodeIds = ref(new Set())
@@ -158,12 +401,21 @@ export const useGraphStore = defineStore('graph', () => {
     const nodeId = typeof nodeOrId === 'string' ? nodeOrId : (nodeOrId.data?.id || nodeOrId.id)
     selectedNodeId.value = nodeId
 
-    // 根据配置决定是否执行游走
+    // 清空之前的游走结果
+    walkedEdges.value = []
+    postWalkedPaths.value = []
+    postWalkedNodes.value = []
+    postWalkedEdges.value = []
+
+    // 根据配置决定执行哪种游走
     if (shouldWalk(nodeId)) {
+      // 人设节点游走
       highlightedNodeIds.value = executeWalk(nodeId)
+    } else if (shouldPostWalk(nodeId)) {
+      // 帖子节点游走
+      highlightedNodeIds.value = executePostWalk(nodeId)
     } else {
       highlightedNodeIds.value = new Set([nodeId])
-      walkedEdges.value = []
     }
   }
 
@@ -172,6 +424,9 @@ export const useGraphStore = defineStore('graph', () => {
     selectedNodeId.value = null
     highlightedNodeIds.value = new Set()
     walkedEdges.value = []
+    postWalkedPaths.value = []
+    postWalkedNodes.value = []
+    postWalkedEdges.value = []
   }
 
   // 计算属性:当前选中节点的数据
@@ -207,7 +462,7 @@ export const useGraphStore = defineStore('graph', () => {
     currentPostGraph,
     postTreeData,
     selectPost,
-    // 游走配置
+    // 人设节点游走配置
     walkNodeTypes,
     walkSteps,
     stepConfigs,
@@ -216,6 +471,13 @@ export const useGraphStore = defineStore('graph', () => {
     walkedEdges,
     walkedEdgeSet,
     shouldWalk,
+    // 帖子节点游走配置
+    postWalkConfig,
+    postWalkedPaths,
+    postWalkedNodes,
+    postWalkedEdges,
+    postWalkedEdgeSet,
+    shouldPostWalk,
     // 选中/高亮
     selectedNodeId,
     highlightedNodeIds,

+ 29 - 4
script/visualization/src/style.css

@@ -57,7 +57,8 @@
   /* 所有节点置灰 */
   .tree-node.dimmed,
   .match-node.dimmed,
-  .graph-node.dimmed {
+  .graph-node.dimmed,
+  .walked-node.dimmed {
     opacity: 0.15;
     pointer-events: none;
   }
@@ -65,23 +66,47 @@
   /* 所有边置灰 */
   .tree-link.dimmed,
   .match-link.dimmed,
-  .graph-link.dimmed {
+  .graph-link.dimmed,
+  .walked-link.dimmed {
     stroke-opacity: 0.08 !important;
   }
 
   /* 分数标签置灰 */
-  .match-score.dimmed {
+  .match-score.dimmed,
+  .walked-score.dimmed {
     opacity: 0.15;
   }
 
   /* ========== 统一的高亮样式 ========== */
   .tree-link.highlighted,
   .match-link.highlighted,
-  .graph-link.highlighted {
+  .graph-link.highlighted,
+  .walked-link.highlighted {
     stroke-opacity: 0.8 !important;
     stroke-width: 2 !important;
   }
 
+  /* ========== 游走层节点样式 ========== */
+  .walked-node {
+    cursor: pointer;
+  }
+
+  .walked-node circle,
+  .walked-node rect {
+    transition: all 0.2s;
+  }
+
+  .walked-node:hover circle,
+  .walked-node:hover rect {
+    filter: brightness(1.2);
+  }
+
+  .walked-node text {
+    @apply text-xs;
+    fill: oklch(var(--bc));
+    pointer-events: none;
+  }
+
   .tree-node text {
     @apply text-xs;
     fill: oklch(var(--bc));

+ 2 - 2
script/visualization/src/utils/highlight.js

@@ -41,12 +41,12 @@ export function applyHighlight(svgEl, highlightedIds, walkedEdgeSet = null, sele
   const hasHighlight = highlightedIds.size > 0
 
   // 所有节点:在 highlightedIds 中的保持,否则置灰
-  svg.selectAll('.tree-node, .match-node, .graph-node')
+  svg.selectAll('.tree-node, .match-node, .graph-node, .walked-node')
     .classed('selected', d => getNodeId(d) === selectedId)
     .classed('dimmed', d => hasHighlight && !highlightedIds.has(getNodeId(d)))
 
   // 所有边:在 walkedEdgeSet 中的保持,否则置灰
-  svg.selectAll('.tree-link, .graph-link, .match-link, .match-score')
+  svg.selectAll('.tree-link, .graph-link, .match-link, .match-score, .walked-link, .walked-score')
     .classed('dimmed', function(d) {
       if (!hasHighlight) return false
       if (d && walkedEdgeSet) {