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/public/index.html CHANGED
@@ -33,6 +33,7 @@
33
33
  <div data-tab="entity" class="active">实体</div>
34
34
  <div data-tab="relation">关系</div>
35
35
  <div data-tab="search">检索</div>
36
+ <div data-tab="mcp">MCP</div>
36
37
  <div data-tab="log">日志</div>
37
38
  <div data-tab="version">版本</div>
38
39
  </div>
@@ -63,11 +64,27 @@
63
64
  <input id="r-name" placeholder="例如:位于">
64
65
  <label>大类(对应线型/颜色)</label>
65
66
  <select id="r-category"></select>
67
+ <label>置信度</label>
68
+ <select id="r-confidence">
69
+ <option value="确证">确证(有明确依据)</option>
70
+ <option value="推测">推测(合理推断)</option>
71
+ <option value="存疑">存疑(待核实)</option>
72
+ </select>
73
+ <label>来源引用(URL / 文献+页码,可选)</label>
74
+ <input id="r-source-ref" placeholder="例如:《明史》卷304">
66
75
  <label>终点实体</label>
67
76
  <select id="r-target"></select>
68
77
  <div class="row"><button class="primary" id="r-submit">添加</button></div>
69
78
  </div>
70
- <div class="card"><h3>关系列表</h3><div id="r-list"></div></div>
79
+ <div class="card"><h3>关系列表</h3>
80
+ <div class="conf-filter" id="conf-filter">
81
+ <span class="cf-chip active" data-c="">全部</span>
82
+ <span class="cf-chip" data-c="确证">确证</span>
83
+ <span class="cf-chip" data-c="推测">推测</span>
84
+ <span class="cf-chip" data-c="存疑">存疑</span>
85
+ </div>
86
+ <div id="r-list"></div>
87
+ </div>
71
88
  </div>
72
89
 
73
90
  <div class="tabbody" id="tab-search">
@@ -103,6 +120,19 @@
103
120
  <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
121
  <div id="c-results"></div>
105
122
  </div>
123
+ <div class="card">
124
+ <h3>文档批量入图(md / txt / pdf)</h3>
125
+ <div class="row" style="gap:12px">
126
+ <button class="primary" id="ing-btn">选择文档</button>
127
+ <label style="display:flex;align-items:center;gap:4px;font-size:12px;cursor:pointer">
128
+ <input type="checkbox" id="ing-auto" style="width:auto"> 跳过审核直接入库
129
+ </label>
130
+ <span id="ing-status" style="font-size:11px;color:#8fa3c0;align-self:center"></span>
131
+ </div>
132
+ <div id="ing-tasks"></div>
133
+ <div class="hint-text">LLM逐片抽取候选三元组 → 审核勾选 → 确认入库(先自动打保存点)。新实体来源标记"文档",关系自带来源引用"《文档》片段n"。勾选"跳过审核"后抽取完成即自动全量入库。</div>
134
+ <input type="file" id="ing-file" accept=".md,.markdown,.txt,.pdf" style="display:none">
135
+ </div>
106
136
  </div>
107
137
 
108
138
  <div class="tabbody" id="tab-log">
@@ -136,6 +166,60 @@
136
166
  </div>
137
167
  <div class="card"><h3>提交历史(与操作日志双向绑定)</h3><div id="v-list"></div></div>
138
168
  </div>
