local-knowledge-graph 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/HELP.md CHANGED
@@ -148,16 +148,18 @@ kg
148
148
 
149
149
  ## 功能速览
150
150
 
151
- - **AI 对话建图**:右侧输入自然语言指令(联网补全),或上传文档(md/txt/pdf/docx)批量抽取三元组
151
+ - **AI 对话建图**:右侧输入自然语言指令(联网补全),或上传文档(md/txt/pdf/docx)批量抽取三元组;AI 输出不合规时自动按修复回执重试(最多 2 轮),仍失败则给出完整问题清单
152
152
  - **智能提问**:检索页签一句话找关系(LLM 编译检索计划,本地只读执行,可生成带引用的综述)
153
153
  - **关系推荐**:共同邻居算法找"该连没连"的实体对,AI 判断后一键采纳入库(检索页签"关系推荐"卡 / 实体卡"推荐关系"按钮)
154
154
  - **语义检索**:配置硅基流动 API Key 后支持语义+关键词混合检索(未配置时自动退化为关键词模式)
155
155
  - **最短路径 / 中心子图 / 推理**:挖掘实体间关联链路、层级范围与隐性关系(方法体系见上文"知识推理"章节)
156
156
  - **撤销**:Ctrl+Z 或按钮,可连续撤销
157
157
  - **浅色主题**:视图设置 → 界面主题 → 浅色,整站配色与 3D 画布同步切换,选择本地记忆
158
- - **保存点与回溯**:版本页签打保存点,随时回滚;每次保存点自动滚动备份到 `data/backups/`
158
+ - **保存点与回溯**:版本页签打保存点,随时回滚;勾选两个保存点可对比版本差异(Diff:实体/关系增删改与属性字段级变化);每次保存点自动滚动备份到 `data/backups/`
159
+ - **视图分享**:视图菜单"复制视图链接"生成 `#entity=5` / `#ego=5&depth=2` / `#path=6-13` 深链,打开即恢复同一视图;单文件网页同样支持 `#entity=`
160
+ - **演示数据**:命令行 `kg --demo` 一键体验(独立临时目录,不影响真实数据);或帮助面板"载入演示数据"灌入太阳系示例(先自动打保存点备份现有数据)
159
161
  - **图片绑定**:实体与关系信息卡上传本地图片(缩略图+灯箱,佐证材料留存)
160
- - **导出**:RDF(Turtle)、整库 .db、单文件只读网页版
162
+ - **导出**:RDF(Turtle)、整库 .db、单文件只读网页版、三元组 JSON/CSV
161
163
 
162
164
  ## 键盘快捷键
163
165
 
@@ -187,6 +189,7 @@ kg
187
189
 
188
190
  ## 版本历史摘要
189
191
 
192
+ - **v1.12.0**:四项交互升级——①AI 修复回执:kg-ops 校验失败时返回结构化回执(规则码+失败原因+证据+修正建议),OpenCode 自动按回执修正重试(最多 2 轮),拦截时前端展示完整问题清单 ②保存点 Diff:版本页签勾选两个保存点即可对比差异(实体/关系的增/删/改,属性字段级 before→after)③URL 深链:`#entity=5`、`#ego=5&depth=2`、`#path=6-13` 直达视图,视图菜单"复制视图链接"一键分享(确定性布局保证两端构图一致,单文件网页同样支持 `#entity=`)④演示画廊:`kg --demo` 一键体验(一次性数据目录+太阳系示例图谱),帮助面板"载入演示数据"按钮灌入现有图谱(先自动打保存点);三元组导入新增别名列支持
190
193
  - **v1.11.0**:九项图谱能力升级——①关系属性(JSON 编辑/kg-ops/导入导出全链路,weight 数值映射线明暗)②三元组 JSON/CSV 导入导出(中文表头、同名复用、重复去重、支持子图 ids 参数)③重复三元组校验(同起点+终点+名称返回 409,前端友好提示)④关系方向箭头与互惠对双箭头 ⑤图例点击显隐 + 属性条件过滤 + 检索结果全部高亮 ⑥统计页签(度分布/枢纽 Top10/大类分布)⑦多选模式与子图提取/导出 ⑧PNG 截图(2 倍分辨率)⑨单文件网页导出补齐关系属性/置信度/来源引用展示与平行边标签错开;OpenCode 识别增强(PATH 扫描/常见位置/npm 全局四级定位)与检索页签一键安装(npm 全局安装 + 自动模型配置:本机 Ollama/LM Studio 优先,否则 Zen 免费模型)
191
194
  - **v1.10.0**:关系推荐——共同邻居算法(Adamic-Adar 打分,排除已有直接边)全图/中心实体两种扫描;AI 判断候选关系(LLM 给出关系名/大类/置信度/依据,人工确认后入库,不直接写库);检索页签新增"关系推荐"卡,实体信息卡新增"推荐关系"按钮
192
195
  - **v1.9.0**:检索配置增强(Base URL 页面可配 + 测试连接按钮,保存前即可验证密钥/地址/模型);关系图片绑定(信息卡上传/缩略图/灯箱,佐证材料与关系绑定,删关系连带清理);帮助文档全面增补(检索配置详解/关系编辑与图片/知识推理方法体系与应用场景)
package/README.md CHANGED
@@ -21,7 +21,9 @@
21
21
  | 迷你 Cypher | 只读关系查询:`MATCH (a)-[r:互动]->(b) WHERE a.name contains 郑和 RETURN a.name, r.name LIMIT 10` |
22
22
  | 图统计 | 度分布直方图、枢纽节点 Top10(点击飞行定位)、实体大类分布(统计页签) |
23
23
  | 数据资产 | 实体图片(本地存储,独立表)、RDF Turtle 导出、SQLite 整库导出/导入(自动备份保存点)、三元组 JSON/CSV 导入导出(中文表头,同名实体复用、重复三元组自动去重)、单文件只读网页另存 |
24
- | 保存点 | 每次合规写入自动打 Git 保存点,违规写入自动回滚到上个合规版本;可一键回溯任意历史版本 |
24
+ | 保存点 | 每次合规写入自动打 Git 保存点,违规写入自动回滚到上个合规版本;可一键回溯任意历史版本,勾选两个保存点可对比版本差异(Diff) |
25
+ | 视图深链 | `#entity=5` / `#ego=5&depth=2` / `#path=6-13` URL 直达视图,"复制视图链接"一键分享,单文件网页同样支持 |
26
+ | 演示模式 | `kg --demo` 一次性数据目录+太阳系示例图谱一键体验;帮助面板可向现有图谱载入演示数据(先自动备份) |
25
27
  | MCP Server | 零依赖 stdio JSON-RPC,10 个工具可直接接入 Claude Desktop / OpenCode 等支持 MCP 的客户端 |
26
28
  | 双端适配 | 桌面端完整面板;手机端单行工具栏+抽屉面板+文件菜单 |
27
29
 
