local-knowledge-graph 1.6.0 → 1.7.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 +2 -0
- package/lib/agent.js +4 -4
- package/lib/ask.js +59 -1
- package/lib/db.js +204 -9
- package/lib/embeddings.js +5 -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/server.js +18 -0
- package/package.json +3 -2
- package/public/app.js +367 -33
- package/public/index.html +30 -1
- package/public/style.css +28 -5
- package/server.js +95 -1
package/public/app.js
CHANGED
|
@@ -13,8 +13,22 @@ 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);
|
|
19
33
|
function toast(msg, isErr) {
|
|
20
34
|
const t = $('toast');
|
|
@@ -121,20 +135,25 @@ let UI_THEME = 'dark';
|
|
|
121
135
|
try { if (localStorage.getItem(UI_THEME_KEY) === 'light') UI_THEME = 'light'; } catch (_) {}
|
|
122
136
|
document.documentElement.dataset.theme = UI_THEME;
|
|
123
137
|
|
|
124
|
-
function
|
|
138
|
+
function applyUiTheme(mode) {
|
|
125
139
|
UI_THEME = UI_THEMES[mode] ? mode : 'dark';
|
|
126
140
|
document.documentElement.dataset.theme = UI_THEME;
|
|
127
141
|
try { localStorage.setItem(UI_THEME_KEY, UI_THEME); } catch (_) {}
|
|
128
142
|
if (typeof scene !== 'undefined' && typeof grid !== 'undefined' && grid) {
|
|
129
143
|
const t = UI_THEMES[UI_THEME];
|
|
130
144
|
scene.remove(grid);
|
|
131
|
-
grid.dispose();
|
|
145
|
+
if (typeof grid.dispose === 'function') grid.dispose(); // three r128 GridHelper 无 dispose
|
|
132
146
|
grid = new THREE.GridHelper(480, 48, t.grid1, t.grid2);
|
|
133
147
|
grid.position.y = -60;
|
|
134
148
|
scene.add(grid);
|
|
135
149
|
}
|
|
136
150
|
}
|
|
137
151
|
|
|
152
|
+
/* 启动期兜底:任何未捕获异常立刻弹出提示,避免画布空白却无感知 */
|
|
153
|
+
window.addEventListener('error', (e) => {
|
|
154
|
+
try { toast('脚本异常: ' + (e.message || '未知错误'), 6000); } catch (_) {}
|
|
155
|
+
});
|
|
156
|
+
|
|
138
157
|
/* ================= Three.js 场景 ================= */
|
|
139
158
|
const wrap = $('canvas-wrap');
|
|
140
159
|
const scene = new THREE.Scene();
|
|
@@ -156,7 +175,10 @@ scene.add(dirLight);
|
|
|
156
175
|
let grid = new THREE.GridHelper(480, 48, 0x1c2a47, 0x141e35);
|
|
157
176
|
grid.position.y = -60;
|
|
158
177
|
scene.add(grid);
|
|
159
|
-
|
|
178
|
+
applyUiTheme(UI_THEME); // 按存储的主题重建网格(CSS背景经 data-theme 生效)
|
|
179
|
+
|
|
180
|
+
const savedTheme = loadTheme();
|
|
181
|
+
if (savedTheme && applyTheme(savedTheme)) { /* 启动时恢复用户保存的3D样式主题 */ }
|
|
160
182
|
|
|
161
183
|
let nodeGroup = new THREE.Group();
|
|
162
184
|
let linkGroup = new THREE.Group();
|
|
@@ -248,7 +270,9 @@ function rebuildGraph() {
|
|
|
248
270
|
// 中心层级模式:仅构建子图;全图模式:构建全部
|
|
249
271
|
const sub = state.ego ? calcEgo(state.ego.centerId, state.ego.depth) : null;
|
|
250
272
|
const ents = sub ? sub.entities : state.entities;
|
|
251
|
-
const
|
|
273
|
+
const allRels = sub ? sub.relations : state.relations;
|
|
274
|
+
const rels = allRels.filter((r) => !state.confFilter || (r.confidence || '确证') === state.confFilter);
|
|
275
|
+
state.pathHi = null;
|
|
252
276
|
|
|
253
277
|
state.entityMap.clear();
|
|
254
278
|
const N = ents.length;
|
|
@@ -303,13 +327,17 @@ function rebuildGraph() {
|
|
|
303
327
|
const b = simNodes.find((n) => n.id === r.target_id);
|
|
304
328
|
if (!a || !b) return;
|
|
305
329
|
const st = RELATION_STYLE[r.category] || { color: 0x999999, dashed: false };
|
|
330
|
+
const conf = r.confidence || '确证';
|
|
331
|
+
const baseOp = st.opacity === undefined ? 0.9 : st.opacity;
|
|
332
|
+
const op = conf === '存疑' ? Math.min(baseOp, 0.35) : baseOp; // 存疑降不透明度
|
|
333
|
+
const dashed = st.dashed || conf === '推测'; // 推测强制虚线
|
|
306
334
|
const geo = new THREE.BufferGeometry().setFromPoints([a.pos, b.pos]);
|
|
307
|
-
const
|
|
308
|
-
const mat = st.dashed
|
|
335
|
+
const mat = dashed
|
|
309
336
|
? new THREE.LineDashedMaterial({ color: st.color, dashSize: st.dashSize || 6, gapSize: st.gapSize || 4, transparent: true, opacity: op })
|
|
310
337
|
: new THREE.LineBasicMaterial({ color: st.color, transparent: true, opacity: op });
|
|
311
338
|
const line = new THREE.Line(geo, mat);
|
|
312
339
|
line.userData.relationId = r.id;
|
|
340
|
+
line.userData.baseOpacity = op;
|
|
313
341
|
linkGroup.add(line);
|
|
314
342
|
const mid = a.pos.clone().add(b.pos).multiplyScalar(0.5);
|
|
315
343
|
// 线标注显示具体关系名(如"父子"),线型/颜色仍由大类规定
|
|
@@ -317,7 +345,7 @@ function rebuildGraph() {
|
|
|
317
345
|
lbl.userData.text = r.name;
|
|
318
346
|
lbl.position.copy(mid);
|
|
319
347
|
labelGroup.add(lbl);
|
|
320
|
-
simLinks.push({ id: r.id, a, b, line, label: lbl, dashed:
|
|
348
|
+
simLinks.push({ id: r.id, a, b, line, label: lbl, dashed, confidence: conf });
|
|
321
349
|
});
|
|
322
350
|
|
|
323
351
|
// 推理关系叠加:虚化虚线 + "(推)"标注,负数id与库中显式关系区分;仅显示两端均在当前视图的边
|
|
@@ -512,19 +540,42 @@ function renderInfoCard() {
|
|
|
512
540
|
(rows.length ? '' : '<span class="kv">加载中…</span>') + '</div></div>';
|
|
513
541
|
}
|
|
514
542
|
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>` : '';
|
|
543
|
+
// 别名区
|
|
544
|
+
const als = state.aliases[e.id] || [];
|
|
545
|
+
const aliasHtml = `<div class="kv"><b>别名</b>:${als.length
|
|
546
|
+
? als.map((a) => `<span class="alias-chip">${escapeHtml(a)}<i onclick="delAlias(${e.id},'${escapeHtml(a).replace(/'/g, "\\'")}')">×</i></span>`).join('')
|
|
547
|
+
: '(无)'} <input id="alias-new" placeholder="加别名" style="width:88px"><button class="ghost" onclick="addAlias(${e.id})">添</button></div>`;
|
|
548
|
+
// 同名实体互链
|
|
549
|
+
const twins = state.entities.filter((x) => x.name === e.name && x.id !== e.id);
|
|
550
|
+
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>` : '';
|
|
551
|
+
// 关系预览(带置信度)
|
|
552
|
+
const myRels = relsInScope.filter((r) => r.source_id === e.id || r.target_id === e.id).slice(0, 12);
|
|
553
|
+
const relNameOf = (id) => { const mm = state.entityMap.get(id); return mm ? escapeHtml(mm.entity.name) : '#' + id; };
|
|
554
|
+
const relPreview = myRels.length
|
|
555
|
+
? `<div class="kv" style="margin-top:4px"><b>关系明细</b></div>` + myRels.map((r) => {
|
|
556
|
+
const dir = r.source_id === e.id;
|
|
557
|
+
const other = dir ? r.target_id : r.source_id;
|
|
558
|
+
return `<div class="kv" style="padding-left:6px">${dir ? '' : relNameOf(other) + ' ←'}「${escapeHtml(r.name)}」${dir ? '→ ' + relNameOf(other) : ''} ${confBadge(r)}</div>`;
|
|
559
|
+
}).join('')
|
|
560
|
+
: '';
|
|
515
561
|
card.innerHTML = `
|
|
516
562
|
<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>
|
|
517
563
|
<div class="kv">id: ${e.id} 来源: ${e.source}</div>
|
|
518
564
|
<div class="kv">创建: ${e.created_at}</div>
|
|
519
565
|
${levelHtml}
|
|
566
|
+
${aliasHtml}
|
|
567
|
+
${twinHtml}
|
|
520
568
|
<div class="kv">关联关系: ${relCount} 条</div>
|
|
521
569
|
${attrHtml || '<div class="kv">(无属性)</div>'}
|
|
570
|
+
${relPreview}
|
|
522
571
|
${imgHtml}
|
|
523
572
|
<div class="btns">
|
|
524
573
|
<button onclick="focusEgo(${e.id})">以此为中心</button>
|
|
525
574
|
<button onclick="askPath(${e.id}, '${escapeHtml(e.name).replace(/'/g, "\\'")}')">查路径</button>
|
|
575
|
+
<button onclick="loadSimilar(${e.id})">相似实体</button>
|
|
526
576
|
<button onclick="$('entity-img-input').click()">绑图片</button>
|
|
527
577
|
</div>
|
|
578
|
+
<div id="similar-box"></div>
|
|
528
579
|
<div class="btns"><button onclick="editEntity(${e.id})">编辑</button><button class="danger" onclick="delEntity(${e.id})">删除</button></div>`;
|
|
529
580
|
card.style.display = 'block';
|
|
530
581
|
if (imgCount > 0 && !imgs) loadEntityImages(e.id);
|
|
@@ -558,10 +609,15 @@ function renderInfoCard() {
|
|
|
558
609
|
if (!r) { card.style.display = 'none'; return; }
|
|
559
610
|
const s = state.entityMap.get(r.source_id), t = state.entityMap.get(r.target_id);
|
|
560
611
|
card.innerHTML = `
|
|
561
|
-
<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
|
|
612
|
+
<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>
|
|
562
613
|
<div class="kv"><b>${s ? escapeHtml(s.entity.name) : '?'}</b> --> <b>${t ? escapeHtml(t.entity.name) : '?'}</b></div>
|
|
563
614
|
<div class="kv">id: ${r.id} 来源: ${r.source}</div>
|
|
564
|
-
<div class="
|
|
615
|
+
<div class="kv"><b>置信度</b>:
|
|
616
|
+
<select id="rel-conf" style="font-size:11px">
|
|
617
|
+
${['确证', '推测', '存疑'].map((c) => `<option value="${c}"${(r.confidence || '确证') === c ? ' selected' : ''}>${c}</option>`).join('')}
|
|
618
|
+
</select></div>
|
|
619
|
+
<div class="kv"><b>来源引用</b>:<input id="rel-sref" value="${escapeHtml(r.source_ref || '')}" placeholder="URL/文献+页码" style="width:150px"></div>
|
|
620
|
+
<div class="btns"><button onclick="saveRelMeta(${r.id})">保存标注</button><button class="danger" onclick="delRelation(${r.id})">删除</button></div>`;
|
|
565
621
|
card.style.display = 'block';
|
|
566
622
|
}
|
|
567
623
|
}
|
|
@@ -822,7 +878,9 @@ function renderLegend() {
|
|
|
822
878
|
$('legend').innerHTML = '<b>实体样式</b><br>' +
|
|
823
879
|
meta.entity_categories.map((c) => `<span class="sw" style="background:${ENTITY_STYLE[c].css}"></span>${c} · ${ENTITY_STYLE[c].shape}`).join('<br>') +
|
|
824
880
|
'<br><b>关系线型</b><br>' +
|
|
825
|
-
meta.relation_categories.map((c) => `<span class="ln ${RELATION_STYLE[c].dashed ? 'dash' : ''}" style="border-color:${RELATION_STYLE[c].css}"></span>${c}关系`).join('<br>')
|
|
881
|
+
meta.relation_categories.map((c) => `<span class="ln ${RELATION_STYLE[c].dashed ? 'dash' : ''}" style="border-color:${RELATION_STYLE[c].css}"></span>${c}关系`).join('<br>') +
|
|
882
|
+
'<br><b>置信度</b><br>' +
|
|
883
|
+
['确证', '推测', '存疑'].map((c) => `<span class="sw" style="background:${CONF_STYLE[c]}"></span>${c}${c === '推测' ? '(虚线)' : c === '存疑' ? '(淡化)' : ''}`).join('<br>');
|
|
826
884
|
}
|
|
827
885
|
|
|
828
886
|
function refreshEntityOptions() {
|
|
@@ -903,16 +961,72 @@ async function submitRelation() {
|
|
|
903
961
|
target_id: Number($('r-target').value),
|
|
904
962
|
name: $('r-name').value.trim(),
|
|
905
963
|
category: $('r-category').value,
|
|
964
|
+
confidence: $('r-confidence').value,
|
|
965
|
+
source_ref: $('r-source-ref').value.trim(),
|
|
906
966
|
};
|
|
907
967
|
if (!body.name) return toast('请输入关系名称', true);
|
|
908
968
|
if (!body.source_id || !body.target_id) return toast('请先创建实体', true);
|
|
909
969
|
await api('/api/relations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
910
970
|
$('r-name').value = '';
|
|
971
|
+
$('r-source-ref').value = '';
|
|
911
972
|
toast('关系已添加');
|
|
912
973
|
await refreshAll();
|
|
913
974
|
} catch (e) { toast(e.message, true); }
|
|
914
975
|
}
|
|
915
976
|
|
|
977
|
+
// 保存关系标注(置信度+来源引用)
|
|
978
|
+
async function saveRelMeta(id) {
|
|
979
|
+
try {
|
|
980
|
+
await api(`/api/relations/${id}`, {
|
|
981
|
+
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
982
|
+
body: JSON.stringify({ confidence: $('rel-conf').value, source_ref: $('rel-sref').value.trim() }),
|
|
983
|
+
});
|
|
984
|
+
toast('标注已保存');
|
|
985
|
+
await refreshAll();
|
|
986
|
+
} catch (e) { toast(e.message, true); }
|
|
987
|
+
}
|
|
988
|
+
window.saveRelMeta = saveRelMeta;
|
|
989
|
+
|
|
990
|
+
// 别名增删
|
|
991
|
+
async function addAlias(entityId) {
|
|
992
|
+
const inp = $('alias-new');
|
|
993
|
+
const alias = inp ? inp.value.trim() : '';
|
|
994
|
+
if (!alias) return toast('请输入别名', true);
|
|
995
|
+
try {
|
|
996
|
+
await api('/api/aliases', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entity_id: entityId, alias }) });
|
|
997
|
+
toast('别名已添加');
|
|
998
|
+
await refreshAll();
|
|
999
|
+
} catch (e) { toast(e.message, true); }
|
|
1000
|
+
}
|
|
1001
|
+
window.addAlias = addAlias;
|
|
1002
|
+
|
|
1003
|
+
async function delAlias(entityId, alias) {
|
|
1004
|
+
try {
|
|
1005
|
+
const records = await api('/api/aliases/records');
|
|
1006
|
+
const hit = records.find((x) => x.entity_id === entityId && x.alias === alias);
|
|
1007
|
+
if (!hit) throw new Error('别名不存在或已删除');
|
|
1008
|
+
await api(`/api/aliases/${hit.id}`, { method: 'DELETE' });
|
|
1009
|
+
toast('别名已删除');
|
|
1010
|
+
await refreshAll();
|
|
1011
|
+
} catch (e) { toast(e.message, true); }
|
|
1012
|
+
}
|
|
1013
|
+
window.delAlias = delAlias;
|
|
1014
|
+
|
|
1015
|
+
// 相似实体推荐
|
|
1016
|
+
async function loadSimilar(id) {
|
|
1017
|
+
const box = $('similar-box');
|
|
1018
|
+
if (!box) return;
|
|
1019
|
+
box.innerHTML = '<div class="kv">相似度计算中…</div>';
|
|
1020
|
+
try {
|
|
1021
|
+
const r = await api(`/api/similar/${id}`);
|
|
1022
|
+
if (!r.results.length) { box.innerHTML = '<div class="kv">暂无相似实体(可先构建全量向量提升效果)</div>'; return; }
|
|
1023
|
+
const nameOf = (e2) => { const mm = state.entityMap.get(e2.id); return mm ? escapeHtml(mm.entity.name) : '#' + e2.id; };
|
|
1024
|
+
box.innerHTML = `<div class="kv"><b>相似实体</b> <span class="tag">${r.mode === 'semantic' ? '语义' : '结构'}</span></div>` +
|
|
1025
|
+
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('');
|
|
1026
|
+
} catch (e) { box.innerHTML = `<div class="kv" style="color:#e0a768">${escapeHtml(e.message)}</div>`; }
|
|
1027
|
+
}
|
|
1028
|
+
window.loadSimilar = loadSimilar;
|
|
1029
|
+
|
|
916
1030
|
async function delRelation(id) {
|
|
917
1031
|
if (!confirm(`删除关系 #${id}?`)) return;
|
|
918
1032
|
try {
|
|
@@ -940,19 +1054,30 @@ function renderEntityList() {
|
|
|
940
1054
|
}
|
|
941
1055
|
|
|
942
1056
|
function renderRelationList() {
|
|
943
|
-
|
|
1057
|
+
const shown = state.relations.filter((r) => !state.confFilter || (r.confidence || '确证') === state.confFilter);
|
|
1058
|
+
$('r-list').innerHTML = shown.map((r) => {
|
|
944
1059
|
const s = state.entityMap.get(r.source_id), t = state.entityMap.get(r.target_id);
|
|
945
1060
|
return `
|
|
946
1061
|
<div class="list-item">
|
|
947
1062
|
<div class="main">
|
|
948
|
-
<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
|
|
949
|
-
<div class="sub">#${r.id} · ${s ? escapeHtml(s.entity.name) : '?'} → ${t ? escapeHtml(t.entity.name) : '?'} · ${r.source}</div>
|
|
1063
|
+
<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>
|
|
1064
|
+
<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>
|
|
950
1065
|
</div>
|
|
951
1066
|
<button class="danger" onclick="delRelation(${r.id})">删</button>
|
|
952
1067
|
</div>`;
|
|
953
|
-
}).join('') || '<div class="sub" style="color:#5c6f92"
|
|
1068
|
+
}).join('') || '<div class="sub" style="color:#5c6f92">' + (state.confFilter ? `暂无「${state.confFilter}」关系` : '暂无关系') + '</div>';
|
|
954
1069
|
}
|
|
955
1070
|
|
|
1071
|
+
// 置信度过滤条(事件委托)
|
|
1072
|
+
document.addEventListener('click', (e) => {
|
|
1073
|
+
const chip = e.target.closest && e.target.closest('.cf-chip');
|
|
1074
|
+
if (!chip) return;
|
|
1075
|
+
state.confFilter = chip.dataset.c || '';
|
|
1076
|
+
document.querySelectorAll('.cf-chip').forEach((x) => x.classList.toggle('active', x === chip));
|
|
1077
|
+
renderRelationList();
|
|
1078
|
+
rebuildGraph();
|
|
1079
|
+
});
|
|
1080
|
+
|
|
956
1081
|
/* 日志人话渲染:op_type + snapshot JSON → 可读中文;已删实体名称回退#id */
|
|
957
1082
|
const OP_LABELS = {
|
|
958
1083
|
ADD_ENTITY: '新增实体', UPDATE_ENTITY: '更新实体', DELETE_ENTITY: '删除实体',
|
|
@@ -1102,6 +1227,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
1102
1227
|
}
|
|
1103
1228
|
if ($('style-panel').classList.contains('show')) { $('style-panel').classList.remove('show'); return; }
|
|
1104
1229
|
if ($('file-menu').classList.contains('show')) { $('file-menu').classList.remove('show'); return; }
|
|
1230
|
+
if (state.pathHi) { clearCanvasHi(); return; }
|
|
1105
1231
|
if (state.selected) { state.selected = null; renderInfoCard(); return; }
|
|
1106
1232
|
if (state.ego) exitEgo();
|
|
1107
1233
|
return;
|
|
@@ -1113,39 +1239,218 @@ document.addEventListener('keydown', (e) => {
|
|
|
1113
1239
|
}
|
|
1114
1240
|
});
|
|
1115
1241
|
|
|
1116
|
-
/* =================
|
|
1242
|
+
/* ================= 路径查询(多路径枚举)与画布高亮 ================= */
|
|
1117
1243
|
function renderPathPanel(r) {
|
|
1118
1244
|
const panel = $('path-panel');
|
|
1119
|
-
|
|
1120
|
-
|
|
1245
|
+
// 兼容旧单路径格式
|
|
1246
|
+
if (!r.paths) {
|
|
1247
|
+
if (!r.found) {
|
|
1248
|
+
$('path-body').innerHTML = '<div class="kv">两实体间在6层内无连通路径</div>';
|
|
1249
|
+
panel.style.display = 'block';
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
r = { found: true, paths: [{ hops: r.hops, entities: r.entities, relations: r.relations }] };
|
|
1253
|
+
}
|
|
1254
|
+
if (!r.found || !r.paths.length) {
|
|
1255
|
+
$('path-body').innerHTML = `<div class="kv">${escapeHtml(r.hint || '两实体间无连通路径')}</div>`;
|
|
1121
1256
|
panel.style.display = 'block';
|
|
1122
1257
|
return;
|
|
1123
1258
|
}
|
|
1124
|
-
const
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1259
|
+
const nameOf = (ent) => escapeHtml(ent ? ent.name : '#' + ent);
|
|
1260
|
+
let html = '';
|
|
1261
|
+
r.paths.forEach((p, pi) => {
|
|
1262
|
+
const rows = [];
|
|
1263
|
+
p.entities.forEach((ent, i) => {
|
|
1264
|
+
if (i > 0) {
|
|
1265
|
+
const rel = p.relations[i - 1];
|
|
1266
|
+
const conf = rel.confidence || '确证';
|
|
1267
|
+
const dir = rel.source_id === p.entities[i - 1].id ? '→' : '←';
|
|
1268
|
+
rows.push(`<div class="p-rel">—${dir} ${escapeHtml(rel.name)} <span style="color:${CONF_STYLE[conf]}">${conf}</span> ${dir === '→' ? '→' : '—'}—</div>`);
|
|
1269
|
+
}
|
|
1270
|
+
rows.push(`<div class="p-ent" onclick="focusEntity(${ent.id})">${nameOf(ent)}<span class="tag">${escapeHtml(ent.category)}</span></div>`);
|
|
1271
|
+
});
|
|
1272
|
+
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>`;
|
|
1132
1273
|
});
|
|
1133
|
-
|
|
1134
|
-
$('path-
|
|
1274
|
+
state._lastPaths = r.paths;
|
|
1275
|
+
$('path-title').textContent = `关系路径(共${r.paths.length}条)`;
|
|
1276
|
+
$('path-body').innerHTML = html;
|
|
1135
1277
|
panel.style.display = 'block';
|
|
1136
1278
|
}
|
|
1279
|
+
|
|
1280
|
+
// 画布路径高亮:路径元素保持原样,其余整体降为微透明
|
|
1281
|
+
function applyCanvasHi(nodes, rels) {
|
|
1282
|
+
state.pathHi = { nodes, rels };
|
|
1283
|
+
for (const n of simNodes) {
|
|
1284
|
+
const on = nodes.has(n.id);
|
|
1285
|
+
n.mesh.material.transparent = true;
|
|
1286
|
+
n.mesh.material.opacity = on ? 1 : 0.06;
|
|
1287
|
+
if (n.label) n.label.material.opacity = on ? 1 : 0.08;
|
|
1288
|
+
}
|
|
1289
|
+
for (const l of simLinks) {
|
|
1290
|
+
const base = l.line.userData.baseOpacity === undefined ? 0.9 : l.line.userData.baseOpacity;
|
|
1291
|
+
const on = rels.has(l.id);
|
|
1292
|
+
l.line.material.opacity = on ? Math.max(base, 0.95) : 0.05;
|
|
1293
|
+
if (l.label) l.label.material.opacity = on ? 1 : 0.06;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function clearCanvasHi() {
|
|
1298
|
+
if (!state.pathHi) return;
|
|
1299
|
+
state.pathHi = null;
|
|
1300
|
+
for (const n of simNodes) {
|
|
1301
|
+
n.mesh.material.opacity = 1;
|
|
1302
|
+
if (n.label) n.label.material.opacity = 1;
|
|
1303
|
+
}
|
|
1304
|
+
for (const l of simLinks) {
|
|
1305
|
+
l.line.material.opacity = l.line.userData.baseOpacity === undefined ? 0.9 : l.line.userData.baseOpacity;
|
|
1306
|
+
if (l.label) l.label.material.opacity = 1;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// 路径面板/证据路径共用:按 路径对象 或 hops 数组高亮
|
|
1311
|
+
function highlightCanvasPath(pi) {
|
|
1312
|
+
const p = state._lastPaths && state._lastPaths[pi];
|
|
1313
|
+
if (!p) return;
|
|
1314
|
+
applyCanvasHi(new Set(p.entities.map((e) => e.id)), new Set(p.relations.map((x) => x.id)));
|
|
1315
|
+
}
|
|
1316
|
+
window.highlightCanvasPath = highlightCanvasPath;
|
|
1317
|
+
|
|
1137
1318
|
async function askPath(fromId, fromName) {
|
|
1138
|
-
const to = prompt(`查询「${fromName}
|
|
1319
|
+
const to = prompt(`查询「${fromName}」到哪位实体的关系路径?(输入名称或id,2-6层,最多返回5条)`, '');
|
|
1139
1320
|
if (to === null) return;
|
|
1140
1321
|
const key = to.trim();
|
|
1141
1322
|
if (!key) return;
|
|
1142
1323
|
try {
|
|
1143
|
-
const r = await api(`/api/graph/
|
|
1324
|
+
const r = await api(`/api/graph/paths?from=${fromId}&to=${encodeURIComponent(key)}`);
|
|
1144
1325
|
renderPathPanel(r);
|
|
1145
1326
|
} catch (e) { toast(e.message, true); }
|
|
1146
1327
|
}
|
|
1147
1328
|
window.askPath = askPath;
|
|
1148
|
-
$('path-close').addEventListener('click', () => { $('path-panel').style.display = 'none'; });
|
|
1329
|
+
$('path-close').addEventListener('click', () => { $('path-panel').style.display = 'none'; clearCanvasHi(); });
|
|
1330
|
+
|
|
1331
|
+
/* ================= 文档批量入图 ================= */
|
|
1332
|
+
async function loadIngestTasks() {
|
|
1333
|
+
try {
|
|
1334
|
+
const ts = await api('/api/ingest/tasks');
|
|
1335
|
+
if (state.ingViewId) {
|
|
1336
|
+
const t = ts.find((x) => x.id === state.ingViewId);
|
|
1337
|
+
if (t && (t.status === 'extracting' || t.status === 'parsing')) {
|
|
1338
|
+
$('ing-tasks').innerHTML = `<div class="kv">任务 ${escapeHtml(t.display_name)} 抽取中…(${t.chunks_total || '?'} 片段)</div>`;
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
if (t) { ingView(t.id); return; }
|
|
1342
|
+
state.ingViewId = null;
|
|
1343
|
+
}
|
|
1344
|
+
$('ing-tasks').innerHTML = ts.map((t) => {
|
|
1345
|
+
const st = t.status === 'review' ? '<span style="color:#7ee787">待审核</span>'
|
|
1346
|
+
: t.status === 'committed' ? '<span style="color:#7fd1ff">已入库</span>'
|
|
1347
|
+
: t.status === 'failed' ? `<span style="color:#f0883e">失败:${escapeHtml(t.error || '未知')}</span>`
|
|
1348
|
+
: t.status === 'interrupted' ? '<span style="color:#f0883e">已中断</span>'
|
|
1349
|
+
: '<span style="color:#e0a768">抽取中…</span>';
|
|
1350
|
+
const acts = [];
|
|
1351
|
+
if (t.status === 'review') acts.push(`<button onclick="ingView('${t.id}')">审核</button>`);
|
|
1352
|
+
if (t.status === 'committed') acts.push(`<span class="tag">实体+${t.entity_count} 关系+${t.relation_count}</span>`);
|
|
1353
|
+
if (t.status !== 'extracting' && t.status !== 'parsing') acts.push(`<button class="danger" onclick="ingDelete('${t.id}')">删</button>`);
|
|
1354
|
+
return `<div class="list-item"><div class="main"><div class="name">${escapeHtml(t.display_name)}</div>
|
|
1355
|
+
<div class="sub">${st} · 实体${t.entity_count}/关系${t.relation_count}${t.failed_chunks ? ` · 失败片段${t.failed_chunks}` : ''}</div></div>${acts.join('')}</div>`;
|
|
1356
|
+
}).join('') || '<div class="sub" style="color:#5c6f92">暂无任务</div>';
|
|
1357
|
+
} catch (_) { /* 服务暂不可用时静默 */ }
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
async function ingView(id) {
|
|
1361
|
+
state.ingViewId = id;
|
|
1362
|
+
const t = await api('/api/ingest/' + id);
|
|
1363
|
+
if (t.status !== 'review') { state.ingViewId = null; loadIngestTasks(); return; }
|
|
1364
|
+
state.ingSel = {
|
|
1365
|
+
entities: new Set(t.candidates.entities.filter((c) => c.selected && !c.dupe_of_candidate).map((c) => c.name)),
|
|
1366
|
+
relations: new Set(t.candidates.relations.filter((r) => r.selected && !r.unresolved).map((r) => r.from + '|' + r.name + '|' + r.to)),
|
|
1367
|
+
};
|
|
1368
|
+
const catCss = (c) => (ENTITY_STYLE[c] ? ENTITY_STYLE[c].css : '#ccc');
|
|
1369
|
+
const entRows = t.candidates.entities.map((c) => {
|
|
1370
|
+
if (c.dupe_of_candidate) return `<div class="kv" style="color:#5c6f92">· ${escapeHtml(c.name)}(候选内部重复,跳过)</div>`;
|
|
1371
|
+
const on = state.ingSel.entities.has(c.name);
|
|
1372
|
+
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>';
|
|
1373
|
+
return `<label class="list-item" style="cursor:pointer"><input type="checkbox" ${on ? 'checked' : ''} onchange="ingToggle('e','${escapeHtml(c.name).replace(/'/g, "\\'")}',this.checked)">
|
|
1374
|
+
<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>
|
|
1375
|
+
<div class="sub">${Object.keys(c.attributes || {}).length}属性${c.aliases.length ? ' · 别名:' + escapeHtml(c.aliases.join('/')) : ''}</div></div></label>`;
|
|
1376
|
+
}).join('');
|
|
1377
|
+
const relRows = t.candidates.relations.map((r) => {
|
|
1378
|
+
const key = r.from + '|' + r.name + '|' + r.to;
|
|
1379
|
+
if (r.unresolved) return `<div class="kv" style="color:#5c6f92">· ${escapeHtml(r.from)} —${escapeHtml(r.name)}→ ${escapeHtml(r.to)}(端点缺失,跳过)</div>`;
|
|
1380
|
+
const on = state.ingSel.relations.has(key);
|
|
1381
|
+
return `<label class="list-item" style="cursor:pointer"><input type="checkbox" ${on ? 'checked' : ''} onchange="ingToggle('r','${escapeHtml(key).replace(/'/g, "\\'")}',this.checked)">
|
|
1382
|
+
<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>
|
|
1383
|
+
<div class="sub">${r.category} · ${escapeHtml(r.source_ref || '')}</div></div></label>`;
|
|
1384
|
+
}).join('');
|
|
1385
|
+
$('ing-tasks').innerHTML = `
|
|
1386
|
+
<div class="kv"><b>审核:${escapeHtml(t.display_name)}</b>(${t.chunks_total}片段${t.failed_chunks.length ? ',失败' + t.failed_chunks.length : ''})</div>
|
|
1387
|
+
<div class="ask-sec">实体候选(${t.candidates.entities.length})</div>${entRows || '<div class="kv">无</div>'}
|
|
1388
|
+
<div class="ask-sec">关系候选(${t.candidates.relations.length})</div>${relRows || '<div class="kv">无</div>'}
|
|
1389
|
+
<div class="row" style="margin-top:8px">
|
|
1390
|
+
<button class="primary" onclick="ingCommit('${t.id}')">确认入库(先打保存点)</button>
|
|
1391
|
+
<button class="ghost" onclick="ingBack()">返回</button>
|
|
1392
|
+
</div>`;
|
|
1393
|
+
}
|
|
1394
|
+
function ingToggle(kind, key, on) {
|
|
1395
|
+
if (!state.ingSel) return;
|
|
1396
|
+
const set = kind === 'e' ? state.ingSel.entities : state.ingSel.relations;
|
|
1397
|
+
if (on) set.add(key); else set.delete(key);
|
|
1398
|
+
}
|
|
1399
|
+
function ingBack() { state.ingViewId = null; loadIngestTasks(); }
|
|
1400
|
+
async function ingCommit(id) {
|
|
1401
|
+
if (!state.ingSel) return;
|
|
1402
|
+
try {
|
|
1403
|
+
const r = await api(`/api/ingest/${id}/commit`, {
|
|
1404
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1405
|
+
body: JSON.stringify({ selected: { entities: [...state.ingSel.entities], relations: [...state.ingSel.relations] } }),
|
|
1406
|
+
});
|
|
1407
|
+
toast(`已入库:实体+${r.entities_added} 关系+${r.relations_added}${r.relations_skipped ? '(跳过' + r.relations_skipped + ')' : ''},保存点已创建`);
|
|
1408
|
+
state.ingViewId = null;
|
|
1409
|
+
loadIngestTasks();
|
|
1410
|
+
await refreshAll();
|
|
1411
|
+
} catch (e) { toast(e.message, true); }
|
|
1412
|
+
}
|
|
1413
|
+
async function ingDelete(id) {
|
|
1414
|
+
if (!confirm('删除该任务记录?(已入库数据不受影响)')) return;
|
|
1415
|
+
try { await api('/api/ingest/' + id, { method: 'DELETE' }); loadIngestTasks(); } catch (e) { toast(e.message, true); }
|
|
1416
|
+
}
|
|
1417
|
+
$('ing-btn').addEventListener('click', () => $('ing-file').click());
|
|
1418
|
+
$('ing-auto').checked = localStorage.getItem('ing_auto_commit') === '1';
|
|
1419
|
+
$('ing-auto').addEventListener('change', () => { localStorage.setItem('ing_auto_commit', $('ing-auto').checked ? '1' : '0'); });
|
|
1420
|
+
$('ing-file').addEventListener('change', async () => {
|
|
1421
|
+
const f = $('ing-file').files[0];
|
|
1422
|
+
if (!f) return;
|
|
1423
|
+
$('ing-file').value = '';
|
|
1424
|
+
if (f.size > 10 * 1024 * 1024) return toast('文件超过10MB上限', true);
|
|
1425
|
+
$('ing-status').textContent = '上传中…';
|
|
1426
|
+
try {
|
|
1427
|
+
const b64 = await new Promise((res, rej) => {
|
|
1428
|
+
const rd = new FileReader();
|
|
1429
|
+
rd.onload = () => res(String(rd.result).split(',')[1]);
|
|
1430
|
+
rd.onerror = () => rej(new Error('读取失败'));
|
|
1431
|
+
rd.readAsDataURL(f);
|
|
1432
|
+
});
|
|
1433
|
+
const autoCommit = $('ing-auto').checked;
|
|
1434
|
+
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 }) });
|
|
1435
|
+
$('ing-status').textContent = `任务已创建(${r.id}),抽取中…`;
|
|
1436
|
+
state.ingViewId = null;
|
|
1437
|
+
const poll = setInterval(async () => {
|
|
1438
|
+
const t = await api('/api/ingest/' + r.id).catch(() => null);
|
|
1439
|
+
if (!t) { clearInterval(poll); return; }
|
|
1440
|
+
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(); } }
|
|
1441
|
+
else if (t.status === 'failed') { clearInterval(poll); $('ing-status').textContent = '失败:' + (t.error || ''); loadIngestTasks(); }
|
|
1442
|
+
else if (t.status === 'committed') { clearInterval(poll); $('ing-status').textContent = '已直接入库'; loadIngestTasks(); await refreshAll(); }
|
|
1443
|
+
}, 3000);
|
|
1444
|
+
} catch (e) {
|
|
1445
|
+
$('ing-status').textContent = '';
|
|
1446
|
+
toast(e.message, true);
|
|
1447
|
+
}
|
|
1448
|
+
});
|
|
1449
|
+
window.ingView = ingView;
|
|
1450
|
+
window.ingToggle = ingToggle;
|
|
1451
|
+
window.ingCommit = ingCommit;
|
|
1452
|
+
window.ingDelete = ingDelete;
|
|
1453
|
+
window.ingBack = ingBack;
|
|
1149
1454
|
|
|
1150
1455
|
/* ================= OpenCode 对话 ================= */
|
|
1151
1456
|
function appendMsg(role, text, chips, chipWarn) {
|
|
@@ -1334,9 +1639,11 @@ function renderStylePanel() {
|
|
|
1334
1639
|
$('style-close').addEventListener('click', () => $('style-panel').classList.remove('show'));
|
|
1335
1640
|
const themeSel = $('ui-theme-select');
|
|
1336
1641
|
themeSel.value = UI_THEME;
|
|
1337
|
-
themeSel.addEventListener('change', () => {
|
|
1642
|
+
themeSel.addEventListener('change', () => { applyUiTheme(themeSel.value); toast(themeSel.value === 'light' ? '已切换浅色主题' : '已切换深色主题'); });
|
|
1338
1643
|
$('style-save').addEventListener('click', () => {
|
|
1339
|
-
|
|
1644
|
+
const t = currentThemeJson();
|
|
1645
|
+
localStorage.setItem(THEME_KEY, JSON.stringify(t));
|
|
1646
|
+
applyTheme(t);
|
|
1340
1647
|
toast('主题已保存到本地');
|
|
1341
1648
|
});
|
|
1342
1649
|
$('style-reset').addEventListener('click', () => {
|
|
@@ -1572,6 +1879,7 @@ async function refreshAll(rebuild = true) {
|
|
|
1572
1879
|
const [graph, meta] = await Promise.all([api('/api/graph'), api('/api/meta')]);
|
|
1573
1880
|
state.entities = graph.entities;
|
|
1574
1881
|
state.relations = graph.relations;
|
|
1882
|
+
state.aliases = graph.aliases || {};
|
|
1575
1883
|
state.version = meta.version;
|
|
1576
1884
|
state.imageCounts = new Map((graph.image_counts || []).map((x) => [Number(x.entity_id), x.count]));
|
|
1577
1885
|
// 推理开关开启时同步刷新推理缓存,保证叠加边与新数据一致
|
|
@@ -1584,6 +1892,7 @@ async function refreshAll(rebuild = true) {
|
|
|
1584
1892
|
updateEgoBar();
|
|
1585
1893
|
}
|
|
1586
1894
|
$('stat-badge').textContent = `实体 ${meta.counts.entities} / 关系 ${meta.counts.relations} / 日志 ${meta.counts.logs}`;
|
|
1895
|
+
loadIngestTasks();
|
|
1587
1896
|
const badge = $('agent-badge');
|
|
1588
1897
|
if (meta.agent_available) { badge.textContent = 'OpenCode 已就绪'; badge.className = 'badge ok'; }
|
|
1589
1898
|
else { badge.textContent = 'OpenCode 未安装'; badge.className = 'badge off'; }
|
|
@@ -1626,6 +1935,7 @@ async function askSubmit() {
|
|
|
1626
1935
|
}
|
|
1627
1936
|
|
|
1628
1937
|
function renderAskResult(r) {
|
|
1938
|
+
state.lastAsk = r;
|
|
1629
1939
|
const box = $('a-result');
|
|
1630
1940
|
const chips = r.steps.map((s) => {
|
|
1631
1941
|
const icon = s.status === 'ok' ? '✔' : s.status === 'timeout' ? '⏱' : '✘';
|
|
@@ -1654,8 +1964,32 @@ function renderAskResult(r) {
|
|
|
1654
1964
|
if (r.synthesis) synth = `<div class="ask-synth">${renderSynthesis(r.synthesis, r.entities)}</div>`;
|
|
1655
1965
|
else if (r.synth_error) synth = `<div class="kv" style="color:#e0a768">综述生成失败:${escapeHtml(r.synth_error)}</div>`;
|
|
1656
1966
|
|
|
1657
|
-
|
|
1967
|
+
// 证据路径:A —关系→ B 链条,可一键画布高亮
|
|
1968
|
+
let ev = '';
|
|
1969
|
+
if (r.evidence_paths && r.evidence_paths.length) {
|
|
1970
|
+
const ename = (id) => { const e = r.entities.find((x) => x.id === id); return e ? escapeHtml(e.name) : '#' + id; };
|
|
1971
|
+
const cname = (c) => `<span style="color:${CONF_STYLE[c] || '#8fa3c0'}">${c}</span>`;
|
|
1972
|
+
ev = `<div class="ask-sec">证据路径(${r.evidence_paths.length})</div>` + r.evidence_paths.map((p, pi) => {
|
|
1973
|
+
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(' ⇒ ');
|
|
1974
|
+
return `<div class="kv ask-path-row">⛓ ${chain} <button class="ghost" onclick="highlightEvidencePath(${pi})">高亮</button></div>`;
|
|
1975
|
+
}).join('');
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
box.innerHTML = `<div class="ask-steps">${chips}</div>${synth}${ev}<div class="ask-sec">实体(${r.entities.length})</div>${ents}${cn}`;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
// 证据路径一键高亮:以最近一次提问结果中的 hops 构建高亮集
|
|
1982
|
+
function highlightEvidencePath(pi) {
|
|
1983
|
+
const r = state.lastAsk;
|
|
1984
|
+
if (!r || !r.evidence_paths || !r.evidence_paths[pi]) return;
|
|
1985
|
+
const p = r.evidence_paths[pi];
|
|
1986
|
+
const nodes = new Set([p.hops[0].source_id, p.hops[p.hops.length - 1].target_id]);
|
|
1987
|
+
const rels = new Set();
|
|
1988
|
+
for (const h of p.hops) { rels.add(h.id); nodes.add(h.source_id); nodes.add(h.target_id); }
|
|
1989
|
+
applyCanvasHi(nodes, rels);
|
|
1990
|
+
toast('已在画布高亮该证据路径');
|
|
1658
1991
|
}
|
|
1992
|
+
window.highlightEvidencePath = highlightEvidencePath;
|
|
1659
1993
|
|
|
1660
1994
|
// 综述文本中的「名称#id」渲染为可点击引用
|
|
1661
1995
|
function renderSynthesis(text, entities) {
|
package/public/index.html
CHANGED
|
@@ -63,11 +63,27 @@
|
|
|
63
63
|
<input id="r-name" placeholder="例如:位于">
|
|
64
64
|
<label>大类(对应线型/颜色)</label>
|
|
65
65
|
<select id="r-category"></select>
|
|
66
|
+
<label>置信度</label>
|
|
67
|
+
<select id="r-confidence">
|
|
68
|
+
<option value="确证">确证(有明确依据)</option>
|
|
69
|
+
<option value="推测">推测(合理推断)</option>
|
|
70
|
+
<option value="存疑">存疑(待核实)</option>
|
|
71
|
+
</select>
|
|
72
|
+
<label>来源引用(URL / 文献+页码,可选)</label>
|
|
73
|
+
<input id="r-source-ref" placeholder="例如:《明史》卷304">
|
|
66
74
|
<label>终点实体</label>
|
|
67
75
|
<select id="r-target"></select>
|
|
68
76
|
<div class="row"><button class="primary" id="r-submit">添加</button></div>
|
|
69
77
|
</div>
|
|
70
|
-
<div class="card"><h3>关系列表</h3
|
|
78
|
+
<div class="card"><h3>关系列表</h3>
|
|
79
|
+
<div class="conf-filter" id="conf-filter">
|
|
80
|
+
<span class="cf-chip active" data-c="">全部</span>
|
|
81
|
+
<span class="cf-chip" data-c="确证">确证</span>
|
|
82
|
+
<span class="cf-chip" data-c="推测">推测</span>
|
|
83
|
+
<span class="cf-chip" data-c="存疑">存疑</span>
|
|
84
|
+
</div>
|
|
85
|
+
<div id="r-list"></div>
|
|
86
|
+
</div>
|
|
71
87
|
</div>
|
|
72
88
|
|
|
73
89
|
<div class="tabbody" id="tab-search">
|
|
@@ -103,6 +119,19 @@
|
|
|
103
119
|
<div class="row"><button class="primary" id="c-run">执行</button><span id="c-hint" style="font-size:11px;color:#8fa3c0;align-self:center">支持 contains / = 与 类型:互动|归属</span></div>
|
|
104
120
|
<div id="c-results"></div>
|
|
105
121
|
</div>
|
|
122
|
+
<div class="card">
|
|
123
|
+
<h3>文档批量入图(md / txt / pdf)</h3>
|
|
124
|
+
<div class="row" style="gap:12px">
|
|
125
|
+
<button class="primary" id="ing-btn">选择文档</button>
|
|
126
|
+
<label style="display:flex;align-items:center;gap:4px;font-size:12px;cursor:pointer">
|
|
127
|
+
<input type="checkbox" id="ing-auto" style="width:auto"> 跳过审核直接入库
|
|
128
|
+
</label>
|
|
129
|
+
<span id="ing-status" style="font-size:11px;color:#8fa3c0;align-self:center"></span>
|
|
130
|
+
</div>
|
|
131
|
+
<div id="ing-tasks"></div>
|
|
132
|
+
<div class="hint-text">LLM逐片抽取候选三元组 → 审核勾选 → 确认入库(先自动打保存点)。新实体来源标记"文档",关系自带来源引用"《文档》片段n"。勾选"跳过审核"后抽取完成即自动全量入库。</div>
|
|
133
|
+
<input type="file" id="ing-file" accept=".md,.markdown,.txt,.pdf" style="display:none">
|
|
134
|
+
</div>
|
|
106
135
|
</div>
|
|
107
136
|
|
|
108
137
|
<div class="tabbody" id="tab-log">
|