local-knowledge-graph 1.6.1 → 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/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');
@@ -256,7 +270,9 @@ function rebuildGraph() {
256
270
  // 中心层级模式:仅构建子图;全图模式:构建全部
257
271
  const sub = state.ego ? calcEgo(state.ego.centerId, state.ego.depth) : null;
258
272
  const ents = sub ? sub.entities : state.entities;
259
- const rels = sub ? sub.relations : state.relations;
273
+ const allRels = sub ? sub.relations : state.relations;
274
+ const rels = allRels.filter((r) => !state.confFilter || (r.confidence || '确证') === state.confFilter);
275
+ state.pathHi = null;
260
276
 
261
277
  state.entityMap.clear();
262
278
  const N = ents.length;
@@ -311,13 +327,17 @@ function rebuildGraph() {
311
327
  const b = simNodes.find((n) => n.id === r.target_id);
312
328
  if (!a || !b) return;
313
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 === '推测'; // 推测强制虚线
314
334
  const geo = new THREE.BufferGeometry().setFromPoints([a.pos, b.pos]);
315
- const op = st.opacity === undefined ? 0.9 : st.opacity;
316
- const mat = st.dashed
335
+ const mat = dashed
317
336
  ? new THREE.LineDashedMaterial({ color: st.color, dashSize: st.dashSize || 6, gapSize: st.gapSize || 4, transparent: true, opacity: op })
318
337
  : new THREE.LineBasicMaterial({ color: st.color, transparent: true, opacity: op });
319
338
  const line = new THREE.Line(geo, mat);
320
339
  line.userData.relationId = r.id;
340
+ line.userData.baseOpacity = op;
321
341
  linkGroup.add(line);
322
342
  const mid = a.pos.clone().add(b.pos).multiplyScalar(0.5);
323
343
  // 线标注显示具体关系名(如"父子"),线型/颜色仍由大类规定
@@ -325,7 +345,7 @@ function rebuildGraph() {
325
345
  lbl.userData.text = r.name;
326
346
  lbl.position.copy(mid);
327
347
  labelGroup.add(lbl);
328
- simLinks.push({ id: r.id, a, b, line, label: lbl, dashed: st.dashed });
348
+ simLinks.push({ id: r.id, a, b, line, label: lbl, dashed, confidence: conf });
329
349
  });
330
350
 
331
351
  // 推理关系叠加:虚化虚线 + "(推)"标注,负数id与库中显式关系区分;仅显示两端均在当前视图的边
@@ -520,19 +540,42 @@ function renderInfoCard() {
520
540
  (rows.length ? '' : '<span class="kv">加载中…</span>') + '</div></div>';
521
541
  }
522
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
+ : '';
523
561
  card.innerHTML = `
524
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>
525
563
  <div class="kv">id: ${e.id} 来源: ${e.source}</div>
526
564
  <div class="kv">创建: ${e.created_at}</div>
527
565
  ${levelHtml}
566
+ ${aliasHtml}
567
+ ${twinHtml}
528
568
  <div class="kv">关联关系: ${relCount} 条</div>
529
569
  ${attrHtml || '<div class="kv">(无属性)</div>'}
570
+ ${relPreview}
530
571
  ${imgHtml}
531
572
  <div class="btns">
532
573
  <button onclick="focusEgo(${e.id})">以此为中心</button>
533
574
  <button onclick="askPath(${e.id}, '${escapeHtml(e.name).replace(/'/g, "\\'")}')">查路径</button>
575
+ <button onclick="loadSimilar(${e.id})">相似实体</button>
534
576
  <button onclick="$('entity-img-input').click()">绑图片</button>
535
577
  </div>
578
+ <div id="similar-box"></div>
536
579
  <div class="btns"><button onclick="editEntity(${e.id})">编辑</button><button class="danger" onclick="delEntity(${e.id})">删除</button></div>`;
537
580
  card.style.display = 'block';
538
581
  if (imgCount > 0 && !imgs) loadEntityImages(e.id);
@@ -566,10 +609,15 @@ function renderInfoCard() {
566
609
  if (!r) { card.style.display = 'none'; return; }
567
610
  const s = state.entityMap.get(r.source_id), t = state.entityMap.get(r.target_id);
568
611
  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></h4>
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>
570
613
  <div class="kv"><b>${s ? escapeHtml(s.entity.name) : '?'}</b> --&gt; <b>${t ? escapeHtml(t.entity.name) : '?'}</b></div>
571
614
  <div class="kv">id: ${r.id} 来源: ${r.source}</div>
572
- <div class="btns"><button class="danger" onclick="delRelation(${r.id})">删除</button></div>`;
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>`;
573
621
  card.style.display = 'block';
574
622
  }
575
623
  }
