local-knowledge-graph 1.10.4 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -16,6 +16,10 @@ const state = {
16
16
  meta: null, // /api/meta 缓存(图例与下拉框用)
17
17
  aliases: {}, // entityId -> [别名](/api/graph 附带)
18
18
  confFilter: '', // 关系置信度过滤:''=全部 | 确证 | 推测 | 存疑
19
+ hiddenCats: new Set(), // 图例点击隐藏的大类:'e:物理实体' / 'r:互动'
20
+ attrFilter: null, // 实体属性过滤 {key, op, value}
21
+ multiSel: null, // 多选模式:Set<entityId>,null=未开启
22
+ subGraph: null, // 子图模式:Set<entityId>,仅显示集合内实体及互相关系
19
23
  pathHi: null, // 画布路径高亮 { nodes:Set, rels:Set }
20
24
  lastAsk: null, // 最近一次智能提问响应(证据路径高亮用)
21
25
  ingViewId: null, // 文档入图:当前审核任务id
@@ -298,6 +302,56 @@ function makeRelLine(pa, pb, arc, color, dashed, opacity, dashSize, gapSize) {
298
302
  return line;
299
303
  }
300
304
 
305
+ /* ---- 关系方向箭头 ---- */
306
+ // 锥体尖端默认朝 +Y,用四元数对齐到边切线方向;互惠对(同名双向)画双锥
307
+ const CONE_UP = new THREE.Vector3(0, 1, 0);
308
+ function makeCone(color, opacity) {
309
+ const geo = new THREE.ConeGeometry(1.1, 3.2, 12);
310
+ const mat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity });
311
+ return new THREE.Mesh(geo, mat);
312
+ }
313
+
314
+ // 锥体沿曲线放置:t 为弧上参数,直线时退化为线性插值;tipAhead 表示尖端朝行进方向
315
+ function placeCone(cone, a, b, arc, t) {
316
+ let p, dir;
317
+ if (arc) {
318
+ _arcDir.subVectors(b, a);
319
+ _arcOff.copy(arcOffsetVec(_arcDir, arc));
320
+ p = arcPoint(new THREE.Vector3(), a, b, _arcDir, _arcOff, t);
321
+ dir = arcPoint(new THREE.Vector3(), a, b, _arcDir, _arcOff, Math.min(t + 0.06, 1)).sub(p);
322
+ if (dir.lengthSq() < 1e-9) dir = new THREE.Vector3().subVectors(b, a);
323
+ } else {
324
+ dir = new THREE.Vector3().subVectors(b, a);
325
+ const len = dir.length();
326
+ if (len < 1e-6) return;
327
+ dir.multiplyScalar(1 / len);
328
+ p = new THREE.Vector3().copy(a).addScaledVector(dir, len * t);
329
+ }
330
+ cone.position.copy(p);
331
+ dir.normalize();
332
+ cone.quaternion.setFromUnitVectors(CONE_UP, dir);
333
+ }
334
+
335
+ // 每帧同步锥体位置与透明度(跟随线高亮/淡化)
336
+ function updateRelCones(l) {
337
+ if (!l.cone) return;
338
+ const op = l.line.material.opacity;
339
+ if (l.cone) { l.cone.material.opacity = op; placeCone(l.cone, l.a.pos, l.b.pos, l.arc, 0.84); }
340
+ if (l.cone2) { l.cone2.material.opacity = op; placeCone(l.cone2, l.b.pos, l.a.pos, l.arc ? flipArc(l.arc) : null, 0.84); }
341
+ }
342
+
343
+ // 反向端锥体取对称弧位:镜像 idx,使双锥各走各的弧道
344
+ function flipArc(arc) {
345
+ return { idx: arc.total - 1 - arc.idx, total: arc.total };
346
+ }
347
+
348
+ // 统一设置一条边的透明度(线+锥体),高亮/淡化共用
349
+ function setLinkOpacity(l, op) {
350
+ l.line.material.opacity = op;
351
+ if (l.cone) l.cone.material.opacity = op;
352
+ if (l.cone2) l.cone2.material.opacity = op;
353
+ }
354
+
301
355
  // 每帧根据节点最新位置刷新弧线几何与标签位置(Sprite 标签贴线、近大远小)
302
356
  const _arcDir = new THREE.Vector3(), _arcOff = new THREE.Vector3(), _arcTmp = new THREE.Vector3();
303
357
  function updateRelLine(l) {
@@ -308,6 +362,7 @@ function updateRelLine(l) {
308
362
  posAttr.needsUpdate = true;
309
363
  if (l.dashed) l.line.computeLineDistances();
310
364
  if (l.label) l.label.position.copy(l.a.pos).add(l.b.pos).multiplyScalar(0.5);
365
+ updateRelCones(l);
311
366
  return;
312
367
  }
313
368
  _arcDir.subVectors(l.b.pos, l.a.pos);
@@ -324,6 +379,7 @@ function updateRelLine(l) {
324
379
  arcPoint(_arcTmp, l.a.pos, l.b.pos, _arcDir, _arcOff, 0.5);
325
380
  l.label.position.copy(_arcTmp);
326
381
  }
382
+ updateRelCones(l);
327
383
  }
328
384
 
329
385
  // 弧顶标签初始定位(构建时用;此后每帧由 updateRelLine 跟随)
@@ -355,24 +411,62 @@ function buildNodeMesh(category) {
355
411
  return mesh;
356
412
  }
357
413
 
