local-knowledge-graph 1.6.1 → 1.8.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 +12 -0
- package/bin/cli.js +9 -0
- package/lib/agent.js +4 -4
- package/lib/ask.js +59 -1
- package/lib/bus.js +18 -0
- package/lib/db.js +204 -9
- package/lib/embeddings.js +8 -1
- package/lib/ingest.js +355 -0
- package/lib/rdf.js +5 -0
- package/lib/similar.js +81 -0
- package/lib/validator.js +26 -2
- package/mcp/core.js +258 -0
- package/mcp/http.js +82 -0
- package/mcp/server.js +14 -223
- package/package.json +3 -2
- package/public/app.js +435 -28
- package/public/index.html +85 -1
- package/public/style.css +29 -0
- package/server.js +184 -1
package/public/app.js
CHANGED
|
@@ -13,9 +13,38 @@ const state = {
|
|
|
13
13
|
imageCounts: new Map(), // entityId -> 图片数量
|
|
14
14
|
entityImages: new Map(), // entityId -> [图片行]
|
|
15
15
|
meta: null, // /api/meta 缓存(图例与下拉框用)
|
|
16
|
+
aliases: {}, // entityId -> [别名](/api/graph 附带)
|
|
17
|
+
confFilter: '', // 关系置信度过滤:''=全部 | 确证 | 推测 | 存疑
|
|
18
|
+
pathHi: null, // 画布路径高亮 { nodes:Set, rels:Set }
|
|
19
|
+
lastAsk: null, // 最近一次智能提问响应(证据路径高亮用)
|
|
20
|
+
ingViewId: null, // 文档入图:当前审核任务id
|
|
21
|
+
ingSel: null, // 文档入图:审核勾选状态
|
|
16
22
|
};
|
|
17
23
|
|
|
24
|
+
// 置信度三档的展示色
|
|
25
|
+
const CONF_STYLE = { '确证': '#7ee787', '推测': '#e0a768', '存疑': '#8b949e' };
|
|
26
|
+
function confBadge(r) {
|
|
27
|
+
const c = r.confidence || '确证';
|
|
28
|
+
const tip = r.source_ref ? ` title="来源:${escapeHtml(r.source_ref)}"` : '';
|
|
29
|
+
return `<span class="conf-badge" style="color:${CONF_STYLE[c]};border-color:${CONF_STYLE[c]}66"${tip}>${c}</span>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
const $ = (id) => document.getElementById(id);
|
|
33
|
+
async function copyText(text) {
|
|
34
|
+
try {
|
|
35
|
+
await navigator.clipboard.writeText(text);
|
|
36
|
+
toast('已复制到剪贴板');
|
|
37
|
+
} catch (_) {
|
|
38
|
+
const ta = document.createElement('textarea');
|
|
39
|
+
ta.value = text;
|
|
40
|
+
ta.style.position = 'fixed';
|
|
41
|
+
ta.style.opacity = '0';
|
|
42
|
+
document.body.appendChild(ta);
|
|
43
|
+
ta.select();
|
|
44
|
+
try { document.execCommand('copy'); toast('已复制到剪贴板'); } catch (e) { toast('复制失败,请手动选择复制', true); }
|
|
45
|
+
ta.remove();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
19
48
|
function toast(msg, isErr) {
|
|
20
49
|
const t = $('toast');
|
|
21
50
|
t.textContent = msg;
|
|
@@ -256,7 +285,9 @@ function rebuildGraph() {
|
|
|
256
285
|
// 中心层级模式:仅构建子图;全图模式:构建全部
|
|
257
286
|
const sub = state.ego ? calcEgo(state.ego.centerId, state.ego.depth) : null;
|
|
258
287
|
const ents = sub ? sub.entities : state.entities;
|
|
259
|
-
const
|
|
288
|
+
const allRels = sub ? sub.relations : state.relations;
|
|
289
|
+
const rels = allRels.filter((r) => !state.confFilter || (r.confidence || '确证') === state.confFilter);
|
|
290
|
+
state.pathHi = null;
|
|
260
291
|
|
|
261
292
|
state.entityMap.clear();
|
|
262
293
|
const N = ents.length;
|
|
@@ -311,13 +342,17 @@ function rebuildGraph() {
|
|
|
311
342
|
const b = simNodes.find((n) => n.id === r.target_id);
|
|
312
343
|
if (!a || !b) return;
|
|
313
344
|
const st = RELATION_STYLE[r.category] || { color: 0x999999, dashed: false };
|
|
345
|
+
const conf = r.confidence || '确证';
|
|
346
|
+
const baseOp = st.opacity === undefined ? 0.9 : st.opacity;
|
|
347
|
+
const op = conf === '存疑' ? Math.min(baseOp, 0.35) : baseOp; // 存疑降不透明度
|
|
348
|
+
const dashed = st.dashed || conf === '推测'; // 推测强制虚线
|
|
314
349
|
const geo = new THREE.BufferGeometry().setFromPoints([a.pos, b.pos]);
|
|
315
|
-
const
|
|
316
|
-
const mat = st.dashed
|
|
350
|
+
const mat = dashed
|
|
317
351
|
? new THREE.LineDashedMaterial({ color: st.color, dashSize: st.dashSize || 6, gapSize: st.gapSize || 4, transparent: true, opacity: op })
|
|
318
352
|
: new THREE.LineBasicMaterial({ color: st.color, transparent: true, opacity: op });
|
|
319
353
|
const line = new THREE.Line(geo, mat);
|
|
320
354
|
line.userData.relationId = r.id;
|
|
355
|
+
line.userData.baseOpacity = op;
|
|
321
356
|
linkGroup.add(line);
|
|
322
357
|
const mid = a.pos.clone().add(b.pos).multiplyScalar(0.5);
|
|
323
358
|
// 线标注显示具体关系名(如"父子"),线型/颜色仍由大类规定
|
|
@@ -325,7 +360,7 @@ function rebuildGraph() {
|
|
|
325
360
|
lbl.userData.text = r.name;
|
|
326
361
|
lbl.position.copy(mid);
|
|
327
362
|
labelGroup.add(lbl);
|
|
328
|
-
simLinks.push({ id: r.id, a, b, line, label: lbl, dashed:
|
|
363
|
+
simLinks.push({ id: r.id, a, b, line, label: lbl, dashed, confidence: conf });
|
|
329
364
|
});
|
|
330
365
|
|
|
331
366
|
// 推理关系叠加:虚化虚线 + "(推)"标注,负数id与库中显式关系区分;仅显示两端均在当前视图的边
|
|
@@ -520,19 +555,42 @@ function renderInfoCard() {
|
|
|
520
555
|
(rows.length ? '' : '<span class="kv">加载中…</span>') + '</div></div>';
|
|
521
556
|
}
|
|
522
557
|
const levelHtml = m.level !== null && m.level !== undefined ? `<div class="kv">层级: <b style="color:${levelColor(m.level)}">L${m.level}</b>${m.level === 0 ? '(中心)' : ''}</div>` : '';
|
|
558
|
+
// 别名区
|
|
559
|
+
const als = state.aliases[e.id] || [];
|
|
560
|
+
const aliasHtml = `<div class="kv"><b>别名</b>:${als.length
|
|
561
|
+
? als.map((a) => `<span class="alias-chip">${escapeHtml(a)}<i onclick="delAlias(${e.id},'${escapeHtml(a).replace(/'/g, "\\'")}')">×</i></span>`).join('')
|
|
562
|
+
: '(无)'} <input id="alias-new" placeholder="加别名" style="width:88px"><button class="ghost" onclick="addAlias(${e.id})">添</button></div>`;
|
|
563
|
+
// 同名实体互链
|
|
564
|
+
const twins = state.entities.filter((x) => x.name === e.name && x.id !== e.id);
|
|
565
|
+
const twinHtml = twins.length ? `<div class="kv">同名实体:${twins.map((t) => `<span style="color:#7fd1ff;cursor:pointer" onclick="focusEntity(${t.id})">#${t.id}</span>`).join('、')}</div>` : '';
|
|
566
|
+
// 关系预览(带置信度)
|
|
567
|
+
const myRels = relsInScope.filter((r) => r.source_id === e.id || r.target_id === e.id).slice(0, 12);
|
|
568
|
+
const relNameOf = (id) => { const mm = state.entityMap.get(id); return mm ? escapeHtml(mm.entity.name) : '#' + id; };
|
|
569
|
+
const relPreview = myRels.length
|
|
570
|
+
? `<div class="kv" style="margin-top:4px"><b>关系明细</b></div>` + myRels.map((r) => {
|
|
571
|
+
const dir = r.source_id === e.id;
|
|
572
|
+
const other = dir ? r.target_id : r.source_id;
|
|
573
|
+
return `<div class="kv" style="padding-left:6px">${dir ? '' : relNameOf(other) + ' ←'}「${escapeHtml(r.name)}」${dir ? '→ ' + relNameOf(other) : ''} ${confBadge(r)}</div>`;
|
|
574
|
+
}).join('')
|
|
575
|
+
: '';
|
|
523
576
|
card.innerHTML = `
|
|
524
577
|
<h4>${escapeHtml(e.name)} <span class="tag" style="color:${ENTITY_STYLE[e.category].css};border-color:${ENTITY_STYLE[e.category].css}55">${e.category} · ${ENTITY_STYLE[e.category].shape}</span></h4>
|
|
525
578
|
<div class="kv">id: ${e.id} 来源: ${e.source}</div>
|
|
526
579
|
<div class="kv">创建: ${e.created_at}</div>
|
|
527
580
|
${levelHtml}
|
|
581
|
+
${aliasHtml}
|
|
582
|
+
${twinHtml}
|
|
528
583
|
<div class="kv">关联关系: ${relCount} 条</div>
|
|
529
584
|
${attrHtml || '<div class="kv">(无属性)</div>'}
|
|
585
|
+
${relPreview}
|
|
530
586
|
${imgHtml}
|
|
531
587
|
<div class="btns">
|
|
532
588
|
<button onclick="focusEgo(${e.id})">以此为中心</button>
|
|
533
589
|
<button onclick="askPath(${e.id}, '${escapeHtml(e.name).replace(/'/g, "\\'")}')">查路径</button>
|
|
590
|
+
<button onclick="loadSimilar(${e.id})">相似实体</button>
|
|
534
591
|
<button onclick="$('entity-img-input').click()">绑图片</button>
|
|
535
592
|
</div>
|
|
593
|
+
<div id="similar-box"></div>
|
|
536
594
|
<div class="btns"><button onclick="editEntity(${e.id})">编辑</button><button class="danger" onclick="delEntity(${e.id})">删除</button></div>`;
|
|
537
595
|
card.style.display = 'block';
|
|
538
596
|
if (imgCount > 0 && !imgs) loadEntityImages(e.id);
|
|
@@ -566,10 +624,15 @@ function renderInfoCard() {
|
|
|
566
624
|
if (!r) { card.style.display = 'none'; return; }
|
|
567
625
|
const s = state.entityMap.get(r.source_id), t = state.entityMap.get(r.target_id);
|
|
568
626
|
card.innerHTML = `
|
|
569
|
-
<h4>${escapeHtml(r.name)} <span class="tag" style="color:${RELATION_STYLE[r.category].css};border-color:${RELATION_STYLE[r.category].css}55">${r.category}关系</span
|
|
627
|
+
<h4>${escapeHtml(r.name)} <span class="tag" style="color:${RELATION_STYLE[r.category].css};border-color:${RELATION_STYLE[r.category].css}55">${r.category}关系</span> ${confBadge(r)}</h4>
|
|
570
628
|
<div class="kv"><b>${s ? escapeHtml(s.entity.name) : '?'}</b> --> <b>${t ? escapeHtml(t.entity.name) : '?'}</b></div>
|
|
571
629
|
<div class="kv">id: ${r.id} 来源: ${r.source}</div>
|
|
572
|
-
<div class="
|
|
630
|
+
<div class="kv"><b>置信度</b>:
|
|
631
|
+
<select id="rel-conf" style="font-size:11px">
|
|
632
|
+
${['确证', '推测', '存疑'].map((c) => `<option value="${c}"${(r.confidence || '确证') === c ? ' selected' : ''}>${c}</option>`).join('')}
|
|
633
|
+
</select></div>
|
|
634
|
+
<div class="kv"><b>来源引用</b>:<input id="rel-sref" value="${escapeHtml(r.source_ref || '')}" placeholder="URL/文献+页码" style="width:150px"></div>
|
|
635
|
+
<div class="btns"><button onclick="saveRelMeta(${r.id})">保存标注</button><button class="danger" onclick="delRelation(${r.id})">删除</button></div>`;
|
|
573
636
|
card.style.display = 'block';
|
|
574
637
|
}
|
|
575
638
|
}
|
|
@@ -830,7 +893,9 @@ function renderLegend() {
|
|
|
830
893
|
$('legend').innerHTML = '<b>实体样式</b><br>' +
|
|
831
894
|
meta.entity_categories.map((c) => `<span class="sw" style="background:${ENTITY_STYLE[c].css}"></span>${c} · ${ENTITY_STYLE[c].shape}`).join('<br>') +
|
|
832
895
|
'<br><b>关系线型</b><br>' +
|
|
833
|
-
meta.relation_categories.map((c) => `<span class="ln ${RELATION_STYLE[c].dashed ? 'dash' : ''}" style="border-color:${RELATION_STYLE[c].css}"></span>${c}关系`).join('<br>')
|
|
896
|
+
meta.relation_categories.map((c) => `<span class="ln ${RELATION_STYLE[c].dashed ? 'dash' : ''}" style="border-color:${RELATION_STYLE[c].css}"></span>${c}关系`).join('<br>') +
|
|
897
|
+
'<br><b>置信度</b><br>' +
|
|
898
|
+
['确证', '推测', '存疑'].map((c) => `<span class="sw" style="background:${CONF_STYLE[c]}"></span>${c}${c === '推测' ? '(虚线)' : c === '存疑' ? '(淡化)' : ''}`).join('<br>');
|
|
834
899
|
}
|
|
835
900
|
|
|
836
901
|
function refreshEntityOptions() {
|
|
@@ -911,16 +976,72 @@ async function submitRelation() {
|
|
|
911
976
|
target_id: Number($('r-target').value),
|
|
912
977
|
name: $('r-name').value.trim(),
|
|
913
978
|
category: $('r-category').value,
|
|
979
|
+
confidence: $('r-confidence').value,
|
|
980
|
+
source_ref: $('r-source-ref').value.trim(),
|
|
914
981
|
};
|
|
915
982
|
if (!body.name) return toast('请输入关系名称', true);
|
|
916
983
|
if (!body.source_id || !body.target_id) return toast('请先创建实体', true);
|
|
917
984
|
await api('/api/relations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
918
985
|
$('r-name').value = '';
|
|
986
|
+
$('r-source-ref').value = '';
|
|
919
987
|
toast('关系已添加');
|
|
920
988
|
await refreshAll();
|
|
921
989
|
} catch (e) { toast(e.message, true); }
|
|
922
990
|
}
|
|
923
991
|
|
|
992
|
+
// 保存关系标注(置信度+来源引用)
|
|
993
|
+
async function saveRelMeta(id) {
|
|
994
|
+
try {
|
|
995
|
+
await api(`/api/relations/${id}`, {
|
|
996
|
+
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
997
|
+
body: JSON.stringify({ confidence: $('rel-conf').value, source_ref: $('rel-sref').value.trim() }),
|
|
998
|
+
});
|
|
999
|
+
toast('标注已保存');
|
|
1000
|
+
await refreshAll();
|
|
1001
|
+
} catch (e) { toast(e.message, true); }
|
|
1002
|
+
}
|
|
1003
|
+
window.saveRelMeta = saveRelMeta;
|
|
1004
|
+
|
|
1005
|
+
// 别名增删
|
|
1006
|
+
async function addAlias(entityId) {
|
|
1007
|
+
const inp = $('alias-new');
|
|
1008
|
+
const alias = inp ? inp.value.trim() : '';
|
|
1009
|
+
if (!alias) return toast('请输入别名', true);
|
|
1010
|
+
try {
|
|
1011
|
+
await api('/api/aliases', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entity_id: entityId, alias }) });
|
|
1012
|
+
toast('别名已添加');
|
|
1013
|
+
await refreshAll();
|
|
1014
|
+
} catch (e) { toast(e.message, true); }
|
|
1015
|
+
}
|
|
1016
|
+
window.addAlias = addAlias;
|
|
1017
|
+
|
|
1018
|
+
async function delAlias(entityId, alias) {
|
|
1019
|
+
try {
|
|
1020
|
+
const records = await api('/api/aliases/records');
|
|
1021
|
+
const hit = records.find((x) => x.entity_id === entityId && x.alias === alias);
|
|
1022
|
+
if (!hit) throw new Error('别名不存在或已删除');
|
|
1023
|
+
await api(`/api/aliases/${hit.id}`, { method: 'DELETE' });
|
|
1024
|
+
toast('别名已删除');
|
|
1025
|
+
await refreshAll();
|
|
1026
|
+
} catch (e) { toast(e.message, true); }
|
|
1027
|
+
}
|
|
1028
|
+
window.delAlias = delAlias;
|
|
1029
|
+
|
|
1030
|
+
// 相似实体推荐
|
|
1031
|
+
async function loadSimilar(id) {
|
|
1032
|
+
const box = $('similar-box');
|
|
1033
|
+
if (!box) return;
|
|
1034
|
+
box.innerHTML = '<div class="kv">相似度计算中…</div>';
|
|
1035
|
+
try {
|
|
1036
|
+
const r = await api(`/api/similar/${id}`);
|
|
1037
|
+
if (!r.results.length) { box.innerHTML = '<div class="kv">暂无相似实体(可先构建全量向量提升效果)</div>'; return; }
|
|
1038
|
+
const nameOf = (e2) => { const mm = state.entityMap.get(e2.id); return mm ? escapeHtml(mm.entity.name) : '#' + e2.id; };
|
|
1039
|
+
box.innerHTML = `<div class="kv"><b>相似实体</b> <span class="tag">${r.mode === 'semantic' ? '语义' : '结构'}</span></div>` +
|
|
1040
|
+
r.results.map((x) => `<div class="kv" style="cursor:pointer;padding-left:6px" onclick="focusEntity(${x.entity.id})">${nameOf(x.entity)} <span style="color:#8fa3c0">${(x.score * 100).toFixed(1)}%</span></div>`).join('');
|
|
1041
|
+
} catch (e) { box.innerHTML = `<div class="kv" style="color:#e0a768">${escapeHtml(e.message)}</div>`; }
|
|
1042
|
+
}
|
|
1043
|
+
window.loadSimilar = loadSimilar;
|
|
1044
|
+
|
|
924
1045
|
async function delRelation(id) {
|
|
925
1046
|
if (!confirm(`删除关系 #${id}?`)) return;
|
|
926
1047
|
try {
|
|
@@ -948,19 +1069,30 @@ function renderEntityList() {
|
|
|
948
1069
|
}
|
|
949
1070
|
|
|
950
1071
|
function renderRelationList() {
|
|
951
|
-
|
|
1072
|
+
const shown = state.relations.filter((r) => !state.confFilter || (r.confidence || '确证') === state.confFilter);
|
|
1073
|
+
$('r-list').innerHTML = shown.map((r) => {
|
|
952
1074
|
const s = state.entityMap.get(r.source_id), t = state.entityMap.get(r.target_id);
|
|
953
1075
|
return `
|
|
954
1076
|
<div class="list-item">
|
|
955
1077
|
<div class="main">
|
|
956
|
-
<div class="name">${escapeHtml(r.name)}<span class="tag" style="color:${RELATION_STYLE[r.category].css};border-color:${RELATION_STYLE[r.category].css}55">${r.category}</span
|
|
957
|
-
<div class="sub">#${r.id} · ${s ? escapeHtml(s.entity.name) : '?'} → ${t ? escapeHtml(t.entity.name) : '?'} · ${r.source}</div>
|
|
1078
|
+
<div class="name">${escapeHtml(r.name)}<span class="tag" style="color:${RELATION_STYLE[r.category].css};border-color:${RELATION_STYLE[r.category].css}55">${r.category}</span>${confBadge(r)}</div>
|
|
1079
|
+
<div class="sub">#${r.id} · ${s ? escapeHtml(s.entity.name) : '?'} → ${t ? escapeHtml(t.entity.name) : '?'} · ${r.source}${r.source_ref ? ' · ' + escapeHtml(r.source_ref) : ''}</div>
|
|
958
1080
|
</div>
|
|
959
1081
|
<button class="danger" onclick="delRelation(${r.id})">删</button>
|
|
960
1082
|
</div>`;
|
|
961
|
-
}).join('') || '<div class="sub" style="color:#5c6f92"
|
|
1083
|
+
}).join('') || '<div class="sub" style="color:#5c6f92">' + (state.confFilter ? `暂无「${state.confFilter}」关系` : '暂无关系') + '</div>';
|
|
962
1084
|
}
|
|
963
1085
|
|
|
1086
|
+
// 置信度过滤条(事件委托)
|
|
1087
|
+
document.addEventListener('click', (e) => {
|
|
1088
|
+
const chip = e.target.closest && e.target.closest('.cf-chip');
|
|
1089
|
+
if (!chip) return;
|
|
1090
|
+
state.confFilter = chip.dataset.c || '';
|
|
1091
|
+
document.querySelectorAll('.cf-chip').forEach((x) => x.classList.toggle('active', x === chip));
|
|
1092
|
+
renderRelationList();
|
|
1093
|
+
rebuildGraph();
|
|
1094
|
+
});
|
|
1095
|
+
|
|
964
1096
|
/* 日志人话渲染:op_type + snapshot JSON → 可读中文;已删实体名称回退#id */
|
|
965
1097
|
const OP_LABELS = {
|
|
966
1098
|
ADD_ENTITY: '新增实体', UPDATE_ENTITY: '更新实体', DELETE_ENTITY: '删除实体',
|
|
@@ -1110,6 +1242,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
1110
1242
|
}
|
|
1111
1243
|
if ($('style-panel').classList.contains('show')) { $('style-panel').classList.remove('show'); return; }
|
|
1112
1244
|
if ($('file-menu').classList.contains('show')) { $('file-menu').classList.remove('show'); return; }
|
|
1245
|
+
if (state.pathHi) { clearCanvasHi(); return; }
|
|
1113
1246
|
if (state.selected) { state.selected = null; renderInfoCard(); return; }
|
|
1114
1247
|
if (state.ego) exitEgo();
|
|
1115
1248
|
return;
|
|
@@ -1121,39 +1254,218 @@ document.addEventListener('keydown', (e) => {
|
|
|
1121
1254
|
}
|
|
1122
1255
|
});
|
|
1123
1256
|
|
|
1124
|
-
/* =================
|
|
1257
|
+
/* ================= 路径查询(多路径枚举)与画布高亮 ================= */
|
|
1125
1258
|
function renderPathPanel(r) {
|
|
1126
1259
|
const panel = $('path-panel');
|
|
1127
|
-
|
|
1128
|
-
|
|
1260
|
+
// 兼容旧单路径格式
|
|
1261
|
+
if (!r.paths) {
|
|
1262
|
+
if (!r.found) {
|
|
1263
|
+
$('path-body').innerHTML = '<div class="kv">两实体间在6层内无连通路径</div>';
|
|
1264
|
+
panel.style.display = 'block';
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
r = { found: true, paths: [{ hops: r.hops, entities: r.entities, relations: r.relations }] };
|
|
1268
|
+
}
|
|
1269
|
+
if (!r.found || !r.paths.length) {
|
|
1270
|
+
$('path-body').innerHTML = `<div class="kv">${escapeHtml(r.hint || '两实体间无连通路径')}</div>`;
|
|
1129
1271
|
panel.style.display = 'block';
|
|
1130
1272
|
return;
|
|
1131
1273
|
}
|
|
1132
|
-
const
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1274
|
+
const nameOf = (ent) => escapeHtml(ent ? ent.name : '#' + ent);
|
|
1275
|
+
let html = '';
|
|
1276
|
+
r.paths.forEach((p, pi) => {
|
|
1277
|
+
const rows = [];
|
|
1278
|
+
p.entities.forEach((ent, i) => {
|
|
1279
|
+
if (i > 0) {
|
|
1280
|
+
const rel = p.relations[i - 1];
|
|
1281
|
+
const conf = rel.confidence || '确证';
|
|
1282
|
+
const dir = rel.source_id === p.entities[i - 1].id ? '→' : '←';
|
|
1283
|
+
rows.push(`<div class="p-rel">—${dir} ${escapeHtml(rel.name)} <span style="color:${CONF_STYLE[conf]}">${conf}</span> ${dir === '→' ? '→' : '—'}—</div>`);
|
|
1284
|
+
}
|
|
1285
|
+
rows.push(`<div class="p-ent" onclick="focusEntity(${ent.id})">${nameOf(ent)}<span class="tag">${escapeHtml(ent.category)}</span></div>`);
|
|
1286
|
+
});
|
|
1287
|
+
html += `<div class="p-path"><div class="p-head2">路径${r.paths.length > 1 ? pi + 1 : ''}(${p.hops} 跳)<button class="ghost" onclick="highlightCanvasPath(${pi})">画布高亮</button></div>${rows.join('')}</div>`;
|
|
1140
1288
|
});
|
|
1141
|
-
|
|
1142
|
-
$('path-
|
|
1289
|
+
state._lastPaths = r.paths;
|
|
1290
|
+
$('path-title').textContent = `关系路径(共${r.paths.length}条)`;
|
|
1291
|
+
$('path-body').innerHTML = html;
|
|
1143
1292
|
panel.style.display = 'block';
|
|
1144
1293
|
}
|
|
1294
|
+
|
|
1295
|
+
// 画布路径高亮:路径元素保持原样,其余整体降为微透明
|
|
1296
|
+
function applyCanvasHi(nodes, rels) {
|
|
1297
|
+
state.pathHi = { nodes, rels };
|
|
1298
|
+
for (const n of simNodes) {
|
|
1299
|
+
const on = nodes.has(n.id);
|
|
1300
|
+
n.mesh.material.transparent = true;
|
|
1301
|
+
n.mesh.material.opacity = on ? 1 : 0.06;
|
|
1302
|
+
if (n.label) n.label.material.opacity = on ? 1 : 0.08;
|
|
1303
|
+
}
|
|
1304
|
+
for (const l of simLinks) {
|
|
1305
|
+
const base = l.line.userData.baseOpacity === undefined ? 0.9 : l.line.userData.baseOpacity;
|
|
1306
|
+
const on = rels.has(l.id);
|
|
1307
|
+
l.line.material.opacity = on ? Math.max(base, 0.95) : 0.05;
|
|
1308
|
+
if (l.label) l.label.material.opacity = on ? 1 : 0.06;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
function clearCanvasHi() {
|
|
1313
|
+
if (!state.pathHi) return;
|
|
1314
|
+
state.pathHi = null;
|
|
1315
|
+
for (const n of simNodes) {
|
|
1316
|
+
n.mesh.material.opacity = 1;
|
|
1317
|
+
if (n.label) n.label.material.opacity = 1;
|
|
1318
|
+
}
|
|
1319
|
+
for (const l of simLinks) {
|
|
1320
|
+
l.line.material.opacity = l.line.userData.baseOpacity === undefined ? 0.9 : l.line.userData.baseOpacity;
|
|
1321
|
+
if (l.label) l.label.material.opacity = 1;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// 路径面板/证据路径共用:按 路径对象 或 hops 数组高亮
|
|
1326
|
+
function highlightCanvasPath(pi) {
|
|
1327
|
+
const p = state._lastPaths && state._lastPaths[pi];
|
|
1328
|
+
if (!p) return;
|
|
1329
|
+
applyCanvasHi(new Set(p.entities.map((e) => e.id)), new Set(p.relations.map((x) => x.id)));
|
|
1330
|
+
}
|
|
1331
|
+
window.highlightCanvasPath = highlightCanvasPath;
|
|
1332
|
+
|
|
1145
1333
|
async function askPath(fromId, fromName) {
|
|
1146
|
-
const to = prompt(`查询「${fromName}
|
|
1334
|
+
const to = prompt(`查询「${fromName}」到哪位实体的关系路径?(输入名称或id,2-6层,最多返回5条)`, '');
|
|
1147
1335
|
if (to === null) return;
|
|
1148
1336
|
const key = to.trim();
|
|
1149
1337
|
if (!key) return;
|
|
1150
1338
|
try {
|
|
1151
|
-
const r = await api(`/api/graph/
|
|
1339
|
+
const r = await api(`/api/graph/paths?from=${fromId}&to=${encodeURIComponent(key)}`);
|
|
1152
1340
|
renderPathPanel(r);
|
|
1153
1341
|
} catch (e) { toast(e.message, true); }
|
|
1154
1342
|
}
|
|
1155
1343
|
window.askPath = askPath;
|
|
1156
|
-
$('path-close').addEventListener('click', () => { $('path-panel').style.display = 'none'; });
|
|
1344
|
+
$('path-close').addEventListener('click', () => { $('path-panel').style.display = 'none'; clearCanvasHi(); });
|
|
1345
|
+
|
|
1346
|
+
/* ================= 文档批量入图 ================= */
|
|
1347
|
+
async function loadIngestTasks() {
|
|
1348
|
+
try {
|
|
1349
|
+
const ts = await api('/api/ingest/tasks');
|
|
1350
|
+
if (state.ingViewId) {
|
|
1351
|
+
const t = ts.find((x) => x.id === state.ingViewId);
|
|
1352
|
+
if (t && (t.status === 'extracting' || t.status === 'parsing')) {
|
|
1353
|
+
$('ing-tasks').innerHTML = `<div class="kv">任务 ${escapeHtml(t.display_name)} 抽取中…(${t.chunks_total || '?'} 片段)</div>`;
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
if (t) { ingView(t.id); return; }
|
|
1357
|
+
state.ingViewId = null;
|
|
1358
|
+
}
|
|
1359
|
+
$('ing-tasks').innerHTML = ts.map((t) => {
|
|
1360
|
+
const st = t.status === 'review' ? '<span style="color:#7ee787">待审核</span>'
|
|
1361
|
+
: t.status === 'committed' ? '<span style="color:#7fd1ff">已入库</span>'
|
|
1362
|
+
: t.status === 'failed' ? `<span style="color:#f0883e">失败:${escapeHtml(t.error || '未知')}</span>`
|
|
1363
|
+
: t.status === 'interrupted' ? '<span style="color:#f0883e">已中断</span>'
|
|
1364
|
+
: '<span style="color:#e0a768">抽取中…</span>';
|
|
1365
|
+
const acts = [];
|
|
1366
|
+
if (t.status === 'review') acts.push(`<button onclick="ingView('${t.id}')">审核</button>`);
|
|
1367
|
+
if (t.status === 'committed') acts.push(`<span class="tag">实体+${t.entity_count} 关系+${t.relation_count}</span>`);
|
|
1368
|
+
if (t.status !== 'extracting' && t.status !== 'parsing') acts.push(`<button class="danger" onclick="ingDelete('${t.id}')">删</button>`);
|
|
1369
|
+
return `<div class="list-item"><div class="main"><div class="name">${escapeHtml(t.display_name)}</div>
|
|
1370
|
+
<div class="sub">${st} · 实体${t.entity_count}/关系${t.relation_count}${t.failed_chunks ? ` · 失败片段${t.failed_chunks}` : ''}</div></div>${acts.join('')}</div>`;
|
|
1371
|
+
}).join('') || '<div class="sub" style="color:#5c6f92">暂无任务</div>';
|
|
1372
|
+
} catch (_) { /* 服务暂不可用时静默 */ }
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
async function ingView(id) {
|
|
1376
|
+
state.ingViewId = id;
|
|
1377
|
+
const t = await api('/api/ingest/' + id);
|
|
1378
|
+
if (t.status !== 'review') { state.ingViewId = null; loadIngestTasks(); return; }
|
|
1379
|
+
state.ingSel = {
|
|
1380
|
+
entities: new Set(t.candidates.entities.filter((c) => c.selected && !c.dupe_of_candidate).map((c) => c.name)),
|
|
1381
|
+
relations: new Set(t.candidates.relations.filter((r) => r.selected && !r.unresolved).map((r) => r.from + '|' + r.name + '|' + r.to)),
|
|
1382
|
+
};
|
|
1383
|
+
const catCss = (c) => (ENTITY_STYLE[c] ? ENTITY_STYLE[c].css : '#ccc');
|
|
1384
|
+
const entRows = t.candidates.entities.map((c) => {
|
|
1385
|
+
if (c.dupe_of_candidate) return `<div class="kv" style="color:#5c6f92">· ${escapeHtml(c.name)}(候选内部重复,跳过)</div>`;
|
|
1386
|
+
const on = state.ingSel.entities.has(c.name);
|
|
1387
|
+
const match = c.existing_id !== null && c.existing_id !== undefined ? `<span class="tag" style="color:#7ee787;border-color:#7ee78755">并入已有#${c.existing_id}</span>` : '<span class="tag">新建</span>';
|
|
1388
|
+
return `<label class="list-item" style="cursor:pointer"><input type="checkbox" ${on ? 'checked' : ''} onchange="ingToggle('e','${escapeHtml(c.name).replace(/'/g, "\\'")}',this.checked)">
|
|
1389
|
+
<div class="main"><div class="name">${escapeHtml(c.name)}<span class="tag" style="color:${catCss(c.category)};border-color:${catCss(c.category)}55">${c.category}</span>${match}</div>
|
|
1390
|
+
<div class="sub">${Object.keys(c.attributes || {}).length}属性${c.aliases.length ? ' · 别名:' + escapeHtml(c.aliases.join('/')) : ''}</div></div></label>`;
|
|
1391
|
+
}).join('');
|
|
1392
|
+
const relRows = t.candidates.relations.map((r) => {
|
|
1393
|
+
const key = r.from + '|' + r.name + '|' + r.to;
|
|
1394
|
+
if (r.unresolved) return `<div class="kv" style="color:#5c6f92">· ${escapeHtml(r.from)} —${escapeHtml(r.name)}→ ${escapeHtml(r.to)}(端点缺失,跳过)</div>`;
|
|
1395
|
+
const on = state.ingSel.relations.has(key);
|
|
1396
|
+
return `<label class="list-item" style="cursor:pointer"><input type="checkbox" ${on ? 'checked' : ''} onchange="ingToggle('r','${escapeHtml(key).replace(/'/g, "\\'")}',this.checked)">
|
|
1397
|
+
<div class="main"><div class="name">${escapeHtml(r.from)} —${escapeHtml(r.name)}→ ${escapeHtml(r.to)}<span class="tag" style="color:${CONF_STYLE[r.confidence]};border-color:${CONF_STYLE[r.confidence]}66">${r.confidence}</span></div>
|
|
1398
|
+
<div class="sub">${r.category} · ${escapeHtml(r.source_ref || '')}</div></div></label>`;
|
|
1399
|
+
}).join('');
|
|
1400
|
+
$('ing-tasks').innerHTML = `
|
|
1401
|
+
<div class="kv"><b>审核:${escapeHtml(t.display_name)}</b>(${t.chunks_total}片段${t.failed_chunks.length ? ',失败' + t.failed_chunks.length : ''})</div>
|
|
1402
|
+
<div class="ask-sec">实体候选(${t.candidates.entities.length})</div>${entRows || '<div class="kv">无</div>'}
|
|
1403
|
+
<div class="ask-sec">关系候选(${t.candidates.relations.length})</div>${relRows || '<div class="kv">无</div>'}
|
|
1404
|
+
<div class="row" style="margin-top:8px">
|
|
1405
|
+
<button class="primary" onclick="ingCommit('${t.id}')">确认入库(先打保存点)</button>
|
|
1406
|
+
<button class="ghost" onclick="ingBack()">返回</button>
|
|
1407
|
+
</div>`;
|
|
1408
|
+
}
|
|
1409
|
+
function ingToggle(kind, key, on) {
|
|
1410
|
+
if (!state.ingSel) return;
|
|
1411
|
+
const set = kind === 'e' ? state.ingSel.entities : state.ingSel.relations;
|
|
1412
|
+
if (on) set.add(key); else set.delete(key);
|
|
1413
|
+
}
|
|
1414
|
+
function ingBack() { state.ingViewId = null; loadIngestTasks(); }
|
|
1415
|
+
async function ingCommit(id) {
|
|
1416
|
+
if (!state.ingSel) return;
|
|
1417
|
+
try {
|
|
1418
|
+
const r = await api(`/api/ingest/${id}/commit`, {
|
|
1419
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1420
|
+
body: JSON.stringify({ selected: { entities: [...state.ingSel.entities], relations: [...state.ingSel.relations] } }),
|
|
1421
|
+
});
|
|
1422
|
+
toast(`已入库:实体+${r.entities_added} 关系+${r.relations_added}${r.relations_skipped ? '(跳过' + r.relations_skipped + ')' : ''},保存点已创建`);
|
|
1423
|
+
state.ingViewId = null;
|
|
1424
|
+
loadIngestTasks();
|
|
1425
|
+
await refreshAll();
|
|
1426
|
+
} catch (e) { toast(e.message, true); }
|
|
1427
|
+
}
|
|
1428
|
+
async function ingDelete(id) {
|
|
1429
|
+
if (!confirm('删除该任务记录?(已入库数据不受影响)')) return;
|
|
1430
|
+
try { await api('/api/ingest/' + id, { method: 'DELETE' }); loadIngestTasks(); } catch (e) { toast(e.message, true); }
|
|
1431
|
+
}
|
|
1432
|
+
$('ing-btn').addEventListener('click', () => $('ing-file').click());
|
|
1433
|
+
$('ing-auto').checked = localStorage.getItem('ing_auto_commit') === '1';
|
|
1434
|
+
$('ing-auto').addEventListener('change', () => { localStorage.setItem('ing_auto_commit', $('ing-auto').checked ? '1' : '0'); });
|
|
1435
|
+
$('ing-file').addEventListener('change', async () => {
|
|
1436
|
+
const f = $('ing-file').files[0];
|
|
1437
|
+
if (!f) return;
|
|
1438
|
+
$('ing-file').value = '';
|
|
1439
|
+
if (f.size > 10 * 1024 * 1024) return toast('文件超过10MB上限', true);
|
|
1440
|
+
$('ing-status').textContent = '上传中…';
|
|
1441
|
+
try {
|
|
1442
|
+
const b64 = await new Promise((res, rej) => {
|
|
1443
|
+
const rd = new FileReader();
|
|
1444
|
+
rd.onload = () => res(String(rd.result).split(',')[1]);
|
|
1445
|
+
rd.onerror = () => rej(new Error('读取失败'));
|
|
1446
|
+
rd.readAsDataURL(f);
|
|
1447
|
+
});
|
|
1448
|
+
const autoCommit = $('ing-auto').checked;
|
|
1449
|
+
const r = await api('/api/ingest', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: f.name, content_b64: b64, auto_commit: autoCommit }) });
|
|
1450
|
+
$('ing-status').textContent = `任务已创建(${r.id}),抽取中…`;
|
|
1451
|
+
state.ingViewId = null;
|
|
1452
|
+
const poll = setInterval(async () => {
|
|
1453
|
+
const t = await api('/api/ingest/' + r.id).catch(() => null);
|
|
1454
|
+
if (!t) { clearInterval(poll); return; }
|
|
1455
|
+
if (t.status === 'review') { clearInterval(poll); if (autoCommit) { $('ing-status').textContent = '自动入库中…'; try { await api('/api/ingest/' + r.id + '/commit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); $('ing-status').textContent = '已自动入库'; await refreshAll(); } catch (e) { toast(e.message, true); } loadIngestTasks(); } else { $('ing-status').textContent = '抽取完成,请审核'; loadIngestTasks(); } }
|
|
1456
|
+
else if (t.status === 'failed') { clearInterval(poll); $('ing-status').textContent = '失败:' + (t.error || ''); loadIngestTasks(); }
|
|
1457
|
+
else if (t.status === 'committed') { clearInterval(poll); $('ing-status').textContent = '已直接入库'; loadIngestTasks(); await refreshAll(); }
|
|
1458
|
+
}, 3000);
|
|
1459
|
+
} catch (e) {
|
|
1460
|
+
$('ing-status').textContent = '';
|
|
1461
|
+
toast(e.message, true);
|
|
1462
|
+
}
|
|
1463
|
+
});
|
|
1464
|
+
window.ingView = ingView;
|
|
1465
|
+
window.ingToggle = ingToggle;
|
|
1466
|
+
window.ingCommit = ingCommit;
|
|
1467
|
+
window.ingDelete = ingDelete;
|
|
1468
|
+
window.ingBack = ingBack;
|
|
1157
1469
|
|
|
1158
1470
|
/* ================= OpenCode 对话 ================= */
|
|
1159
1471
|
function appendMsg(role, text, chips, chipWarn) {
|
|
@@ -1582,6 +1894,7 @@ async function refreshAll(rebuild = true) {
|
|
|
1582
1894
|
const [graph, meta] = await Promise.all([api('/api/graph'), api('/api/meta')]);
|
|
1583
1895
|
state.entities = graph.entities;
|
|
1584
1896
|
state.relations = graph.relations;
|
|
1897
|
+
state.aliases = graph.aliases || {};
|
|
1585
1898
|
state.version = meta.version;
|
|
1586
1899
|
state.imageCounts = new Map((graph.image_counts || []).map((x) => [Number(x.entity_id), x.count]));
|
|
1587
1900
|
// 推理开关开启时同步刷新推理缓存,保证叠加边与新数据一致
|
|
@@ -1594,6 +1907,7 @@ async function refreshAll(rebuild = true) {
|
|
|
1594
1907
|
updateEgoBar();
|
|
1595
1908
|
}
|
|
1596
1909
|
$('stat-badge').textContent = `实体 ${meta.counts.entities} / 关系 ${meta.counts.relations} / 日志 ${meta.counts.logs}`;
|
|
1910
|
+
loadIngestTasks();
|
|
1597
1911
|
const badge = $('agent-badge');
|
|
1598
1912
|
if (meta.agent_available) { badge.textContent = 'OpenCode 已就绪'; badge.className = 'badge ok'; }
|
|
1599
1913
|
else { badge.textContent = 'OpenCode 未安装'; badge.className = 'badge off'; }
|
|
@@ -1636,6 +1950,7 @@ async function askSubmit() {
|
|
|
1636
1950
|
}
|
|
1637
1951
|
|
|
1638
1952
|
function renderAskResult(r) {
|
|
1953
|
+
state.lastAsk = r;
|
|
1639
1954
|
const box = $('a-result');
|
|
1640
1955
|
const chips = r.steps.map((s) => {
|
|
1641
1956
|
const icon = s.status === 'ok' ? '✔' : s.status === 'timeout' ? '⏱' : '✘';
|
|
@@ -1664,9 +1979,33 @@ function renderAskResult(r) {
|
|
|
1664
1979
|
if (r.synthesis) synth = `<div class="ask-synth">${renderSynthesis(r.synthesis, r.entities)}</div>`;
|
|
1665
1980
|
else if (r.synth_error) synth = `<div class="kv" style="color:#e0a768">综述生成失败:${escapeHtml(r.synth_error)}</div>`;
|
|
1666
1981
|
|
|
1667
|
-
|
|
1982
|
+
// 证据路径:A —关系→ B 链条,可一键画布高亮
|
|
1983
|
+
let ev = '';
|
|
1984
|
+
if (r.evidence_paths && r.evidence_paths.length) {
|
|
1985
|
+
const ename = (id) => { const e = r.entities.find((x) => x.id === id); return e ? escapeHtml(e.name) : '#' + id; };
|
|
1986
|
+
const cname = (c) => `<span style="color:${CONF_STYLE[c] || '#8fa3c0'}">${c}</span>`;
|
|
1987
|
+
ev = `<div class="ask-sec">证据路径(${r.evidence_paths.length})</div>` + r.evidence_paths.map((p, pi) => {
|
|
1988
|
+
const chain = p.hops.map((h) => `${ename(h.source_id)} —${escapeHtml(h.name)}${h.confidence && h.confidence !== '确证' ? '(' + cname(h.confidence) + ')' : ''}→ ${ename(h.target_id)}`).join(' ⇒ ');
|
|
1989
|
+
return `<div class="kv ask-path-row">⛓ ${chain} <button class="ghost" onclick="highlightEvidencePath(${pi})">高亮</button></div>`;
|
|
1990
|
+
}).join('');
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
box.innerHTML = `<div class="ask-steps">${chips}</div>${synth}${ev}<div class="ask-sec">实体(${r.entities.length})</div>${ents}${cn}`;
|
|
1668
1994
|
}
|
|
1669
1995
|
|
|
1996
|
+
// 证据路径一键高亮:以最近一次提问结果中的 hops 构建高亮集
|
|
1997
|
+
function highlightEvidencePath(pi) {
|
|
1998
|
+
const r = state.lastAsk;
|
|
1999
|
+
if (!r || !r.evidence_paths || !r.evidence_paths[pi]) return;
|
|
2000
|
+
const p = r.evidence_paths[pi];
|
|
2001
|
+
const nodes = new Set([p.hops[0].source_id, p.hops[p.hops.length - 1].target_id]);
|
|
2002
|
+
const rels = new Set();
|
|
2003
|
+
for (const h of p.hops) { rels.add(h.id); nodes.add(h.source_id); nodes.add(h.target_id); }
|
|
2004
|
+
applyCanvasHi(nodes, rels);
|
|
2005
|
+
toast('已在画布高亮该证据路径');
|
|
2006
|
+
}
|
|
2007
|
+
window.highlightEvidencePath = highlightEvidencePath;
|
|
2008
|
+
|
|
1670
2009
|
// 综述文本中的「名称#id」渲染为可点击引用
|
|
1671
2010
|
function renderSynthesis(text, entities) {
|
|
1672
2011
|
const ids = new Set(entities.map((e) => e.id));
|
|
@@ -1702,6 +2041,74 @@ $('a-synth').addEventListener('change', () => {
|
|
|
1702
2041
|
api('/api/ask/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ synthesis: $('a-synth').checked }) }).catch(() => {});
|
|
1703
2042
|
});
|
|
1704
2043
|
|
|
2044
|
+
/* ================= MCP 外部接入 ================= */
|
|
2045
|
+
let mcpState = { enabled: false, readonly: false, token: '', endpoint: '/mcp' };
|
|
2046
|
+
|
|
2047
|
+
function mcpSnippets() {
|
|
2048
|
+
const origin = location.origin;
|
|
2049
|
+
const http = JSON.stringify({ mcpServers: { 'local-knowledge-graph': { url: `${origin}/mcp?token=${mcpState.token}` } } }, null, 2);
|
|
2050
|
+
const desktop = JSON.stringify({ mcpServers: { 'local-knowledge-graph': { url: `${origin}/mcp`, headers: { Authorization: `Bearer ${mcpState.token}` } } } }, null, 2);
|
|
2051
|
+
const stdio = JSON.stringify({ mcpServers: { 'local-knowledge-graph': { command: 'npx', args: ['-y', 'local-knowledge-graph', '--mcp'] } } }, null, 2);
|
|
2052
|
+
$('mcp-snippet-http').textContent = http;
|
|
2053
|
+
$('mcp-snippet-desktop').textContent = desktop;
|
|
2054
|
+
$('mcp-snippet-stdio').textContent = stdio;
|
|
2055
|
+
$('mcp-endpoint').textContent = `${origin}${mcpState.endpoint}`;
|
|
2056
|
+
$('mcp-token').textContent = mcpState.token;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
function renderMcp() {
|
|
2060
|
+
$('mcp-toggle').checked = mcpState.enabled;
|
|
2061
|
+
$('mcp-readonly').checked = mcpState.readonly;
|
|
2062
|
+
$('mcp-cfg').style.display = mcpState.enabled ? '' : 'none';
|
|
2063
|
+
$('mcp-status').textContent = mcpState.enabled
|
|
2064
|
+
? (mcpState.readonly ? '状态:已启用(只读)— 外部 Agent 仅可查询' : '状态:已启用(读写)— 外部 Agent 可查询与写入')
|
|
2065
|
+
: '状态:已停用 — /mcp 端点关闭';
|
|
2066
|
+
if (mcpState.enabled) mcpSnippets();
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
async function loadMcp() {
|
|
2070
|
+
try {
|
|
2071
|
+
mcpState = await api('/api/mcp/settings');
|
|
2072
|
+
renderMcp();
|
|
2073
|
+
} catch (_) { /* 服务未就绪时静默 */ }
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
async function saveMcp(patch) {
|
|
2077
|
+
try {
|
|
2078
|
+
mcpState = await api('/api/mcp/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) });
|
|
2079
|
+
renderMcp();
|
|
2080
|
+
} catch (e) { toast(e.message, true); }
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
$('mcp-toggle').addEventListener('change', () => saveMcp({ enabled: $('mcp-toggle').checked }));
|
|
2084
|
+
$('mcp-readonly').addEventListener('change', () => saveMcp({ readonly: $('mcp-readonly').checked }));
|
|
2085
|
+
$('mcp-regen').addEventListener('click', async () => {
|
|
2086
|
+
try {
|
|
2087
|
+
const r = await api('/api/mcp/token/regen', { method: 'POST' });
|
|
2088
|
+
mcpState.token = r.token;
|
|
2089
|
+
renderMcp();
|
|
2090
|
+
toast('令牌已重新生成,旧令牌立即失效');
|
|
2091
|
+
} catch (e) { toast(e.message, true); }
|
|
2092
|
+
});
|
|
2093
|
+
$('mcp-copy-token').addEventListener('click', () => copyText(mcpState.token));
|
|
2094
|
+
$('mcp-copy-http').addEventListener('click', () => copyText($('mcp-snippet-http').textContent));
|
|
2095
|
+
$('mcp-copy-desktop').addEventListener('click', () => copyText($('mcp-snippet-desktop').textContent));
|
|
2096
|
+
$('mcp-copy-stdio').addEventListener('click', () => copyText($('mcp-snippet-stdio').textContent));
|
|
2097
|
+
loadMcp();
|
|
2098
|
+
|
|
2099
|
+
/* ================= 实时同步:SSE + 外部变更提示 ================= */
|
|
2100
|
+
let syncTimer = null;
|
|
2101
|
+
let lastSyncToast = 0;
|
|
2102
|
+
try {
|
|
2103
|
+
const es = new EventSource('/api/events');
|
|
2104
|
+
es.addEventListener('graph-changed', () => {
|
|
2105
|
+
clearTimeout(syncTimer);
|
|
2106
|
+
syncTimer = setTimeout(() => { refreshAll().catch(() => {}); }, 800);
|
|
2107
|
+
const now = Date.now();
|
|
2108
|
+
if (now - lastSyncToast > 60000) { lastSyncToast = now; toast('图谱已被外部更新,已自动同步'); }
|
|
2109
|
+
});
|
|
2110
|
+
} catch (_) { /* 浏览器不支持时依赖手动刷新 */ }
|
|
2111
|
+
|
|
1705
2112
|
$('s-save').addEventListener('click', async () => {
|
|
1706
2113
|
const patch = { model: $('s-model').value.trim() || 'BAAI/bge-m3' };
|
|
1707
2114
|
const key = $('s-key').value.trim();
|