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/lib/db.js
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
6
|
+
const V = require('./validator');
|
|
7
|
+
const { DATA_DIR, DB_PATH } = require('./paths');
|
|
8
|
+
|
|
9
|
+
let db = null;
|
|
10
|
+
let version = 0; // 数据版本号(=最新日志id),用于前端实时同步
|
|
11
|
+
|
|
12
|
+
function open() {
|
|
13
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
14
|
+
db = new DatabaseSync(DB_PATH);
|
|
15
|
+
db.exec('PRAGMA journal_mode = DELETE; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 4000;');
|
|
16
|
+
initSchema();
|
|
17
|
+
const ok = integrityCheck();
|
|
18
|
+
if (!ok.ok) throw new Error('数据库完整性校验失败: ' + ok.detail);
|
|
19
|
+
const row = db.prepare('SELECT COALESCE(MAX(id),0) AS m FROM operation_logs').get();
|
|
20
|
+
version = row.m;
|
|
21
|
+
return db;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function integrityCheck() {
|
|
25
|
+
try {
|
|
26
|
+
const r = db.prepare('PRAGMA integrity_check').get();
|
|
27
|
+
const v = Object.values(r)[0];
|
|
28
|
+
if (v === 'ok') return { ok: true };
|
|
29
|
+
return { ok: false, detail: String(v) };
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return { ok: false, detail: e.message };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function initSchema() {
|
|
36
|
+
db.exec(`
|
|
37
|
+
CREATE TABLE IF NOT EXISTS entities (
|
|
38
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
39
|
+
name TEXT NOT NULL,
|
|
40
|
+
category TEXT NOT NULL CHECK(category IN ('物理实体','抽象实体','数值实体','时间实体')),
|
|
41
|
+
attributes TEXT NOT NULL DEFAULT '{}',
|
|
42
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
43
|
+
source TEXT NOT NULL CHECK(source IN ('手工','OpenCode'))
|
|
44
|
+
);
|
|
45
|
+
CREATE TABLE IF NOT EXISTS relations (
|
|
46
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
47
|
+
source_id INTEGER NOT NULL,
|
|
48
|
+
target_id INTEGER NOT NULL,
|
|
49
|
+
name TEXT NOT NULL,
|
|
50
|
+
category TEXT NOT NULL CHECK(category IN ('空间','互动','归属','时间','属性')),
|
|
51
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
52
|
+
source TEXT NOT NULL CHECK(source IN ('手工','OpenCode'))
|
|
53
|
+
);
|
|
54
|
+
CREATE TABLE IF NOT EXISTS operation_logs (
|
|
55
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
56
|
+
op_type TEXT NOT NULL,
|
|
57
|
+
snapshot TEXT NOT NULL,
|
|
58
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
59
|
+
source TEXT NOT NULL CHECK(source IN ('手工','OpenCode','系统'))
|
|
60
|
+
);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_rel_source ON relations(source_id);
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_rel_target ON relations(target_id);
|
|
63
|
+
CREATE TABLE IF NOT EXISTS entity_images (
|
|
64
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
65
|
+
entity_id INTEGER NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
|
66
|
+
filename TEXT NOT NULL,
|
|
67
|
+
stored_path TEXT NOT NULL,
|
|
68
|
+
caption TEXT NOT NULL DEFAULT '',
|
|
69
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
70
|
+
);
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_img_entity ON entity_images(entity_id);
|
|
72
|
+
`);
|
|
73
|
+
// 旧库迁移:entity_embeddings 已拆分至独立 vectors.db,主库内残留表直接清除
|
|
74
|
+
const allTables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((c) => c.name);
|
|
75
|
+
if (allTables.includes('entity_embeddings')) {
|
|
76
|
+
db.exec('DROP TABLE entity_embeddings');
|
|
77
|
+
}
|
|
78
|
+
// 旧库迁移:补 thumb_path 缩略图列
|
|
79
|
+
const imgCols = db.prepare('PRAGMA table_info(entity_images)').all().map((c) => c.name);
|
|
80
|
+
if (!imgCols.includes('thumb_path')) {
|
|
81
|
+
db.exec("ALTER TABLE entity_images ADD COLUMN thumb_path TEXT NOT NULL DEFAULT ''");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function tx(fn) {
|
|
86
|
+
db.exec('BEGIN');
|
|
87
|
+
try {
|
|
88
|
+
const result = fn();
|
|
89
|
+
db.exec('COMMIT');
|
|
90
|
+
bumpVersion();
|
|
91
|
+
return result;
|
|
92
|
+
} catch (e) {
|
|
93
|
+
try { db.exec('ROLLBACK'); } catch (_) { /* 保持上一个合规版本 */ }
|
|
94
|
+
throw e;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function bumpVersion() {
|
|
99
|
+
const row = db.prepare('SELECT COALESCE(MAX(id),0) AS m FROM operation_logs').get();
|
|
100
|
+
version = row.m;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function logOp(opType, snapshot, source) {
|
|
104
|
+
const info = db.prepare('INSERT INTO operation_logs (op_type, snapshot, source) VALUES (?, ?, ?)').run(opType, JSON.stringify(snapshot), source);
|
|
105
|
+
version = Number(info.lastInsertRowid);
|
|
106
|
+
return Number(info.lastInsertRowid);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getEntity(id) {
|
|
110
|
+
return db.prepare('SELECT * FROM entities WHERE id = ?').get(id);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function listEntities() {
|
|
114
|
+
return db.prepare('SELECT * FROM entities ORDER BY id').all();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getRelation(id) {
|
|
118
|
+
return db.prepare('SELECT * FROM relations WHERE id = ?').get(id);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function listRelations() {
|
|
122
|
+
return db.prepare('SELECT * FROM relations ORDER BY id').all();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---- 核心写操作(无事务,供单笔API与Agent批量事务复用)----
|
|
126
|
+
function writeErr(check) {
|
|
127
|
+
const e = new Error('RDF校验未通过: ' + check.errors.join('; '));
|
|
128
|
+
e.status = 400; e.errors = check.errors;
|
|
129
|
+
return e;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function addEntityCore(input, source) {
|
|
133
|
+
const check = V.validateEntityInput(input);
|
|
134
|
+
if (!check.ok) throw writeErr(check);
|
|
135
|
+
const v = check.value;
|
|
136
|
+
const info = db.prepare('INSERT INTO entities (name, category, attributes, source) VALUES (?, ?, ?, ?)')
|
|
137
|
+
.run(v.name, v.category, JSON.stringify(v.attributes), source);
|
|
138
|
+
const id = Number(info.lastInsertRowid);
|
|
139
|
+
const row = getEntity(id);
|
|
140
|
+
logOp('ADD_ENTITY', { entity: row }, source);
|
|
141
|
+
return row;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function updateEntityCore(id, patch, source) {
|
|
145
|
+
const existing = getEntity(id);
|
|
146
|
+
if (!existing) { const e = new Error(`实体id=${id} 不存在`); e.status = 404; throw e; }
|
|
147
|
+
const check = V.validateEntityPatch(patch, existing);
|
|
148
|
+
if (!check.ok) throw writeErr(check);
|
|
149
|
+
const v = check.value;
|
|
150
|
+
db.prepare('UPDATE entities SET name = ?, category = ?, attributes = ? WHERE id = ?')
|
|
151
|
+
.run(v.name, v.category, JSON.stringify(v.attributes), id);
|
|
152
|
+
const row = getEntity(id);
|
|
153
|
+
logOp('UPDATE_ENTITY', { before: existing, after: row }, source);
|
|
154
|
+
return row;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function deleteEntityCore(id, source) {
|
|
158
|
+
const existing = getEntity(id);
|
|
159
|
+
if (!existing) { const e = new Error(`实体id=${id} 不存在`); e.status = 404; throw e; }
|
|
160
|
+
const rels = db.prepare('SELECT * FROM relations WHERE source_id = ? OR target_id = ?').all(id, id);
|
|
161
|
+
for (const r of rels) {
|
|
162
|
+
db.prepare('DELETE FROM relations WHERE id = ?').run(r.id);
|
|
163
|
+
logOp('DELETE_RELATION', { relation: r, reason: `级联删除(实体id=${id})` }, source);
|
|
164
|
+
}
|
|
165
|
+
const imgRows = listEntityImages(id);
|
|
166
|
+
db.prepare('DELETE FROM entity_images WHERE entity_id = ?').run(id);
|
|
167
|
+
db.prepare('DELETE FROM entities WHERE id = ?').run(id);
|
|
168
|
+
logOp('DELETE_ENTITY', { entity: existing, cascaded_relations: rels.length, cascaded_images: imgRows.length }, source);
|
|
169
|
+
return { deleted: existing, cascaded_relations: rels.length, image_files: imgRows.map((r) => r.stored_path) };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function addRelationCore(input, source) {
|
|
173
|
+
const check = V.validateRelationInput(input, db);
|
|
174
|
+
if (!check.ok) throw writeErr(check);
|
|
175
|
+
const v = check.value;
|
|
176
|
+
const info = db.prepare('INSERT INTO relations (source_id, target_id, name, category, source) VALUES (?, ?, ?, ?, ?)')
|
|
177
|
+
.run(v.source_id, v.target_id, v.name, v.category, source);
|
|
178
|
+
const id = Number(info.lastInsertRowid);
|
|
179
|
+
const row = getRelation(id);
|
|
180
|
+
logOp('ADD_RELATION', { relation: row }, source);
|
|
181
|
+
return row;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function updateRelationCore(id, patch, source) {
|
|
185
|
+
const existing = getRelation(id);
|
|
186
|
+
if (!existing) { const e = new Error(`关系id=${id} 不存在`); e.status = 404; throw e; }
|
|
187
|
+
const check = V.validateRelationPatch(patch, existing, db);
|
|
188
|
+
if (!check.ok) throw writeErr(check);
|
|
189
|
+
const v = check.value;
|
|
190
|
+
db.prepare('UPDATE relations SET source_id = ?, target_id = ?, name = ?, category = ? WHERE id = ?')
|
|
191
|
+
.run(v.source_id, v.target_id, v.name, v.category, id);
|
|
192
|
+
const row = getRelation(id);
|
|
193
|
+
logOp('UPDATE_RELATION', { before: existing, after: row }, source);
|
|
194
|
+
return row;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function deleteRelationCore(id, source) {
|
|
198
|
+
const existing = getRelation(id);
|
|
199
|
+
if (!existing) { const e = new Error(`关系id=${id} 不存在`); e.status = 404; throw e; }
|
|
200
|
+
db.prepare('DELETE FROM relations WHERE id = ?').run(id);
|
|
201
|
+
logOp('DELETE_RELATION', { relation: existing }, source);
|
|
202
|
+
return { deleted: existing };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function addEntity(input, source) {
|
|
206
|
+
return tx(() => addEntityCore(input, source));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function updateEntity(id, patch, source) {
|
|
210
|
+
return tx(() => updateEntityCore(id, patch, source));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function deleteEntity(id, source) {
|
|
214
|
+
return tx(() => deleteEntityCore(id, source));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function addRelation(input, source) {
|
|
218
|
+
return tx(() => addRelationCore(input, source));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function updateRelation(id, patch, source) {
|
|
222
|
+
return tx(() => updateRelationCore(id, patch, source));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function deleteRelation(id, source) {
|
|
226
|
+
return tx(() => deleteRelationCore(id, source));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 实体键解析(id或精确名称;多候选抛409带candidates,供HTTP端点与ask模块共用)
|
|
230
|
+
function resolveKey(key) {
|
|
231
|
+
const trimmed = String(key || '').trim();
|
|
232
|
+
if (!trimmed) { const e = new Error('必须提供实体(id或名称)'); e.status = 400; throw e; }
|
|
233
|
+
if (/^\d+$/.test(trimmed)) {
|
|
234
|
+
const byId = getEntity(Number(trimmed));
|
|
235
|
+
if (byId) return byId.id;
|
|
236
|
+
}
|
|
237
|
+
const hits = listEntities().filter((e) => e.name === trimmed);
|
|
238
|
+
if (hits.length === 0) { const e = new Error(`实体"${trimmed}"不存在`); e.status = 404; throw e; }
|
|
239
|
+
if (hits.length > 1) {
|
|
240
|
+
const e = new Error(`实体名"${trimmed}"存在${hits.length}个候选,请改用id`);
|
|
241
|
+
e.status = 409;
|
|
242
|
+
e.candidates = hits.map((h) => ({ id: h.id, name: h.name, category: h.category }));
|
|
243
|
+
throw e;
|
|
244
|
+
}
|
|
245
|
+
return hits[0].id;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---- 两实体间最短关系链(双向BFS,无向)----
|
|
249
|
+
function findPath(fromId, toId, maxHops = 6) {
|
|
250
|
+
if (fromId === toId) {
|
|
251
|
+
const self = getEntity(fromId);
|
|
252
|
+
return self ? { found: true, hops: 0, entities: [self], relations: [] } : { found: false, hops: null, entities: [], relations: [] };
|
|
253
|
+
}
|
|
254
|
+
const adj = new Map();
|
|
255
|
+
const touch = (id) => { if (!adj.has(id)) adj.set(id, []); };
|
|
256
|
+
for (const r of listRelations()) {
|
|
257
|
+
touch(r.source_id); touch(r.target_id);
|
|
258
|
+
adj.get(r.source_id).push(r);
|
|
259
|
+
adj.get(r.target_id).push(r);
|
|
260
|
+
}
|
|
261
|
+
const prev = new Map([[fromId, null]]); // id -> { from, rel }
|
|
262
|
+
let frontier = [fromId];
|
|
263
|
+
for (let d = 0; d < maxHops && frontier.length; d++) {
|
|
264
|
+
const next = [];
|
|
265
|
+
for (const id of frontier) {
|
|
266
|
+
for (const r of adj.get(id) || []) {
|
|
267
|
+
const other = r.source_id === id ? r.target_id : r.source_id;
|
|
268
|
+
if (prev.has(other)) continue;
|
|
269
|
+
prev.set(other, { from: id, rel: r });
|
|
270
|
+
if (other === toId) {
|
|
271
|
+
const rels = [];
|
|
272
|
+
let cur = toId;
|
|
273
|
+
while (prev.get(cur)) { const p = prev.get(cur); rels.unshift(p.rel); cur = p.from; }
|
|
274
|
+
const entities = [getEntity(fromId)];
|
|
275
|
+
for (const rr of rels) {
|
|
276
|
+
const prevId = entities[entities.length - 1].id;
|
|
277
|
+
entities.push(getEntity(rr.source_id === prevId ? rr.target_id : rr.source_id));
|
|
278
|
+
}
|
|
279
|
+
return { found: true, hops: rels.length, entities, relations: rels };
|
|
280
|
+
}
|
|
281
|
+
next.push(other);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
frontier = next;
|
|
285
|
+
}
|
|
286
|
+
return { found: false, hops: null, entities: [], relations: [] };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---- 撤销最近一条操作:按快照生成逆向写入,单事务 + 单条UNDO日志 ----
|
|
290
|
+
// DELETE_ENTITY 的级联关系/图片不在快照内,仅恢复实体本身(完整恢复请回溯保存点)
|
|
291
|
+
// 连续撤销:向后扫描时跳过UNDO日志与其已撤销的目标日志;候选为RESTORE时拦截
|
|
292
|
+
function undoLast(source) {
|
|
293
|
+
const logs = db.prepare('SELECT * FROM operation_logs ORDER BY id DESC LIMIT 100').all();
|
|
294
|
+
const undoneIds = new Set();
|
|
295
|
+
for (const l of logs) {
|
|
296
|
+
if (l.op_type !== 'UNDO') continue;
|
|
297
|
+
try { const s = JSON.parse(l.snapshot); if (s.undone_log) undoneIds.add(s.undone_log); } catch (_) { /* 快照异常忽略 */ }
|
|
298
|
+
}
|
|
299
|
+
const last = logs.find((l) => l.op_type !== 'UNDO' && !undoneIds.has(l.id));
|
|
300
|
+
if (!last) { const e = new Error('暂无可撤销的操作'); e.status = 400; throw e; }
|
|
301
|
+
if (last.op_type === 'RESTORE') { const e = new Error('最近的操作是版本回溯,无法撤销,请回溯到更早的保存点'); e.status = 400; throw e; }
|
|
302
|
+
let snap;
|
|
303
|
+
try { snap = JSON.parse(last.snapshot); } catch (_) { const e = new Error('快照解析失败,无法撤销'); e.status = 500; throw e; }
|
|
304
|
+
return tx(() => {
|
|
305
|
+
const caveats = [];
|
|
306
|
+
let summary = '';
|
|
307
|
+
switch (last.op_type) {
|
|
308
|
+
case 'ADD_ENTITY': {
|
|
309
|
+
if (!getEntity(snap.entity.id)) { summary = `实体「${snap.entity.name}」已被后续操作删除,无需撤销`; break; }
|
|
310
|
+
const refs = db.prepare('SELECT * FROM relations WHERE source_id = ? OR target_id = ?').all(snap.entity.id, snap.entity.id);
|
|
311
|
+
for (const rr of refs) db.prepare('DELETE FROM relations WHERE id = ?').run(rr.id);
|
|
312
|
+
db.prepare('DELETE FROM entities WHERE id = ?').run(snap.entity.id);
|
|
313
|
+
summary = `已撤销新增:删除实体「${snap.entity.name}」`;
|
|
314
|
+
if (refs.length) caveats.push(`同时清理了引用该实体的 ${refs.length} 条关系`);
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
case 'UPDATE_ENTITY': {
|
|
318
|
+
if (!getEntity(snap.before.id)) { const e = new Error('实体已不存在,无法撤销更新'); e.status = 409; throw e; }
|
|
319
|
+
db.prepare('UPDATE entities SET name = ?, category = ?, attributes = ? WHERE id = ?')
|
|
320
|
+
.run(snap.before.name, snap.before.category, snap.before.attributes, snap.before.id);
|
|
321
|
+
summary = `已撤销更新:实体「${snap.before.name}」还原为修改前数据`;
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
case 'DELETE_ENTITY': {
|
|
325
|
+
if (getEntity(snap.entity.id)) { summary = `实体「${snap.entity.name}」已重新存在,无需撤销`; break; }
|
|
326
|
+
V.validateEntityInput({ name: snap.entity.name, category: snap.entity.category, attributes: JSON.parse(snap.entity.attributes || '{}') });
|
|
327
|
+
db.prepare('INSERT INTO entities (id, name, category, attributes, source, created_at) VALUES (?, ?, ?, ?, ?, ?)')
|
|
328
|
+
.run(snap.entity.id, snap.entity.name, snap.entity.category, snap.entity.attributes, snap.entity.source, snap.entity.created_at);
|
|
329
|
+
summary = `已撤销删除:恢复实体「${snap.entity.name}」`;
|
|
330
|
+
if (snap.cascaded_relations > 0) caveats.push(`级联删除的 ${snap.cascaded_relations} 条关系已随快照遗失,如需完整恢复请回溯保存点`);
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
case 'ADD_RELATION': {
|
|
334
|
+
if (!getRelation(snap.relation.id)) { summary = `关系#${snap.relation.id}已被后续操作删除,无需撤销`; break; }
|
|
335
|
+
db.prepare('DELETE FROM relations WHERE id = ?').run(snap.relation.id);
|
|
336
|
+
summary = `已撤销新增:删除关系「${entNameIn(snap.relation.source_id)} —[${snap.relation.name}]→ ${entNameIn(snap.relation.target_id)}」`;
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
case 'UPDATE_RELATION': {
|
|
340
|
+
if (!getRelation(snap.before.id)) { const e = new Error('关系已不存在,无法撤销更新'); e.status = 409; throw e; }
|
|
341
|
+
V.validateRelationInput({ source_id: snap.before.source_id, target_id: snap.before.target_id, name: snap.before.name, category: snap.before.category }, db);
|
|
342
|
+
db.prepare('UPDATE relations SET source_id = ?, target_id = ?, name = ?, category = ? WHERE id = ?')
|
|
343
|
+
.run(snap.before.source_id, snap.before.target_id, snap.before.name, snap.before.category, snap.before.id);
|
|
344
|
+
summary = `已撤销更新:关系#${snap.before.id} [${snap.before.name}] 还原为修改前数据`;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
case 'DELETE_RELATION': {
|
|
348
|
+
if (getRelation(snap.relation.id)) { summary = `关系#${snap.relation.id}已重新存在,无需撤销`; break; }
|
|
349
|
+
V.validateRelationInput({ source_id: snap.relation.source_id, target_id: snap.relation.target_id, name: snap.relation.name, category: snap.relation.category }, db);
|
|
350
|
+
db.prepare('INSERT INTO relations (id, source_id, target_id, name, category, source, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
351
|
+
.run(snap.relation.id, snap.relation.source_id, snap.relation.target_id, snap.relation.name, snap.relation.category, snap.relation.source, snap.relation.created_at);
|
|
352
|
+
summary = `已撤销删除:恢复关系「${entNameIn(snap.relation.source_id)} —[${snap.relation.name}]→ ${entNameIn(snap.relation.target_id)}」`;
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
default: { const e = new Error(`未知操作类型 ${last.op_type},无法撤销`); e.status = 400; throw e; }
|
|
356
|
+
}
|
|
357
|
+
logOp('UNDO', { undone_log: last.id, undone_type: last.op_type, summary, caveats }, source);
|
|
358
|
+
return { undone: { id: last.id, op_type: last.op_type }, summary, caveats };
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// 撤销摘要里的实体名(撤销时实体可能刚被删,查不到就回退#id)
|
|
363
|
+
function entNameIn(id) {
|
|
364
|
+
const m = getEntity(id);
|
|
365
|
+
return m ? `「${m.name}」` : `#${id}`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// 批量应用OpenCode操作(单事务原子提交,任一违规整体回滚,保持上一个合规版本)
|
|
369
|
+
// ref占位符解析:add_entity可带ref,后续关系用ref引用新实体
|
|
370
|
+
function applyAgentOps(ops) {
|
|
371
|
+
const applied = [];
|
|
372
|
+
const refMap = {};
|
|
373
|
+
return tx(() => {
|
|
374
|
+
for (const op of ops) {
|
|
375
|
+
if (!op || typeof op !== 'object') throw badOp('每个操作必须为JSON对象');
|
|
376
|
+
switch (op.op) {
|
|
377
|
+
case 'add_entity': {
|
|
378
|
+
const row = addEntityCore({ name: op.name, category: op.category, attributes: op.attributes }, 'OpenCode');
|
|
379
|
+
if (op.ref !== undefined) {
|
|
380
|
+
if (typeof op.ref !== 'string' || !op.ref.trim()) throw badOp('ref必须为非空字符串');
|
|
381
|
+
refMap[op.ref.trim()] = row.id;
|
|
382
|
+
}
|
|
383
|
+
applied.push({ op: 'add_entity', id: row.id, name: row.name });
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
case 'add_relation': {
|
|
387
|
+
const sid = resolveRef(op.source_id, op.source_ref, refMap);
|
|
388
|
+
const tid = resolveRef(op.target_id, op.target_ref, refMap);
|
|
389
|
+
const row = addRelationCore({ source_id: sid, target_id: tid, name: op.name, category: op.category }, 'OpenCode');
|
|
390
|
+
applied.push({ op: 'add_relation', id: row.id, name: row.name });
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
case 'update_entity': {
|
|
394
|
+
const patch = {};
|
|
395
|
+
for (const k of ['name', 'category', 'attributes']) if (op[k] !== undefined) patch[k] = op[k];
|
|
396
|
+
const row = updateEntityCore(Number(op.id), patch, 'OpenCode');
|
|
397
|
+
applied.push({ op: 'update_entity', id: row.id, name: row.name });
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
case 'delete_entity': {
|
|
401
|
+
const r = deleteEntityCore(Number(op.id), 'OpenCode');
|
|
402
|
+
applied.push({ op: 'delete_entity', id: Number(op.id), cascaded_relations: r.cascaded_relations });
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
case 'update_relation': {
|
|
406
|
+
const patch = {};
|
|
407
|
+
for (const k of ['source_id', 'target_id', 'name', 'category']) if (op[k] !== undefined) patch[k] = op[k];
|
|
408
|
+
const row = updateRelationCore(Number(op.id), patch, 'OpenCode');
|
|
409
|
+
applied.push({ op: 'update_relation', id: row.id, name: row.name });
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
case 'delete_relation': {
|
|
413
|
+
deleteRelationCore(Number(op.id), 'OpenCode');
|
|
414
|
+
applied.push({ op: 'delete_relation', id: Number(op.id) });
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
default:
|
|
418
|
+
throw badOp(`未知操作类型"${op.op}",仅支持 add_entity/add_relation/update_entity/delete_entity/update_relation/delete_relation`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return applied;
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
function resolveRef(idVal, refVal, map) {
|
|
425
|
+
if (refVal !== undefined && refVal !== null) {
|
|
426
|
+
const key = String(refVal).trim();
|
|
427
|
+
if (!map[key]) throw badOp(`ref占位符"${key}"尚未由任何add_entity定义`);
|
|
428
|
+
return map[key];
|
|
429
|
+
}
|
|
430
|
+
const n = Number(idVal);
|
|
431
|
+
if (!Number.isInteger(n) || n <= 0) throw badOp(`source_id/target_id必须为正整数或使用ref占位符`);
|
|
432
|
+
return n;
|
|
433
|
+
}
|
|
434
|
+
function badOp(msg) { const e = new Error('OpenCode操作校验未通过: ' + msg); e.status = 400; return e; }
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ---- 实体图片绑定(资产元数据;文件本体由 server 层存取于 data/uploads/)----
|
|
438
|
+
function addEntityImage(entityId, item) {
|
|
439
|
+
if (!getEntity(entityId)) { const e = new Error(`实体id=${entityId} 不存在`); e.status = 404; throw e; }
|
|
440
|
+
if (typeof item.filename !== 'string' || !item.filename.trim()) { const e = new Error('filename必须为非空字符串'); e.status = 400; throw e; }
|
|
441
|
+
if (typeof item.stored_path !== 'string' || !item.stored_path.trim()) { const e = new Error('stored_path必须为非空字符串'); e.status = 400; throw e; }
|
|
442
|
+
const caption = typeof item.caption === 'string' ? item.caption.trim().slice(0, 300) : '';
|
|
443
|
+
const thumbPath = typeof item.thumb_path === 'string' ? item.thumb_path.trim() : '';
|
|
444
|
+
const info = db.prepare('INSERT INTO entity_images (entity_id, filename, stored_path, caption, thumb_path) VALUES (?, ?, ?, ?, ?)')
|
|
445
|
+
.run(entityId, item.filename.trim().slice(0, 200), item.stored_path.trim(), caption, thumbPath);
|
|
446
|
+
return getEntityImage(Number(info.lastInsertRowid));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function getEntityImage(id) {
|
|
450
|
+
return db.prepare('SELECT * FROM entity_images WHERE id = ?').get(id);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function listEntityImages(entityId) {
|
|
454
|
+
return db.prepare('SELECT * FROM entity_images WHERE entity_id = ? ORDER BY id').all(entityId);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function deleteEntityImage(id) {
|
|
458
|
+
const row = getEntityImage(id);
|
|
459
|
+
if (!row) { const e = new Error(`图片绑定id=${id} 不存在`); e.status = 404; throw e; }
|
|
460
|
+
db.prepare('DELETE FROM entity_images WHERE id = ?').run(id);
|
|
461
|
+
return row;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// 剪除悬空图片行:.db 文件可迁移,uploads/ 资产不随库走,导入后行指向的文件可能不存在
|
|
465
|
+
function pruneMissingImages() {
|
|
466
|
+
const rows = db.prepare('SELECT id, stored_path FROM entity_images').all();
|
|
467
|
+
const missing = rows.filter((r) => !r.stored_path || !fs.existsSync(path.join(DATA_DIR, r.stored_path)));
|
|
468
|
+
const del = db.prepare('DELETE FROM entity_images WHERE id = ?');
|
|
469
|
+
for (const r of missing) del.run(r.id);
|
|
470
|
+
return { pruned: missing.length, paths: missing.map((m) => m.stored_path) };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function imageCounts() {
|
|
474
|
+
return db.prepare('SELECT entity_id, COUNT(*) AS count FROM entity_images GROUP BY entity_id').all();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ---- 中心层级子图:双向BFS,level=到中心的最短跳数 ----
|
|
478
|
+
function egoSubgraph(centerId, depth) {
|
|
479
|
+
const center = getEntity(centerId);
|
|
480
|
+
if (!center) { const e = new Error(`中心实体id=${centerId} 不存在`); e.status = 404; throw e; }
|
|
481
|
+
const maxDepth = Number.isInteger(depth) && depth > 0 ? depth : Infinity;
|
|
482
|
+
const adj = new Map();
|
|
483
|
+
const touch = (id) => { if (!adj.has(id)) adj.set(id, []); };
|
|
484
|
+
for (const r of listRelations()) {
|
|
485
|
+
touch(r.source_id); touch(r.target_id);
|
|
486
|
+
adj.get(r.source_id).push(r);
|
|
487
|
+
adj.get(r.target_id).push(r);
|
|
488
|
+
}
|
|
489
|
+
const level = new Map([[centerId, 0]]);
|
|
490
|
+
let frontier = [centerId];
|
|
491
|
+
while (frontier.length && level.size <= adj.size) {
|
|
492
|
+
const next = [];
|
|
493
|
+
for (const id of frontier) {
|
|
494
|
+
const cur = level.get(id);
|
|
495
|
+
if (cur >= maxDepth) continue;
|
|
496
|
+
for (const r of adj.get(id) || []) {
|
|
497
|
+
const other = r.source_id === id ? r.target_id : r.source_id;
|
|
498
|
+
if (!level.has(other)) { level.set(other, cur + 1); next.push(other); }
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
frontier = next;
|
|
502
|
+
}
|
|
503
|
+
const entities = listEntities()
|
|
504
|
+
.filter((e) => level.has(e.id))
|
|
505
|
+
.map((e) => ({ ...e, level: level.get(e.id) }))
|
|
506
|
+
.sort((a, b) => a.level - b.level || a.id - b.id);
|
|
507
|
+
const relations = listRelations().filter((r) => level.has(r.source_id) && level.has(r.target_id));
|
|
508
|
+
return { center, depth: Number.isFinite(maxDepth) ? maxDepth : null, entities, relations };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function getGraph() {
|
|
512
|
+
return { entities: listEntities(), relations: listRelations(), image_counts: imageCounts() };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function getLogs(limit = 200) {
|
|
516
|
+
return db.prepare('SELECT * FROM operation_logs ORDER BY id DESC LIMIT ?').all(limit);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function maxLogId() {
|
|
520
|
+
return db.prepare('SELECT COALESCE(MAX(id),0) AS m FROM operation_logs').get().m;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// 回溯完成后的系统级日志(用于日志与Git提交记录互溯)
|
|
524
|
+
function logRestore(hash, backupHash) {
|
|
525
|
+
return tx(() => logOp('RESTORE', { action: '版本回溯', restored_to: hash, backup_savepoint: backupHash || null, counts: counts() }, '系统'));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function counts() {
|
|
529
|
+
return {
|
|
530
|
+
entities: db.prepare('SELECT COUNT(*) AS c FROM entities').get().c,
|
|
531
|
+
relations: db.prepare('SELECT COUNT(*) AS c FROM relations').get().c,
|
|
532
|
+
logs: db.prepare('SELECT COUNT(*) AS c FROM operation_logs').get().c,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function close() {
|
|
537
|
+
try { if (db) db.close(); } catch (_) { /* ignore */ }
|
|
538
|
+
db = null;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function reopen() {
|
|
542
|
+
close();
|
|
543
|
+
return open();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// 迷你Cypher子集:MATCH (a)-[r:类型]->(b) [WHERE ...] RETURN ... LIMIT n
|
|
547
|
+
// 支持WHERE: a.name含/等值、b.name含、r.name含;RETURN默认边列表。只读实现,供检索页与MCP使用。
|
|
548
|
+
function miniCypher(q) {
|
|
549
|
+
const m = q.match(/MATCH\s*\(\s*(\w+)?\s*\)\s*-\s*\[\s*(\w+)?\s*(?::\s*([^\]]+?))?\s*\]\s*->\s*\(\s*(\w+)?\s*\)\s*(WHERE\s+[\s\S]+?)?\s*RETURN\s+([\s\S]+?)(?:\s+LIMIT\s+(\d+))?\s*$/i);
|
|
550
|
+
if (!m) throw new Error('仅支持形如 MATCH (a)-[r:类型]->(b) WHERE ... RETURN ... LIMIT n 的只读查询');
|
|
551
|
+
const [, aVar, , relType, bVar, whereRaw, returnRaw, limitRaw] = m;
|
|
552
|
+
const limit = Math.min(Number(limitRaw) || 100, 500);
|
|
553
|
+
const ents = listEntities();
|
|
554
|
+
const byId = new Map(ents.map((e) => [e.id, e]));
|
|
555
|
+
const nameOf = (id) => (byId.get(id) ? byId.get(id).name : '#' + id);
|
|
556
|
+
|
|
557
|
+
const evalCond = (cond, bind) => {
|
|
558
|
+
const c = cond.trim().replace(/^WHERE\s+/i, '');
|
|
559
|
+
const mm = c.match(/^(\w+)\.(name|category|source)\s*(=|~|contains)\s*(.+)$/i) || c.match(/^(\w+)\.(name|category|source)\s+(contains|~|=)\s+(.+)$/i);
|
|
560
|
+
if (!mm) throw new Error(`不支持的WHERE条件: ${c}`);
|
|
561
|
+
const [, varName, field, opRaw, valRaw] = mm;
|
|
562
|
+
const op = opRaw.toLowerCase();
|
|
563
|
+
const node = bind[varName.toLowerCase()];
|
|
564
|
+
if (!node) return false;
|
|
565
|
+
const val = String(valRaw).trim().replace(/^['"]|['"]$/g, '');
|
|
566
|
+
const actual = String(node[field] || '');
|
|
567
|
+
if (op === '=' || op === '==') return actual === val;
|
|
568
|
+
return actual.includes(val); // contains / ~
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
let rows = listRelations();
|
|
572
|
+
if (relType && relType.trim()) {
|
|
573
|
+
const types = relType.split('|').map((s) => s.trim().toLowerCase());
|
|
574
|
+
rows = rows.filter((r) => types.includes(String(r.category).toLowerCase()) || types.includes(String(r.name).toLowerCase()));
|
|
575
|
+
}
|
|
576
|
+
const bindVars = {};
|
|
577
|
+
if (aVar) bindVars[aVar.toLowerCase()] = 'source';
|
|
578
|
+
if (bVar) bindVars[bVar.toLowerCase()] = 'target';
|
|
579
|
+
|
|
580
|
+
const whereConds = whereRaw ? whereRaw.split(/\s+AND\s+/i) : [];
|
|
581
|
+
const out = [];
|
|
582
|
+
for (const r of rows) {
|
|
583
|
+
const bind = { source: byId.get(r.source_id), target: byId.get(r.target_id), r };
|
|
584
|
+
const getNode = (v) => (bindVars[v] === 'source' ? bind.source : bindVars[v] === 'target' ? bind.target : null);
|
|
585
|
+
let ok = true;
|
|
586
|
+
for (const cond of whereConds) {
|
|
587
|
+
const varName = cond.trim().split('.')[0].replace(/^WHERE\s+/i, '').toLowerCase();
|
|
588
|
+
const sub = { [varName]: getNode(varName), r: bind.r };
|
|
589
|
+
if (!evalCond(cond, sub)) { ok = false; break; }
|
|
590
|
+
}
|
|
591
|
+
if (!ok) continue;
|
|
592
|
+
const retRaw = returnRaw.trim().toLowerCase();
|
|
593
|
+
if (retRaw === 'count(*)' || retRaw.includes('count')) out.push({ count: 1 });
|
|
594
|
+
else out.push({ source: nameOf(r.source_id), relation: r.name, category: r.category, target: nameOf(r.target_id), source_id: r.source_id, target_id: r.target_id, relation_id: r.id });
|
|
595
|
+
if (out.length >= limit) break;
|
|
596
|
+
}
|
|
597
|
+
if (returnRaw.trim().toLowerCase().includes('count')) return [{ count: out.reduce((s, x) => s + (x.count || 1), 0) }];
|
|
598
|
+
return out;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
module.exports = {
|
|
602
|
+
DATA_DIR, DB_PATH,
|
|
603
|
+
open, close, reopen, integrityCheck,
|
|
604
|
+
getEntity, listEntities, getRelation, listRelations,
|
|
605
|
+
addEntity, updateEntity, deleteEntity,
|
|
606
|
+
addRelation, updateRelation, deleteRelation,
|
|
607
|
+
addEntityImage, getEntityImage, listEntityImages, deleteEntityImage, imageCounts, pruneMissingImages,
|
|
608
|
+
egoSubgraph, findPath, resolveKey,
|
|
609
|
+
undoLast,
|
|
610
|
+
miniCypher,
|
|
611
|
+
applyAgentOps, getGraph, getLogs, maxLogId, counts, logRestore,
|
|
612
|
+
getVersion: () => version,
|
|
613
|
+
};
|