local-knowledge-graph 1.5.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/.opencode/skill/kg-triples/SKILL.md +55 -0
- package/HELP.md +112 -0
- package/LICENSE +21 -0
- package/README.md +165 -0
- package/bin/cli.js +86 -0
- package/lib/agent.js +211 -0
- package/lib/ask.js +290 -0
- package/lib/db.js +613 -0
- package/lib/embeddings.js +166 -0
- package/lib/git.js +175 -0
- package/lib/importer.js +54 -0
- package/lib/inference.js +123 -0
- package/lib/paths.js +53 -0
- package/lib/rdf.js +64 -0
- package/lib/updater.js +144 -0
- package/lib/validator.js +146 -0
- package/lib/vectors.js +68 -0
- package/lib/viewer.js +33 -0
- package/lib/viewer_template.html +283 -0
- package/mcp/server.js +262 -0
- package/package.json +44 -0
- package/public/app.js +1761 -0
- package/public/index.html +238 -0
- package/public/style.css +374 -0
- package/public/vendor/OrbitControls.js +1045 -0
- package/public/vendor/three.min.js +6 -0
- package/server.js +625 -0
- package/tools/ego.js +122 -0
- package/tools/search.js +101 -0
package/server.js
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const express = require('express');
|
|
6
|
+
const db = require('./lib/db');
|
|
7
|
+
const git = require('./lib/git');
|
|
8
|
+
const rdf = require('./lib/rdf');
|
|
9
|
+
const agent = require('./lib/agent');
|
|
10
|
+
const V = require('./lib/validator');
|
|
11
|
+
const inference = require('./lib/inference');
|
|
12
|
+
const embeddings = require('./lib/embeddings');
|
|
13
|
+
const askLib = require('./lib/ask');
|
|
14
|
+
const updater = require('./lib/updater');
|
|
15
|
+
const importer = require('./lib/importer');
|
|
16
|
+
|
|
17
|
+
const PORT = Number(process.env.PORT || 3000);
|
|
18
|
+
const app = express();
|
|
19
|
+
app.use(express.json({ limit: '30mb' }));
|
|
20
|
+
app.use(express.static(path.join(__dirname, 'public')));
|
|
21
|
+
app.use('/uploads', express.static(path.join(git.DATA_DIR, 'uploads')));
|
|
22
|
+
|
|
23
|
+
const api = express.Router();
|
|
24
|
+
|
|
25
|
+
// ---------- 元信息 ----------
|
|
26
|
+
api.get('/meta', (req, res) => {
|
|
27
|
+
res.json({
|
|
28
|
+
entity_categories: V.ENTITY_CATEGORIES,
|
|
29
|
+
relation_categories: V.RELATION_CATEGORIES,
|
|
30
|
+
counts: db.counts(),
|
|
31
|
+
version: db.getVersion(),
|
|
32
|
+
agent_available: agentAvailable,
|
|
33
|
+
agent_session: agentSessionInfo(),
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function agentSessionInfo() {
|
|
38
|
+
try {
|
|
39
|
+
const f = path.join(git.DATA_DIR, '.agent_session');
|
|
40
|
+
return fs.existsSync(f) ? { has_session: true } : { has_session: false };
|
|
41
|
+
} catch (_) { return { has_session: false }; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------- 实体 ----------
|
|
45
|
+
api.get('/entities', (req, res) => res.json(db.listEntities()));
|
|
46
|
+
api.post('/entities', (req, res) => res.json(db.addEntity(req.body, '手工')));
|
|
47
|
+
api.put('/entities/:id', (req, res) => res.json(db.updateEntity(Number(req.params.id), req.body, '手工')));
|
|
48
|
+
api.delete('/entities/:id', (req, res) => {
|
|
49
|
+
const r = db.deleteEntity(Number(req.params.id), '手工');
|
|
50
|
+
cleanupImageFiles(r.image_files); // 级联删除图片行后移除文件本体
|
|
51
|
+
res.json(r);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ---------- 中心层级子图(ego) ----------
|
|
55
|
+
api.get('/graph/ego', (req, res) => {
|
|
56
|
+
let centerId;
|
|
57
|
+
try { centerId = db.resolveKey(req.query.center); }
|
|
58
|
+
catch (e) { return res.status(e.status || 500).json({ error: e.message, candidates: e.candidates }); }
|
|
59
|
+
const raw = req.query.depth;
|
|
60
|
+
let depth = null;
|
|
61
|
+
if (raw !== undefined && String(raw).trim() !== '') {
|
|
62
|
+
depth = Number(raw);
|
|
63
|
+
if (!Number.isInteger(depth) || depth < 0) return res.status(400).json({ error: 'depth必须为非负整数(省略或0表示全部层级)' });
|
|
64
|
+
if (depth === 0) depth = null; // 0 = 全部层级
|
|
65
|
+
}
|
|
66
|
+
res.json(db.egoSubgraph(centerId, depth));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ---------- 两实体最短路径 ----------
|
|
70
|
+
api.get('/graph/path', (req, res) => {
|
|
71
|
+
try {
|
|
72
|
+
const fromId = db.resolveKey(req.query.from);
|
|
73
|
+
const toId = db.resolveKey(req.query.to);
|
|
74
|
+
let maxHops = 6;
|
|
75
|
+
if (req.query.max !== undefined && String(req.query.max).trim() !== '') {
|
|
76
|
+
maxHops = Number(req.query.max);
|
|
77
|
+
if (!Number.isInteger(maxHops) || maxHops < 1 || maxHops > 12) return res.status(400).json({ error: 'max必须为1-12的整数' });
|
|
78
|
+
}
|
|
79
|
+
res.json(db.findPath(fromId, toId, maxHops));
|
|
80
|
+
} catch (e) { res.status(e.status || 500).json({ error: e.message, candidates: e.candidates }); }
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ---------- 撤销最近操作(快照逆向写入) ----------
|
|
84
|
+
api.post('/undo', (req, res) => {
|
|
85
|
+
try { res.json({ ok: true, ...db.undoLast('手工'), counts: db.counts(), version: db.getVersion() }); }
|
|
86
|
+
catch (e) { res.status(e.status || 500).json({ error: e.message }); }
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ---------- 智能检索(LLM编译检索计划,只读执行) ----------
|
|
90
|
+
api.post('/ask', async (req, res) => {
|
|
91
|
+
if (agentBusy) return res.status(429).json({ error: '已有AI任务执行中(问答、导入或智能检索),请稍后再试' });
|
|
92
|
+
const question = (req.body || {}).question;
|
|
93
|
+
if (!question || !String(question).trim()) return res.status(400).json({ error: '问题不能为空' });
|
|
94
|
+
agentBusy = true;
|
|
95
|
+
try {
|
|
96
|
+
const s = embeddings.loadSettings();
|
|
97
|
+
const result = await askLib.ask(question, { synthesis: s.ask_synthesis !== false });
|
|
98
|
+
res.json(result);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
res.status(e.status || 500).json({ error: e.message });
|
|
101
|
+
} finally {
|
|
102
|
+
agentBusy = false;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
api.get('/ask/settings', (req, res) => {
|
|
107
|
+
res.json({ synthesis: embeddings.loadSettings().ask_synthesis !== false });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
api.put('/ask/settings', (req, res) => {
|
|
111
|
+
embeddings.saveSettings({ ask_synthesis: !!(req.body || {}).synthesis });
|
|
112
|
+
res.json({ ok: true, synthesis: embeddings.loadSettings().ask_synthesis !== false });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ---------- 版本与更新 ----------
|
|
116
|
+
api.get('/version', (req, res) => res.json({ version: updater.currentVersion() }));
|
|
117
|
+
|
|
118
|
+
api.get('/update/check', async (req, res) => {
|
|
119
|
+
try { res.json(await updater.checkUpdate()); }
|
|
120
|
+
catch (e) { res.status(500).json({ ok: false, error: e.message }); }
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
let updating = false;
|
|
124
|
+
api.post('/update/apply', (req, res) => {
|
|
125
|
+
if (updating) return res.status(429).json({ error: '更新正在进行中,请稍候' });
|
|
126
|
+
updating = true;
|
|
127
|
+
try {
|
|
128
|
+
const r = updater.applyUpdate();
|
|
129
|
+
if (!r.ok) return res.status(400).json(r);
|
|
130
|
+
res.json(r);
|
|
131
|
+
if (!r.up_to_date) scheduleRestart();
|
|
132
|
+
} catch (e) {
|
|
133
|
+
res.status(500).json({ ok: false, error: e.message });
|
|
134
|
+
} finally {
|
|
135
|
+
updating = false;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ---------- 实体图片绑定 ----------
|
|
140
|
+
const IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'];
|
|
141
|
+
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
142
|
+
|
|
143
|
+
function cleanupImageFiles(storedPaths) {
|
|
144
|
+
for (const p of storedPaths || []) {
|
|
145
|
+
try { fs.unlinkSync(path.join(git.DATA_DIR, p)); } catch (_) { /* 文件已不存在时容忍 */ }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
api.get('/entities/:id/images', (req, res) => {
|
|
150
|
+
const id = Number(req.params.id);
|
|
151
|
+
if (!db.getEntity(id)) return res.status(404).json({ error: `实体id=${id} 不存在` });
|
|
152
|
+
const rel = (p) => (p ? '/uploads/' + p.replace(/^uploads[\\/]/, '') : null);
|
|
153
|
+
res.json(db.listEntityImages(id).map((r) => ({ ...r, url: rel(r.stored_path), thumb_url: rel(r.thumb_path) || rel(r.stored_path) })));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
api.post('/entities/:id/images', (req, res) => {
|
|
157
|
+
const id = Number(req.params.id);
|
|
158
|
+
const { filename, content_b64, caption, thumb_b64 } = req.body || {};
|
|
159
|
+
if (!filename || !content_b64) return res.status(400).json({ error: '必须提供文件名与内容(content_b64)' });
|
|
160
|
+
const ext = path.extname(String(filename)).toLowerCase();
|
|
161
|
+
if (!IMAGE_EXTS.includes(ext)) return res.status(400).json({ error: `仅支持图片格式: ${IMAGE_EXTS.join(' ')}` });
|
|
162
|
+
let buf;
|
|
163
|
+
try { buf = Buffer.from(content_b64, 'base64'); } catch (_) { return res.status(400).json({ error: 'content_b64不是合法的base64' }); }
|
|
164
|
+
if (!buf.length) return res.status(400).json({ error: '文件内容为空' });
|
|
165
|
+
if (buf.length > MAX_IMAGE_BYTES) return res.status(400).json({ error: '单张图片不得超过10MB' });
|
|
166
|
+
|
|
167
|
+
const dir = path.join(git.DATA_DIR, 'uploads', `e${id}`);
|
|
168
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
169
|
+
const safe = path.basename(String(filename)).replace(/[^\w.\-\u4e00-\u9fa5]/g, '_').slice(0, 80) || 'image';
|
|
170
|
+
const storedPath = path.join('uploads', `e${id}`, `${Date.now()}_${safe}`);
|
|
171
|
+
fs.writeFileSync(path.join(git.DATA_DIR, storedPath), buf);
|
|
172
|
+
// 缩略图(前端canvas生成的小图,可选):列表加载用,原图仅灯箱打开
|
|
173
|
+
let thumbPath = '';
|
|
174
|
+
if (thumb_b64) {
|
|
175
|
+
try {
|
|
176
|
+
const tbuf = Buffer.from(thumb_b64, 'base64');
|
|
177
|
+
if (tbuf.length > 0 && tbuf.length <= 2 * 1024 * 1024) {
|
|
178
|
+
thumbPath = path.join('uploads', `e${id}`, `${Date.now()}_thumb_${safe}`);
|
|
179
|
+
fs.writeFileSync(path.join(git.DATA_DIR, thumbPath), tbuf);
|
|
180
|
+
}
|
|
181
|
+
} catch (_) { thumbPath = ''; }
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const row = db.addEntityImage(id, { filename, stored_path: storedPath, caption, thumb_path: thumbPath });
|
|
185
|
+
const rel = (p) => (p ? '/uploads/' + p.replace(/^uploads[\\/]/, '') : null);
|
|
186
|
+
res.json({ ...row, url: rel(row.stored_path), thumb_url: rel(row.thumb_path) || rel(row.stored_path) });
|
|
187
|
+
} catch (e) {
|
|
188
|
+
cleanupImageFiles([storedPath, thumbPath]); // 入库失败时回滚文件,避免孤儿文件
|
|
189
|
+
throw e;
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
api.delete('/images/:imgId', (req, res) => {
|
|
194
|
+
const row = db.deleteEntityImage(Number(req.params.imgId));
|
|
195
|
+
cleanupImageFiles([row.stored_path, row.thumb_path].filter(Boolean));
|
|
196
|
+
res.json({ deleted: row });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ---------- 关系 ----------
|
|
200
|
+
api.get('/relations', (req, res) => res.json(db.listRelations()));
|
|
201
|
+
api.post('/relations', (req, res) => res.json(db.addRelation(req.body, '手工')));
|
|
202
|
+
api.put('/relations/:id', (req, res) => res.json(db.updateRelation(Number(req.params.id), req.body, '手工')));
|
|
203
|
+
api.delete('/relations/:id', (req, res) => res.json(db.deleteRelation(Number(req.params.id), '手工')));
|
|
204
|
+
|
|
205
|
+
// ---------- 图谱 / 日志 ----------
|
|
206
|
+
api.get('/graph', (req, res) => res.json(db.getGraph()));
|
|
207
|
+
|
|
208
|
+
// 推理引擎:动态推导隐性关系(传递/对称/逆),可选限定中心实体
|
|
209
|
+
api.get('/inference', (req, res) => {
|
|
210
|
+
const g = db.getGraph();
|
|
211
|
+
const inferred = inference.computeInferred(g);
|
|
212
|
+
const center = String(req.query.center || '').trim();
|
|
213
|
+
let result = inferred;
|
|
214
|
+
if (center) {
|
|
215
|
+
const byId = new Map(g.entities.map((e) => [e.id, e]));
|
|
216
|
+
let cid = null;
|
|
217
|
+
if (/^\d+$/.test(center)) {
|
|
218
|
+
if (byId.has(Number(center))) cid = Number(center);
|
|
219
|
+
else return res.status(404).json({ error: `实体"${center}"不存在` });
|
|
220
|
+
} else {
|
|
221
|
+
const cands = g.entities.filter((e) => e.name === center);
|
|
222
|
+
if (cands.length === 0) return res.status(404).json({ error: `实体"${center}"不存在` });
|
|
223
|
+
if (cands.length > 1) return res.status(409).json({ error: `实体名"${center}"存在${cands.length}个候选,请改用id`, candidates: cands.map((h) => ({ id: h.id, name: h.name, category: h.category })) });
|
|
224
|
+
cid = cands[0].id;
|
|
225
|
+
}
|
|
226
|
+
result = inferred.filter((i) => i.source_id === cid || i.target_id === cid);
|
|
227
|
+
}
|
|
228
|
+
const nodes = new Set();
|
|
229
|
+
for (const i of result) { nodes.add(i.source_id); nodes.add(i.target_id); }
|
|
230
|
+
res.json({ inferred: result, entities: g.entities.filter((e) => nodes.has(e.id)), ontology: inference.loadOntology() });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
api.get('/ontology', (req, res) => res.json(inference.loadOntology()));
|
|
234
|
+
|
|
235
|
+
// ---------- 向量混合检索 ----------
|
|
236
|
+
api.get('/embeddings/status', (req, res) => res.json(embeddings.status()));
|
|
237
|
+
|
|
238
|
+
api.get('/embeddings/settings', (req, res) => {
|
|
239
|
+
const s = embeddings.loadSettings();
|
|
240
|
+
res.json({ ...s, api_key: s.api_key ? '已配置' : '' }); // 不回传真实密钥
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
api.put('/embeddings/settings', (req, res) => {
|
|
244
|
+
try {
|
|
245
|
+
const patch = req.body || {};
|
|
246
|
+
// 前端传"已配置"占位时保留原密钥
|
|
247
|
+
if (patch.api_key === '已配置') delete patch.api_key;
|
|
248
|
+
const s = embeddings.saveSettings(patch);
|
|
249
|
+
res.json({ ...s, api_key: s.api_key ? '已配置' : '' });
|
|
250
|
+
} catch (e) { res.status(400).json({ error: e.message }); }
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
api.post('/embeddings/build', async (req, res) => {
|
|
254
|
+
try {
|
|
255
|
+
const result = await embeddings.build();
|
|
256
|
+
res.json(result);
|
|
257
|
+
} catch (e) { res.status(400).json({ error: e.message }); }
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
api.post('/search', async (req, res) => {
|
|
261
|
+
const q = String((req.body || {}).query || '').trim();
|
|
262
|
+
if (!q) return res.status(400).json({ error: 'query不能为空' });
|
|
263
|
+
try {
|
|
264
|
+
res.json(await embeddings.search(q, (req.body || {}).top_k));
|
|
265
|
+
} catch (e) { res.status(400).json({ error: e.message }); }
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
api.post('/cypher', (req, res) => {
|
|
269
|
+
const q = String((req.body || {}).query || '').trim();
|
|
270
|
+
if (!q) return res.status(400).json({ error: 'query不能为空' });
|
|
271
|
+
try { res.json({ rows: db.miniCypher(q) }); }
|
|
272
|
+
catch (e) { res.status(400).json({ error: e.message }); }
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
api.put('/ontology', (req, res) => {
|
|
276
|
+
try { res.json(inference.saveOntology(req.body || {})); }
|
|
277
|
+
catch (e) { res.status(400).json({ error: '本体规则保存失败: ' + e.message }); }
|
|
278
|
+
});
|
|
279
|
+
api.get('/logs', (req, res) => res.json(db.getLogs(Number(req.query.limit) || 200)));
|
|
280
|
+
|
|
281
|
+
// ---------- RDF导出 ----------
|
|
282
|
+
api.get('/export/rdf', (req, res) => {
|
|
283
|
+
const ttl = rdf.exportTurtle(db.getGraph());
|
|
284
|
+
res.setHeader('Content-Type', 'text/turtle; charset=utf-8');
|
|
285
|
+
res.setHeader('Content-Disposition', 'attachment; filename="knowledge_graph.ttl"');
|
|
286
|
+
res.send(ttl);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ---------- 文件:图谱保存/打开/另存 ----------
|
|
290
|
+
api.get('/export/db', (req, res) => {
|
|
291
|
+
const raw = String(req.query.name || '').trim().replace(/[\\/:*?"<>|]/g, '_') || 'kg.db';
|
|
292
|
+
const name = /\.(db|sqlite|sqlite3)$/i.test(raw) ? raw : raw.replace(/\.[^.]*$/, '') + '.db';
|
|
293
|
+
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(name)}`);
|
|
294
|
+
res.setHeader('Content-Type', 'application/octet-stream');
|
|
295
|
+
res.send(fs.readFileSync(db.DB_PATH)); // 写操作均经事务提交,主库文件始终处于一致状态
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
api.post('/graph/import', (req, res) => {
|
|
299
|
+
const { filename, content_b64 } = req.body || {};
|
|
300
|
+
if (!content_b64) return res.status(400).json({ error: '必须提供图谱数据库文件(content_b64)' });
|
|
301
|
+
let buf;
|
|
302
|
+
try { buf = Buffer.from(content_b64, 'base64'); } catch (_) { return res.status(400).json({ error: 'content_b64不是合法的base64' }); }
|
|
303
|
+
|
|
304
|
+
// 临时库校验:完整性 + 必需表 + 必需列(lib/importer.js)
|
|
305
|
+
const os = require('os');
|
|
306
|
+
const check = importer.validateImportBuffer(buf);
|
|
307
|
+
if (!check.ok) return res.status(400).json({ error: check.error });
|
|
308
|
+
const importedMaxLog = check.importedMaxLog;
|
|
309
|
+
const counts = check.counts;
|
|
310
|
+
const tmp = path.join(os.tmpdir(), `kg_import_${Date.now()}.db`);
|
|
311
|
+
fs.writeFileSync(tmp, buf);
|
|
312
|
+
|
|
313
|
+
// 当前数据自动备份保存点,再替换主库
|
|
314
|
+
let backup = null;
|
|
315
|
+
try { backup = git.savepoint(`打开图谱前自动备份 ${new Date().toLocaleString('zh-CN')}`, '系统'); } catch (_) { backup = null; }
|
|
316
|
+
|
|
317
|
+
fs.copyFileSync(tmp, db.DB_PATH);
|
|
318
|
+
fs.unlinkSync(tmp);
|
|
319
|
+
git.writeMeta({ last_saved_log: importedMaxLog }); // 日志区间绑定基准与导入库对齐
|
|
320
|
+
db.reopen();
|
|
321
|
+
const checkAfter = db.integrityCheck();
|
|
322
|
+
if (!checkAfter.ok) { const e = new Error('导入后完整性校验失败: ' + checkAfter.detail); e.status = 500; throw e; }
|
|
323
|
+
// .db 不携带 uploads/ 资产:剪除指向不存在文件的图片行,避免前端裂图
|
|
324
|
+
const prunedImages = db.pruneMissingImages();
|
|
325
|
+
|
|
326
|
+
res.json({ ok: true, counts: db.counts(), imported: { entities: counts.entities, relations: counts.relations }, backup_short: backup && backup.hash ? backup.hash : null, pruned_images: prunedImages.pruned });
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ---------- 另存为图谱网页(单文件只读查看器) ----------
|
|
330
|
+
api.get('/export/html', (req, res) => {
|
|
331
|
+
const viewer = require('./lib/viewer');
|
|
332
|
+
const html = viewer.buildViewerHtml(db.getGraph());
|
|
333
|
+
const raw = String(req.query.name || '').trim();
|
|
334
|
+
const name = /[\\/:*?"<>|]/.test(raw) || !raw ? 'kg-viewer.html' : raw;
|
|
335
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
336
|
+
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(/\.html?$/i.test(name) ? name : name + '.html')}`);
|
|
337
|
+
res.send(html);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// ---------- Git保存点与回溯 ----------
|
|
341
|
+
api.get('/git/history', (req, res) => res.json(git.history(Number(req.query.limit) || 100)));
|
|
342
|
+
api.post('/git/savepoint', (req, res) => {
|
|
343
|
+
const r = git.savepoint(req.body && req.body.message, '手工');
|
|
344
|
+
res.json(r);
|
|
345
|
+
});
|
|
346
|
+
api.post('/git/restore', (req, res) => {
|
|
347
|
+
const hash = req.body && req.body.hash;
|
|
348
|
+
if (!hash || typeof hash !== 'string') return res.status(400).json({ error: '必须提供要恢复的保存点hash' });
|
|
349
|
+
const r = git.restore(hash.trim());
|
|
350
|
+
db.logRestore(r.restored, r.backup);
|
|
351
|
+
res.json(r);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// ---------- OpenCode自然语言指令 ----------
|
|
355
|
+
// AI任务互斥:OpenCode进程最长5分钟,防止并发请求叠加进程互抢SQLite与内存
|
|
356
|
+
let agentBusy = false;
|
|
357
|
+
|
|
358
|
+
api.post('/agent', async (req, res) => {
|
|
359
|
+
if (agentBusy) return res.status(429).json({ error: '已有AI任务执行中(问答或文档导入),请等待完成后再试' });
|
|
360
|
+
const instruction = req.body && req.body.instruction;
|
|
361
|
+
if (!instruction || !String(instruction).trim()) return res.status(400).json({ error: '指令不能为空' });
|
|
362
|
+
agentBusy = true;
|
|
363
|
+
try {
|
|
364
|
+
const result = await agent.runAgent(instruction);
|
|
365
|
+
if (!result.ok) return res.status(502).json({ error: result.error });
|
|
366
|
+
|
|
367
|
+
let applied = [];
|
|
368
|
+
let applyError = null;
|
|
369
|
+
if (result.ops && result.ops.length) {
|
|
370
|
+
try {
|
|
371
|
+
applied = db.applyAgentOps(result.ops);
|
|
372
|
+
// Agent批次写入后自动打保存点,与日志双向绑定
|
|
373
|
+
const title = `OpenCode: ${String(instruction).slice(0, 60).replace(/\n/g, ' ')}`;
|
|
374
|
+
let sp = null;
|
|
375
|
+
try { sp = git.savepoint(title, 'OpenCode'); } catch (e) { sp = { committed: false, message: e.message }; }
|
|
376
|
+
result.savepoint = sp;
|
|
377
|
+
} catch (e) {
|
|
378
|
+
applyError = e.message; // 违规操作被拦截,数据库保持上一个合规版本
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
res.json({ reply: result.reply, ops_found: (result.ops || []).length, applied, apply_error: applyError, parse_error: result.parse_error, savepoint: result.savepoint || null, retried: result.retried || false, partial_reply: result.partial_reply || null, session: result.session ? { has_session: true } : null });
|
|
382
|
+
} finally { agentBusy = false; }
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// ---------- 文档导入:文本/文档 → 三元组 → 融合入库 ----------
|
|
386
|
+
const TEXT_LIKE_EXT = ['.txt', '.md', '.markdown', '.csv', '.json', '.html', '.htm', '.xml'];
|
|
387
|
+
const MAX_DOC_BYTES = 20 * 1024 * 1024;
|
|
388
|
+
const MAX_CHUNKS = 40;
|
|
389
|
+
const CHUNK_SIZE = 1400;
|
|
390
|
+
|
|
391
|
+
function safeName(name) {
|
|
392
|
+
const base = String(name || 'document').split(/[\\/]/).pop().replace(/[^\w.\-\u4e00-\u9fa5]/g, '_');
|
|
393
|
+
return base.slice(0, 80) || 'document';
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function splitChunks(text, size = CHUNK_SIZE) {
|
|
397
|
+
const paras = text.split(/\n{2,}/);
|
|
398
|
+
const chunks = [];
|
|
399
|
+
let cur = '';
|
|
400
|
+
const pushCur = () => { if (cur.trim()) chunks.push(cur.trim()); cur = ''; };
|
|
401
|
+
for (const p of paras) {
|
|
402
|
+
if (p.length > size * 1.5) {
|
|
403
|
+
pushCur();
|
|
404
|
+
let s = '';
|
|
405
|
+
for (const sent of p.split(/(?<=[。!?.!?\n])/)) {
|
|
406
|
+
if ((s + sent).length > size && s) { chunks.push(s.trim()); s = ''; }
|
|
407
|
+
s += sent;
|
|
408
|
+
}
|
|
409
|
+
cur = s;
|
|
410
|
+
} else if ((cur + p).length > size && cur) {
|
|
411
|
+
pushCur();
|
|
412
|
+
cur = p;
|
|
413
|
+
} else {
|
|
414
|
+
cur += (cur ? '\n\n' : '') + p;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
pushCur();
|
|
418
|
+
return chunks;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function docInstruction(filename, i, n, userInstruction) {
|
|
422
|
+
let s = `【文档导入任务】请使用 kg-triples 技能的完整流程,从文件《${filename}》的第 ${i}/${n} 块文本中抽取三元组(含属性)。`;
|
|
423
|
+
if (i > 1) s += '注意与"当前图谱"中已入库实体对齐:同名实体直接用其id引用,禁止重复创建。';
|
|
424
|
+
if (userInstruction) s += `\n用户补充要求:${userInstruction}`;
|
|
425
|
+
return s;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// 文档导入进度(内存态,供前端轮询;docId=本次导入的时间戳标识)
|
|
429
|
+
let docProgress = { active: false, stage: 'idle', chunk: 0, chunks: 0, mode: '', filename: '' };
|
|
430
|
+
|
|
431
|
+
api.get('/agent/doc/progress', (req, res) => res.json(docProgress));
|
|
432
|
+
|
|
433
|
+
api.post('/agent/doc', async (req, res) => {
|
|
434
|
+
if (agentBusy) return res.status(429).json({ error: '已有AI任务执行中(问答或文档导入),请等待完成后再试' });
|
|
435
|
+
const { filename, content_b64, instruction } = req.body || {};
|
|
436
|
+
if (!filename || !content_b64) return res.status(400).json({ error: '必须提供文件名与内容(content_b64)' });
|
|
437
|
+
let buf;
|
|
438
|
+
try { buf = Buffer.from(content_b64, 'base64'); } catch (_) { return res.status(400).json({ error: 'content_b64不是合法的base64' }); }
|
|
439
|
+
if (!buf.length) return res.status(400).json({ error: '文件内容为空' });
|
|
440
|
+
if (buf.length > MAX_DOC_BYTES) return res.status(400).json({ error: `文件超过${MAX_DOC_BYTES / 1024 / 1024}MB上限,请拆分后导入` });
|
|
441
|
+
agentBusy = true;
|
|
442
|
+
docProgress = { active: true, stage: 'preparing', chunk: 0, chunks: 0, mode: '', filename: safeName(filename) };
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
const name = safeName(filename);
|
|
446
|
+
const ext = path.extname(name).toLowerCase();
|
|
447
|
+
const uploadsDir = path.join(git.DATA_DIR, 'uploads');
|
|
448
|
+
fs.mkdirSync(uploadsDir, { recursive: true });
|
|
449
|
+
const savedPath = path.join(uploadsDir, `${Date.now()}_${name}`);
|
|
450
|
+
fs.writeFileSync(savedPath, buf);
|
|
451
|
+
|
|
452
|
+
const report = { filename: name, mode: '', chunks: 0, chunks_ok: 0, entities_added: 0, relations_added: 0, others: 0, errors: [], reply: '' };
|
|
453
|
+
const tally = (applied) => {
|
|
454
|
+
for (const a of applied || []) {
|
|
455
|
+
if (a.op === 'add_entity') report.entities_added++;
|
|
456
|
+
else if (a.op === 'add_relation') report.relations_added++;
|
|
457
|
+
else report.others++;
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const isTextLike = TEXT_LIKE_EXT.includes(ext);
|
|
462
|
+
const doApply = (r) => {
|
|
463
|
+
if (!r.ok) { report.errors.push(r.error); return r; }
|
|
464
|
+
if (r.ops && r.ops.length) {
|
|
465
|
+
try {
|
|
466
|
+
const applied = db.applyAgentOps(r.ops);
|
|
467
|
+
tally(applied);
|
|
468
|
+
report.chunks_ok++;
|
|
469
|
+
} catch (e) {
|
|
470
|
+
report.errors.push(`第${report.chunks_ok + 1}块操作被RDF校验拦截: ${e.message}`);
|
|
471
|
+
}
|
|
472
|
+
} else {
|
|
473
|
+
report.chunks_ok++;
|
|
474
|
+
}
|
|
475
|
+
if (r.reply) report.reply = r.reply;
|
|
476
|
+
return r;
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
try {
|
|
480
|
+
if (isTextLike && buf.length > 1500) {
|
|
481
|
+
// 文本类大文档:后端分块流水线,逐块抽取+融合,图谱摘要随每块刷新实现跨块对齐
|
|
482
|
+
report.mode = 'chunked';
|
|
483
|
+
const chunks = splitChunks(buf.toString('utf8'));
|
|
484
|
+
if (chunks.length > MAX_CHUNKS) {
|
|
485
|
+
docProgress = { ...docProgress, active: false, stage: 'error' };
|
|
486
|
+
return res.status(400).json({ error: `文档分块后达${chunks.length}块(上限${MAX_CHUNKS}),请拆分后导入` });
|
|
487
|
+
}
|
|
488
|
+
report.chunks = chunks.length;
|
|
489
|
+
docProgress = { ...docProgress, mode: 'chunked', chunks: chunks.length, stage: 'importing' };
|
|
490
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
491
|
+
docProgress = { ...docProgress, chunk: i + 1 };
|
|
492
|
+
const inst = `${docInstruction(name, i + 1, chunks.length, instruction)}\n【文本块内容】\n<<<\n${chunks[i]}\n>>>`;
|
|
493
|
+
const r = doApply(await agent.runAgent(inst));
|
|
494
|
+
if (!r.ok) break; // agent层错误(超时/服务错误)时终止后续块
|
|
495
|
+
}
|
|
496
|
+
} else {
|
|
497
|
+
// 小文本或PDF/Word等二进制文档:整体作为附件交给OpenCode(kg-triples技能)
|
|
498
|
+
report.mode = 'attached';
|
|
499
|
+
report.chunks = 1;
|
|
500
|
+
docProgress = { ...docProgress, mode: 'attached', chunks: 1, chunk: 1, stage: 'importing' };
|
|
501
|
+
const inst = docInstruction(name, 1, 1, instruction) + ' 文档已作为附件挂载,请先读取再抽取。';
|
|
502
|
+
doApply(await agent.runAgent(inst, [savedPath]));
|
|
503
|
+
}
|
|
504
|
+
docProgress = { ...docProgress, active: true, stage: 'savepoint' };
|
|
505
|
+
} finally {
|
|
506
|
+
try { fs.unlinkSync(savedPath); } catch (_) { /* 保留亦可 */ }
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// 汇总保存点(含导入统计,与日志双向绑定)
|
|
510
|
+
let savepoint = null;
|
|
511
|
+
try {
|
|
512
|
+
savepoint = git.savepoint(`文档导入: ${name}(实体+${report.entities_added} 关系+${report.relations_added})`, 'OpenCode');
|
|
513
|
+
} catch (e) { savepoint = { committed: false, message: e.message }; }
|
|
514
|
+
docProgress = { active: false, stage: report.errors.length && !report.chunks_ok ? 'error' : 'done', chunk: docProgress.chunk, chunks: docProgress.chunks, mode: report.mode, filename: name };
|
|
515
|
+
|
|
516
|
+
res.json({ report, applied_total: report.entities_added + report.relations_added + report.others, savepoint, retried: false });
|
|
517
|
+
} finally { agentBusy = false; }
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
app.use('/api', api);
|
|
521
|
+
|
|
522
|
+
// 统一错误处理
|
|
523
|
+
app.use((err, req, res, next) => {
|
|
524
|
+
const status = err.status || 500;
|
|
525
|
+
res.status(status).json({ error: err.message, errors: err.errors || undefined });
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// ---------- 启动流程:异常兜底 + 自动拉起OpenCode ----------
|
|
529
|
+
let agentAvailable = false;
|
|
530
|
+
|
|
531
|
+
function checkAgent() {
|
|
532
|
+
try {
|
|
533
|
+
require('child_process').execFileSync('opencode', ['--version'], { encoding: 'utf8', timeout: 15000 });
|
|
534
|
+
agentAvailable = true;
|
|
535
|
+
} catch (_) {
|
|
536
|
+
agentAvailable = false;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function bootstrap() {
|
|
541
|
+
require('./lib/paths').ensureLegacyMigration();
|
|
542
|
+
git.ensureRepo();
|
|
543
|
+
git.backupCopy(true); // 启动时强制留一份滚动副本,目录损坏时仍有外部备份可救
|
|
544
|
+
try {
|
|
545
|
+
db.open();
|
|
546
|
+
} catch (e) {
|
|
547
|
+
console.error('[兜底] 数据库异常:', e.message);
|
|
548
|
+
// 数据库出错自动保留并恢复上一个合规版本
|
|
549
|
+
try {
|
|
550
|
+
const commits = git.history(5);
|
|
551
|
+
const good = commits.find((c) => c.title !== '回滚前自动备份');
|
|
552
|
+
if (good) {
|
|
553
|
+
console.error('[兜底] 正在恢复最近合规版本:', good.short, good.title);
|
|
554
|
+
git.restore(good.hash);
|
|
555
|
+
db.open();
|
|
556
|
+
console.error('[兜底] 已恢复上一个合规版本');
|
|
557
|
+
} else {
|
|
558
|
+
throw e;
|
|
559
|
+
}
|
|
560
|
+
} catch (e2) {
|
|
561
|
+
console.error('[兜底] 恢复失败,请检查 data/ 目录:', e2.message);
|
|
562
|
+
process.exit(1);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
seedIfEmpty();
|
|
566
|
+
checkAgent();
|
|
567
|
+
console.log(agentAvailable ? '[OpenCode] 进程已就绪,可在前端输入自然语言指令' : '[OpenCode] 未检测到 opencode CLI,自然语言补全不可用(其余功能不受影响)');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function seedIfEmpty() {
|
|
571
|
+
if (db.counts().entities > 0) return;
|
|
572
|
+
console.log('[初始化] 空库,写入示例数据…');
|
|
573
|
+
const beer = db.addEntity({ name: '长江', category: '物理实体', attributes: { 长度公里: 6397, 类型: '河流' } }, '手工');
|
|
574
|
+
const country = db.addEntity({ name: '中国', category: '物理实体', attributes: { 大洲: '亚洲' } }, '手工');
|
|
575
|
+
const commerce = db.addEntity({ name: '电子商务', category: '抽象实体', attributes: { 兴起年代: '1990年代' } }, '手工');
|
|
576
|
+
const year = db.addEntity({ name: '2026年', category: '时间实体', attributes: { 年份: 2026 } }, '手工');
|
|
577
|
+
const users = db.addEntity({ name: '网民规模', category: '数值实体', attributes: { 数值: 11.7, 单位: '亿' } }, '手工');
|
|
578
|
+
db.addRelation({ source_id: beer.id, target_id: country.id, name: '流经', category: '空间' }, '手工');
|
|
579
|
+
db.addRelation({ source_id: users.id, target_id: commerce.id, name: '推动发展', category: '互动' }, '手工');
|
|
580
|
+
db.addRelation({ source_id: commerce.id, target_id: year.id, name: '成熟于', category: '时间' }, '手工');
|
|
581
|
+
git.savepoint('初始化:数据库与示例数据', '系统');
|
|
582
|
+
console.log('[初始化] 完成,已创建首个Git保存点');
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
bootstrap();
|
|
586
|
+
|
|
587
|
+
const HOST = process.env.KG_HOST || '127.0.0.1';
|
|
588
|
+
|
|
589
|
+
// 自动重启(更新后):派生脱离的新进程接管,当前进程退出
|
|
590
|
+
function scheduleRestart() {
|
|
591
|
+
console.log('[更新] 3秒后自动重启服务…');
|
|
592
|
+
setTimeout(() => {
|
|
593
|
+
try {
|
|
594
|
+
const { spawn } = require('child_process');
|
|
595
|
+
const log = fs.openSync(path.join(require('./lib/paths').DATA_DIR, 'restart.log'), 'a');
|
|
596
|
+
const child = spawn(process.execPath, [...process.execArgv, path.join(__dirname, 'server.js')], {
|
|
597
|
+
detached: true, stdio: ['ignore', log, log], env: process.env, cwd: __dirname,
|
|
598
|
+
});
|
|
599
|
+
child.unref();
|
|
600
|
+
console.log(`[更新] 新进程已启动 (pid ${child.pid}),当前进程即将退出`);
|
|
601
|
+
fs.writeSync(log, `\n[${new Date().toISOString()}] 更新重启:新进程 pid ${child.pid}\n`);
|
|
602
|
+
setTimeout(() => process.exit(0), 500);
|
|
603
|
+
} catch (e) {
|
|
604
|
+
console.error('[更新] 自动重启失败,请手动重新运行启动脚本:', e.message);
|
|
605
|
+
}
|
|
606
|
+
}, 3000);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// 端口绑定(更新重启衔接时旧进程尚未释放端口,自动重试)
|
|
610
|
+
(function bind(attempt) {
|
|
611
|
+
const server = app.listen(PORT, HOST, () => {
|
|
612
|
+
console.log(`本地知识图谱整合器已启动: http://localhost:${PORT} (监听${HOST}${HOST === '127.0.0.1' ? ',如需局域网访问设 KG_HOST=0.0.0.0' : ',已暴露到局域网'})`);
|
|
613
|
+
console.log('数据文件: data/kg.db (本地Git仓库托管,可打保存点/回溯)');
|
|
614
|
+
console.log('全流程本地运行,仅OpenCode可联网补全公开信息');
|
|
615
|
+
});
|
|
616
|
+
server.on('error', (e) => {
|
|
617
|
+
if (e.code === 'EADDRINUSE' && attempt < 15) {
|
|
618
|
+
console.log(`端口 ${PORT} 被占用(可能为更新重启衔接),1秒后重试 (${attempt + 1}/15)`);
|
|
619
|
+
setTimeout(() => bind(attempt + 1), 1000);
|
|
620
|
+
} else {
|
|
621
|
+
console.error('监听失败:', e.message);
|
|
622
|
+
process.exit(1);
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
})(0);
|