Pārlūkot izejas kodu

fix: 关系图谱挂到工具调用下并补齐人员归属连线

- 图谱块从消息正文末尾移到对应 get_case_graph 工具行下方,与产物卡片位置一致;移除消息级重复渲染。- 新增 parseGraphToolStep,ProcessTimeline 按工具步骤 ID 渲染图谱,并传递原始查询参数用于全量取数。- 对完全没有边的人员节点,按原始 nodecn/mc/name 匹配银行卡/手机号,补一条 obj_card/obj_tel 展示边;已有关系的节点不重复补,原始字段不改。
cc 1 dienu atpakaļ
vecāks
revīzija
3aea7db9c8

+ 0 - 20
ai-electron/frontend/src/ai/components/ChatMessageItem.vue

@@ -70,17 +70,6 @@
               :streaming="!!message.streaming"
             />
 
-            <!-- 关系图谱:直接消费 get_case_graph 的工具结果,不再依赖模型输出 ```graph 围栏 -->
-            <GraphAnalysisBlock
-              v-for="(item, index) in graphItems"
-              :key="item.key"
-              :payload="item.payload"
-              :query="item.query"
-              :meta="item.meta"
-              :index="index"
-              :total="graphItems.length"
-            />
-
             <div v-if="message.streaming && !hasContent" class="ai-msg__skeleton">
               <div class="ai-skeleton" :style="{ height: '14px', width: '62%' }"></div>
               <div class="ai-skeleton" :style="{ height: '14px', width: '88%' }"></div>
@@ -139,9 +128,7 @@
   import type { ChatMessage } from '../store/chatStream';
   import Icon from '@/core/components/Icon/src/Icon.vue';
   import BlockRenderer from './BlockRenderer.vue';
-  import GraphAnalysisBlock from './graph/GraphAnalysisBlock.vue';
   import ProcessTimeline from './ProcessTimeline.vue';
-  import { parseGraphToolSteps } from '../utils/graphToolData';
   import StreamStatusBar from './StreamStatusBar.vue';
   import SuggestionBar from './SuggestionBar.vue';
   import MessageToolbar from './MessageToolbar.vue';
@@ -174,13 +161,6 @@
 
   const hasContent = computed(() => !!(props.message.blocks || []).some((b) => b.text?.trim()));
 
-  /** 关系图谱工具结果(一次回答通常只有一张;历史消息也兼容旧 render_graph) */
-  const graphItems = computed(() => {
-    // 旧消息的 ```graph 围栏已经由 BlockRenderer 渲染;同一条消息里不要再按旧工具步骤重复出图
-    const hasLegacyFence = (props.message.blocks || []).some((block) => block.kind === 'graph');
-    return parseGraphToolSteps(props.message.toolSteps, { includeLegacy: !hasLegacyFence });
-  });
-
   const timeText = computed(() => {
     const raw = props.message.createAt;
     if (!raw) return '';

+ 30 - 1
ai-electron/frontend/src/ai/components/ProcessTimeline.vue

@@ -61,6 +61,16 @@
           <div v-else-if="isStepOpen(item.key)" class="ai-trace__detail">
             <ToolCallPanel :step="item.step" :message-id="messageId" />
           </div>
+
+          <!-- 图谱直接挂在 get_case_graph 这一次工具调用下面,位置与产物卡片一致 -->
+          <GraphAnalysisBlock
+            v-if="graphOf(item.step)"
+            :key="`graph-${item.step.id}`"
+            class="ai-trace__graph"
+            :payload="graphOf(item.step)!.payload"
+            :query="graphOf(item.step)!.query"
+            :meta="graphOf(item.step)!.meta"
+          />
         </template>
       </template>
     </div>
@@ -87,14 +97,17 @@
    * 2. 历史消息 —— 后端只存了"思考全文 + 工具事件数组",没有时序字段,按 ReAct 常见形态
    *    (思考 i → 工具 i,多余的顺序接到末尾)近似排列。要精确还原需后端落库时一并保存事件顺序。
    */
-  import { computed, onBeforeUnmount, ref, watch } from 'vue';
+  import { computed, defineAsyncComponent, onBeforeUnmount, ref, watch } from 'vue';
   import type { ToolStep } from '../api/types';
   import type { TraceNode } from '../store/chatStream';
   import { toolLabel } from '../utils/constants';
+  import { parseGraphToolStep } from '../utils/graphToolData';
   import { parseJsonPayload } from '../utils/parseMessageContent';
   import Icon from '@/core/components/Icon/src/Icon.vue';
   import ToolCallPanel from './ToolCallPanel.vue';
 
+  const GraphAnalysisBlock = defineAsyncComponent(() => import('./graph/GraphAnalysisBlock.vue'));
+
   const props = withDefaults(
     defineProps<{
       /** 思考全文(多段之间以空行分隔)—— 历史消息的近似排列用 */
@@ -113,6 +126,18 @@
     { thinking: '', thinkingActive: false, steps: () => [], trace: () => [], active: false },
   );
 
+  /** 每个 get_case_graph 工具步骤对应一个图谱块,渲染在该工具调用下方 */
+  const graphItemsByStep = computed(() => {
+    const map = new Map<string, NonNullable<ReturnType<typeof parseGraphToolStep>>>();
+    (props.steps || []).forEach((step) => {
+      const item = parseGraphToolStep(step);
+      if (item) map.set(String(step.id), item);
+    });
+    return map;
+  });
+
+  const graphOf = (step: ToolStep) => graphItemsByStep.value.get(String(step.id)) || null;
+
   /* ---------------- 条目组装 ---------------- */
 
   interface ThinkItem {
@@ -593,6 +618,10 @@
       padding-left: 10px;
       border-left: 2px solid var(--ai-border);
     }
+
+    &__graph {
+      margin: 4px 0 var(--ai-sp-2);
+    }
   }
 
   @keyframes ai-trace-pulse {

+ 62 - 0
ai-electron/frontend/src/ai/components/graph/graphData.ts

@@ -407,6 +407,68 @@ export function parseGraphPayload(raw: unknown): ParsedGraph | null {
     if (edge) edges.push(edge);
   });
 
+  /**
+   * 人员归属补边兜底:
+   * 后端的 obj_card / obj_tel(持卡人 / 机主)关系有时不会进入 edgeList,
+   * 导致人员节点成为孤立点。这里只对「完全没有边」的人员节点,按节点原始
+   * nodecn/mc/name 与人员姓名匹配,补一条人员 → 银行卡/手机号的展示边;
+   * 已有关系的节点不会重复补,也不改变任何原始字段。
+   */
+  const nodesWithEdge = new Set<string>();
+  edges.forEach((edge) => {
+    nodesWithEdge.add(edge.source);
+    nodesWithEdge.add(edge.target);
+  });
+  const normalizeNames = (values: unknown[]) => new Set(values.map((value) => normalizeToken(value)).filter(Boolean));
+  const personNodes = Array.from(ctx.nodeMap.values()).filter(
+    (node) => node.kind === 'person' && !nodesWithEdge.has(node.id),
+  );
+  personNodes.forEach((person) => {
+    const personNames = normalizeNames([
+      person.label,
+      person.fullLabel,
+      person.secondaryLabel,
+      person.raw.nodecn,
+      person.raw.mc,
+      person.raw.name,
+    ]);
+    if (!personNames.size) return;
+    const assets = Array.from(ctx.nodeMap.values()).filter((node) => {
+      if (node.id === person.id || !['bank', 'phone'].includes(node.kind)) return false;
+      const assetNames = normalizeNames([
+        node.raw.nodecn,
+        node.raw.mc,
+        node.raw.name,
+        node.raw.mc1,
+        node.secondaryLabel,
+        node.label,
+      ]);
+      return Array.from(personNames).some((name) => assetNames.has(name));
+    });
+    assets.forEach((asset) => {
+      const edgeId = `person-asset-${person.id}-${asset.id}`;
+      if (usedEdgeIds.has(edgeId)) return;
+      usedEdgeIds.add(edgeId);
+      edges.push({
+        id: edgeId,
+        source: person.id,
+        target: asset.id,
+        clazz: asset.kind === 'phone' ? 'obj_tel' : 'obj_card',
+        label: asset.kind === 'phone' ? '机主' : '持卡人',
+        raw: {
+          node1: person.raw.node || person.label,
+          node2: asset.raw.node || asset.label,
+          node1cn: person.label,
+          node2cn: asset.raw.nodecn || asset.label,
+          mc: asset.kind === 'phone' ? '机主' : '持卡人',
+          clazz: asset.kind === 'phone' ? 'obj_tel' : 'obj_card',
+          num: '1',
+          synthetic: true,
+        },
+      });
+    });
+  });
+
   const nodes = Array.from(ctx.nodeMap.values());
   const totalEdges = toFiniteNumber(payload.totalEdges);
   const totalNodes = toFiniteNumber(payload.totalNodes);

+ 22 - 0
ai-electron/frontend/src/ai/utils/graphToolData.ts

@@ -36,6 +36,28 @@ function looksLikeGraphPayload(value: any): boolean {
   );
 }
 
+/** 解析单个工具步骤的图谱结果;仅 get_case_graph 走全量数据链路 */
+export function parseGraphToolStep(step?: ToolStep | null): GraphToolItem | null {
+  if (!step || step.status === 'error' || String(step.toolName || '') !== 'get_case_graph') return null;
+  const payload = parseJsonPayload(step.result);
+  if (!looksLikeGraphPayload(payload)) return null;
+  const input = parseJsonPayload(step.input);
+  const nestedQuery =
+    input && typeof input === 'object' && !Array.isArray(input)
+      ? ((input as Record<string, unknown>).query ?? input)
+      : null;
+  const query =
+    nestedQuery && typeof nestedQuery === 'object' && !Array.isArray(nestedQuery)
+      ? (nestedQuery as Record<string, unknown>)
+      : undefined;
+  return {
+    key: String(step.id || 'graph'),
+    payload,
+    query,
+    meta: describeGraphQuery(step.input),
+  };
+}
+
 /** 从消息工具步骤里提取所有图谱结果(新工具 + 可选的旧工具) */
 export function parseGraphToolSteps(
   steps?: ToolStep[] | null,