local-knowledge-graph 1.10.3 → 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/HELP.md +24 -1
- package/README.md +10 -4
- package/lib/agent.js +22 -6
- package/lib/db.js +133 -11
- package/lib/ocbin.js +194 -0
- package/lib/ocinstall.js +229 -0
- package/lib/triples_io.js +229 -0
- package/lib/validator.js +35 -8
- package/lib/viewer_template.html +44 -6
- package/package.json +1 -1
- package/public/app.js +457 -23
- package/public/index.html +62 -0
- package/public/style.css +13 -0
- package/server.js +82 -6
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);
|
|
@@ -319,11 +374,12 @@ function updateRelLine(l) {
|
|
|
319
374
|
}
|
|
320
375
|
posAttr.needsUpdate = true;
|
|
321
376
|
if (l.dashed) l.line.computeLineDistances();
|
|
322
|
-
// 标签置于弧顶(t=0.5
|
|
377
|
+
// 标签置于弧顶(t=0.5),贴合弧线
|
|
323
378
|
if (l.label) {
|
|
324
379
|
arcPoint(_arcTmp, l.a.pos, l.b.pos, _arcDir, _arcOff, 0.5);
|
|
325
|
-
l.label.position.copy(_arcTmp)
|
|
380
|
+
l.label.position.copy(_arcTmp);
|
|
326
381
|
}
|
|
382
|
+
updateRelCones(l);
|
|
327
383
|
}
|
|
328
384
|
|
|
329
385
|
// 弧顶标签初始定位(构建时用;此后每帧由 updateRelLine 跟随)
|
|
@@ -331,8 +387,7 @@ function setRelLabelPos(lbl, pa, pb, arc) {
|
|
|
331
387
|
if (!arc) { lbl.position.copy(pa).add(pb).multiplyScalar(0.5); return; }
|
|
332
388
|
const dir = pb.clone().sub(pa);
|
|
333
389
|
const off = arcOffsetVec(dir, arc);
|
|
334
|
-
|
|
335
|
-
lbl.position.copy(p).addScaledVector(off, 6);
|
|
390
|
+
lbl.position.copy(arcPoint(new THREE.Vector3(), pa, pb, dir, off, 0.5));
|
|
336
391
|
}
|
|
337
392
|
|
|
338
393
|
function buildNodeMesh(category) {
|
|
@@ -356,24 +411,62 @@ function buildNodeMesh(category) {
|
|
|
356
411
|
return mesh;
|
|
357
412
|
}
|
|
358
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
|
+
|
|
359
436
|
function rebuildGraph() {
|
|
360
437
|
scene.remove(nodeGroup, linkGroup, labelGroup);
|
|
361
438
|
nodeGroup = new THREE.Group(); linkGroup = new THREE.Group(); labelGroup = new THREE.Group();
|
|
362
439
|
scene.add(nodeGroup, linkGroup, labelGroup);
|
|
363
440
|
simNodes.length = 0; simLinks.length = 0;
|
|
364
441
|
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
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;
|
|
368
451
|
const allRels = sub ? sub.relations : state.relations;
|
|
369
|
-
|
|
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
|
+
}
|
|
370
463
|
state.pathHi = null;
|
|
371
464
|
|
|
372
465
|
state.entityMap.clear();
|
|
373
466
|
const N = ents.length;
|
|
374
467
|
ents.forEach((e, i) => {
|
|
375
468
|
const mesh = buildNodeMesh(e.category);
|
|
376
|
-
if (sub) {
|
|
469
|
+
if (sub && !sub.plain) {
|
|
377
470
|
// 层级球壳分布:中心固定原点,每层外扩
|
|
378
471
|
if (e.level === 0) {
|
|
379
472
|
mesh.position.set(0, 0, 0);
|
|
@@ -432,6 +525,11 @@ function rebuildGraph() {
|
|
|
432
525
|
return n > 1 ? { idx: i, total: n } : null;
|
|
433
526
|
};
|
|
434
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
|
+
|
|
435
533
|
rels.forEach((r) => {
|
|
436
534
|
const a = simNodes.find((n) => n.id === r.source_id);
|
|
437
535
|
const b = simNodes.find((n) => n.id === r.target_id);
|
|
@@ -439,19 +537,33 @@ function rebuildGraph() {
|
|
|
439
537
|
const st = RELATION_STYLE[r.category] || { color: 0x999999, dashed: false };
|
|
440
538
|
const conf = r.confidence || '确证';
|
|
441
539
|
const baseOp = st.opacity === undefined ? 0.9 : st.opacity;
|
|
442
|
-
|
|
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);
|
|
443
546
|
const dashed = st.dashed || conf === '推测'; // 推测强制虚线
|
|
444
547
|
const arc = arcOf(r);
|
|
445
548
|
const line = makeRelLine(a.pos, b.pos, arc, st.color, dashed, op, st.dashSize || 6, st.gapSize || 4);
|
|
446
549
|
line.userData.relationId = r.id;
|
|
447
550
|
line.userData.baseOpacity = op;
|
|
448
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
|
+
}
|
|
449
561
|
// 线标注显示具体关系名(如"父子"),线型/颜色仍由大类规定;Sprite 保持贴线与近大远小
|
|
450
562
|
const lbl = makeLabelSprite(r.name, st.css, 24);
|
|
451
563
|
lbl.userData.text = r.name;
|
|
452
564
|
setRelLabelPos(lbl, a.pos, b.pos, arc);
|
|
453
565
|
labelGroup.add(lbl);
|
|
454
|
-
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 });
|
|
455
567
|
});
|
|
456
568
|
|
|
457
569
|
// 推理关系叠加:虚化虚线 + "(推)"标注,负数id与库中显式关系区分;仅显示两端均在当前视图的边
|
|
@@ -576,7 +688,15 @@ renderer.domElement.addEventListener('pointerup', (e) => {
|
|
|
576
688
|
raycaster.setFromCamera(mouse, camera);
|
|
577
689
|
const meshHits = raycaster.intersectObjects(nodeGroup.children, false);
|
|
578
690
|
if (meshHits.length) {
|
|
579
|
-
|
|
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 };
|
|
580
700
|
renderInfoCard();
|
|
581
701
|
return;
|
|
582
702
|
}
|
|
@@ -726,12 +846,21 @@ function renderInfoCard() {
|
|
|
726
846
|
${['确证', '推测', '存疑'].map((c) => `<option value="${c}"${(r.confidence || '确证') === c ? ' selected' : ''}>${c}</option>`).join('')}
|
|
727
847
|
</select></div>
|
|
728
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>
|
|
729
851
|
${rimgHtml}
|
|
730
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>`;
|
|
731
853
|
card.style.display = 'block';
|
|
732
854
|
if (!rimgs) loadRelationImages(rid);
|
|
733
855
|
}
|
|
734
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
|
+
}
|
|
735
864
|
function escapeHtml(s) {
|
|
736
865
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
737
866
|
}
|
|
@@ -778,6 +907,7 @@ function updateEgoBar() {
|
|
|
778
907
|
|
|
779
908
|
function focusEgo(id) {
|
|
780
909
|
state.ego = { centerId: id, depth: null };
|
|
910
|
+
if (state.subGraph) { state.subGraph = null; updateSubBar(); }
|
|
781
911
|
updateEgoBar();
|
|
782
912
|
rebuildGraph();
|
|
783
913
|
const m = state.entityMap.get(id);
|
|
@@ -799,6 +929,13 @@ function focusEntity(id) {
|
|
|
799
929
|
updateEgoBar();
|
|
800
930
|
rebuildGraph();
|
|
801
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; }
|
|
802
939
|
state.selected = { type: 'entity', id };
|
|
803
940
|
renderInfoCard();
|
|
804
941
|
const nd = simNodes.find((n) => n.id === id);
|
|
@@ -825,6 +962,87 @@ $('ego-depth').addEventListener('change', () => {
|
|
|
825
962
|
$('ego-rebuild').addEventListener('click', () => { if (state.ego) rebuildGraph(); });
|
|
826
963
|
$('ego-exit').addEventListener('click', exitEgo);
|
|
827
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
|
+
|
|
828
1046
|
/* ================= 实体图片绑定与灯箱 ================= */
|
|
829
1047
|
const imgInput = document.createElement('input');
|
|
830
1048
|
imgInput.type = 'file';
|
|
@@ -1000,9 +1218,37 @@ function initTabs() {
|
|
|
1000
1218
|
document.querySelectorAll('.tabbody').forEach((b) => b.classList.toggle('active', b.id === 'tab-' + t.dataset.tab));
|
|
1001
1219
|
if (t.dataset.tab === 'log') loadLogs();
|
|
1002
1220
|
if (t.dataset.tab === 'version') loadHistory();
|
|
1221
|
+
if (t.dataset.tab === 'stats') loadStats();
|
|
1003
1222
|
});
|
|
1004
1223
|
}
|
|
1005
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
|
+
|
|
1006
1252
|
async function api(url, opts) {
|
|
1007
1253
|
const res = await fetch(url, opts);
|
|
1008
1254
|
const data = await res.json().catch(() => ({}));
|
|
@@ -1021,14 +1267,29 @@ function fillCategorySelects(meta) {
|
|
|
1021
1267
|
function renderLegend() {
|
|
1022
1268
|
const meta = state.meta;
|
|
1023
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
|
+
};
|
|
1024
1274
|
$('legend').innerHTML = '<b>实体样式</b><br>' +
|
|
1025
|
-
meta.entity_categories.map((c) =>
|
|
1275
|
+
meta.entity_categories.map((c) => swatch('e', c, 'sw', ENTITY_STYLE[c].css, `${c} · ${ENTITY_STYLE[c].shape}`)).join('<br>') +
|
|
1026
1276
|
'<br><b>关系线型</b><br>' +
|
|
1027
|
-
meta.relation_categories.map((c) =>
|
|
1277
|
+
meta.relation_categories.map((c) => swatch('r', c, 'ln', RELATION_STYLE[c].css, `${c}关系${RELATION_STYLE[c].dashed ? '(虚线)' : ''}`)).join('<br>') +
|
|
1028
1278
|
'<br><b>置信度</b><br>' +
|
|
1029
|
-
['确证', '推测', '存疑'].map((c) => `<span class="sw" style="background:${CONF_STYLE[c]}"></span>${c}${c === '推测' ? '(虚线)' : c === '存疑' ? '(淡化)' : ''}
|
|
1279
|
+
['确证', '推测', '存疑'].map((c) => `<span><span class="sw" style="background:${CONF_STYLE[c]}"></span>${c}${c === '推测' ? '(虚线)' : c === '存疑' ? '(淡化)' : ''}</span>`).join('<br>');
|
|
1030
1280
|
}
|
|
1031
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
|
+
|
|
1032
1293
|
function refreshEntityOptions() {
|
|
1033
1294
|
const opts = state.entities.map((e) => `<option value="${e.id}">#${e.id} ${escapeHtml(e.name)}(${e.category})</option>`).join('');
|
|
1034
1295
|
$('r-source').innerHTML = opts || '<option value="">(请先创建实体)</option>';
|
|
@@ -1117,15 +1378,26 @@ async function submitRelation() {
|
|
|
1117
1378
|
$('r-source-ref').value = '';
|
|
1118
1379
|
toast('关系已添加');
|
|
1119
1380
|
await refreshAll();
|
|
1120
|
-
} catch (e) {
|
|
1381
|
+
} catch (e) {
|
|
1382
|
+
if (/重复|同名/.test(e.message)) toast(`重复三元组被拒绝:${e.message}。同一起点到同一终点的同名关系只能有一条,可在图上点选该关系修改标注,或更换关系名称。`, true);
|
|
1383
|
+
else toast(e.message, true);
|
|
1384
|
+
}
|
|
1121
1385
|
}
|
|
1122
1386
|
|
|
1123
|
-
//
|
|
1387
|
+
// 保存关系标注(置信度+来源引用+关系属性)
|
|
1124
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
|
+
}
|
|
1125
1397
|
try {
|
|
1126
1398
|
await api(`/api/relations/${id}`, {
|
|
1127
1399
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
1128
|
-
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 }),
|
|
1129
1401
|
});
|
|
1130
1402
|
toast('标注已保存');
|
|
1131
1403
|
await refreshAll();
|
|
@@ -1525,7 +1797,7 @@ function applyCanvasHi(nodes, rels) {
|
|
|
1525
1797
|
for (const l of simLinks) {
|
|
1526
1798
|
const base = l.line.userData.baseOpacity === undefined ? 0.9 : l.line.userData.baseOpacity;
|
|
1527
1799
|
const on = rels.has(l.id);
|
|
1528
|
-
l
|
|
1800
|
+
setLinkOpacity(l, on ? Math.max(base, 0.95) : 0.05);
|
|
1529
1801
|
if (l.label) l.label.material.opacity = on ? 1 : 0.06;
|
|
1530
1802
|
}
|
|
1531
1803
|
}
|
|
@@ -1538,7 +1810,7 @@ function clearCanvasHi() {
|
|
|
1538
1810
|
if (n.label) n.label.material.opacity = 1;
|
|
1539
1811
|
}
|
|
1540
1812
|
for (const l of simLinks) {
|
|
1541
|
-
l
|
|
1813
|
+
setLinkOpacity(l, l.line.userData.baseOpacity === undefined ? 0.9 : l.line.userData.baseOpacity);
|
|
1542
1814
|
if (l.label) l.label.material.opacity = 1;
|
|
1543
1815
|
}
|
|
1544
1816
|
}
|
|
@@ -1832,6 +2104,9 @@ function applyStyleLight(dirty) {
|
|
|
1832
2104
|
: new THREE.LineBasicMaterial({ color: st.color, transparent: true, opacity: op });
|
|
1833
2105
|
if (st.dashed) l.line.computeLineDistances();
|
|
1834
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; }
|
|
1835
2110
|
const fresh = relabel(l.label, st.css, 24);
|
|
1836
2111
|
if (fresh) l.label = fresh;
|
|
1837
2112
|
}
|
|
@@ -2067,6 +2342,28 @@ $('fm-savehtml').addEventListener('click', () => {
|
|
|
2067
2342
|
toast('单文件查看器已开始下载:纯静态HTML,内嵌全部图谱数据与图片,发给他人用浏览器打开即可浏览');
|
|
2068
2343
|
});
|
|
2069
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
|
+
});
|
|
2070
2367
|
$('fm-open').addEventListener('click', () => { $('file-menu').classList.remove('show'); $('db-file-input').click(); });
|
|
2071
2368
|
$('db-file-input').addEventListener('change', async () => {
|
|
2072
2369
|
const f = $('db-file-input').files[0];
|
|
@@ -2130,14 +2427,100 @@ async function refreshAll(rebuild = true) {
|
|
|
2130
2427
|
$('stat-badge').textContent = `实体 ${meta.counts.entities} / 关系 ${meta.counts.relations} / 日志 ${meta.counts.logs}`;
|
|
2131
2428
|
loadIngestTasks();
|
|
2132
2429
|
const badge = $('agent-badge');
|
|
2133
|
-
if (meta.agent_available) {
|
|
2134
|
-
|
|
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
|
+
}
|
|
2135
2442
|
refreshEntityOptions();
|
|
2136
2443
|
rebuildGraph();
|
|
2137
2444
|
renderEntityList();
|
|
2138
2445
|
renderRelationList();
|
|
2139
2446
|
}
|
|
2140
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
|
+
|
|
2141
2524
|
function initPolling() {
|
|
2142
2525
|
setInterval(async () => {
|
|
2143
2526
|
try {
|
|
@@ -2236,6 +2619,26 @@ function renderSynthesis(text, entities) {
|
|
|
2236
2619
|
});
|
|
2237
2620
|
}
|
|
2238
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
|
+
|
|
2239
2642
|
async function loadSearchStatus() {
|
|
2240
2643
|
try {
|
|
2241
2644
|
const [st, cfg] = await Promise.all([api('/api/embeddings/status'), api('/api/embeddings/settings')]);
|
|
@@ -2249,12 +2652,21 @@ async function loadSearchStatus() {
|
|
|
2249
2652
|
function renderSearchResults(r) {
|
|
2250
2653
|
$('s-mode').textContent = r.mode === 'hybrid' ? '语义+关键词融合' : '仅关键词(未配置key或未建向量)';
|
|
2251
2654
|
if (!r.results.length) { $('s-results').innerHTML = '<div class="kv" style="margin-top:8px">无匹配结果</div>'; return; }
|
|
2252
|
-
|
|
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) => `
|
|
2253
2658
|
<div class="list-item" style="cursor:pointer" onclick="focusEntity(${x.entity.id})">
|
|
2254
2659
|
<b>${i + 1}. ${escapeHtml(x.entity.name)}</b>
|
|
2255
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>
|
|
2256
2661
|
<div class="kv">语义 ${x.semantic_score ?? '—'} 关键词 ${x.keyword_score ?? '—'} RRF ${x.rrf_score} 关联 ${x.hit_relations} 条</div>
|
|
2257
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());
|
|
2258
2670
|
}
|
|
2259
2671
|
|
|
2260
2672
|
// 智能提问设置:启动时读综述开关,变更即保存
|
|
@@ -2430,6 +2842,28 @@ $('btn-resetview').addEventListener('click', () => {
|
|
|
2430
2842
|
controls.target.set(0, 0, 0);
|
|
2431
2843
|
});
|
|
2432
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
|
+
|
|
2433
2867
|
(async function boot() {
|
|
2434
2868
|
resize();
|
|
2435
2869
|
try {
|