@@ -830,7 +878,9 @@ function renderLegend() {
830
878
  $('legend').innerHTML = '<b>实体样式</b><br>' +
831
879
  meta.entity_categories.map((c) => `<span class="sw" style="background:${ENTITY_STYLE[c].css}"></span>${c} · ${ENTITY_STYLE[c].shape}`).join('<br>') +
832
880
  '<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>');
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>');
834
884
  }
835
885
 
836
886
  function refreshEntityOptions() {
@@ -911,16 +961,72 @@ async function submitRelation() {
911
961
  target_id: Number($('r-target').value),
912
962
  name: $('r-name').value.trim(),
913
963
  category: $('r-category').value,
964
+ confidence: $('r-confidence').value,
965
+ source_ref: $('r-source-ref').value.trim(),
914
966
  };
915
967
  if (!body.name) return toast('请输入关系名称', true);
916
968
  if (!body.source_id || !body.target_id) return toast('请先创建实体', true);
917
969
  await api('/api/relations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
918
970
  $('r-name').value = '';
971
+ $('r-source-ref').value = '';
919
972
  toast('关系已添加');
920
973
  await refreshAll();
921
974
  } catch (e) { toast(e.message, true); }
922
975
  }
923
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
+
924
1030
  async function delRelation(id) {
925
1031
  if (!confirm(`删除关系 #${id}?`)) return;
926
1032
  try {
@@ -948,19 +1054,30 @@ function renderEntityList() {
948
1054
  }
949
1055
 
950
1056
  function renderRelationList() {
951
- $('r-list').innerHTML = state.relations.map((r) => {
1057
+ const shown = state.relations.filter((r) => !state.confFilter || (r.confidence || '确证') === state.confFilter);
1058
+ $('r-list').innerHTML = shown.map((r) => {
952
1059
  const s = state.entityMap.get(r.source_id), t = state.entityMap.get(r.target_id);
953
1060
  return `
954
1061
  <div class="list-item">
955
1062
  <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></div>
957
- <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>
958
1065
  </div>
959
1066
  <button class="danger" onclick="delRelation(${r.id})">删</button>
960
1067
  </div>`;
961
- }).join('') || '<div class="sub" style="color:#5c6f92">暂无关系</div>';
1068
+ }).join('') || '<div class="sub" style="color:#5c6f92">' + (state.confFilter ? `暂无「${state.confFilter}」关系` : '暂无关系') + '</div>';
962
1069
  }
963
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
+
964
1081
  /* 日志人话渲染:op_type + snapshot JSON → 可读中文;已删实体名称回退#id */
965
1082
  const OP_LABELS = {
966
1083
  ADD_ENTITY: '新增实体', UPDATE_ENTITY: '更新实体', DELETE_ENTITY: '删除实体',
@@ -1110,6 +1227,7 @@ document.addEventListener('keydown', (e) => {
1110
1227
  }
1111
1228
  if ($('style-panel').classList.contains('show')) { $('style-panel').classList.remove('show'); return; }
1112
1229
  if ($('file-menu').classList.contains('show')) { $('file-menu').classList.remove('show'); return; }
1230
+ if (state.pathHi) { clearCanvasHi(); return; }
1113
1231
  if (state.selected) { state.selected = null; renderInfoCard(); return; }
1114
1232
  if (state.ego) exitEgo();
1115
1233
  return;
@@ -1121,39 +1239,218 @@ document.addEventListener('keydown', (e) => {
1121
1239
  }
1122
1240
  });
1123
1241
 
1124
- /* ================= 最短路径查询 ================= */
1242
+ /* ================= 路径查询(多路径枚举)与画布高亮 ================= */
1125
1243
  function renderPathPanel(r) {
1126
1244
  const panel = $('path-panel');
1127
- if (!r.found) {
1128
- $('path-body').innerHTML = '<div class="kv">两实体间在6层内无连通路径</div>';
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>`;
1129
1256
  panel.style.display = 'block';
1130
1257
  return;
1131
1258
  }
1132
- const rows = [];
1133
- r.entities.forEach((ent, i) => {
1134
- if (i > 0) {
1135
- const rel = r.relations[i - 1];
1136
- const dir = rel.source_id === r.entities[i - 1].id ? '→' : '←';
1137
- rows.push(`<div class="p-rel">—${dir} ${escapeHtml(rel.name)} ${dir === '→' ? '→' : '—'}—</div>`);
1138
- }
1139
- rows.push(`<div class="p-ent" onclick="focusEntity(${ent.id}); document.getElementById('path-panel').style.display='none'">${escapeHtml(ent.name)}<span class="tag">${escapeHtml(ent.category)}</span></div>`);
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>`;
1140
1273
  });
1141
- $('path-title').textContent = `最短路径(${r.hops} 跳)`;
1142
- $('path-body').innerHTML = rows.join('');
1274
+ state._lastPaths = r.paths;
1275
+ $('path-title').textContent = `关系路径(共${r.paths.length}条)`;
1276
+ $('path-body').innerHTML = html;
1143
1277
  panel.style.display = 'block';
1144
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
+
1145
1318
  async function askPath(fromId, fromName) {
1146
- const to = prompt(`查询「${fromName}」到哪位实体的最短路径?(输入名称或id,最多6层)`, '');
1319
+ const to = prompt(`查询「${fromName}」到哪位实体的关系路径?(输入名称或id,2-6层,最多返回5条)`, '');
1147
1320
  if (to === null) return;
1148
1321
  const key = to.trim();
1149
1322
  if (!key) return;
1150
1323
  try {
1151
- const r = await api(`/api/graph/path?from=${fromId}&to=${encodeURIComponent(key)}`);
1324
+ const r = await api(`/api/graph/paths?from=${fromId}&to=${encodeURIComponent(key)}`);
1152
1325
  renderPathPanel(r);
1153
1326
  } catch (e) { toast(e.message, true); }
1154
1327
  }
1155
1328
  window.askPath = askPath;
1156
- $('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;
1157
1454
 
1158
1455
  /* ================= OpenCode 对话 ================= */
1159
1456
  function appendMsg(role, text, chips, chipWarn) {
@@ -1582,6 +1879,7 @@ async function refreshAll(rebuild = true) {
1582
1879
  const [graph, meta] = await Promise.all([api('/api/graph'), api('/api/meta')]);
1583
1880
  state.entities = graph.entities;
1584
1881
  state.relations = graph.relations;
1882
+ state.aliases = graph.aliases || {};
1585
1883
  state.version = meta.version;
1586
1884
  state.imageCounts = new Map((graph.image_counts || []).map((x) => [Number(x.entity_id), x.count]));
1587
1885
  // 推理开关开启时同步刷新推理缓存,保证叠加边与新数据一致
@@ -1594,6 +1892,7 @@ async function refreshAll(rebuild = true) {
1594
1892
  updateEgoBar();
1595
1893
  }
1596
1894
  $('stat-badge').textContent = `实体 ${meta.counts.entities} / 关系 ${meta.counts.relations} / 日志 ${meta.counts.logs}`;
1895
+ loadIngestTasks();
1597
1896
  const badge = $('agent-badge');
1598
1897
  if (meta.agent_available) { badge.textContent = 'OpenCode 已就绪'; badge.className = 'badge ok'; }
1599
1898
  else { badge.textContent = 'OpenCode 未安装'; badge.className = 'badge off'; }
@@ -1636,6 +1935,7 @@ async function askSubmit() {
1636
1935
  }
1637
1936
 
1638
1937
  function renderAskResult(r) {
1938
+ state.lastAsk = r;
1639
1939
  const box = $('a-result');
1640
1940
  const chips = r.steps.map((s) => {
1641
1941
  const icon = s.status === 'ok' ? '✔' : s.status === 'timeout' ? '⏱' : '✘';
@@ -1664,8 +1964,32 @@ function renderAskResult(r) {
1664
1964
  if (r.synthesis) synth = `<div class="ask-synth">${renderSynthesis(r.synthesis, r.entities)}</div>`;
1665
1965
  else if (r.synth_error) synth = `<div class="kv" style="color:#e0a768">综述生成失败:${escapeHtml(r.synth_error)}</div>`;
1666
1966
 
1667
- box.innerHTML = `<div class="ask-steps">${chips}</div>${synth}<div class="ask-sec">实体(${r.entities.length})</div>${ents}${cn}`;
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('已在画布高亮该证据路径');
1668
1991
  }
1992
+ window.highlightEvidencePath = highlightEvidencePath;
1669
1993
 
1670
1994
  // 综述文本中的「名称#id」渲染为可点击引用
1671
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><div id="r-list"></div></div>
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">
package/public/style.css CHANGED
@@ -19,6 +19,18 @@
19
19
  header .badge.ok { color: #7ee787; border-color: #2ea04366; }
20
20
  header .badge.off { color: #f0883e; border-color: #f0883e66; }
21
21
 
22
+ /* 置信度徽章与过滤条 */
23
+ .conf-badge { font-size: 10px; border: 1px solid; border-radius: 8px; padding: 0 6px; margin-left: 5px; vertical-align: 1px; }
24
+ .conf-filter { display: flex; gap: 5px; margin-bottom: 8px; flex-wrap: wrap; }
25
+ .cf-chip { font-size: 11px; color: #8fa3c0; border: 1px solid #2a3a5c; border-radius: 10px; padding: 2px 10px; cursor: pointer; user-select: none; }
26
+ .cf-chip.active { color: #7fd1ff; border-color: #58a6ff; background: #16233f; }
27
+ .alias-chip { display: inline-block; font-size: 11px; color: #cfe2ff; border: 1px solid #2a3a5c; border-radius: 8px; padding: 0 6px; margin-right: 4px; }
28
+ .alias-chip i { font-style: normal; color: #f0883e; cursor: pointer; margin-left: 3px; }
29
+ .p-path { margin-bottom: 10px; border-top: 1px dashed #24344f; padding-top: 6px; }
30
+ .p-path:first-child { border-top: none; padding-top: 0; }
31
+ .p-head2 { font-size: 11.5px; color: #7fd1ff; margin-bottom: 4px; display: flex; align-items: center; gap: 8px; }
32
+ .ask-path-row { line-height: 1.7; }
33
+
22
34
  button {
23
35
  background: #1b2b4d; color: #cfe2ff; border: 1px solid #2f4a7a; border-radius: 5px;
24
36
  padding: 5px 12px; cursor: pointer; font-size: 12px; font-family: inherit;
@@ -484,3 +496,11 @@
484
496
  html[data-theme="light"] #file-menu button:active { background: #eaf3ff; }
485
497
  html[data-theme="light"] .m-btn { background: #eaf3ff; }
486
498
  html[data-theme="light"] #m-legend { background: rgba(255, 255, 255, 0.92); }
499
+
500
+ /* 浅色主题:置信度/别名/路径新增元素适配 */
501
+ html[data-theme="light"] .cf-chip { color: #5a6b85; border-color: #c9d6e8; }
502
+ html[data-theme="light"] .cf-chip.active { color: #1f6feb; border-color: #1f6feb; background: #e8f1ff; }
503
+ html[data-theme="light"] .alias-chip { color: #2c3e57; border-color: #c9d6e8; }
504
+ html[data-theme="light"] .alias-chip i { color: #c44f27; }
505
+ html[data-theme="light"] .p-path { border-top-color: #dfe7f2; }
506
+ html[data-theme="light"] .p-head2 { color: #1a5dc8; }