package/bin/cli.js CHANGED
@@ -24,6 +24,7 @@ if (args.includes('-h') || args.includes('--help')) {
24
24
  --host <addr> 监听地址(默认 127.0.0.1,局域网访问用 0.0.0.0,环境变量 KG_HOST 同效)
25
25
  --data <dir> 数据目录(默认 ~/.local-knowledge-graph,环境变量 KG_DATA_DIR 同效)
26
26
  --mcp 以 MCP stdio 服务运行(供 Claude Desktop/Cursor 等客户端接入,不启动网页)
27
+ --demo 演示模式:使用一次性数据目录载入太阳系示例图谱并启动(不影响现有数据)
27
28
  --no-open 启动后不自动打开浏览器
28
29
  -v, --version 显示版本
29
30
  -h, --help 显示本帮助
@@ -44,17 +45,40 @@ if (args.includes('--mcp')) {
44
45
  require('../mcp/server.js');
45
46
  } else {
46
47
 
47
- const { ensureLegacyMigration, isDevRepo } = require('../lib/paths');
48
-
48
+ const demoMode = args.includes('--demo');
49
49
  const port = argValue('--port');
50
50
  const host = argValue('--host');
51
51
  const dataDir = argValue('--data');
52
52
  if (port) process.env.PORT = port;
53
53
  if (host) process.env.KG_HOST = host;
54
- if (dataDir) process.env.KG_DATA_DIR = dataDir;
54
+ if (demoMode && !dataDir) {
55
+ // 演示模式默认用一次性目录,绝不污染真实数据;必须在 require paths 前设置 env(DATA_DIR 在模块加载时固化)
56
+ const os = require('os');
57
+ process.env.KG_DATA_DIR = require('fs').mkdtempSync(require('path').join(os.tmpdir(), 'kg-demo-'));
58
+ } else if (dataDir) {
59
+ process.env.KG_DATA_DIR = dataDir;
60
+ }
61
+
62
+ const { ensureLegacyMigration, isDevRepo } = require('../lib/paths');
55
63
 
56
64
  ensureLegacyMigration();
57
65
 
66
+ // 演示模式:服务就绪后自动灌入太阳系示例数据
67
+ if (demoMode) {
68
+ const demoUrl = `http://localhost:${Number(process.env.PORT || 3000)}/api/demo/load`;
69
+ const started = Date.now();
70
+ const demoTimer = setInterval(() => {
71
+ fetch(demoUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', signal: AbortSignal.timeout(4000) })
72
+ .then((r) => r.json())
73
+ .then((j) => {
74
+ clearInterval(demoTimer);
75
+ if (j.ok) console.log(`演示数据已载入:实体 ${j.counts.entities} / 关系 ${j.counts.relations}(数据目录 ${process.env.KG_DATA_DIR})`);
76
+ else console.log('演示数据载入失败: ' + (j.error || '未知'));
77
+ })
78
+ .catch(() => { if (Date.now() - started > 25000) { clearInterval(demoTimer); console.log('演示数据载入超时,可稍后在网页「帮助」面板点击「载入演示数据」'); } });
79
+ }, 800);
80
+ }
81
+
58
82
  // 启动后轮询就绪,自动打开浏览器
59
83
  if (!args.includes('--no-open')) {
60
84
  const listenPort = Number(process.env.PORT || 3000);
package/lib/agent.js CHANGED
@@ -211,6 +211,32 @@ function attempt(sessionId, instruction, files) {
211
211
  });
212
212
  }
213
213
 
214
+ // 修复回路提示词:带上一次的完整操作与结构化回执,要求输出修正后的完整 kg-ops 块
215
+ function buildRepairPrompt(ops, receipts) {
216
+ return `你上一次输出的 kg-ops 操作未通过图谱校验,全部改动已回滚。请根据以下修复回执修正操作,重新输出【完整的】修正后 kg-ops 代码块(包含全部操作,而非仅差异部分)。
217
+
218
+ 【你上次的操作】
219
+ ${JSON.stringify(ops, null, 1)}
220
+
221
+ 【修复回执】(index=操作序号,code=规则码,subject=出错主体,supportedFixes=仅允许的修复方式)
222
+ ${JSON.stringify(receipts, null, 1)}
223
+
224
+ 【修复要求】
225
+ 1. 严格按 supportedFixes 修复,禁止发明新的字段、大类或类型。
226
+ 2. 无法修复的操作直接删除(如重复三元组可改为 update_relation 或整条移除)。
227
+ 3. 重新输出完整 kg-ops 块;说明文字保持简短。`;
228
+ }
229
+
230
+ // 按回执修复:续用上次会话上下文,要求AI输出修正后的完整操作
231
+ async function runRepair(ops, receipts) {
232
+ const sessionId = readSession();
233
+ const preArgs = sessionId ? ['--session', sessionId] : [];
234
+ const r = await runOpenCodeRaw(preArgs, buildRepairPrompt(ops, receipts), [], TIMEOUT_MS);
235
+ if (!r.ok) return { ok: false, error: r.error };
236
+ const parsed = parseOps(r.texts);
237
+ return { ok: true, reply: parsed.reply || r.texts, ops: parsed.ops, parse_error: parsed.parse_error || null };
238
+ }
239
+
214
240
  // 纯文本问答模式(智能检索编译/综述用):无会话、无 kg-ops 解析,仅取回复文本
215
241
  async function runPlain(prompt, timeoutMs = 20000, envExtra) {
216
242
  const p = String(prompt || '').trim();
@@ -224,4 +250,4 @@ function clearSession() {
224
250
  try { fs.unlinkSync(SESSION_FILE); } catch (_) { /* 文件不存在则忽略 */ }
225
251
  }
226
252
 
227
- module.exports = { buildPrompt, runAgent, runPlain, parseOps, parseEventStream };
253
+ module.exports = { buildPrompt, runAgent, runRepair, runPlain, parseOps, parseEventStream };
package/lib/db.js CHANGED
@@ -517,6 +517,7 @@ function entNameIn(id) {
517
517
  function applyAgentOps(ops) {
518
518
  const applied = [];
519
519
  const refMap = {};
520
+ const failures = []; // 修复回执:收集全部失败而非首个,AI可一轮拿到完整问题清单
520
521
  return tx(() => {
521
522
  for (let opIdx = 0; opIdx < ops.length; opIdx++) {
522
523
  const op = ops[opIdx];
@@ -584,11 +585,15 @@ function applyAgentOps(ops) {
584
585
  throw badOp(`未知操作类型"${op.op}",仅支持 add_entity/add_relation/update_entity/delete_entity/update_relation/delete_relation`);
585
586
  }
586
587
  } catch (e) {
587
- const opKind = (op && op.op) || '未知';
588
- e.message = `第${opIdx + 1}条操作(${opKind})失败: ${e.message}`;
589
- throw e;
588
+ // 继续执行后续操作,让AI一次拿到全部问题;事务最终整体回滚,无部分写入
589
+ failures.push(V.buildReceipt(opIdx + 1, op, e));
590
590
  }
591
591
  }
592
+ if (failures.length) {
593
+ const err = new Error(`${failures.length}/${ops.length} 条操作未通过校验,已全部回滚`);
594
+ err.receipts = failures;
595
+ throw err;
596
+ }
592
597
  return applied;
593
598
  });
594
599
 
@@ -902,6 +907,17 @@ function applyTriplesImport(parsed, source) {
902
907
  continue;
903
908
  }
904
909
  const row = addEntityCore({ name: ent.name, category: ent.category, attributes: ent.attributes }, source);
910
+ if (Array.isArray(ent.aliases) && ent.aliases.length) {
911
+ // 同事务内联写别名(addAlias 内部自带事务,嵌套会失败)
912
+ for (const raw of ent.aliases) {
913
+ const n = cleanAlias(raw);
914
+ if (!n || n.length > 200 || n === row.name) continue;
915
+ const dup = db.prepare('SELECT id FROM aliases WHERE entity_id = ? AND alias = ?').get(row.id, n);
916
+ if (dup) continue;
917
+ if (aliasConflict(n, row.id)) continue;
918
+ db.prepare('INSERT INTO aliases (entity_id, alias) VALUES (?, ?)').run(row.id, n);
919
+ }
920
+ }
905
921
  nameMap.set(row.name, row.id);
906
922
  if (ent.fromId) result.entity_ids.set('#' + ent.fromId, row.id);
907
923
  result.entity_ids.set(row.name, row.id);
package/lib/demo.js ADDED
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ // 演示数据集:太阳系主题,供 kg --demo 与网页「载入演示数据」一键体验
4
+ // 结构与 triples_io.parseImport(JSON) 输出一致,直接喂 db.applyTriplesImport
5
+ // 覆盖:物理/抽象/数值/时间 4 实体大类,空间/互动/归属/时间/属性 5 关系大类,确证/存疑/推测 3 置信度,属性与别名
6
+
7
+ function demoData() {
8
+ const entities = [
9
+ { name: '太阳', category: '物理实体', attributes: { 类型: 'G型主序星', 质量: '1.989×10^30 kg', 表面温度: '约5500°C' }, aliases: ['Sol'] },
10
+ { name: '水星', category: '物理实体', attributes: { 直径: '4879 km', 公转周期: '88天' }, aliases: ['Mercury'] },
11
+ { name: '金星', category: '物理实体', attributes: { 直径: '12104 km', 公转周期: '225天' }, aliases: ['启明星', '长庚星'] },
12
+ { name: '地球', category: '物理实体', attributes: { 直径: '12742 km', 公转周期: '365.25天', 卫星数: 1 }, aliases: ['Earth', '蓝星'] },
13
+ { name: '火星', category: '物理实体', attributes: { 直径: '6779 km', 公转周期: '687天' }, aliases: ['红色星球'] },
14
+ { name: '木星', category: '物理实体', attributes: { 直径: '139820 km', 卫星数: 95 }, aliases: ['Jupiter'] },
15
+ { name: '月球', category: '物理实体', attributes: { 直径: '3475 km', 距地距离: '38.4万公里' }, aliases: ['Moon'] },
16
+ { name: '哈雷彗星', category: '物理实体', attributes: { 回归周期: '约76年', 上次回归: '1986年' }, aliases: ['1P/Halley'] },
17
+ { name: '太阳系', category: '抽象实体', attributes: { 行星数: 8, 年龄: '约46亿年' }, aliases: [] },
18
+ { name: '日心说', category: '抽象实体', attributes: { 提出时间: '1543年' }, aliases: ['太阳中心模型'] },
19
+ { name: '万有引力定律', category: '抽象实体', attributes: { 发表时间: '1687年', 著作: '《自然哲学的数学原理》' }, aliases: [] },
20
+ { name: '潮汐', category: '抽象实体', attributes: { 周期: '约12小时25分' }, aliases: ['潮汐现象'] },
21
+ { name: '日食', category: '抽象实体', attributes: {}, aliases: ['日蚀'] },
22
+ { name: '天文单位', category: '数值实体', attributes: { 数值: '约1.496亿公里', 符号: 'AU' }, aliases: ['AU'] },
23
+ { name: '光速', category: '数值实体', attributes: { 数值: '299792458 m/s', 符号: 'c' }, aliases: [] },
24
+ { name: '哥白尼', category: '物理实体', attributes: { 国籍: '波兰', 生卒: '1473-1543' }, aliases: ['Nicolaus Copernicus'] },
25
+ { name: '开普勒', category: '物理实体', attributes: { 国籍: '德意志', 生卒: '1571-1630' }, aliases: ['Johannes Kepler'] },
26
+ { name: '牛顿', category: '物理实体', attributes: { 国籍: '英国', 生卒: '1643-1727' }, aliases: ['Isaac Newton', '艾萨克·牛顿'] },
27
+ ];
28
+ const relations = [
29
+ { source: '水星', target: '太阳', name: '绕行', category: '空间', confidence: '确证', source_ref: 'IAU 行星定义', attributes: {} },
30
+ { source: '金星', target: '太阳', name: '绕行', category: '空间', confidence: '确证', source_ref: 'IAU 行星定义', attributes: {} },
31
+ { source: '地球', target: '太阳', name: '绕行', category: '空间', confidence: '确证', source_ref: 'IAU 行星定义', attributes: { 周期: '恒星年365.256天' } },
32
+ { source: '火星', target: '太阳', name: '绕行', category: '空间', confidence: '确证', source_ref: 'IAU 行星定义', attributes: {} },
33
+ { source: '木星', target: '太阳', name: '绕行', category: '空间', confidence: '确证', source_ref: 'IAU 行星定义', attributes: {} },
34
+ { source: '月球', target: '地球', name: '绕行', category: '空间', confidence: '确证', source_ref: '天文学常识', attributes: {} },
35
+ { source: '地球', target: '太阳系', name: '属于', category: '归属', confidence: '确证', source_ref: '', attributes: {} },
36
+ { source: '哈雷彗星', target: '太阳', name: '绕行', category: '空间', confidence: '确证', source_ref: '《彗星轨道论》1705', attributes: { 轨道: '椭圆', 偏心率: 0.967 } },
37
+ { source: '月球', target: '潮汐', name: '引发', category: '互动', confidence: '确证', source_ref: '牛顿《原理》第一册', attributes: {} },
38
+ { source: '月球', target: '日食', name: '引发', category: '互动', confidence: '确证', source_ref: '', attributes: { 条件: '朔日且月球在黄白交点附近' } },
39
+ { source: '天文单位', target: '太阳', name: '度量', category: '属性', confidence: '确证', source_ref: 'IAU 2012 决议', attributes: { 定义: '精确149597870700米' } },
40
+ { source: '哥白尼', target: '日心说', name: '提出', category: '互动', confidence: '确证', source_ref: '《天体运行论》1543', attributes: {} },
41
+ { source: '开普勒', target: '日心说', name: '完善', category: '互动', confidence: '确证', source_ref: '《新天文学》1609', attributes: { 贡献: '行星运动三定律' } },
42
+ { source: '牛顿', target: '万有引力定律', name: '提出', category: '互动', confidence: '确证', source_ref: '《自然哲学的数学原理》1687', attributes: {} },
43
+ { source: '万有引力定律', target: '日心说', name: '解释', category: '互动', confidence: '确证', source_ref: '', attributes: {} },
44
+ { source: '万有引力定律', target: '潮汐', name: '解释', category: '互动', confidence: '确证', source_ref: '', attributes: {} },
45
+ { source: '牛顿', target: '开普勒', name: '继承发展', category: '互动', confidence: '确证', source_ref: '', attributes: {} },
46
+ { source: '哈雷彗星', target: '牛顿', name: '印证理论', category: '互动', confidence: '存疑', source_ref: '哈雷轨道计算1705', attributes: { 说明: '哈雷用引力定律推算彗星轨道' } },
47
+ { source: '火星', target: '生命', name: '可能孕育', category: '互动', confidence: '推测', source_ref: '机遇号/好奇号探测', attributes: { 证据: '古代河床与有机分子痕迹' } },
48
+ { source: '光速', target: '天文单位', name: '换算', category: '属性', confidence: '存疑', source_ref: '雷达测距', attributes: { 说明: '日地距离约合8分19秒光程' } },
49
+ ];
50
+ // 「生命」实体补进实体表(推测关系的端点,展示存疑淡化/推测虚线样式)
51
+ entities.push({ name: '生命', category: '抽象实体', attributes: {}, aliases: [] });
52
+ // 时间实体示例:《自然哲学的数学原理》出版年
53
+ entities.push({ name: '1687年', category: '时间实体', attributes: { 说明: '《自然哲学的数学原理》出版' }, aliases: [] });
54
+ relations.push({ source: '万有引力定律', target: '1687年', name: '发表于', category: '时间', confidence: '确证', source_ref: '', attributes: {} });
55
+ return { entities, relations };
56
+ }
57
+
58
+ module.exports = { demoData };
package/lib/diff.js ADDED
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ // 版本 Diff:对比两个保存点快照,输出结构化差异(Archify Before/Delta/After 式对比回执)
4
+ // attributes 在库内为 JSON 文本,规范化解析后比较,键序差异不误报
5
+
6
+ function normAttrs(a) {
7
+ if (a === null || a === undefined || a === '') return {};
8
+ if (typeof a === 'object') return a;
9
+ try { const v = JSON.parse(a); return v && typeof v === 'object' ? v : { '值': v }; } catch (_) { return { '原文': String(a) }; }
10
+ }
11
+
12
+ function stable(v) {
13
+ if (v === null || v === undefined) return '';
14
+ if (typeof v !== 'object') return String(v);
15
+ const keys = Object.keys(v).sort();
16
+ return JSON.stringify(keys.map((k) => [k, v[k]]));
17
+ }
18
+
19
+ // 实体比较字段:name/category/attributes;关系比较字段:source_id/target_id/name/category/confidence/source_ref/attributes
20
+ function diffRows(oldRows, newRows, fields, label) {
21
+ const oldById = new Map(oldRows.map((r) => [r.id, r]));
22
+ const newById = new Map(newRows.map((r) => [r.id, r]));
23
+ const added = [], removed = [], changed = [];
24
+ for (const [id, row] of newById) {
25
+ if (!oldById.has(id)) { added.push(pick(row, fields)); continue; }
26
+ const prev = oldById.get(id);
27
+ const attrPrev = normAttrs(prev.attributes);
28
+ const attrNow = normAttrs(row.attributes);
29
+ const changedFields = [];
30
+ const detail = { id, fields: changedFields, from: {}, to: {} };
31
+ for (const f of fields) {
32
+ const a = f === 'attributes' ? stable(attrPrev) : String(prev[f] === null || prev[f] === undefined ? '' : prev[f]);
33
+ const b = f === 'attributes' ? stable(attrNow) : String(row[f] === null || row[f] === undefined ? '' : row[f]);
34
+ if (a !== b) {
35
+ changedFields.push(f);
36
+ detail.from[f] = f === 'attributes' ? attrPrev : (prev[f] === null || prev[f] === undefined ? '' : prev[f]);
37
+ detail.to[f] = f === 'attributes' ? attrNow : (row[f] === null || row[f] === undefined ? '' : row[f]);
38
+ }
39
+ }
40
+ if (changedFields.length) changed.push(detail);
41
+ }
42
+ for (const [id, row] of oldById) if (!newById.has(id)) removed.push(pick(row, fields));
43
+ return { added, removed, changed, summary: { [label + '_added']: added.length, [label + '_removed']: removed.length, [label + '_changed']: changed.length } };
44
+ }
45
+
46
+ function pick(row, fields) {
47
+ const out = { id: row.id };
48
+ for (const f of fields) out[f] = f === 'attributes' ? normAttrs(row.attributes) : (row[f] === null || row[f] === undefined ? '' : row[f]);
49
+ return out;
50
+ }
51
+
52
+ // 对比两个快照 {entities, relations};from=旧 to=新
53
+ function diffSnapshots(fromSnap, toSnap) {
54
+ const ent = diffRows(fromSnap.entities, toSnap.entities, ['name', 'category', 'attributes'], 'entities');
55
+ const rel = diffRows(fromSnap.relations, toSnap.relations, ['source_id', 'target_id', 'name', 'category', 'confidence', 'source_ref', 'attributes'], 'relations');
56
+ return {
57
+ entities: { added: ent.added, removed: ent.removed, changed: ent.changed },
58
+ relations: { added: rel.added, removed: rel.removed, changed: rel.changed },
59
+ summary: { ...ent.summary, ...rel.summary },
60
+ };
61
+ }
62
+
63
+ module.exports = { diffSnapshots, normAttrs };
package/lib/git.js CHANGED
@@ -172,4 +172,27 @@ function restore(hash) {
172
172
  return { restored: hash, backup: backup && backup.hash ? backup.hash : null, counts };
173
173
  }
174
174
 
175
- module.exports = { ensureRepo, savepoint, history, restore, readMeta, writeMeta, backupCopy, DATA_DIR };
175
+ // 导出任意保存点的库快照(只读):供版本 Diff 对比,不动主库
176
+ function exportSnapshot(hash) {
177
+ ensureRepo();
178
+ if (!hasCommits()) { const e = new Error('仓库中还没有任何保存点'); e.status = 400; throw e; }
179
+ try { git(['cat-file', '-e', `${hash}^{commit}`]); } catch (_) {
180
+ const e = new Error(`保存点 ${hash} 不存在`); e.status = 404; throw e;
181
+ }
182
+ const buf = gitBuffer(['show', `${hash}:${DB_FILE}`]);
183
+ const tmp = path.join(os.tmpdir(), `kg_snap_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.db`);
184
+ fs.writeFileSync(tmp, buf);
185
+ const { DatabaseSync } = require('node:sqlite');
186
+ let probe;
187
+ try {
188
+ probe = new DatabaseSync(tmp, { readOnly: true });
189
+ const entities = probe.prepare('SELECT id, name, category, attributes FROM entities ORDER BY id').all();
190
+ const relations = probe.prepare('SELECT id, source_id, target_id, name, category, confidence, source_ref, attributes FROM relations ORDER BY id').all();
191
+ return { entities, relations };
192
+ } finally {
193
+ try { if (probe) probe.close(); } catch (_) {}
194
+ try { fs.unlinkSync(tmp); } catch (_) {}
195
+ }
196
+ }
197
+
198
+ module.exports = { ensureRepo, savepoint, history, restore, exportSnapshot, readMeta, writeMeta, backupCopy, DATA_DIR };
package/lib/triples_io.js CHANGED
@@ -137,6 +137,7 @@ function parseImport(content, format) {
137
137
  entities.push({
138
138
  name: e.name, category: e.category || '抽象实体',
139
139
  attributes: e.attributes && typeof e.attributes === 'object' ? e.attributes : {},
140
+ aliases: Array.isArray(e.aliases) ? e.aliases.map(String) : [],
140
141
  fromId: Number.isInteger(e.id) ? e.id : null,
141
142
  });
142
143
  }
package/lib/validator.js CHANGED
@@ -182,6 +182,43 @@ function validateRelationPatch(patch, existing, db) {
182
182
  return check;
183
183
  }
184
184
 
185
+ // ---- 修复回执(Archify 式):把本包定义的校验错误文案映射为稳定规则码与允许的修复动作 ----
186
+ // 错误文案全部由本包抛出(validator/db),正则匹配可靠;新增校验规则时同步在此登记
187
+ const RECEIPT_RULES = [
188
+ { re: /实体大类必须为/, code: 'E_ENTITY_CATEGORY', fixes: ['category 改为以下之一: 物理实体 / 抽象实体 / 数值实体 / 时间实体'] },
189
+ { re: /关系大类必须为/, code: 'E_RELATION_CATEGORY', fixes: ['category 改为以下之一: 空间 / 互动 / 归属 / 时间 / 属性'] },
190
+ { re: /置信度必须为/, code: 'E_CONFIDENCE', fixes: ['confidence 改为以下之一: 确证 / 推测 / 存疑;省略该字段时默认确证'] },
191
+ { re: /禁止嵌套对象或数组/, code: 'E_ATTR_NESTED', fixes: ['attributes 的值只能是字符串/数值/布尔/空值;把嵌套结构拆成多个扁平键,或改为一段说明文本'] },
192
+ { re: /必须为JSON对象/, code: 'E_ATTR_TYPE', fixes: ['attributes 必须是JSON对象(如 {"键":"值"});无属性时省略该字段'] },
193
+ { re: /起点实体id=\d+ 不存在/, code: 'E_SOURCE_MISSING', fixes: ['先用 {"op":"add_entity",...,"ref":"X"} 创建该实体,再用 "source_ref":"X" 引用;或改用图谱中已存在实体的数字id'] },
194
+ { re: /终点实体id=\d+ 不存在/, code: 'E_TARGET_MISSING', fixes: ['先用 {"op":"add_entity",...,"ref":"X"} 创建该实体,再用 "target_ref":"X" 引用;或改用图谱中已存在实体的数字id'] },
195
+ { re: /起点与终点不能为同一实体/, code: 'E_SELF_LOOP', fixes: ['source 与 target 不能相同;确需表达自反关系时改为实体属性或拆分为两个实体'] },
196
+ { re: /重复三元组|已存在同名/, code: 'E_DUPLICATE_RELATION', fixes: ['同起点+终点+关系名的组合只能存在一条:删除本条 add_relation,需要修改时改用 update_relation,或换一个关系名'] },
197
+ { re: /(实体|关系)名称必须为非空字符串/, code: 'E_NAME_EMPTY', fixes: ['name 必须为非空字符串'] },
198
+ { re: /长度不得超过200字符/, code: 'E_NAME_TOO_LONG', fixes: ['name 截断到 200 字符以内'] },
199
+ { re: /未知操作类型/, code: 'E_UNKNOWN_OP', fixes: ['op 仅支持: add_entity / add_relation / update_entity / delete_entity / update_relation / delete_relation'] },
200
+ { re: /ref必须为非空字符串/, code: 'E_REF_INVALID', fixes: ['ref 必须为非空字符串占位符,如 "ref":"A"'] },
201
+ { re: /引用的实体不存在/, code: 'E_REF_UNRESOLVED', fixes: ['ref 未在本次操作的 add_entity 中定义:先输出对应 add_entity(带相同 ref),或改用数字id'] },
202
+ ];
203
+
204
+ // 构造单条修复回执:index 从1开始对应操作序号;evidence 为出错操作原文(截断)
205
+ function buildReceipt(index, op, err) {
206
+ const msg = String((err && err.message) || err);
207
+ const rule = RECEIPT_RULES.find((r) => r.re.test(msg));
208
+ let evidence = '';
209
+ try { evidence = JSON.stringify(op).slice(0, 300); } catch (_) { evidence = String(op).slice(0, 300); }
210
+ const subjectSrc = op && (op.name !== undefined ? op.name : (op.ref !== undefined ? op.ref : op.id));
211
+ return {
212
+ index,
213
+ op_kind: (op && op.op) || '未知',
214
+ code: rule ? rule.code : 'E_UNKNOWN',
215
+ subject: subjectSrc !== undefined ? String(subjectSrc) : '',
216
+ message: msg,
217
+ evidence,
218
+ supportedFixes: rule ? rule.fixes : ['核对该操作是否符合 RDF 规范(大类/扁平属性/实体引用)后重试'],
219
+ };
220
+ }
221
+
185
222
  module.exports = {
186
223
  ENTITY_CATEGORIES,
187
224
  RELATION_CATEGORIES,
@@ -194,4 +231,6 @@ module.exports = {
194
231
  validateRelationInput,
195
232
  validateRelationPatch,
196
233
  findDuplicateRelation,
234
+ RECEIPT_RULES,
235
+ buildReceipt,
197
236
  };
@@ -316,6 +316,29 @@ function resize() {
316
316
  }
317
317
  window.addEventListener('resize', resize);
318
318
  resize();
319
+
320
+ // 深链:#entity=<id> 打开时自动选中并飞向该实体(与主应用链接格式一致)
321
+ (function applyHash() {
322
+ const m = location.hash.match(/^#entity=(\d+)$/);
323
+ if (!m) return;
324
+ const id = Number(m[1]);
325
+ if (!entityMap.has(id)) return;
326
+ selected.obj = { type: 'entity', id };
327
+ renderCard();
328
+ const nd = simNodes.find(n => n.id === id);
329
+ if (nd) {
330
+ const dir = camera.position.clone().sub(controls.target).normalize();
331
+ const from = camera.position.clone(), fromT = controls.target.clone(), to = nd.pos.clone().add(dir.multiplyScalar(110));
332
+ let t = 0;
333
+ (function fly() {
334
+ t = Math.min(1, t + 0.04);
335
+ const k = t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t + 2, 2)/2;
336
+ camera.position.lerpVectors(from, to, k);
337
+ controls.target.lerpVectors(fromT, nd.pos, k);
338
+ if (t < 1) requestAnimationFrame(fly);
339
+ })();
340
+ }
341
+ })();
319
342
  </script>
320
343
  </body>
321
344
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-knowledge-graph",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "本地知识图谱整合器:3D可视化 + AI对话建图 + 智能检索 + Git回溯 + RDF合规,全流程本地运行",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -1651,21 +1651,109 @@ async function loadLogs() {
1651
1651
  $('log-type').addEventListener('change', renderLogs);
1652
1652
  $('log-kw').addEventListener('input', renderLogs);
1653
1653
 
1654
+ /* ================= 保存点 Diff(勾选两个版本对比,Archify Before/Delta/After 式) ================= */
1655
+ const diffSel = []; // [{hash, date, short, title}],最多2个
1656
+ let diffResult = null;
1657
+
1658
+ function toggleDiffSel(hash) {
1659
+ const idx = diffSel.findIndex((x) => x.hash === hash);
1660
+ if (idx >= 0) diffSel.splice(idx, 1);
1661
+ else {
1662
+ if (diffSel.length >= 2) diffSel.shift();
1663
+ diffSel.push({ hash, date: diffMeta[hash]?.date || '', short: diffMeta[hash]?.short || hash.slice(0, 7), title: diffMeta[hash]?.title || '' });
1664
+ }
1665
+ updateDiffBar();
1666
+ loadHistory();
1667
+ }
1668
+ window.toggleDiffSel = toggleDiffSel;
1669
+ let diffMeta = {};
1670
+
1671
+ function updateDiffBar() {
1672
+ const bar = $('diff-bar');
1673
+ bar.style.display = diffSel.length ? 'flex' : 'none';
1674
+ $('btn-diff-run').disabled = diffSel.length !== 2;
1675
+ $('diff-bar-text').textContent = diffSel.length === 2
1676
+ ? `已选 2 个保存点:${diffSel[0].short} 与 ${diffSel[1].short}`
1677
+ : `已选 ${diffSel.length}/2 个保存点`;
1678
+ }
1679
+
1680
+ async function runDiff() {
1681
+ if (diffSel.length !== 2) return;
1682
+ // history 按时间倒序(新→旧),早的作为 from(基线)
1683
+ const [a, b] = [...diffSel].sort((x, y) => (x.date < y.date ? -1 : 1));
1684
+ try {
1685
+ diffResult = await api(`/api/git/diff?from=${encodeURIComponent(a.hash)}&to=${encodeURIComponent(b.hash)}`);
1686
+ renderDiffResult(a, b);
1687
+ } catch (e) { toast(e.message, true); }
1688
+ }
1689
+ window.runDiff = runDiff;
1690
+
1691
+ function clearDiffSel() {
1692
+ diffSel.length = 0;
1693
+ diffResult = null;
1694
+ $('diff-result').style.display = 'none';
1695
+ updateDiffBar();
1696
+ loadHistory();
1697
+ }
1698
+ window.clearDiffSel = clearDiffSel;
1699
+
1700
+ function diffSection(title, sec, color) {
1701
+ if (!sec.added.length && !sec.removed.length && !sec.changed.length) return '';
1702
+ const row = (icon, label, color2, text) => `<div style="display:flex;gap:6px;align-items:baseline"><span style="color:${color2};flex:0 0 auto">${icon}</span><span style="font-size:12px">${text} <span style="color:#5c6f92">(${label})</span></span></div>`;
1703
+ const f = (o) => typeof o === 'object' && o !== null ? JSON.stringify(o) : String(o ?? '');
1704
+ const parts = [
1705
+ ...sec.added.map((x) => row('+', '新增', '#7ee787', f(x))),
1706
+ ...sec.removed.map((x) => row('−', '移除', '#f87171', f(x))),
1707
+ ...sec.changed.map((c) => row('~', '修改', '#e0c068', `#${c.id} ${c.fields.map((k) => `${k}: ${f(c.from[k])} → ${f(c.to[k])}`).join(';')}`)),
1708
+ ];
1709
+ return `<div style="margin:8px 0"><div style="font-size:12px;font-weight:600;color:${color};margin-bottom:4px">${title}(${sec.added.length}增 ${sec.removed.length}删 ${sec.changed.length}改)</div>${parts.join('')}</div>`;
1710
+ }
1711
+
1712
+ function renderDiffResult(a, b) {
1713
+ const el = $('diff-result');
1714
+ if (!diffResult) { el.style.display = 'none'; return; }
1715
+ const s = diffResult.summary;
1716
+ const total = Object.values(s).reduce((x, y) => x + y, 0);
1717
+ el.style.display = 'block';
1718
+ el.innerHTML = `
1719
+ <div class="card" style="border:1px solid #2d3f5e;margin-top:10px">
1720
+ <h3>版本对比 ${a.short} → ${b.short}</h3>
1721
+ <div style="font-size:12px;color:#8fa3c0;margin-bottom:6px">${escapeHtml(a.title || '')} → ${escapeHtml(b.title || '')}</div>
1722
+ ${total === 0 ? '<div class="sub" style="color:#7ee787">两个版本之间无差异</div>' : `
1723
+ ${diffSection('实体', diffResult.entities, '#9db8e8')}
1724
+ ${diffSection('关系', diffResult.relations, '#9db8e8')}
1725
+ `}
1726
+ <div class="row" style="margin-top:8px"><button id="btn-diff-close" style="padding:4px 10px;font-size:12px">收起对比</button></div>
1727
+ </div>`;
1728
+ $('btn-diff-close').onclick = () => { el.style.display = 'none'; };
1729
+ }
1730
+
1654
1731
  async function loadHistory() {
1655
1732
  try {
1656
1733
  const list = await api('/api/git/history');
1734
+ diffMeta = {};
1735
+ list.forEach((c) => { diffMeta[c.hash] = c; });
1657
1736
  $('v-list').innerHTML = list.map((c, i) => `
1658
1737
  <div class="ver-item">
1659
1738
  <div class="v1"><code>${c.short}</code><span style="color:#7ee787;font-size:10px">${i === 0 ? '当前' : ''}</span>
1660
1739
  <span style="flex:1"></span>
1740
+ <label style="display:flex;align-items:center;gap:4px;font-size:11px;color:#8fa3c0;cursor:pointer"><input type="checkbox" ${diffSel.some((x) => x.hash === c.hash) ? 'checked' : ''} onchange="toggleDiffSel('${c.hash}')" style="width:auto">对比</label>
1661
1741
  ${i === 0 ? '' : `<button onclick="restoreTo('${c.hash}')">恢复此版本</button>`}
1662
1742
  </div>
1663
1743
  <div class="v2">${escapeHtml(c.title)}</div>
1664
1744
  <div class="v3">${c.date} · ${escapeHtml(c.source || c.author)}${c.log_range ? ` · 覆盖日志 #${c.log_range[0]}-#${c.log_range[1]}` : ''}</div>
1665
1745
  </div>`).join('') || '<div class="sub" style="color:#5c6f92">暂无保存点</div>';
1746
+ updateDiffBar();
1747
+ if (diffResult && diffSel.length === 2) {
1748
+ const [a, b] = [...diffSel].sort((x, y) => (x.date < y.date ? -1 : 1));
1749
+ renderDiffResult(a, b);
1750
+ }
1666
1751
  } catch (e) { toast(e.message, true); }
1667
1752
  }
1668
1753
 
1754
+ $('btn-diff-run').addEventListener('click', runDiff);
1755
+ $('btn-diff-clear').addEventListener('click', clearDiffSel);
1756
+
1669
1757
  async function restoreTo(hash) {
1670
1758
  if (!confirm(`回溯到保存点 ${hash.slice(0, 8)}?当前未保存的修改会先自动备份。`)) return;
1671
1759
  try {
@@ -1735,6 +1823,7 @@ document.addEventListener('keydown', (e) => {
1735
1823
  }
1736
1824
  if ($('style-panel').classList.contains('show')) { $('style-panel').classList.remove('show'); return; }
1737
1825
  if ($('file-menu').classList.contains('show')) { $('file-menu').classList.remove('show'); return; }
1826
+ if ($('view-menu').classList.contains('show')) { $('view-menu').classList.remove('show'); return; }
1738
1827
  if (state.pathHi) { clearCanvasHi(); return; }
1739
1828
  if (state.selected) { state.selected = null; renderInfoCard(); return; }
1740
1829
  if (state.ego) exitEgo();
@@ -2052,9 +2141,13 @@ async function sendAgent() {
2052
2141
  r.applied.map((a) => `- ${a.op}: ${a.name || ''}#${a.id}`).join('\n'));
2053
2142
  }
2054
2143
  if (r.ops_found > 0 && (r.applied_total !== undefined ? !r.applied_total : (!r.applied || !r.applied.length))) {
2055
- appendMsg('system', `OpenCode 提交了 ${r.ops_found} 项操作,但被RDF校验拦截${r.apply_error ? ':' + r.apply_error : ''}。数据库保持上一个合规版本。`, ['已拦截'], true);
2144
+ const rep = r.repair_rounds ? `;已自动按回执修复 ${r.repair_rounds} 轮仍未通过` : ';可尝试换一种表述让AI修正';
2145
+ const rcpt = r.receipts && r.receipts.length ? `\n回执:${r.receipts.map((x) => `#${x.index} ${x.code} ${x.message}`).join(';')}` : '';
2146
+ appendMsg('system', `OpenCode 提交了 ${r.ops_found} 项操作,但被RDF校验拦截${r.apply_error ? ':' + r.apply_error : ''}${rep}。数据库保持上一个合规版本。${rcpt}`, ['已拦截'], true);
2056
2147
  } else if (r.apply_error) {
2057
2148
  appendMsg('system', '部分操作被拦截:' + r.apply_error, ['已拦截'], true);
2149
+ } else if (r.repaired) {
2150
+ appendMsg('system', `有 ${r.ops_found} 项操作经 ${r.repair_rounds} 轮回执修复后写入成功`);
2058
2151
  }
2059
2152
  if (r.parse_error) appendMsg('system', '输出协议解析警告: ' + r.parse_error);
2060
2153
  const sp = r.savepoint;
@@ -2307,15 +2400,47 @@ $('help-close').addEventListener('click', () => {
2307
2400
  $('info-card').style.display = '';
2308
2401
  });
2309
2402
 
2403
+ /* ================= 演示数据一键载入(太阳系示例) ================= */
2404
+ $('btn-load-demo').addEventListener('click', async () => {
2405
+ const btn = $('btn-load-demo');
2406
+ if (btn.disabled) return;
2407
+ if (!confirm('载入太阳系演示数据到当前图谱?\n载入前会自动打保存点备份现有数据,可随时通过版本回溯找回。')) return;
2408
+ btn.disabled = true;
2409
+ btn.textContent = '载入中…';
2410
+ try {
2411
+ const r = await api('/api/demo/load', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
2412
+ toast(`演示数据已载入:新建实体 ${r.entities_added}(复用 ${r.entities_reused}),新建关系 ${r.relations_added}(跳过重复 ${r.relations_skipped_duplicate})`);
2413
+ await refreshAll();
2414
+ loadHistory();
2415
+ } catch (e) { toast('载入失败: ' + e.message, true); }
2416
+ btn.disabled = false;
2417
+ btn.textContent = '载入演示数据';
2418
+ });
2419
+
2310
2420
  /* ================= 文件菜单(手机端:打开/保存/另存/网页版) ================= */
2311
2421
  $('m-file').addEventListener('click', (e) => {
2312
2422
  e.stopPropagation();
2423
+ $('view-menu').classList.remove('show');
2313
2424
  $('file-menu').classList.toggle('show');
2314
2425
  });
2315
2426
  document.addEventListener('click', (e) => {
2316
2427
  if (!$('file-menu').contains(e.target) && e.target !== $('m-file')) $('file-menu').classList.remove('show');
2317
2428
  });
2318
2429
 
2430
+ /* ================= 视图菜单(布局/视角/主题/推理/截图折叠) ================= */
2431
+ $('m-view').addEventListener('click', (e) => {
2432
+ e.stopPropagation();
2433
+ $('file-menu').classList.remove('show');
2434
+ $('view-menu').classList.toggle('show');
2435
+ });
2436
+ document.addEventListener('click', (e) => {
2437
+ if (!$('view-menu').contains(e.target) && e.target !== $('m-view')) $('view-menu').classList.remove('show');
2438
+ });
2439
+ // 点任意菜单项执行后自动收起(事件委托,无需逐按钮绑定)
2440
+ $('view-menu').addEventListener('click', () => {
2441
+ setTimeout(() => $('view-menu').classList.remove('show'), 150);
2442
+ });
2443
+
2319
2444
  function downloadUrl(url) {
2320
2445
  const a = document.createElement('a');
2321
2446
  a.href = url;
@@ -2818,7 +2943,6 @@ $('agent-input').addEventListener('keydown', (e) => {
2818
2943
  });
2819
2944
  $('btn-savepoint').addEventListener('click', () => doSavepoint(''));
2820
2945
  $('v-save').addEventListener('click', () => doSavepoint($('v-message').value));
2821
- $('btn-export').addEventListener('click', () => window.open('/api/export/rdf', '_blank'));
2822
2946
  $('btn-inference').addEventListener('click', async () => {
2823
2947
  state.showInferred = !state.showInferred;
2824
2948
  $('btn-inference').classList.toggle('active', state.showInferred);
@@ -2864,12 +2988,80 @@ $('btn-screenshot').addEventListener('click', () => {
2864
2988
  }
2865
2989
  });
2866
2990
 
2991
+ /* ================= URL 深链(#entity=5 / #ego=5&depth=2 / #path=6-13) ================= */
2992
+ // 解析当前视图状态为 hash 参数(优先级:路径高亮 > 中心模式 > 选中实体)
2993
+ function buildViewHash() {
2994
+ if (state.pathHi && state._lastPaths && state._lastPaths.length) {
2995
+ const p = state._lastPaths[0];
2996
+ const a = p.entities[0], b = p.entities[p.entities.length - 1];
2997
+ if (a && b) return `#path=${a.id}-${b.id}`;
2998
+ }
2999
+ if (state.ego) {
3000
+ const d = state.ego.depth;
3001
+ return `#ego=${state.ego.centerId}` + (d ? `&depth=${d}` : '');
3002
+ }
3003
+ if (state.selected && state.selected.type === 'entity') return `#entity=${state.selected.id}`;
3004
+ return '';
3005
+ }
3006
+
3007
+ async function applyViewHash() {
3008
+ const h = location.hash.replace(/^#/, '');
3009
+ if (!h) return false;
3010
+ const params = new URLSearchParams(h);
3011
+ const num = (v) => { const n = Number(v); return Number.isInteger(n) && n > 0 ? n : null; };
3012
+ try {
3013
+ if (params.has('path')) {
3014
+ const m = String(params.get('path')).match(/^(\d+)-(\d+)$/);
3015
+ if (m) {
3016
+ const r = await api(`/api/graph/paths?from=${m[1]}&to=${m[2]}`);
3017
+ if (r.found && r.paths && r.paths.length) {
3018
+ renderPathPanel(r);
3019
+ highlightCanvasPath(0);
3020
+ toast('已按链接打开路径视图(第1条)');
3021
+ return true;
3022
+ }
3023
+ renderPathPanel(r);
3024
+ toast('链接中的两实体之间无连通路径', true);
3025
+ }
3026
+ } else if (params.has('ego')) {
3027
+ const id = num(params.get('ego'));
3028
+ if (id && state.entities.find((e) => e.id === id)) {
3029
+ const dep = num(params.get('depth'));
3030
+ state.ego = { centerId: id, depth: dep };
3031
+ if (state.subGraph) { state.subGraph = null; updateSubBar(); }
3032
+ updateEgoBar();
3033
+ rebuildGraph();
3034
+ focusEntity(id);
3035
+ toast('已按链接打开中心视图');
3036
+ return true;
3037
+ }
3038
+ } else if (params.has('entity')) {
3039
+ const id = num(params.get('entity'));
3040
+ if (id && state.entities.find((e) => e.id === id)) { focusEntity(id); return true; }
3041
+ }
3042
+ } catch (e) { toast('深链视图打开失败: ' + e.message, true); }
3043
+ return false;
3044
+ }
3045
+
3046
+ window.addEventListener('hashchange', () => { applyViewHash(); });
3047
+
3048
+ // 视图菜单:复制视图链接(确定性:布局初始坐标由实体id决定,他人打开看到同一构图)
3049
+ $('btn-share-link').addEventListener('click', async () => {
3050
+ const h = buildViewHash();
3051
+ if (!h) return toast('当前没有可分享的视图状态(先选中实体/进入中心模式/高亮路径)', true);
3052
+ const url = location.origin + location.pathname + h;
3053
+ try { await navigator.clipboard.writeText(url); } catch (_) { copyText(url); }
3054
+ toast('视图链接已复制:' + h);
3055
+ });
3056
+
2867
3057
  (async function boot() {
2868
3058
  resize();
2869
3059
  try {
2870
3060
  const meta = await api('/api/meta');
2871
3061
  fillCategorySelects(meta);
2872
3062
  await refreshAll();
3063
+ // URL 深链:首次加载后按 hash 恢复视图(确定性布局保证两端构图一致)
3064
+ applyViewHash();
2873
3065
  } catch (e) {
2874
3066
  toast('后端连接失败: ' + e.message, true);
2875
3067
  }
package/public/index.html CHANGED
@@ -18,15 +18,10 @@
18
18
  <button id="m-right" class="m-btn" title="打开AI对话">AI</button>
19
19
  <button id="btn-savepoint" title="将当前合规数据提交为Git保存点(仅存差异)">打保存点</button>
20
20
  <button id="btn-undo" title="撤销最近一次操作(按快照还原,快捷键Ctrl+Z)">撤销</button>
21
- <button id="btn-export" title="导出标准RDF(Turtle)格式文件">导出RDF</button>
22
- <button id="btn-relayout" title="重新进行力导向布局">重新布局</button>
23
21
  <button id="btn-multisel" title="多选模式:点击节点加入/移出选区,然后提取子图或导出">多选</button>
24
- <button id="btn-resetview" title="重置相机视角">重置视角</button>
25
- <button id="btn-screenshot" title="将当前3D视图截图为PNG图片(2倍分辨率)">截图</button>
26
- <button id="btn-style" title="自定义实体/关系的颜色、大小与线型">视图设置</button>
27
22
  <button id="btn-help" title="帮助:查看版本现状、检查更新与使用指南">帮助</button>
28
- <button id="btn-inference" title="叠加显示推理得到的隐性关系(虚线,OWL传递/对称/逆规则),点击推理边可查看推导依据">推理</button>
29
- <button id="m-file" class="m-btn" title="文件:打开/保存/另存">文件</button>
23
+ <button id="m-view" class="m-btn" title="视图:布局/视角/主题/推理/截图">视图</button>
24
+ <button id="m-file" class="m-btn" title="文件:打开/保存/另存/导入导出">文件</button>
30
25
  </header>
31
26
 
32
27
  <main>
@@ -214,7 +209,16 @@
214
209
  <div class="row"><button class="primary" id="v-save">打保存点</button></div>
215
210
  <div class="hint-text">数据文件 data/kg.db 由本地Git仓库托管,仅存差异版本;恢复前会自动备份当前状态。</div>
216
211
  </div>
217
- <div class="card"><h3>提交历史(与操作日志双向绑定)</h3><div id="v-list"></div></div>
212
+ <div class="card"><h3>提交历史(与操作日志双向绑定)</h3>
213
+ <div class="hint-text">勾选两个保存点可对比版本差异(Diff)。</div>
214
+ <div id="diff-bar" style="display:none;align-items:center;gap:8px;flex-wrap:wrap;margin:8px 0">
215
+ <span id="diff-bar-text" style="font-size:12px;color:#8fa3c0"></span>
216
+ <button class="primary" id="btn-diff-run" style="padding:4px 10px;font-size:12px">对比所选版本</button>
217
+ <button id="btn-diff-clear" style="padding:4px 10px;font-size:12px">清除选择</button>
218
+ </div>
219
+ <div id="diff-result" style="display:none"></div>
220
+ <div id="v-list"></div>
221
+ </div>
218
222
  </div>
219
223
 
220
224
  <div class="tabbody" id="tab-mcp">
@@ -307,6 +311,7 @@
307
311
  <p class="hp-p">本地知识图谱整合器 —— 把文档与联网信息整理成可导航的 3D 知识图谱。全流程本地运行,数据存于本机 data/ 目录(独立 Git 仓库托管,可回溯)。</p>
308
312
  <h3>版本与更新</h3>
309
313
  <div id="help-upd"></div>
314
+ <div class="hp-line">想先看看效果?<button id="btn-load-demo" class="ghost" style="padding:3px 10px;font-size:12px;margin:0 4px">载入演示数据</button>(太阳系示例,现有数据会先自动打保存点备份)</div>
310
315
  <div class="hp-line">详细帮助文档:项目目录下 <b>HELP.md</b></div>
311
316
  <div class="hp-line">发布记录:<a href="https://github.com/iamsamyiok/local-knowledge-graph/releases" target="_blank" rel="noopener">GitHub Releases</a></div>
312
317
  <h3>快速上手</h3>
@@ -370,6 +375,14 @@
370
375
  <button id="fm-export-csv">导出三元组 CSV</button>
371
376
  <button id="fm-import-triples">导入三元组(JSON/CSV)</button>
372
377
  </div>
378
+ <div id="view-menu">
379
+ <button id="btn-relayout">重新布局</button>
380
+ <button id="btn-resetview">重置视角</button>
381
+ <button id="btn-style">视图设置(颜色/线型/主题)</button>
382
+ <button id="btn-inference">推理关系叠加</button>
383
+ <button id="btn-share-link" title="复制当前视图的URL链接,打开即恢复同一视图">复制视图链接</button>
384
+ <button id="btn-screenshot">截图(PNG)</button>
385
+ </div>
373
386
  <input type="file" id="db-file-input" accept=".db,.sqlite,.sqlite3" style="display:none">
374
387
  <input type="file" id="triples-file-input" accept=".json,.csv" style="display:none">
375
388
  <div id="lightbox">
package/public/style.css CHANGED
@@ -271,19 +271,20 @@
271
271
  .log-filter input { flex: 1; }
272
272
 
273
273
  /* ================= 文件菜单(手机端) ================= */
274
- #file-menu {
274
+ /* 文件菜单与视图菜单:同一套下拉样式,视图菜单锚点按钮在其左侧 */
275
+ #file-menu, #view-menu {
275
276
  position: fixed; top: 46px; right: 8px; z-index: 45; display: none;
276
277
  background: #101a30; border: 1px solid #2a3a5c; border-radius: 8px;
277
278
  box-shadow: 0 8px 24px rgba(0,0,0,.5); min-width: 172px; overflow: hidden;
278
279
  }
279
- #file-menu.show { display: block; }
280
- #file-menu button {
280
+ #file-menu.show, #view-menu.show { display: block; }
281
+ #file-menu button, #view-menu button {
281
282
  display: block; width: 100%; text-align: left; background: transparent;
282
283
  border: none; border-bottom: 1px solid #16213a; border-radius: 0;
283
284
  padding: 11px 14px; font-size: 13px; color: #cfe2ff; min-height: 42px;
284
285
  }
285
- #file-menu button:last-child { border-bottom: none; }
286
- #file-menu button:active { background: #1b2b4d; }
286
+ #file-menu button:last-child, #view-menu button:last-child { border-bottom: none; }
287
+ #file-menu button:active, #view-menu button:active { background: #1b2b4d; }
287
288
 
288
289
  /* ================= 平板/小屏桌面适配(769px-1100px) ================= */
289
290
  @media (min-width: 769px) and (max-width: 1100px) {
@@ -299,7 +300,6 @@
299
300
  /* ================= 手机端适配 ================= */
300
301
  @media (max-width: 768px) {
301
302
  .m-btn { display: inline-block; background: #16233f; }
302
- #btn-inference { display: none; }
303
303
  /* 主题设置移动端可达:按钮保留,面板收窄为屏幕内浮层 */
304
304
  #style-panel { max-width: calc(100vw - 16px); width: 288px; top: 8px; right: 8px; }
305
305
 
@@ -312,7 +312,6 @@
312
312
  header::-webkit-scrollbar { display: none; }
313
313
  header h1 { display: none; }
314
314
  header button { padding: 5px 6px; font-size: 11px; flex-shrink: 0; min-width: 0; }
315
- #btn-export { display: none; } /* 导出RDF移入文件菜单 */
316
315
  #stat-badge, #agent-badge { display: none; }
317
316
 
318
317
  /* 左右面板变为抽屉 */
@@ -504,9 +503,9 @@
504
503
  html[data-theme="light"] .log-filter select, html[data-theme="light"] .log-filter input {
505
504
  background: #ffffff; border-color: #c9d6ea; color: #22304c;
506
505
  }
507
- html[data-theme="light"] #file-menu { background: #ffffff; border-color: #c9d6ea; box-shadow: 0 8px 24px rgba(30, 50, 80, 0.18); }
508
- html[data-theme="light"] #file-menu button { color: #22304c; border-bottom-color: #edf2f9; }
509
- html[data-theme="light"] #file-menu button:active { background: #eaf3ff; }
506
+ html[data-theme="light"] #file-menu, html[data-theme="light"] #view-menu { background: #ffffff; border-color: #c9d6ea; box-shadow: 0 8px 24px rgba(30, 50, 80, 0.18); }
507
+ html[data-theme="light"] #file-menu button, html[data-theme="light"] #view-menu button { color: #22304c; border-bottom-color: #edf2f9; }
508
+ html[data-theme="light"] #file-menu button:active, html[data-theme="light"] #view-menu button:active { background: #eaf3ff; }
510
509
  html[data-theme="light"] .m-btn { background: #eaf3ff; }
511
510
  html[data-theme="light"] #m-legend { background: rgba(255, 255, 255, 0.92); }
512
511
 
package/server.js CHANGED
@@ -605,6 +605,21 @@ api.get('/export/csv', (req, res) => {
605
605
  });
606
606
 
607
607
  // ---------- 三元组 CSV/JSON 批量导入 ----------
608
+ // ---------- 演示数据一键载入(先打保存点保护现有数据,再灌入太阳系示例) ----------
609
+ api.post('/demo/load', (req, res) => {
610
+ try {
611
+ const parsed = require('./lib/demo').demoData();
612
+ const sp = git.savepoint(`载入演示数据前自动备份`, '系统');
613
+ const r = db.applyTriplesImport(parsed, '手工');
614
+ r.entity_ids = undefined;
615
+ bus.emit('graph-changed', { via: 'demo' });
616
+ git.savepoint(`载入演示数据:实体+${r.entities_added} 关系+${r.relations_added}`, '系统');
617
+ res.json({ ok: true, backup: sp && sp.hash ? sp.hash : null, ...r, counts: db.counts(), version: db.getVersion() });
618
+ } catch (e) {
619
+ res.status(e.status || 500).json({ error: e.message });
620
+ }
621
+ });
622
+
608
623
  api.post('/import/triples', (req, res) => {
609
624
  const { content, format, dry_run } = req.body || {};
610
625
  if (!content || typeof content !== 'string') return res.status(400).json({ error: '必须提供 content(CSV/JSON 文本)' });
@@ -643,6 +658,22 @@ api.post('/git/restore', (req, res) => {
643
658
  res.json(r);
644
659
  });
645
660
 
661
+ // ---------- 保存点 Diff(Archify Before/Delta/After 式对比) ----------
662
+ api.get('/git/diff', (req, res) => {
663
+ const from = String(req.query.from || '').trim();
664
+ const to = String(req.query.to || '').trim();
665
+ if (!from || !to) return res.status(400).json({ error: '必须提供 from 与 to 两个保存点hash' });
666
+ if (from === to) return res.status(400).json({ error: '两个保存点相同,无差异可比' });
667
+ try {
668
+ const differ = require('./lib/diff');
669
+ const a = git.exportSnapshot(from);
670
+ const b = git.exportSnapshot(to);
671
+ res.json({ from, to, ...differ.diffSnapshots(a, b) });
672
+ } catch (e) {
673
+ res.status(e.status || 500).json({ error: e.message });
674
+ }
675
+ });
676
+
646
677
  // ---------- MCP 外部接入设置 ----------
647
678
  api.get('/mcp/settings', (req, res) => {
648
679
  const s = embeddings.loadSettings();
@@ -707,19 +738,38 @@ api.post('/agent', async (req, res) => {
707
738
 
708
739
  let applied = [];
709
740
  let applyError = null;
710
- if (result.ops && result.ops.length) {
711
- try {
712
- applied = db.applyAgentOps(result.ops);
713
- // Agent批次写入后自动打保存点,与日志双向绑定
714
- const title = `OpenCode: ${String(instruction).slice(0, 60).replace(/\n/g, ' ')}`;
715
- let sp = null;
716
- try { sp = git.savepoint(title, 'OpenCode'); } catch (e) { sp = { committed: false, message: e.message }; }
717
- result.savepoint = sp;
718
- } catch (e) {
719
- applyError = e.message; // 违规操作被拦截,数据库保持上一个合规版本
741
+ let receipts = [];
742
+ let repair_rounds = 0;
743
+ let repaired = false;
744
+ let curOps = result.ops;
745
+ let curReply = result.reply;
746
+ if (curOps && curOps.length) {
747
+ // 修复回路:写入被回执拦截时,带结构化回执让AI修正,最多2轮(Archify式 repair receipt)
748
+ for (let round = 0; round <= 2; round++) {
749
+ try {
750
+ applied = db.applyAgentOps(curOps);
751
+ // Agent批次写入后自动打保存点,与日志双向绑定
752
+ const title = `OpenCode: ${String(instruction).slice(0, 60).replace(/\n/g, ' ')}`;
753
+ let sp = null;
754
+ try { sp = git.savepoint(title, 'OpenCode'); } catch (e) { sp = { committed: false, message: e.message }; }
755
+ result.savepoint = sp;
756
+ if (round > 0) repaired = true;
757
+ applyError = null;
758
+ receipts = [];
759
+ break;
760
+ } catch (e) {
761
+ applyError = e.message;
762
+ receipts = e.receipts || [];
763
+ if (round === 2 || !receipts.length) break;
764
+ const rep = await agent.runRepair(curOps, receipts);
765
+ repair_rounds++;
766
+ if (!rep.ok || !rep.ops || !rep.ops.length) { applyError += '(AI修复未返回有效操作)'; break; }
767
+ curOps = rep.ops;
768
+ curReply = rep.reply;
769
+ }
720
770
  }
721
771
  }
722
- 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 });
772
+ res.json({ reply: curReply, ops_found: (curOps || []).length, applied, apply_error: applyError, receipts, repair_rounds, repaired, 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 });
723
773
  } finally { agentBusy = false; }
724
774
  });
725
775