169
+
170
+ <div class="tabbody" id="tab-mcp">
171
+ <div class="card">
172
+ <h3>MCP 服务(外部 Agent 接入)</h3>
173
+ <div class="row" style="gap:16px;flex-wrap:wrap">
174
+ <label style="display:flex;align-items:center;gap:6px;cursor:pointer">
175
+ <input type="checkbox" id="mcp-toggle" style="width:auto"> 启用 MCP 服务
176
+ </label>
177
+ <label style="display:flex;align-items:center;gap:6px;cursor:pointer">
178
+ <input type="checkbox" id="mcp-readonly" style="width:auto"> 只读模式(禁用外部写入)
179
+ </label>
180
+ </div>
181
+ <div id="mcp-cfg" style="display:none">
182
+ <div class="row" style="align-items:center;gap:8px;flex-wrap:wrap;margin-top:8px">
183
+ <span style="font-size:12px;color:#8fa3c0">端点</span>
184
+ <code id="mcp-endpoint" style="font-size:11px;word-break:break-all"></code>
185
+ </div>
186
+ <div class="row" style="align-items:center;gap:8px;flex-wrap:wrap">
187
+ <span style="font-size:12px;color:#8fa3c0">令牌</span>
188
+ <code id="mcp-token" style="font-size:11px;word-break:break-all"></code>
189
+ <button id="mcp-copy-token" title="复制令牌">复制</button>
190
+ <button id="mcp-regen" title="重新生成后旧令牌立即失效">重新生成</button>
191
+ </div>
192
+ <div class="hint-text">外部 Agent 凭令牌访问。令牌泄露时点击"重新生成"即刻吊销旧令牌。</div>
193
+
194
+ <h3 style="margin-top:14px">客户端一键配置</h3>
195
+ <label>通用 Streamable HTTP(Cline / opencode / 通用 MCP 客户端)</label>
196
+ <div class="row" style="align-items:flex-start;gap:8px">
197
+ <pre id="mcp-snippet-http" class="mcp-snippet"></pre>
198
+ <button id="mcp-copy-http">复制</button>
199
+ </div>
200
+ <label>Claude Desktop / Cursor(claude_desktop_config.json / mcp.json)</label>
201
+ <div class="row" style="align-items:flex-start;gap:8px">
202
+ <pre id="mcp-snippet-desktop" class="mcp-snippet"></pre>
203
+ <button id="mcp-copy-desktop">复制</button>
204
+ </div>
205
+ <label>本机 stdio(与网页同机时,免令牌)</label>
206
+ <div class="row" style="align-items:flex-start;gap:8px">
207
+ <pre id="mcp-snippet-stdio" class="mcp-snippet"></pre>
208
+ <button id="mcp-copy-stdio">复制</button>
209
+ </div>
210
+ <div class="hint-text">stdio 方式命令:npx -y local-knowledge-graph --mcp(数据目录默认 ~/.local-knowledge-graph,可用环境变量 KG_DATA_DIR 指定其他目录)。远程 Agent 请使用 HTTP 方式并保管好令牌。</div>
211
+ </div>
212
+ <div id="mcp-status" class="hint-text"></div>
213
+ </div>
214
+ <div class="card">
215
+ <h3>外部 Agent 可用的工具(12 个)</h3>
216
+ <div class="hint-text">
217
+ 查询:kg_stats / kg_list_entities / kg_get_entity / kg_get_graph / kg_ego / kg_search / kg_cypher / kg_inference / kg_path / kg_digest / kg_export_rdf<br>
218
+ 写入:kg_apply_ops(add_entity / add_relation / update_entity / update_relation / delete_entity / delete_relation,自动记操作日志+Git保存点)<br>
219
+ 实时同步:任一写入方修改图谱后,页面会在 3 秒内自动刷新。
220
+ </div>
221
+ </div>
222
+ </div>
139
223
  </div>
140
224
 
