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/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 +454 -19
- 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);
|
|
@@ -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
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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) =>
|
|
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) =>
|
|
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 === '存疑' ? '(淡化)' : ''}
|
|
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) {
|
|
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
|
|
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
|
|
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) {
|
|
2133
|
-
|
|
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
|
-
|
|
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 {
|