414
+ // 属性条件匹配:返回 ents 中通过过滤条件的 id 数组;op: eq 等值 / contains 包含 / gt 大于 / lt 小于
415
+ function attrMatchEnts(ents) {
416
+ const { key, op, value } = state.attrFilter;
417
+ if (!key) return ents.map((e) => e.id);
418
+ const kw = String(value);
419
+ const numKw = Number(kw);
420
+ const hasNumKw = kw !== '' && Number.isFinite(numKw);
421
+ return ents.filter((e) => {
422
+ let attrs = {};
423
+ try { attrs = JSON.parse(e.attributes || '{}'); } catch (_) {}
424
+ if (!(key in attrs)) return false;
425
+ const v = attrs[key];
426
+ if (op === 'eq') return String(v) === kw;
427
+ if (op === 'contains') return String(v).includes(kw);
428
+ if (op === 'exists') return true;
429
+ const numV = Number(v);
430
+ if (op === 'gt') return hasNumKw && Number.isFinite(numV) && numV > numKw;
431
+ if (op === 'lt') return hasNumKw && Number.isFinite(numV) && numV < numKw;
432
+ return false;
433
+ }).map((e) => e.id);
434
+ }
435
+
358
436
  function rebuildGraph() {
359
437
  scene.remove(nodeGroup, linkGroup, labelGroup);
360
438
  nodeGroup = new THREE.Group(); linkGroup = new THREE.Group(); labelGroup = new THREE.Group();
361
439
  scene.add(nodeGroup, linkGroup, labelGroup);
362
440
  simNodes.length = 0; simLinks.length = 0;
363
441
 
364
- // 中心层级模式:仅构建子图;全图模式:构建全部
365
- const sub = state.ego ? calcEgo(state.ego.centerId, state.ego.depth) : null;
366
- const ents = sub ? sub.entities : state.entities;
442
+ // 中心层级模式:仅构建子图;子图模式:仅显示所选集合;全图模式:构建全部
443
+ let sub = null;
444
+ if (state.ego) sub = calcEgo(state.ego.centerId, state.ego.depth);
445
+ else if (state.subGraph) {
446
+ const se = state.entities.filter((e) => state.subGraph.has(e.id));
447
+ const sids = new Set(se.map((e) => e.id));
448
+ sub = { plain: true, entities: se, relations: state.relations.filter((r) => sids.has(r.source_id) && sids.has(r.target_id)) };
449
+ }
450
+ let ents = sub ? sub.entities : state.entities;
367
451
  const allRels = sub ? sub.relations : state.relations;
368
- const rels = allRels.filter((r) => !state.confFilter || (r.confidence || '确证') === state.confFilter);
452
+ let rels = allRels.filter((r) => !state.confFilter || (r.confidence || '确证') === state.confFilter);
453
+ // 图例点击隐藏的大类:实体与关系分别过滤
454
+ if (state.hiddenCats.size) {
455
+ ents = ents.filter((e) => !state.hiddenCats.has('e:' + e.category));
456
+ rels = rels.filter((r) => !state.hiddenCats.has('r:' + r.category));
457
+ }
458
+ // 属性条件过滤:仅保留属性匹配的实体(关联边因端点缺失自动不画)
459
+ if (state.attrFilter) {
460
+ const okIds = new Set(attrMatchEnts(ents));
461
+ ents = ents.filter((e) => okIds.has(e.id));
462
+ }
369
463
  state.pathHi = null;
370
464
 
371
465
  state.entityMap.clear();
372
466
  const N = ents.length;
373
467
  ents.forEach((e, i) => {
374
468
  const mesh = buildNodeMesh(e.category);
375
- if (sub) {
469
+ if (sub && !sub.plain) {
376
470
  // 层级球壳分布:中心固定原点,每层外扩
377
471
  if (e.level === 0) {
378
472
  mesh.position.set(0, 0, 0);
@@ -431,6 +525,11 @@ function rebuildGraph() {
431
525
  return n > 1 ? { idx: i, total: n } : null;
432
526
  };
433
527
 
528
+ // 互惠对检测:A→B 与 B→A 存在同名关系时,该边两端各画一个箭头
529
+ const dirKeys = new Set();
530
+ for (const r of rels) dirKeys.add(`${r.source_id}>${r.target_id}>${r.name}`);
531
+ const isReciprocal = (r) => dirKeys.has(`${r.target_id}>${r.source_id}>${r.name}`);
532
+
434
533
  rels.forEach((r) => {
435
534
  const a = simNodes.find((n) => n.id === r.source_id);
436
535
  const b = simNodes.find((n) => n.id === r.target_id);
@@ -438,19 +537,33 @@ function rebuildGraph() {
438
537
  const st = RELATION_STYLE[r.category] || { color: 0x999999, dashed: false };
439
538
  const conf = r.confidence || '确证';
440
539
  const baseOp = st.opacity === undefined ? 0.9 : st.opacity;
441
- const op = conf === '存疑' ? Math.min(baseOp, 0.35) : baseOp; // 存疑降不透明度
540
+ let op = conf === '存疑' ? Math.min(baseOp, 0.35) : baseOp; // 存疑降不透明度
541
+ // 属性 weight 数值映射不透明度:weight=1 → ×0.65,每级 +0.09,上限 ×1(WebGL 线宽不可靠,用明暗表达强弱)
542
+ let rAttrs = {};
543
+ try { rAttrs = JSON.parse(r.attributes || '{}'); } catch (_) {}
544
+ const w = Number(rAttrs.weight);
545
+ if (Number.isFinite(w) && w > 0) op *= Math.min(0.65 + 0.09 * w, 1);
442
546
  const dashed = st.dashed || conf === '推测'; // 推测强制虚线
443
547
  const arc = arcOf(r);
444
548
  const line = makeRelLine(a.pos, b.pos, arc, st.color, dashed, op, st.dashSize || 6, st.gapSize || 4);
445
549
  line.userData.relationId = r.id;
446
550
  line.userData.baseOpacity = op;
447
551
  linkGroup.add(line);
552
+ const rec = isReciprocal(r);
553
+ let cone = null, cone2 = null;
554
+ if (rec) {
555
+ cone = makeCone(st.color, op); cone2 = makeCone(st.color, op);
556
+ linkGroup.add(cone, cone2);
557
+ } else {
558
+ cone = makeCone(st.color, op);
559
+ linkGroup.add(cone);
560
+ }
448
561
  // 线标注显示具体关系名(如"父子"),线型/颜色仍由大类规定;Sprite 保持贴线与近大远小
449
562
  const lbl = makeLabelSprite(r.name, st.css, 24);
450
563
  lbl.userData.text = r.name;
451
564
  setRelLabelPos(lbl, a.pos, b.pos, arc);
452
565
  labelGroup.add(lbl);
453
- simLinks.push({ id: r.id, a, b, line, label: lbl, dashed, confidence: conf, arc });
566
+ simLinks.push({ id: r.id, a, b, line, label: lbl, dashed, confidence: conf, arc, cone, cone2 });
454
567
  });
455
568
 
456
569
  // 推理关系叠加:虚化虚线 + "(推)"标注,负数id与库中显式关系区分;仅显示两端均在当前视图的边
@@ -575,7 +688,15 @@ renderer.domElement.addEventListener('pointerup', (e) => {
575
688
  raycaster.setFromCamera(mouse, camera);
576
689
  const meshHits = raycaster.intersectObjects(nodeGroup.children, false);
577
690
  if (meshHits.length) {
578
- state.selected = { type: 'entity', id: meshHits[0].object.userData.entityId };
691
+ const eid = meshHits[0].object.userData.entityId;
692
+ // 多选模式:点击节点 toggle 选区,标记金色光圈;点线/空白仍维持选区
693
+ if (state.multiSel) {
694
+ if (state.multiSel.has(eid)) { state.multiSel.delete(eid); setMsRing(meshHits[0].object, false); }
695
+ else { state.multiSel.add(eid); setMsRing(meshHits[0].object, true); }
696
+ updateMsBar();
697
+ return;
698
+ }
699
+ state.selected = { type: 'entity', id: eid };
579
700
  renderInfoCard();
580
701
  return;
581
702
  }
@@ -725,12 +846,21 @@ function renderInfoCard() {
725
846
  ${['确证', '推测', '存疑'].map((c) => `<option value="${c}"${(r.confidence || '确证') === c ? ' selected' : ''}>${c}</option>`).join('')}
726
847
  </select></div>
727
848
  <div class="kv"><b>来源引用</b>:<input id="rel-sref" value="${escapeHtml(r.source_ref || '')}" placeholder="URL/文献+页码" style="width:150px"></div>
849
+ <div class="kv"><b>关系属性</b>:<span style="color:#5c6f92">(JSON 对象,如 {"开始时间":"2024-01","weight":4})</span></div>
850
+ <textarea id="rel-attrs" class="mcp-snippet" style="width:100%;min-height:56px;font-size:11px">${escapeHtml(relAttrsText(r))}</textarea>
728
851
  ${rimgHtml}
729
852
  <div class="btns"><button onclick="saveRelMeta(${r.id})">保存标注</button><button onclick="$('entity-img-input').click()">绑图片</button><button class="danger" onclick="delRelation(${r.id})">删除</button></div>`;
730
853
  card.style.display = 'block';
731
854
  if (!rimgs) loadRelationImages(rid);
732
855
  }
733
856
  }
857
+
858
+ // 关系属性文本化:库行为 JSON 字符串或对象,统一pretty输出;空对象显示空 {}
859
+ function relAttrsText(r) {
860
+ let a = {};
861
+ try { a = typeof r.attributes === 'string' ? JSON.parse(r.attributes || '{}') : (r.attributes || {}); } catch (_) { return String(r.attributes || ''); }
862
+ return Object.keys(a).length ? JSON.stringify(a, null, 1) : '';
863
+ }
734
864
  function escapeHtml(s) {
735
865
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
736
866
  }
@@ -777,6 +907,7 @@ function updateEgoBar() {
777
907
 
778
908
  function focusEgo(id) {
779
909
  state.ego = { centerId: id, depth: null };
910
+ if (state.subGraph) { state.subGraph = null; updateSubBar(); }
780
911
  updateEgoBar();
781
912
  rebuildGraph();
782
913
  const m = state.entityMap.get(id);
@@ -798,6 +929,13 @@ function focusEntity(id) {
798
929
  updateEgoBar();
799
930
  rebuildGraph();
800
931
  }
932
+ if (state.subGraph && !simNodes.some((n) => n.id === id)) {
933
+ toast('目标实体不在当前子图内,已退出子图模式');
934
+ state.subGraph = null;
935
+ updateSubBar();
936
+ rebuildGraph();
937
+ }
938
+ if (!state.entityMap.has(id)) { toast('该实体的样式大类已被图例隐藏,点击图例条目可恢复显示', true); return; }
801
939
  state.selected = { type: 'entity', id };
802
940
  renderInfoCard();
803
941
  const nd = simNodes.find((n) => n.id === id);
@@ -824,6 +962,87 @@ $('ego-depth').addEventListener('change', () => {
824
962
  $('ego-rebuild').addEventListener('click', () => { if (state.ego) rebuildGraph(); });
825
963
  $('ego-exit').addEventListener('click', exitEgo);
826
964
 
965
+ /* ================= 多选与子图提取 ================= */
966
+ function updateMsBar() {
967
+ const bar = $('ms-bar');
968
+ if (!state.multiSel) { bar.classList.remove('show'); return; }
969
+ $('ms-count').textContent = `已选 ${state.multiSel.size} 个`;
970
+ bar.classList.add('show');
971
+ }
972
+
973
+ function updateSubBar() {
974
+ const bar = $('sub-bar');
975
+ if (!state.subGraph) { bar.classList.remove('show'); return; }
976
+ const names = state.entities.filter((e) => state.subGraph.has(e.id)).map((e) => e.name);
977
+ $('sub-name').textContent = names.length <= 3 ? names.join('、') : `${names.slice(0, 3).join('、')} 等 ${names.length} 个`;
978
+ bar.classList.add('show');
979
+ }
980
+
981
+ // 多选标记:金色光圈 Sprite,userData.msRing 便于移除
982
+ function setMsRing(mesh, on) {
983
+ const old = mesh.children.find((c) => c.userData && c.userData.msRing);
984
+ if (old) { mesh.remove(old); old.material.map && old.material.map.dispose(); old.material.dispose(); }
985
+ if (on) {
986
+ const ring = makeRingSprite('#ffd75f', false);
987
+ ring.userData.msRing = true;
988
+ mesh.add(ring);
989
+ }
990
+ }
991
+
992
+ function toggleMultiSel() {
993
+ if (state.multiSel) {
994
+ for (const m of state.entityMap.values()) setMsRing(m.mesh, false);
995
+ state.multiSel = null;
996
+ $('btn-multisel').classList.remove('active');
997
+ updateMsBar();
998
+ return;
999
+ }
1000
+ state.ego = null; updateEgoBar();
1001
+ state.subGraph = null; updateSubBar();
1002
+ state.multiSel = new Set();
1003
+ $('btn-multisel').classList.add('active');
1004
+ updateMsBar();
1005
+ toast('多选模式已开启:点击图上节点加入/移出选区');
1006
+ }
1007
+ $('btn-multisel').addEventListener('click', toggleMultiSel);
1008
+
1009
+ $('ms-clear').addEventListener('click', () => {
1010
+ if (!state.multiSel) return;
1011
+ for (const m of state.entityMap.values()) setMsRing(m.mesh, false);
1012
+ state.multiSel.clear();
1013
+ updateMsBar();
1014
+ });
1015
+ $('ms-exit').addEventListener('click', toggleMultiSel);
1016
+
1017
+ // 提取子图:以当前选区为集合进入子图模式(互斥退出多选)
1018
+ $('ms-extract').addEventListener('click', () => {
1019
+ if (!state.multiSel || !state.multiSel.size) return toast('请先在图上点选至少一个实体', true);
1020
+ state.subGraph = new Set(state.multiSel);
1021
+ for (const m of state.entityMap.values()) setMsRing(m.mesh, false);
1022
+ state.multiSel = null;
1023
+ $('btn-multisel').classList.remove('active');
1024
+ updateMsBar();
1025
+ updateSubBar();
1026
+ rebuildGraph();
1027
+ toast(`已提取子图:${state.subGraph.size} 个实体`);
1028
+ });
1029
+
1030
+ function subExportIds() {
1031
+ if (!state.subGraph) return null;
1032
+ return [...state.subGraph].join(',');
1033
+ }
1034
+ $('ms-export-json').addEventListener('click', () => {
1035
+ if (!state.multiSel || !state.multiSel.size) return toast('请先在图上点选至少一个实体', true);
1036
+ downloadUrl('/api/export/json?ids=' + [...state.multiSel].join(','));
1037
+ });
1038
+ $('ms-export-csv').addEventListener('click', () => {
1039
+ if (!state.multiSel || !state.multiSel.size) return toast('请先在图上点选至少一个实体', true);
1040
+ downloadUrl('/api/export/csv?ids=' + [...state.multiSel].join(','));
1041
+ });
1042
+ $('sub-export-json').addEventListener('click', () => { const q = subExportIds(); if (q) downloadUrl('/api/export/json?ids=' + q); });
1043
+ $('sub-export-csv').addEventListener('click', () => { const q = subExportIds(); if (q) downloadUrl('/api/export/csv?ids=' + q); });
1044
+ $('sub-exit').addEventListener('click', () => { state.subGraph = null; updateSubBar(); rebuildGraph(); });
1045
+
827
1046
  /* ================= 实体图片绑定与灯箱 ================= */
828
1047
  const imgInput = document.createElement('input');
829
1048
  imgInput.type = 'file';
@@ -999,9 +1218,37 @@ function initTabs() {
999
1218
  document.querySelectorAll('.tabbody').forEach((b) => b.classList.toggle('active', b.id === 'tab-' + t.dataset.tab));
1000
1219
  if (t.dataset.tab === 'log') loadLogs();
1001
1220
  if (t.dataset.tab === 'version') loadHistory();
1221
+ if (t.dataset.tab === 'stats') loadStats();
1002
1222
  });
1003
1223
  }
1004
1224
 
1225
+ /* ================= 统计页签 ================= */
1226
+ async function loadStats() {
1227
+ try {
1228
+ const s = await api('/api/stats');
1229
+ $('st-status').textContent = `共 ${s.entities} 实体 / ${s.relations} 关系`;
1230
+ $('st-overview').innerHTML = `
1231
+ <div class="kv">实体 <b>${s.entities}</b> 关系 <b>${s.relations}</b> 平均度 <b>${s.avg_degree}</b> 最高度 <b>${s.max_degree}</b></div>`;
1232
+ const maxCnt = Math.max(...s.degree_distribution.map((d) => d.count), 1);
1233
+ $('st-dist').innerHTML = s.degree_distribution.map((d) => `
1234
+ <div class="kv" style="display:flex;align-items:center;gap:6px"><span style="width:34px">度${d.degree}</span>
1235
+ <span style="flex:1;height:8px;background:#1c2740;border-radius:4px;overflow:hidden"><span style="display:block;height:100%;width:${(d.count / maxCnt) * 100}%;background:#4f8cff"></span></span>
1236
+ <span style="width:36px;text-align:right">${d.count}</span></div>`).join('') || '<div class="kv">暂无数据</div>';
1237
+ $('st-hubs').innerHTML = s.hubs.map((h, i) => `
1238
+ <div class="list-item" style="cursor:pointer" onclick="focusEntity(${h.id})">
1239
+ <b>${i + 1}. ${escapeHtml(h.name)}</b>
1240
+ <span class="tag" style="color:${ENTITY_STYLE[h.category].css};border-color:${ENTITY_STYLE[h.category].css}55">${h.category}</span>
1241
+ <div class="kv">总度 ${h.degree}(出 ${h.out} / 入 ${h.in})${h.relations.length ? ' 常涉关系:' + h.relations.map(escapeHtml).join('、') : ''}</div>
1242
+ </div>`).join('') || '<div class="kv">暂无数据</div>';
1243
+ const maxCat = Math.max(...s.categories.map((c) => c.count), 1);
1244
+ $('st-cats').innerHTML = s.categories.map((c) => `
1245
+ <div class="kv" style="display:flex;align-items:center;gap:6px">
1246
+ <span style="width:70px;color:${ENTITY_STYLE[c.category] ? ENTITY_STYLE[c.category].css : '#ccc'}">${escapeHtml(c.category)}</span>
1247
+ <span style="flex:1;height:8px;background:#1c2740;border-radius:4px;overflow:hidden"><span style="display:block;height:100%;width:${(c.count / maxCat) * 100}%;background:${ENTITY_STYLE[c.category] ? ENTITY_STYLE[c.category].css : '#8fa3c0'}"></span></span>
1248
+ <span style="width:36px;text-align:right">${c.count}</span></div>`).join('') || '<div class="kv">暂无数据</div>';
1249
+ } catch (e) { $('st-status').textContent = '加载失败: ' + e.message; }
1250
+ }
1251
+
1005
1252
  async function api(url, opts) {
1006
1253
  const res = await fetch(url, opts);
1007
1254
  const data = await res.json().catch(() => ({}));
@@ -1020,14 +1267,29 @@ function fillCategorySelects(meta) {
1020
1267
  function renderLegend() {
1021
1268
  const meta = state.meta;
1022
1269
  if (!meta) return;
1270
+ const swatch = (kind, cat, cls, css, label) => {
1271
+ const hidden = state.hiddenCats.has(kind + ':' + cat);
1272
+ return `<span class="lg-item" data-kind="${kind}" data-cat="${escapeHtml(cat)}" style="cursor:pointer;opacity:${hidden ? 0.25 : 1};${hidden ? 'text-decoration:line-through;' : ''}" title="点击显示/隐藏该大类"><span class="${cls}" style="background:${cls === 'sw' ? css : 'transparent'};${cls === 'ln' ? 'border-color:' + css : ''}"></span>${label}</span>`;
1273
+ };
1023
1274
  $('legend').innerHTML = '<b>实体样式</b><br>' +
1024
- meta.entity_categories.map((c) => `<span class="sw" style="background:${ENTITY_STYLE[c].css}"></span>${c} · ${ENTITY_STYLE[c].shape}`).join('<br>') +
1275
+ meta.entity_categories.map((c) => swatch('e', c, 'sw', ENTITY_STYLE[c].css, `${c} · ${ENTITY_STYLE[c].shape}`)).join('<br>') +
1025
1276
  '<br><b>关系线型</b><br>' +
1026
- meta.relation_categories.map((c) => `<span class="ln ${RELATION_STYLE[c].dashed ? 'dash' : ''}" style="border-color:${RELATION_STYLE[c].css}"></span>${c}关系`).join('<br>') +
1277
+ meta.relation_categories.map((c) => swatch('r', c, 'ln', RELATION_STYLE[c].css, `${c}关系${RELATION_STYLE[c].dashed ? '(虚线)' : ''}`)).join('<br>') +
1027
1278
  '<br><b>置信度</b><br>' +
1028
- ['确证', '推测', '存疑'].map((c) => `<span class="sw" style="background:${CONF_STYLE[c]}"></span>${c}${c === '推测' ? '(虚线)' : c === '存疑' ? '(淡化)' : ''}`).join('<br>');
1279
+ ['确证', '推测', '存疑'].map((c) => `<span><span class="sw" style="background:${CONF_STYLE[c]}"></span>${c}${c === '推测' ? '(虚线)' : c === '存疑' ? '(淡化)' : ''}</span>`).join('<br>');
1029
1280
  }
1030
1281
 
1282
+ // 图例点击切换大类显隐;重建时实体/关系分别按 e:/r: 前缀过滤
1283
+ $('legend').addEventListener('click', (ev) => {
1284
+ const item = ev.target.closest('.lg-item');
1285
+ if (!item) return;
1286
+ const key = item.dataset.kind + ':' + item.dataset.cat;
1287
+ if (state.hiddenCats.has(key)) state.hiddenCats.delete(key);
1288
+ else state.hiddenCats.add(key);
1289
+ renderLegend();
1290
+ rebuildGraph();
1291
+ });
1292
+
1031
1293
  function refreshEntityOptions() {
1032
1294
  const opts = state.entities.map((e) => `<option value="${e.id}">#${e.id} ${escapeHtml(e.name)}(${e.category})</option>`).join('');
1033
1295
  $('r-source').innerHTML = opts || '<option value="">(请先创建实体)</option>';
@@ -1116,15 +1378,26 @@ async function submitRelation() {
1116
1378
  $('r-source-ref').value = '';
1117
1379
  toast('关系已添加');
1118
1380
  await refreshAll();
1119
- } catch (e) { toast(e.message, true); }
1381
+ } catch (e) {
1382
+ if (/重复|同名/.test(e.message)) toast(`重复三元组被拒绝:${e.message}。同一起点到同一终点的同名关系只能有一条,可在图上点选该关系修改标注,或更换关系名称。`, true);
1383
+ else toast(e.message, true);
1384
+ }
1120
1385
  }
1121
1386
 
1122
- // 保存关系标注(置信度+来源引用)
1387
+ // 保存关系标注(置信度+来源引用+关系属性)
1123
1388
  async function saveRelMeta(id) {
1389
+ let attrs = {};
1390
+ const raw = $('rel-attrs') ? $('rel-attrs').value.trim() : '';
1391
+ if (raw) {
1392
+ try {
1393
+ attrs = JSON.parse(raw);
1394
+ if (!attrs || typeof attrs !== 'object' || Array.isArray(attrs)) throw new Error('必须是JSON对象');
1395
+ } catch (e) { return toast('关系属性JSON不合法: ' + e.message, true); }
1396
+ }
1124
1397
  try {
1125
1398
  await api(`/api/relations/${id}`, {
1126
1399
  method: 'PUT', headers: { 'Content-Type': 'application/json' },
1127
- body: JSON.stringify({ confidence: $('rel-conf').value, source_ref: $('rel-sref').value.trim() }),
1400
+ body: JSON.stringify({ confidence: $('rel-conf').value, source_ref: $('rel-sref').value.trim(), attributes: attrs }),
1128
1401
  });
1129
1402
  toast('标注已保存');
1130
1403
  await refreshAll();
@@ -1524,7 +1797,7 @@ function applyCanvasHi(nodes, rels) {
1524
1797
  for (const l of simLinks) {
1525
1798
  const base = l.line.userData.baseOpacity === undefined ? 0.9 : l.line.userData.baseOpacity;
1526
1799
  const on = rels.has(l.id);
1527
- l.line.material.opacity = on ? Math.max(base, 0.95) : 0.05;
1800
+ setLinkOpacity(l, on ? Math.max(base, 0.95) : 0.05);
1528
1801
  if (l.label) l.label.material.opacity = on ? 1 : 0.06;
1529
1802
  }
1530
1803
  }
@@ -1537,7 +1810,7 @@ function clearCanvasHi() {
1537
1810
  if (n.label) n.label.material.opacity = 1;
1538
1811
  }
1539
1812
  for (const l of simLinks) {
1540
- l.line.material.opacity = l.line.userData.baseOpacity === undefined ? 0.9 : l.line.userData.baseOpacity;
1813
+ setLinkOpacity(l, l.line.userData.baseOpacity === undefined ? 0.9 : l.line.userData.baseOpacity);
1541
1814
  if (l.label) l.label.material.opacity = 1;
1542
1815
  }
1543
1816
  }
@@ -1831,6 +2104,9 @@ function applyStyleLight(dirty) {
1831
2104
  : new THREE.LineBasicMaterial({ color: st.color, transparent: true, opacity: op });
1832
2105
  if (st.dashed) l.line.computeLineDistances();
1833
2106
  l.dashed = st.dashed;
2107
+ l.line.userData.baseOpacity = op;
2108
+ if (l.cone) { l.cone.material.color.setHex(st.color); l.cone.material.opacity = l.line.material.opacity; }
2109
+ if (l.cone2) { l.cone2.material.color.setHex(st.color); l.cone2.material.opacity = l.line.material.opacity; }
1834
2110
  const fresh = relabel(l.label, st.css, 24);
1835
2111
  if (fresh) l.label = fresh;
1836
2112
  }
@@ -2066,6 +2342,28 @@ $('fm-savehtml').addEventListener('click', () => {
2066
2342
  toast('单文件查看器已开始下载:纯静态HTML,内嵌全部图谱数据与图片,发给他人用浏览器打开即可浏览');
2067
2343
  });
2068
2344
  $('fm-rdf').addEventListener('click', () => { window.open('/api/export/rdf', '_blank'); $('file-menu').classList.remove('show'); });
2345
+ $('fm-export-json').addEventListener('click', () => { downloadUrl('/api/export/json'); $('file-menu').classList.remove('show'); });
2346
+ $('fm-export-csv').addEventListener('click', () => { downloadUrl('/api/export/csv'); $('file-menu').classList.remove('show'); });
2347
+ $('fm-import-triples').addEventListener('click', () => { $('file-menu').classList.remove('show'); $('triples-file-input').click(); });
2348
+ $('triples-file-input').addEventListener('change', async () => {
2349
+ const f = $('triples-file-input').files[0];
2350
+ $('triples-file-input').value = '';
2351
+ if (!f) return;
2352
+ const isCsv = /\.csv$/i.test(f.name), isJson = /\.json$/i.test(f.name);
2353
+ if (!isCsv && !isJson) return toast('请选择 .json 或 .csv 三元组文件', true);
2354
+ if (!confirm(`导入《${f.name}》将合并到当前图谱(同名实体复用、重复关系自动跳过)。是否继续?`)) return;
2355
+ try {
2356
+ const content = await f.text();
2357
+ const r = await api('/api/import/triples', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, format: isJson ? 'json' : 'csv' }) });
2358
+ let msg = `导入完成:新建实体 ${r.entities_added}(复用 ${r.entities_reused}),新建关系 ${r.relations_added}(跳过重复 ${r.relations_skipped_duplicate})`;
2359
+ if (r.errors.length) msg += `;失败 ${r.errors.length} 条:${r.errors.slice(0, 3).join(';')}`;
2360
+ if (r.warnings.length) msg += `;警告 ${r.warnings.length} 条:${r.warnings.slice(0, 2).join(';')}`;
2361
+ toast(msg);
2362
+ exitEgo();
2363
+ await refreshAll();
2364
+ loadHistory();
2365
+ } catch (e) { toast('导入失败: ' + e.message, true); }
2366
+ });
2069
2367
  $('fm-open').addEventListener('click', () => { $('file-menu').classList.remove('show'); $('db-file-input').click(); });
2070
2368
  $('db-file-input').addEventListener('change', async () => {
2071
2369
  const f = $('db-file-input').files[0];
@@ -2129,14 +2427,100 @@ async function refreshAll(rebuild = true) {
2129
2427
  $('stat-badge').textContent = `实体 ${meta.counts.entities} / 关系 ${meta.counts.relations} / 日志 ${meta.counts.logs}`;
2130
2428
  loadIngestTasks();
2131
2429
  const badge = $('agent-badge');
2132
- if (meta.agent_available) { badge.textContent = 'OpenCode 已就绪'; badge.className = 'badge ok'; }
2133
- else { badge.textContent = 'OpenCode 未安装'; badge.className = 'badge off'; }
2430
+ if (meta.agent_available) {
2431
+ badge.textContent = 'OpenCode 已就绪'; badge.className = 'badge ok';
2432
+ badge.style.cursor = ''; badge.title = ''; badge.onclick = null;
2433
+ ocShowInstallCard(false);
2434
+ } else {
2435
+ badge.textContent = 'OpenCode 未安装'; badge.className = 'badge off';
2436
+ badge.style.cursor = 'pointer';
2437
+ badge.title = '点击前往一键安装';
2438
+ badge.onclick = () => gotoInstallCard();
2439
+ ocShowInstallCard(true);
2440
+ try { ocRenderInstall(await api('/api/agent/install')); } catch (_) { /* 状态接口异常时仅显示卡片 */ }
2441
+ }
2134
2442
  refreshEntityOptions();
2135
2443
  rebuildGraph();
2136
2444
  renderEntityList();
2137
2445
  renderRelationList();
2138
2446
  }
2139
2447
 
2448
+ /* ================= OpenCode 一键安装 ================= */
2449
+ let ocInstallPollTimer = null;
2450
+
2451
+ function gotoInstallCard() {
2452
+ document.querySelector('[data-tab="search"]').click();
2453
+ setTimeout(() => $('oc-install-card').scrollIntoView({ behavior: 'smooth', block: 'center' }), 60);
2454
+ }
2455
+
2456
+ function ocShowInstallCard(show) {
2457
+ const card = $('oc-install-card');
2458
+ if (card) card.style.display = show ? '' : 'none';
2459
+ if (!show && ocInstallPollTimer) { clearInterval(ocInstallPollTimer); ocInstallPollTimer = null; }
2460
+ }
2461
+
2462
+ function ocRenderInstall(st) {
2463
+ const btn = $('oc-install-btn');
2464
+ const stEl = $('oc-install-status');
2465
+ const logEl = $('oc-install-log');
2466
+ const busy = st.phase === 'installing' || st.phase === 'configuring';
2467
+ btn.disabled = busy;
2468
+ btn.textContent = busy ? '安装中…' : (st.phase === 'error' ? '重试安装' : (st.phase === 'done' ? '重新安装(force)' : '下载并安装 OpenCode'));
2469
+ if (st.phase === 'done') {
2470
+ stEl.textContent = `安装完成 ✓ ${st.model ? st.model.detail : ''}`;
2471
+ stEl.style.color = '#7ee787';
2472
+ } else if (st.phase === 'error') {
2473
+ stEl.textContent = `失败:${st.error}`;
2474
+ stEl.style.color = '#f0883e';
2475
+ } else {
2476
+ stEl.textContent = st.step || '未检测到 opencode,点击按钮自动完成安装';
2477
+ if (busy && st.started_at) {
2478
+ const secs = Math.max(0, Math.round((Date.now() - st.started_at) / 1000));
2479
+ stEl.textContent += ` 已耗时 ${secs >= 60 ? `${Math.floor(secs / 60)} 分 ${secs % 60} 秒` : `${secs} 秒`}`;
2480
+ }
2481
+ stEl.style.color = '';
2482
+ }
2483
+ if (st.log && st.log.length && st.phase !== 'idle') {
2484
+ logEl.style.display = '';
2485
+ logEl.textContent = st.log.join('\n');
2486
+ logEl.scrollTop = logEl.scrollHeight;
2487
+ } else {
2488
+ logEl.style.display = 'none';
2489
+ }
2490
+ }
2491
+
2492
+ function ocPollInstall() {
2493
+ if (ocInstallPollTimer) return;
2494
+ ocInstallPollTimer = setInterval(async () => {
2495
+ try {
2496
+ const st = await api('/api/agent/install');
2497
+ ocRenderInstall(st);
2498
+ if (st.phase === 'done' || st.phase === 'error') {
2499
+ clearInterval(ocInstallPollTimer); ocInstallPollTimer = null;
2500
+ if (st.phase === 'done') { toast('OpenCode 安装完成,已可使用'); await refreshAll(); }
2501
+ }
2502
+ } catch (_) { /* 下次轮询重试 */ }
2503
+ }, 1500);
2504
+ }
2505
+
2506
+ $('oc-install-btn').addEventListener('click', async () => {
2507
+ const btn = $('oc-install-btn');
2508
+ btn.disabled = true;
2509
+ $('oc-install-status').textContent = '正在发起安装…';
2510
+ try {
2511
+ const r = await api('/api/agent/install', {
2512
+ method: 'POST',
2513
+ headers: { 'Content-Type': 'application/json' },
2514
+ body: JSON.stringify({ force: btn.textContent.includes('force') }),
2515
+ });
2516
+ ocRenderInstall(r.install);
2517
+ ocPollInstall();
2518
+ } catch (e) {
2519
+ $('oc-install-status').textContent = `发起失败:${e.message}`;
2520
+ $('oc-install-status').style.color = '#f0883e';
2521
+ }
2522
+ });
2523
+
2140
2524
  function initPolling() {
2141
2525
  setInterval(async () => {
2142
2526
  try {
@@ -2235,6 +2619,26 @@ function renderSynthesis(text, entities) {
2235
2619
  });
2236
2620
  }
2237
2621
 
2622
+ // 属性过滤:应用/清除后重建图(过滤仅影响画布显示,数据保持完整)
2623
+ $('af-apply').addEventListener('click', () => {
2624
+ const key = $('af-key').value.trim();
2625
+ if (!key) return toast('请先输入属性键', true);
2626
+ const op = $('af-op').value;
2627
+ const value = $('af-value').value.trim();
2628
+ if ((op === 'gt' || op === 'lt') && !Number.isFinite(Number(value))) return toast('大于/小于条件需要填写数字', true);
2629
+ if (op !== 'exists' && value === '') return toast('请输入比较值', true);
2630
+ state.attrFilter = { key, op, value };
2631
+ const n = attrMatchEnts(state.entities).length;
2632
+ $('af-status').textContent = `匹配 ${n} / ${state.entities.length} 个实体`;
2633
+ rebuildGraph();
2634
+ });
2635
+ $('af-clear').addEventListener('click', () => {
2636
+ state.attrFilter = null;
2637
+ $('af-key').value = ''; $('af-value').value = '';
2638
+ $('af-status').textContent = '';
2639
+ rebuildGraph();
2640
+ });
2641
+
2238
2642
  async function loadSearchStatus() {
2239
2643
  try {
2240
2644
  const [st, cfg] = await Promise.all([api('/api/embeddings/status'), api('/api/embeddings/settings')]);
@@ -2248,12 +2652,21 @@ async function loadSearchStatus() {
2248
2652
  function renderSearchResults(r) {
2249
2653
  $('s-mode').textContent = r.mode === 'hybrid' ? '语义+关键词融合' : '仅关键词(未配置key或未建向量)';
2250
2654
  if (!r.results.length) { $('s-results').innerHTML = '<div class="kv" style="margin-top:8px">无匹配结果</div>'; return; }
2251
- $('s-results').innerHTML = r.results.map((x, i) => `
2655
+ state._lastSearchIds = r.results.map((x) => x.entity.id);
2656
+ $('s-results').innerHTML = `<div class="row" style="margin-bottom:6px"><button class="ghost" id="s-hi-all">全部高亮(${r.results.length} 个实体及互相关系)</button><button class="ghost" id="s-hi-clear">清除高亮</button></div>` +
2657
+ r.results.map((x, i) => `
2252
2658
  <div class="list-item" style="cursor:pointer" onclick="focusEntity(${x.entity.id})">
2253
2659
  <b>${i + 1}. ${escapeHtml(x.entity.name)}</b>
2254
2660
  <span class="tag" style="color:${ENTITY_STYLE[x.entity.category].css};border-color:${ENTITY_STYLE[x.entity.category].css}55">${x.entity.category}</span>
2255
2661
  <div class="kv">语义 ${x.semantic_score ?? '—'} 关键词 ${x.keyword_score ?? '—'} RRF ${x.rrf_score} 关联 ${x.hit_relations} 条</div>
2256
2662
  </div>`).join('');
2663
+ $('s-hi-all').addEventListener('click', () => {
2664
+ const ids = new Set(state._lastSearchIds || []);
2665
+ const rels = new Set(state.relations.filter((x) => ids.has(x.source_id) && ids.has(x.target_id)).map((x) => x.id));
2666
+ applyCanvasHi(ids, rels);
2667
+ toast(`已高亮 ${ids.size} 个实体、${rels.size} 条互相关系`);
2668
+ });
2669
+ $('s-hi-clear').addEventListener('click', () => clearCanvasHi());
2257
2670
  }
2258
2671
 
2259
2672
  // 智能提问设置:启动时读综述开关,变更即保存
@@ -2429,6 +2842,28 @@ $('btn-resetview').addEventListener('click', () => {
2429
2842
  controls.target.set(0, 0, 0);
2430
2843
  });
2431
2844
 
2845
+ // PNG快照:2倍像素比渲染一帧后立即读取画布(WebGL默认帧缓冲渲染即清空,须同步toDataURL)
2846
+ $('btn-screenshot').addEventListener('click', () => {
2847
+ const w = renderer.domElement.clientWidth, h = renderer.domElement.clientHeight;
2848
+ const oldPr = renderer.getPixelRatio();
2849
+ try {
2850
+ renderer.setPixelRatio(2);
2851
+ renderer.setSize(w, h, false);
2852
+ renderer.render(scene, camera);
2853
+ const url = renderer.domElement.toDataURL('image/png');
2854
+ const a = document.createElement('a');
2855
+ a.href = url;
2856
+ a.download = `kg-snapshot-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`;
2857
+ a.click();
2858
+ toast(`已保存截图(${w * 2}x${h * 2})`);
2859
+ } catch (e) {
2860
+ toast('截图失败: ' + e.message, true);
2861
+ } finally {
2862
+ renderer.setPixelRatio(oldPr);
2863
+ renderer.setSize(w, h, false);
2864
+ }
2865
+ });
2866
+
2432
2867
  (async function boot() {
2433
2868
  resize();
2434
2869
  try {