local-knowledge-graph 1.10.4 → 1.12.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 +30 -4
- package/README.md +13 -5
- package/bin/cli.js +27 -3
- package/lib/agent.js +49 -7
- package/lib/db.js +152 -14
- package/lib/demo.js +58 -0
- package/lib/diff.js +63 -0
- package/lib/git.js +24 -1
- package/lib/ocbin.js +194 -0
- package/lib/ocinstall.js +229 -0
- package/lib/triples_io.js +230 -0
- package/lib/validator.js +74 -8
- package/lib/viewer_template.html +67 -6
- package/package.json +1 -1
- package/public/app.js +648 -21
- package/public/index.html +82 -7
- package/public/style.css +22 -10
- package/server.js +143 -17
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();
|
|
@@ -1378,21 +1651,109 @@ async function loadLogs() {
|
|
|
1378
1651
|
$('log-type').addEventListener('change', renderLogs);
|
|
1379
1652
|
$('log-kw').addEventListener('input', renderLogs);
|
|
1380
1653
|
|
|
1654
|
+
/* ================= 保存点 Diff(勾选两个版本对比,Archify Before/Delta/After 式) ================= */
|
|
1655
|
+
const diffSel = []; // [{hash, date, short, title}],最多2个
|
|
1656
|
+
let diffResult = null;
|
|
1657
|
+
|
|
1658
|
+
function toggleDiffSel(hash) {
|
|
1659
|
+
const idx = diffSel.findIndex((x) => x.hash === hash);
|
|
1660
|
+
if (idx >= 0) diffSel.splice(idx, 1);
|
|
1661
|
+
else {
|
|
1662
|
+
if (diffSel.length >= 2) diffSel.shift();
|
|
1663
|
+
diffSel.push({ hash, date: diffMeta[hash]?.date || '', short: diffMeta[hash]?.short || hash.slice(0, 7), title: diffMeta[hash]?.title || '' });
|
|
1664
|
+
}
|
|
1665
|
+
updateDiffBar();
|
|
1666
|
+
loadHistory();
|
|
1667
|
+
}
|
|
1668
|
+
window.toggleDiffSel = toggleDiffSel;
|
|
1669
|
+
let diffMeta = {};
|
|
1670
|
+
|
|
1671
|
+
function updateDiffBar() {
|
|
1672
|
+
const bar = $('diff-bar');
|
|
1673
|
+
bar.style.display = diffSel.length ? 'flex' : 'none';
|
|
1674
|
+
$('btn-diff-run').disabled = diffSel.length !== 2;
|
|
1675
|
+
$('diff-bar-text').textContent = diffSel.length === 2
|
|
1676
|
+
? `已选 2 个保存点:${diffSel[0].short} 与 ${diffSel[1].short}`
|
|
1677
|
+
: `已选 ${diffSel.length}/2 个保存点`;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
async function runDiff() {
|
|
1681
|
+
if (diffSel.length !== 2) return;
|
|
1682
|
+
// history 按时间倒序(新→旧),早的作为 from(基线)
|
|
1683
|
+
const [a, b] = [...diffSel].sort((x, y) => (x.date < y.date ? -1 : 1));
|
|
1684
|
+
try {
|
|
1685
|
+
diffResult = await api(`/api/git/diff?from=${encodeURIComponent(a.hash)}&to=${encodeURIComponent(b.hash)}`);
|
|
1686
|
+
renderDiffResult(a, b);
|
|
1687
|
+
} catch (e) { toast(e.message, true); }
|
|
1688
|
+
}
|
|
1689
|
+
window.runDiff = runDiff;
|
|
1690
|
+
|
|
1691
|
+
function clearDiffSel() {
|
|
1692
|
+
diffSel.length = 0;
|
|
1693
|
+
diffResult = null;
|
|
1694
|
+
$('diff-result').style.display = 'none';
|
|
1695
|
+
updateDiffBar();
|
|
1696
|
+
loadHistory();
|
|
1697
|
+
}
|
|
1698
|
+
window.clearDiffSel = clearDiffSel;
|
|
1699
|
+
|
|
1700
|
+
function diffSection(title, sec, color) {
|
|
1701
|
+
if (!sec.added.length && !sec.removed.length && !sec.changed.length) return '';
|
|
1702
|
+
const row = (icon, label, color2, text) => `<div style="display:flex;gap:6px;align-items:baseline"><span style="color:${color2};flex:0 0 auto">${icon}</span><span style="font-size:12px">${text} <span style="color:#5c6f92">(${label})</span></span></div>`;
|
|
1703
|
+
const f = (o) => typeof o === 'object' && o !== null ? JSON.stringify(o) : String(o ?? '');
|
|
1704
|
+
const parts = [
|
|
1705
|
+
...sec.added.map((x) => row('+', '新增', '#7ee787', f(x))),
|
|
1706
|
+
...sec.removed.map((x) => row('−', '移除', '#f87171', f(x))),
|
|
1707
|
+
...sec.changed.map((c) => row('~', '修改', '#e0c068', `#${c.id} ${c.fields.map((k) => `${k}: ${f(c.from[k])} → ${f(c.to[k])}`).join(';')}`)),
|
|
1708
|
+
];
|
|
1709
|
+
return `<div style="margin:8px 0"><div style="font-size:12px;font-weight:600;color:${color};margin-bottom:4px">${title}(${sec.added.length}增 ${sec.removed.length}删 ${sec.changed.length}改)</div>${parts.join('')}</div>`;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
function renderDiffResult(a, b) {
|
|
1713
|
+
const el = $('diff-result');
|
|
1714
|
+
if (!diffResult) { el.style.display = 'none'; return; }
|
|
1715
|
+
const s = diffResult.summary;
|
|
1716
|
+
const total = Object.values(s).reduce((x, y) => x + y, 0);
|
|
1717
|
+
el.style.display = 'block';
|
|
1718
|
+
el.innerHTML = `
|
|
1719
|
+
<div class="card" style="border:1px solid #2d3f5e;margin-top:10px">
|
|
1720
|
+
<h3>版本对比 ${a.short} → ${b.short}</h3>
|
|
1721
|
+
<div style="font-size:12px;color:#8fa3c0;margin-bottom:6px">${escapeHtml(a.title || '')} → ${escapeHtml(b.title || '')}</div>
|
|
1722
|
+
${total === 0 ? '<div class="sub" style="color:#7ee787">两个版本之间无差异</div>' : `
|
|
1723
|
+
${diffSection('实体', diffResult.entities, '#9db8e8')}
|
|
1724
|
+
${diffSection('关系', diffResult.relations, '#9db8e8')}
|
|
1725
|
+
`}
|
|
1726
|
+
<div class="row" style="margin-top:8px"><button id="btn-diff-close" style="padding:4px 10px;font-size:12px">收起对比</button></div>
|
|
1727
|
+
</div>`;
|
|
1728
|
+
$('btn-diff-close').onclick = () => { el.style.display = 'none'; };
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1381
1731
|
async function loadHistory() {
|
|
1382
1732
|
try {
|
|
1383
1733
|
const list = await api('/api/git/history');
|
|
1734
|
+
diffMeta = {};
|
|
1735
|
+
list.forEach((c) => { diffMeta[c.hash] = c; });
|
|
1384
1736
|
$('v-list').innerHTML = list.map((c, i) => `
|
|
1385
1737
|
<div class="ver-item">
|
|
1386
1738
|
<div class="v1"><code>${c.short}</code><span style="color:#7ee787;font-size:10px">${i === 0 ? '当前' : ''}</span>
|
|
1387
1739
|
<span style="flex:1"></span>
|
|
1740
|
+
<label style="display:flex;align-items:center;gap:4px;font-size:11px;color:#8fa3c0;cursor:pointer"><input type="checkbox" ${diffSel.some((x) => x.hash === c.hash) ? 'checked' : ''} onchange="toggleDiffSel('${c.hash}')" style="width:auto">对比</label>
|
|
1388
1741
|
${i === 0 ? '' : `<button onclick="restoreTo('${c.hash}')">恢复此版本</button>`}
|
|
1389
1742
|
</div>
|
|
1390
1743
|
<div class="v2">${escapeHtml(c.title)}</div>
|
|
1391
1744
|
<div class="v3">${c.date} · ${escapeHtml(c.source || c.author)}${c.log_range ? ` · 覆盖日志 #${c.log_range[0]}-#${c.log_range[1]}` : ''}</div>
|
|
1392
1745
|
</div>`).join('') || '<div class="sub" style="color:#5c6f92">暂无保存点</div>';
|
|
1746
|
+
updateDiffBar();
|
|
1747
|
+
if (diffResult && diffSel.length === 2) {
|
|
1748
|
+
const [a, b] = [...diffSel].sort((x, y) => (x.date < y.date ? -1 : 1));
|
|
1749
|
+
renderDiffResult(a, b);
|
|
1750
|
+
}
|
|
1393
1751
|
} catch (e) { toast(e.message, true); }
|
|
1394
1752
|
}
|
|
1395
1753
|
|
|
1754
|
+
$('btn-diff-run').addEventListener('click', runDiff);
|
|
1755
|
+
$('btn-diff-clear').addEventListener('click', clearDiffSel);
|
|
1756
|
+
|
|
1396
1757
|
async function restoreTo(hash) {
|
|
1397
1758
|
if (!confirm(`回溯到保存点 ${hash.slice(0, 8)}?当前未保存的修改会先自动备份。`)) return;
|
|
1398
1759
|
try {
|
|
@@ -1462,6 +1823,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
1462
1823
|
}
|
|
1463
1824
|
if ($('style-panel').classList.contains('show')) { $('style-panel').classList.remove('show'); return; }
|
|
1464
1825
|
if ($('file-menu').classList.contains('show')) { $('file-menu').classList.remove('show'); return; }
|
|
1826
|
+
if ($('view-menu').classList.contains('show')) { $('view-menu').classList.remove('show'); return; }
|
|
1465
1827
|
if (state.pathHi) { clearCanvasHi(); return; }
|
|
1466
1828
|
if (state.selected) { state.selected = null; renderInfoCard(); return; }
|
|
1467
1829
|
if (state.ego) exitEgo();
|
|
@@ -1524,7 +1886,7 @@ function applyCanvasHi(nodes, rels) {
|
|
|
1524
1886
|
for (const l of simLinks) {
|
|
1525
1887
|
const base = l.line.userData.baseOpacity === undefined ? 0.9 : l.line.userData.baseOpacity;
|
|
1526
1888
|
const on = rels.has(l.id);
|
|
1527
|
-
l
|
|
1889
|
+
setLinkOpacity(l, on ? Math.max(base, 0.95) : 0.05);
|
|
1528
1890
|
if (l.label) l.label.material.opacity = on ? 1 : 0.06;
|
|
1529
1891
|
}
|
|
1530
1892
|
}
|
|
@@ -1537,7 +1899,7 @@ function clearCanvasHi() {
|
|
|
1537
1899
|
if (n.label) n.label.material.opacity = 1;
|
|
1538
1900
|
}
|
|
1539
1901
|
for (const l of simLinks) {
|
|
1540
|
-
l
|
|
1902
|
+
setLinkOpacity(l, l.line.userData.baseOpacity === undefined ? 0.9 : l.line.userData.baseOpacity);
|
|
1541
1903
|
if (l.label) l.label.material.opacity = 1;
|
|
1542
1904
|
}
|
|
1543
1905
|
}
|
|
@@ -1779,9 +2141,13 @@ async function sendAgent() {
|
|
|
1779
2141
|
r.applied.map((a) => `- ${a.op}: ${a.name || ''}#${a.id}`).join('\n'));
|
|
1780
2142
|
}
|
|
1781
2143
|
if (r.ops_found > 0 && (r.applied_total !== undefined ? !r.applied_total : (!r.applied || !r.applied.length))) {
|
|
1782
|
-
|
|
2144
|
+
const rep = r.repair_rounds ? `;已自动按回执修复 ${r.repair_rounds} 轮仍未通过` : ';可尝试换一种表述让AI修正';
|
|
2145
|
+
const rcpt = r.receipts && r.receipts.length ? `\n回执:${r.receipts.map((x) => `#${x.index} ${x.code} ${x.message}`).join(';')}` : '';
|
|
2146
|
+
appendMsg('system', `OpenCode 提交了 ${r.ops_found} 项操作,但被RDF校验拦截${r.apply_error ? ':' + r.apply_error : ''}${rep}。数据库保持上一个合规版本。${rcpt}`, ['已拦截'], true);
|
|
1783
2147
|
} else if (r.apply_error) {
|
|
1784
2148
|
appendMsg('system', '部分操作被拦截:' + r.apply_error, ['已拦截'], true);
|
|
2149
|
+
} else if (r.repaired) {
|
|
2150
|
+
appendMsg('system', `有 ${r.ops_found} 项操作经 ${r.repair_rounds} 轮回执修复后写入成功`);
|
|
1785
2151
|
}
|
|
1786
2152
|
if (r.parse_error) appendMsg('system', '输出协议解析警告: ' + r.parse_error);
|
|
1787
2153
|
const sp = r.savepoint;
|
|
@@ -1831,6 +2197,9 @@ function applyStyleLight(dirty) {
|
|
|
1831
2197
|
: new THREE.LineBasicMaterial({ color: st.color, transparent: true, opacity: op });
|
|
1832
2198
|
if (st.dashed) l.line.computeLineDistances();
|
|
1833
2199
|
l.dashed = st.dashed;
|
|
2200
|
+
l.line.userData.baseOpacity = op;
|
|
2201
|
+
if (l.cone) { l.cone.material.color.setHex(st.color); l.cone.material.opacity = l.line.material.opacity; }
|
|
2202
|
+
if (l.cone2) { l.cone2.material.color.setHex(st.color); l.cone2.material.opacity = l.line.material.opacity; }
|
|
1834
2203
|
const fresh = relabel(l.label, st.css, 24);
|
|
1835
2204
|
if (fresh) l.label = fresh;
|
|
1836
2205
|
}
|
|
@@ -2031,15 +2400,47 @@ $('help-close').addEventListener('click', () => {
|
|
|
2031
2400
|
$('info-card').style.display = '';
|
|
2032
2401
|
});
|
|
2033
2402
|
|
|
2403
|
+
/* ================= 演示数据一键载入(太阳系示例) ================= */
|
|
2404
|
+
$('btn-load-demo').addEventListener('click', async () => {
|
|
2405
|
+
const btn = $('btn-load-demo');
|
|
2406
|
+
if (btn.disabled) return;
|
|
2407
|
+
if (!confirm('载入太阳系演示数据到当前图谱?\n载入前会自动打保存点备份现有数据,可随时通过版本回溯找回。')) return;
|
|
2408
|
+
btn.disabled = true;
|
|
2409
|
+
btn.textContent = '载入中…';
|
|
2410
|
+
try {
|
|
2411
|
+
const r = await api('/api/demo/load', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
|
2412
|
+
toast(`演示数据已载入:新建实体 ${r.entities_added}(复用 ${r.entities_reused}),新建关系 ${r.relations_added}(跳过重复 ${r.relations_skipped_duplicate})`);
|
|
2413
|
+
await refreshAll();
|
|
2414
|
+
loadHistory();
|
|
2415
|
+
} catch (e) { toast('载入失败: ' + e.message, true); }
|
|
2416
|
+
btn.disabled = false;
|
|
2417
|
+
btn.textContent = '载入演示数据';
|
|
2418
|
+
});
|
|
2419
|
+
|
|
2034
2420
|
/* ================= 文件菜单(手机端:打开/保存/另存/网页版) ================= */
|
|
2035
2421
|
$('m-file').addEventListener('click', (e) => {
|
|
2036
2422
|
e.stopPropagation();
|
|
2423
|
+
$('view-menu').classList.remove('show');
|
|
2037
2424
|
$('file-menu').classList.toggle('show');
|
|
2038
2425
|
});
|
|
2039
2426
|
document.addEventListener('click', (e) => {
|
|
2040
2427
|
if (!$('file-menu').contains(e.target) && e.target !== $('m-file')) $('file-menu').classList.remove('show');
|
|
2041
2428
|
});
|
|
2042
2429
|
|
|
2430
|
+
/* ================= 视图菜单(布局/视角/主题/推理/截图折叠) ================= */
|
|
2431
|
+
$('m-view').addEventListener('click', (e) => {
|
|
2432
|
+
e.stopPropagation();
|
|
2433
|
+
$('file-menu').classList.remove('show');
|
|
2434
|
+
$('view-menu').classList.toggle('show');
|
|
2435
|
+
});
|
|
2436
|
+
document.addEventListener('click', (e) => {
|
|
2437
|
+
if (!$('view-menu').contains(e.target) && e.target !== $('m-view')) $('view-menu').classList.remove('show');
|
|
2438
|
+
});
|
|
2439
|
+
// 点任意菜单项执行后自动收起(事件委托,无需逐按钮绑定)
|
|
2440
|
+
$('view-menu').addEventListener('click', () => {
|
|
2441
|
+
setTimeout(() => $('view-menu').classList.remove('show'), 150);
|
|
2442
|
+
});
|
|
2443
|
+
|
|
2043
2444
|
function downloadUrl(url) {
|
|
2044
2445
|
const a = document.createElement('a');
|
|
2045
2446
|
a.href = url;
|
|
@@ -2066,6 +2467,28 @@ $('fm-savehtml').addEventListener('click', () => {
|
|
|
2066
2467
|
toast('单文件查看器已开始下载:纯静态HTML,内嵌全部图谱数据与图片,发给他人用浏览器打开即可浏览');
|
|
2067
2468
|
});
|
|
2068
2469
|
$('fm-rdf').addEventListener('click', () => { window.open('/api/export/rdf', '_blank'); $('file-menu').classList.remove('show'); });
|
|
2470
|
+
$('fm-export-json').addEventListener('click', () => { downloadUrl('/api/export/json'); $('file-menu').classList.remove('show'); });
|
|
2471
|
+
$('fm-export-csv').addEventListener('click', () => { downloadUrl('/api/export/csv'); $('file-menu').classList.remove('show'); });
|
|
2472
|
+
$('fm-import-triples').addEventListener('click', () => { $('file-menu').classList.remove('show'); $('triples-file-input').click(); });
|
|
2473
|
+
$('triples-file-input').addEventListener('change', async () => {
|
|
2474
|
+
const f = $('triples-file-input').files[0];
|
|
2475
|
+
$('triples-file-input').value = '';
|
|
2476
|
+
if (!f) return;
|
|
2477
|
+
const isCsv = /\.csv$/i.test(f.name), isJson = /\.json$/i.test(f.name);
|
|
2478
|
+
if (!isCsv && !isJson) return toast('请选择 .json 或 .csv 三元组文件', true);
|
|
2479
|
+
if (!confirm(`导入《${f.name}》将合并到当前图谱(同名实体复用、重复关系自动跳过)。是否继续?`)) return;
|
|
2480
|
+
try {
|
|
2481
|
+
const content = await f.text();
|
|
2482
|
+
const r = await api('/api/import/triples', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, format: isJson ? 'json' : 'csv' }) });
|
|
2483
|
+
let msg = `导入完成:新建实体 ${r.entities_added}(复用 ${r.entities_reused}),新建关系 ${r.relations_added}(跳过重复 ${r.relations_skipped_duplicate})`;
|
|
2484
|
+
if (r.errors.length) msg += `;失败 ${r.errors.length} 条:${r.errors.slice(0, 3).join(';')}`;
|
|
2485
|
+
if (r.warnings.length) msg += `;警告 ${r.warnings.length} 条:${r.warnings.slice(0, 2).join(';')}`;
|
|
2486
|
+
toast(msg);
|
|
2487
|
+
exitEgo();
|
|
2488
|
+
await refreshAll();
|
|
2489
|
+
loadHistory();
|
|
2490
|
+
} catch (e) { toast('导入失败: ' + e.message, true); }
|
|
2491
|
+
});
|
|
2069
2492
|
$('fm-open').addEventListener('click', () => { $('file-menu').classList.remove('show'); $('db-file-input').click(); });
|
|
2070
2493
|
$('db-file-input').addEventListener('change', async () => {
|
|
2071
2494
|
const f = $('db-file-input').files[0];
|
|
@@ -2129,14 +2552,100 @@ async function refreshAll(rebuild = true) {
|
|
|
2129
2552
|
$('stat-badge').textContent = `实体 ${meta.counts.entities} / 关系 ${meta.counts.relations} / 日志 ${meta.counts.logs}`;
|
|
2130
2553
|
loadIngestTasks();
|
|
2131
2554
|
const badge = $('agent-badge');
|
|
2132
|
-
if (meta.agent_available) {
|
|
2133
|
-
|
|
2555
|
+
if (meta.agent_available) {
|
|
2556
|
+
badge.textContent = 'OpenCode 已就绪'; badge.className = 'badge ok';
|
|
2557
|
+
badge.style.cursor = ''; badge.title = ''; badge.onclick = null;
|
|
2558
|
+
ocShowInstallCard(false);
|
|
2559
|
+
} else {
|
|
2560
|
+
badge.textContent = 'OpenCode 未安装'; badge.className = 'badge off';
|
|
2561
|
+
badge.style.cursor = 'pointer';
|
|
2562
|
+
badge.title = '点击前往一键安装';
|
|
2563
|
+
badge.onclick = () => gotoInstallCard();
|
|
2564
|
+
ocShowInstallCard(true);
|
|
2565
|
+
try { ocRenderInstall(await api('/api/agent/install')); } catch (_) { /* 状态接口异常时仅显示卡片 */ }
|
|
2566
|
+
}
|
|
2134
2567
|
refreshEntityOptions();
|
|
2135
2568
|
rebuildGraph();
|
|
2136
2569
|
renderEntityList();
|
|
2137
2570
|
renderRelationList();
|
|
2138
2571
|
}
|
|
2139
2572
|
|
|
2573
|
+
/* ================= OpenCode 一键安装 ================= */
|
|
2574
|
+
let ocInstallPollTimer = null;
|
|
2575
|
+
|
|
2576
|
+
function gotoInstallCard() {
|
|
2577
|
+
document.querySelector('[data-tab="search"]').click();
|
|
2578
|
+
setTimeout(() => $('oc-install-card').scrollIntoView({ behavior: 'smooth', block: 'center' }), 60);
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
function ocShowInstallCard(show) {
|
|
2582
|
+
const card = $('oc-install-card');
|
|
2583
|
+
if (card) card.style.display = show ? '' : 'none';
|
|
2584
|
+
if (!show && ocInstallPollTimer) { clearInterval(ocInstallPollTimer); ocInstallPollTimer = null; }
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
function ocRenderInstall(st) {
|
|
2588
|
+
const btn = $('oc-install-btn');
|
|
2589
|
+
const stEl = $('oc-install-status');
|
|
2590
|
+
const logEl = $('oc-install-log');
|
|
2591
|
+
const busy = st.phase === 'installing' || st.phase === 'configuring';
|
|
2592
|
+
btn.disabled = busy;
|
|
2593
|
+
btn.textContent = busy ? '安装中…' : (st.phase === 'error' ? '重试安装' : (st.phase === 'done' ? '重新安装(force)' : '下载并安装 OpenCode'));
|
|
2594
|
+
if (st.phase === 'done') {
|
|
2595
|
+
stEl.textContent = `安装完成 ✓ ${st.model ? st.model.detail : ''}`;
|
|
2596
|
+
stEl.style.color = '#7ee787';
|
|
2597
|
+
} else if (st.phase === 'error') {
|
|
2598
|
+
stEl.textContent = `失败:${st.error}`;
|
|
2599
|
+
stEl.style.color = '#f0883e';
|
|
2600
|
+
} else {
|
|
2601
|
+
stEl.textContent = st.step || '未检测到 opencode,点击按钮自动完成安装';
|
|
2602
|
+
if (busy && st.started_at) {
|
|
2603
|
+
const secs = Math.max(0, Math.round((Date.now() - st.started_at) / 1000));
|
|
2604
|
+
stEl.textContent += ` 已耗时 ${secs >= 60 ? `${Math.floor(secs / 60)} 分 ${secs % 60} 秒` : `${secs} 秒`}`;
|
|
2605
|
+
}
|
|
2606
|
+
stEl.style.color = '';
|
|
2607
|
+
}
|
|
2608
|
+
if (st.log && st.log.length && st.phase !== 'idle') {
|
|
2609
|
+
logEl.style.display = '';
|
|
2610
|
+
logEl.textContent = st.log.join('\n');
|
|
2611
|
+
logEl.scrollTop = logEl.scrollHeight;
|
|
2612
|
+
} else {
|
|
2613
|
+
logEl.style.display = 'none';
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
function ocPollInstall() {
|
|
2618
|
+
if (ocInstallPollTimer) return;
|
|
2619
|
+
ocInstallPollTimer = setInterval(async () => {
|
|
2620
|
+
try {
|
|
2621
|
+
const st = await api('/api/agent/install');
|
|
2622
|
+
ocRenderInstall(st);
|
|
2623
|
+
if (st.phase === 'done' || st.phase === 'error') {
|
|
2624
|
+
clearInterval(ocInstallPollTimer); ocInstallPollTimer = null;
|
|
2625
|
+
if (st.phase === 'done') { toast('OpenCode 安装完成,已可使用'); await refreshAll(); }
|
|
2626
|
+
}
|
|
2627
|
+
} catch (_) { /* 下次轮询重试 */ }
|
|
2628
|
+
}, 1500);
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
$('oc-install-btn').addEventListener('click', async () => {
|
|
2632
|
+
const btn = $('oc-install-btn');
|
|
2633
|
+
btn.disabled = true;
|
|
2634
|
+
$('oc-install-status').textContent = '正在发起安装…';
|
|
2635
|
+
try {
|
|
2636
|
+
const r = await api('/api/agent/install', {
|
|
2637
|
+
method: 'POST',
|
|
2638
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2639
|
+
body: JSON.stringify({ force: btn.textContent.includes('force') }),
|
|
2640
|
+
});
|
|
2641
|
+
ocRenderInstall(r.install);
|
|
2642
|
+
ocPollInstall();
|
|
2643
|
+
} catch (e) {
|
|
2644
|
+
$('oc-install-status').textContent = `发起失败:${e.message}`;
|
|
2645
|
+
$('oc-install-status').style.color = '#f0883e';
|
|
2646
|
+
}
|
|
2647
|
+
});
|
|
2648
|
+
|
|
2140
2649
|
function initPolling() {
|
|
2141
2650
|
setInterval(async () => {
|
|
2142
2651
|
try {
|
|
@@ -2235,6 +2744,26 @@ function renderSynthesis(text, entities) {
|
|
|
2235
2744
|
});
|
|
2236
2745
|
}
|
|
2237
2746
|
|
|
2747
|
+
// 属性过滤:应用/清除后重建图(过滤仅影响画布显示,数据保持完整)
|
|
2748
|
+
$('af-apply').addEventListener('click', () => {
|
|
2749
|
+
const key = $('af-key').value.trim();
|
|
2750
|
+
if (!key) return toast('请先输入属性键', true);
|
|
2751
|
+
const op = $('af-op').value;
|
|
2752
|
+
const value = $('af-value').value.trim();
|
|
2753
|
+
if ((op === 'gt' || op === 'lt') && !Number.isFinite(Number(value))) return toast('大于/小于条件需要填写数字', true);
|
|
2754
|
+
if (op !== 'exists' && value === '') return toast('请输入比较值', true);
|
|
2755
|
+
state.attrFilter = { key, op, value };
|
|
2756
|
+
const n = attrMatchEnts(state.entities).length;
|
|
2757
|
+
$('af-status').textContent = `匹配 ${n} / ${state.entities.length} 个实体`;
|
|
2758
|
+
rebuildGraph();
|
|
2759
|
+
});
|
|
2760
|
+
$('af-clear').addEventListener('click', () => {
|
|
2761
|
+
state.attrFilter = null;
|
|
2762
|
+
$('af-key').value = ''; $('af-value').value = '';
|
|
2763
|
+
$('af-status').textContent = '';
|
|
2764
|
+
rebuildGraph();
|
|
2765
|
+
});
|
|
2766
|
+
|
|
2238
2767
|
async function loadSearchStatus() {
|
|
2239
2768
|
try {
|
|
2240
2769
|
const [st, cfg] = await Promise.all([api('/api/embeddings/status'), api('/api/embeddings/settings')]);
|
|
@@ -2248,12 +2777,21 @@ async function loadSearchStatus() {
|
|
|
2248
2777
|
function renderSearchResults(r) {
|
|
2249
2778
|
$('s-mode').textContent = r.mode === 'hybrid' ? '语义+关键词融合' : '仅关键词(未配置key或未建向量)';
|
|
2250
2779
|
if (!r.results.length) { $('s-results').innerHTML = '<div class="kv" style="margin-top:8px">无匹配结果</div>'; return; }
|
|
2251
|
-
|
|
2780
|
+
state._lastSearchIds = r.results.map((x) => x.entity.id);
|
|
2781
|
+
$('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>` +
|
|
2782
|
+
r.results.map((x, i) => `
|
|
2252
2783
|
<div class="list-item" style="cursor:pointer" onclick="focusEntity(${x.entity.id})">
|
|
2253
2784
|
<b>${i + 1}. ${escapeHtml(x.entity.name)}</b>
|
|
2254
2785
|
<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
2786
|
<div class="kv">语义 ${x.semantic_score ?? '—'} 关键词 ${x.keyword_score ?? '—'} RRF ${x.rrf_score} 关联 ${x.hit_relations} 条</div>
|
|
2256
2787
|
</div>`).join('');
|
|
2788
|
+
$('s-hi-all').addEventListener('click', () => {
|
|
2789
|
+
const ids = new Set(state._lastSearchIds || []);
|
|
2790
|
+
const rels = new Set(state.relations.filter((x) => ids.has(x.source_id) && ids.has(x.target_id)).map((x) => x.id));
|
|
2791
|
+
applyCanvasHi(ids, rels);
|
|
2792
|
+
toast(`已高亮 ${ids.size} 个实体、${rels.size} 条互相关系`);
|
|
2793
|
+
});
|
|
2794
|
+
$('s-hi-clear').addEventListener('click', () => clearCanvasHi());
|
|
2257
2795
|
}
|
|
2258
2796
|
|
|
2259
2797
|
// 智能提问设置:启动时读综述开关,变更即保存
|
|
@@ -2405,7 +2943,6 @@ $('agent-input').addEventListener('keydown', (e) => {
|
|
|
2405
2943
|
});
|
|
2406
2944
|
$('btn-savepoint').addEventListener('click', () => doSavepoint(''));
|
|
2407
2945
|
$('v-save').addEventListener('click', () => doSavepoint($('v-message').value));
|
|
2408
|
-
$('btn-export').addEventListener('click', () => window.open('/api/export/rdf', '_blank'));
|
|
2409
2946
|
$('btn-inference').addEventListener('click', async () => {
|
|
2410
2947
|
state.showInferred = !state.showInferred;
|
|
2411
2948
|
$('btn-inference').classList.toggle('active', state.showInferred);
|
|
@@ -2429,12 +2966,102 @@ $('btn-resetview').addEventListener('click', () => {
|
|
|
2429
2966
|
controls.target.set(0, 0, 0);
|
|
2430
2967
|
});
|
|
2431
2968
|
|
|
2969
|
+
// PNG快照:2倍像素比渲染一帧后立即读取画布(WebGL默认帧缓冲渲染即清空,须同步toDataURL)
|
|
2970
|
+
$('btn-screenshot').addEventListener('click', () => {
|
|
2971
|
+
const w = renderer.domElement.clientWidth, h = renderer.domElement.clientHeight;
|
|
2972
|
+
const oldPr = renderer.getPixelRatio();
|
|
2973
|
+
try {
|
|
2974
|
+
renderer.setPixelRatio(2);
|
|
2975
|
+
renderer.setSize(w, h, false);
|
|
2976
|
+
renderer.render(scene, camera);
|
|
2977
|
+
const url = renderer.domElement.toDataURL('image/png');
|
|
2978
|
+
const a = document.createElement('a');
|
|
2979
|
+
a.href = url;
|
|
2980
|
+
a.download = `kg-snapshot-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`;
|
|
2981
|
+
a.click();
|
|
2982
|
+
toast(`已保存截图(${w * 2}x${h * 2})`);
|
|
2983
|
+
} catch (e) {
|
|
2984
|
+
toast('截图失败: ' + e.message, true);
|
|
2985
|
+
} finally {
|
|
2986
|
+
renderer.setPixelRatio(oldPr);
|
|
2987
|
+
renderer.setSize(w, h, false);
|
|
2988
|
+
}
|
|
2989
|
+
});
|
|
2990
|
+
|
|
2991
|
+
/* ================= URL 深链(#entity=5 / #ego=5&depth=2 / #path=6-13) ================= */
|
|
2992
|
+
// 解析当前视图状态为 hash 参数(优先级:路径高亮 > 中心模式 > 选中实体)
|
|
2993
|
+
function buildViewHash() {
|
|
2994
|
+
if (state.pathHi && state._lastPaths && state._lastPaths.length) {
|
|
2995
|
+
const p = state._lastPaths[0];
|
|
2996
|
+
const a = p.entities[0], b = p.entities[p.entities.length - 1];
|
|
2997
|
+
if (a && b) return `#path=${a.id}-${b.id}`;
|
|
2998
|
+
}
|
|
2999
|
+
if (state.ego) {
|
|
3000
|
+
const d = state.ego.depth;
|
|
3001
|
+
return `#ego=${state.ego.centerId}` + (d ? `&depth=${d}` : '');
|
|
3002
|
+
}
|
|
3003
|
+
if (state.selected && state.selected.type === 'entity') return `#entity=${state.selected.id}`;
|
|
3004
|
+
return '';
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
async function applyViewHash() {
|
|
3008
|
+
const h = location.hash.replace(/^#/, '');
|
|
3009
|
+
if (!h) return false;
|
|
3010
|
+
const params = new URLSearchParams(h);
|
|
3011
|
+
const num = (v) => { const n = Number(v); return Number.isInteger(n) && n > 0 ? n : null; };
|
|
3012
|
+
try {
|
|
3013
|
+
if (params.has('path')) {
|
|
3014
|
+
const m = String(params.get('path')).match(/^(\d+)-(\d+)$/);
|
|
3015
|
+
if (m) {
|
|
3016
|
+
const r = await api(`/api/graph/paths?from=${m[1]}&to=${m[2]}`);
|
|
3017
|
+
if (r.found && r.paths && r.paths.length) {
|
|
3018
|
+
renderPathPanel(r);
|
|
3019
|
+
highlightCanvasPath(0);
|
|
3020
|
+
toast('已按链接打开路径视图(第1条)');
|
|
3021
|
+
return true;
|
|
3022
|
+
}
|
|
3023
|
+
renderPathPanel(r);
|
|
3024
|
+
toast('链接中的两实体之间无连通路径', true);
|
|
3025
|
+
}
|
|
3026
|
+
} else if (params.has('ego')) {
|
|
3027
|
+
const id = num(params.get('ego'));
|
|
3028
|
+
if (id && state.entities.find((e) => e.id === id)) {
|
|
3029
|
+
const dep = num(params.get('depth'));
|
|
3030
|
+
state.ego = { centerId: id, depth: dep };
|
|
3031
|
+
if (state.subGraph) { state.subGraph = null; updateSubBar(); }
|
|
3032
|
+
updateEgoBar();
|
|
3033
|
+
rebuildGraph();
|
|
3034
|
+
focusEntity(id);
|
|
3035
|
+
toast('已按链接打开中心视图');
|
|
3036
|
+
return true;
|
|
3037
|
+
}
|
|
3038
|
+
} else if (params.has('entity')) {
|
|
3039
|
+
const id = num(params.get('entity'));
|
|
3040
|
+
if (id && state.entities.find((e) => e.id === id)) { focusEntity(id); return true; }
|
|
3041
|
+
}
|
|
3042
|
+
} catch (e) { toast('深链视图打开失败: ' + e.message, true); }
|
|
3043
|
+
return false;
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
window.addEventListener('hashchange', () => { applyViewHash(); });
|
|
3047
|
+
|
|
3048
|
+
// 视图菜单:复制视图链接(确定性:布局初始坐标由实体id决定,他人打开看到同一构图)
|
|
3049
|
+
$('btn-share-link').addEventListener('click', async () => {
|
|
3050
|
+
const h = buildViewHash();
|
|
3051
|
+
if (!h) return toast('当前没有可分享的视图状态(先选中实体/进入中心模式/高亮路径)', true);
|
|
3052
|
+
const url = location.origin + location.pathname + h;
|
|
3053
|
+
try { await navigator.clipboard.writeText(url); } catch (_) { copyText(url); }
|
|
3054
|
+
toast('视图链接已复制:' + h);
|
|
3055
|
+
});
|
|
3056
|
+
|
|
2432
3057
|
(async function boot() {
|
|
2433
3058
|
resize();
|
|
2434
3059
|
try {
|
|
2435
3060
|
const meta = await api('/api/meta');
|
|
2436
3061
|
fillCategorySelects(meta);
|
|
2437
3062
|
await refreshAll();
|
|
3063
|
+
// URL 深链:首次加载后按 hash 恢复视图(确定性布局保证两端构图一致)
|
|
3064
|
+
applyViewHash();
|
|
2438
3065
|
} catch (e) {
|
|
2439
3066
|
toast('后端连接失败: ' + e.message, true);
|
|
2440
3067
|
}
|