141
225
  <div id="canvas-wrap">
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,20 @@
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; }
507
+
508
+ /* MCP 设置片段 */
509
+ .mcp-snippet {
510
+ flex: 1; margin: 0; padding: 8px 10px; font-size: 10.5px; line-height: 1.5;
511
+ background: #0d1526; color: #9fb6d9; border: 1px solid #24324d; border-radius: 8px;
512
+ white-space: pre-wrap; word-break: break-all; max-height: 110px; overflow: auto;
513
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
514
+ }
515
+ html[data-theme="light"] .mcp-snippet { background: #f4f7fc; color: #33445e; border-color: #d8e2f0; }
package/server.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const path = require('path');
4
4
  const fs = require('fs');
5
+ const crypto = require('crypto');
5
6
  const express = require('express');
6
7
  const db = require('./lib/db');
7
8
  const git = require('./lib/git');
@@ -13,6 +14,30 @@ const embeddings = require('./lib/embeddings');
13
14
  const askLib = require('./lib/ask');
14
15
  const updater = require('./lib/updater');
15
16
  const importer = require('./lib/importer');
17
+ const bus = require('./lib/bus');
18
+ const { createMcpHandler } = require('./mcp/http');
19
+
20
+ // ---------- 图谱变更总线:db 写函数统一包装,任一写入即时广播 ----------
21
+ for (const fn of ['addEntity', 'updateEntity', 'deleteEntity', 'addRelation', 'updateRelation', 'deleteRelation', 'addAlias', 'removeAlias', 'deleteEntityImage', 'applyAgentOps', 'undoLast', 'logRestore']) {
22
+ const orig = db[fn];
23
+ if (typeof orig === 'function') {
24
+ db[fn] = function (...args) {
25
+ const r = orig.apply(this, args);
26
+ bus.emit('graph-changed', { via: fn });
27
+ return r;
28
+ };
29
+ }
30
+ }
31
+
32
+ function mcpServerVersion() {
33
+ try { return require('./package.json').version || '1.0.0'; } catch (_) { return '1.0.0'; }
34
+ }
35
+ const mcpHandler = createMcpHandler({
36
+ isEnabled: () => embeddings.loadSettings().mcp_enabled === true,
37
+ getToken: () => embeddings.loadSettings().mcp_token || '',
38
+ isReadonly: () => embeddings.loadSettings().mcp_readonly === true,
39
+ serverVersion: mcpServerVersion(),
40
+ });
16
41
 
17
42
  const PORT = Number(process.env.PORT || 3000);
18
43
  const app = express();
@@ -43,7 +68,27 @@ function agentSessionInfo() {
43
68
 
44
69
  // ---------- 实体 ----------
45
70
  api.get('/entities', (req, res) => res.json(db.listEntities()));
46
- api.post('/entities', (req, res) => res.json(db.addEntity(req.body, '手工')));
71
+ api.post('/entities', (req, res) => {
72
+ try {
73
+ const body = req.body || {};
74
+ const name = body.name;
75
+ // 同名消歧:主名或别名占用时,按 merge_into / force 分流
76
+ if (typeof name === 'string' && name.trim()) {
77
+ const conflicts = db.findNameConflicts(name);
78
+ if (conflicts.length) {
79
+ const mergeInto = Number(body.merge_into);
80
+ if (body.merge_into !== undefined && Number.isInteger(mergeInto) && mergeInto > 0) {
81
+ const alias = db.addAlias(mergeInto, name.trim(), '手工');
82
+ return res.json({ merged: true, alias, entity: db.getEntity(mergeInto) });
83
+ }
84
+ if (body.force !== true && body.force !== 1 && body.force !== '1') {
85
+ return res.status(409).json({ error: `名称"${name.trim()}"已被占用,可并入现有实体、改用限定名或强制创建`, conflicts });
86
+ }
87
+ }
88
+ }
89
+ res.json(db.addEntity(body, '手工'));
90
+ } catch (e) { res.status(e.status || 500).json({ error: e.message, conflicts: e.conflicts }); }
91
+ });
47
92
  api.put('/entities/:id', (req, res) => res.json(db.updateEntity(Number(req.params.id), req.body, '手工')));
48
93
  api.delete('/entities/:id', (req, res) => {
49
94
  const r = db.deleteEntity(Number(req.params.id), '手工');
@@ -81,6 +126,80 @@ api.get('/graph/path', (req, res) => {
81
126
  });
82
127
 
83
128
  // ---------- 撤销最近操作(快照逆向写入) ----------
129
+ api.get('/aliases', (req, res) => res.json(db.aliasMap()));
130
+
131
+ api.get('/aliases/records', (req, res) => res.json(db.aliasRecords()));
132
+
133
+ api.post('/aliases', (req, res) => {
134
+ try {
135
+ const { entity_id, alias } = req.body || {};
136
+ res.json(db.addAlias(Number(entity_id), alias, '手工'));
137
+ } catch (e) { res.status(e.status || 500).json({ error: e.message, conflicts: e.conflicts }); }
138
+ });
139
+
140
+ api.delete('/aliases/:id', (req, res) => {
141
+ try { res.json(db.removeAlias(Number(req.params.id), '手工')); }
142
+ catch (e) { res.status(e.status || 500).json({ error: e.message }); }
143
+ });
144
+
145
+ api.get('/graph/paths', (req, res) => {
146
+ try {
147
+ const fromId = db.resolveKey(req.query.from);
148
+ const toId = db.resolveKey(req.query.to);
149
+ let maxHops = 4;
150
+ if (req.query.max !== undefined && String(req.query.max).trim() !== '') {
151
+ maxHops = Number(req.query.max);
152
+ if (!Number.isInteger(maxHops)) return res.status(400).json({ error: 'max必须为整数' });
153
+ }
154
+ res.json(db.findPaths(fromId, toId, { maxHops }));
155
+ } catch (e) { res.status(e.status || 500).json({ error: e.message, candidates: e.candidates }); }
156
+ });
157
+
158
+ // ---------- 相似实体(语义Top-K,结构降级) ----------
159
+ api.get('/similar/:id', async (req, res) => {
160
+ try {
161
+ const k = req.query.k !== undefined ? Number(req.query.k) : 8;
162
+ res.json(await require('./lib/similar').similarEntities(Number(req.params.id), Number.isInteger(k) ? k : 8));
163
+ } catch (e) { res.status(e.status || 500).json({ error: e.message }); }
164
+ });
165
+
166
+ // ---------- 文档批量入图 ----------
167
+ const ingest = require('./lib/ingest');
168
+ ingest.loadPersisted();
169
+
170
+ api.post('/ingest', async (req, res) => {
171
+ if (agentBusy) return res.status(429).json({ error: '已有AI任务执行中(问答、导入或智能检索),请稍后再试' });
172
+ const { filename, content_b64, auto_commit } = req.body || {};
173
+ if (!filename || !content_b64) return res.status(400).json({ error: 'filename 与 content_b64 必填' });
174
+ let buf;
175
+ try { buf = Buffer.from(content_b64, 'base64'); } catch (_) { return res.status(400).json({ error: 'base64解码失败' }); }
176
+ agentBusy = true;
177
+ try {
178
+ res.json(await ingest.createTask(String(filename), buf, {
179
+ autoCommit: auto_commit === true || auto_commit === 1 || auto_commit === '1',
180
+ onSettled: () => { agentBusy = false; },
181
+ }));
182
+ } catch (e) {
183
+ agentBusy = false;
184
+ res.status(e.status || 500).json({ error: e.message });
185
+ }
186
+ });
187
+
188
+ api.get('/ingest/tasks', (req, res) => res.json(ingest.listTasks()));
189
+ api.get('/ingest/:id', (req, res) => {
190
+ const t = ingest.getTask(req.params.id);
191
+ if (!t) return res.status(404).json({ error: '任务不存在' });
192
+ res.json(t);
193
+ });
194
+ api.post('/ingest/:id/commit', (req, res) => {
195
+ try { res.json(ingest.commitTask(req.params.id, (req.body || {}).selected || null, '手工')); }
196
+ catch (e) { res.status(e.status || 500).json({ error: e.message }); }
197
+ });
198
+ api.delete('/ingest/:id', (req, res) => {
199
+ try { res.json(ingest.deleteTask(req.params.id)); }
200
+ catch (e) { res.status(e.status || 500).json({ error: e.message }); }
201
+ });
202
+
84
203
  api.post('/undo', (req, res) => {
85
204
  try { res.json({ ok: true, ...db.undoLast('手工'), counts: db.counts(), version: db.getVersion() }); }
86
205
  catch (e) { res.status(e.status || 500).json({ error: e.message }); }
@@ -351,6 +470,55 @@ api.post('/git/restore', (req, res) => {
351
470
  res.json(r);
352
471
  });
353
472
 
473
+ // ---------- MCP 外部接入设置 ----------
474
+ api.get('/mcp/settings', (req, res) => {
475
+ const s = embeddings.loadSettings();
476
+ res.json({
477
+ enabled: s.mcp_enabled === true,
478
+ readonly: s.mcp_readonly === true,
479
+ token: s.mcp_token || '',
480
+ endpoint: `/mcp`,
481
+ version: mcpServerVersion(),
482
+ });
483
+ });
484
+
485
+ api.put('/mcp/settings', (req, res) => {
486
+ try {
487
+ const patch = {};
488
+ const b = req.body || {};
489
+ if (typeof b.enabled === 'boolean') patch.mcp_enabled = b.enabled;
490
+ if (typeof b.readonly === 'boolean') patch.mcp_readonly = b.readonly;
491
+ const cur = embeddings.loadSettings();
492
+ if (patch.mcp_enabled === true && !cur.mcp_token) patch.mcp_token = crypto.randomBytes(24).toString('hex');
493
+ const s = embeddings.saveSettings(patch);
494
+ res.json({ enabled: s.mcp_enabled === true, readonly: s.mcp_readonly === true, token: s.mcp_token || '', endpoint: `/mcp`, version: mcpServerVersion() });
495
+ } catch (e) { res.status(400).json({ error: e.message }); }
496
+ });
497
+
498
+ api.post('/mcp/token/regen', (req, res) => {
499
+ const token = crypto.randomBytes(24).toString('hex');
500
+ embeddings.saveSettings({ mcp_token: token });
501
+ res.json({ token });
502
+ });
503
+
504
+ // ---------- 实时同步:SSE 事件流 ----------
505
+ api.get('/events', (req, res) => {
506
+ res.writeHead(200, {
507
+ 'Content-Type': 'text/event-stream',
508
+ 'Cache-Control': 'no-cache',
509
+ Connection: 'keep-alive',
510
+ 'X-Accel-Buffering': 'no',
511
+ });
512
+ res.write('retry: 3000\n\n');
513
+ const send = (type, payload) => {
514
+ try { res.write(`event: ${type}\ndata: ${JSON.stringify(payload || {})}\n\n`); } catch (_) { /* 连接已断 */ }
515
+ };
516
+ send('hello', { ts: Date.now() });
517
+ const unsub = bus.on((type, payload) => send(type, payload));
518
+ const heartbeat = setInterval(() => { try { res.write(': ping\n\n'); } catch (_) { /* 忽略 */ } }, 25000);
519
+ req.on('close', () => { clearInterval(heartbeat); unsub(); });
520
+ });
521
+
354
522
  // ---------- OpenCode自然语言指令 ----------
355
523
  // AI任务互斥:OpenCode进程最长5分钟,防止并发请求叠加进程互抢SQLite与内存
356
524
  let agentBusy = false;
@@ -517,6 +685,9 @@ api.post('/agent/doc', async (req, res) => {
517
685
  } finally { agentBusy = false; }
518
686
  });
519
687
 
688
+ // MCP 端点(置于 /api 之前;内部有开关与令牌校验)
689
+ app.use('/mcp', mcpHandler);
690
+
520
691
  app.use('/api', api);
521
692
 
522
693
  // 统一错误处理
@@ -623,3 +794,15 @@ function scheduleRestart() {
623
794
  }
624
795
  });
625
796
  })(0);
797
+
798
+ // ---------- 跨进程变更探测:外部进程(stdio MCP/CLI)写库时兜底广播 ----------
799
+ let lastSignature = '';
800
+ function detectExternalChange() {
801
+ try {
802
+ const sig = JSON.stringify([db.counts(), db.maxLogId()]);
803
+ if (lastSignature && sig !== lastSignature) bus.emit('graph-changed', { via: 'external' });
804
+ lastSignature = sig;
805
+ } catch (_) { /* 数据库短暂不可用时跳过本轮 */ }
806
+ }
807
+ setInterval(detectExternalChange, 3000).unref();
808
+