local-knowledge-graph 1.8.0 → 1.9.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 +53 -3
- package/lib/db.js +82 -7
- package/mcp/core.js +39 -5
- package/package.json +1 -1
- package/public/app.js +79 -13
- package/public/index.html +6 -3
- package/server.js +78 -2
package/HELP.md
CHANGED
|
@@ -61,10 +61,23 @@ kg
|
|
|
61
61
|
|
|
62
62
|
程序自身零内置密钥,所有需要 key 的能力都在界面上自由配置:
|
|
63
63
|
|
|
64
|
-
- **语义检索 / 智能提问综述**:检索页签"语义检索配置"
|
|
64
|
+
- **语义检索 / 智能提问综述**:检索页签"语义检索配置"卡片完成三步——
|
|
65
|
+
1. **API Base URL**:默认 `https://api.siliconflow.cn/v1`(硅基流动)。任何 OpenAI 兼容接口都可换:填其 Base URL 即可(如 `https://api.openai.com/v1`、`https://api.deepseek.com/v1` 及各类本地/私有化网关)
|
|
66
|
+
2. **API Key + 模型**:粘贴密钥并填模型名(硅基流动默认 `BAAI/bge-m3`;换服务方时选其支持的 embedding 模型,如 OpenAI `text-embedding-3-small`)
|
|
67
|
+
3. **测试连接**:保存前先点「测试连接」,用页面上当前填写的配置(未保存也生效)向服务方发一次真实请求,成功会显示模型名、向量维度与耗时;失败给出具体错误(密钥无效 / 地址不可达 / 模型名错误)
|
|
68
|
+
|
|
69
|
+
Key 与 Base URL 仅存本地数据目录的 settings.json,服务端永不回传真实值。不配置则自动退化为关键词检索,其余功能不受影响。改配置后需重新「构建全量向量」才能让新模型生效于语义检索。
|
|
65
70
|
- **OpenCode(AI 对话建图 / 智能提问编译)**:使用 opencode CLI 自身的认证(`opencode auth login`),应用不经手密钥。未安装或未认证时仅这两个功能降级。
|
|
66
71
|
- 更换数据目录(`kg --data <目录>`)会使用该目录下的独立配置,互不串扰。
|
|
67
72
|
|
|
73
|
+
## 关系编辑与关系图片
|
|
74
|
+
|
|
75
|
+
关系(边)与实体一样支持人工与 AI 两种编辑方式,并可绑定佐证图片:
|
|
76
|
+
|
|
77
|
+
- **人工编辑**:画布上点击关系边,右侧信息卡可修改——置信度三档(确证/推测/存疑)、来源引用(URL/文献+页码)、删除关系。所有修改实时写库并产生可撤销的操作记录。
|
|
78
|
+
- **AI 编辑**:外部 Agent 通过 MCP 的 `kg_apply_ops` 可对关系做增删改(支持 `confidence` 置信度、`evidence_ref` 来源引用、按名称引用端点实体);文档批量入图抽取出的关系同样带置信度与来源,经审核勾选后入库。AI 写入与人工修改互相实时可见。
|
|
79
|
+
- **关系图片**:选中关系后点「绑图片」上传本地图片(支持多选,jpg/png/gif/webp/bmp,单张 ≤10MB,自动生成缩略图)。图片在关系信息卡缩略图网格展示,点击进入灯箱翻看,灯箱内可删除。典型用途:史料扫描件、聊天记录截图、地图/图表、新闻照片等佐证材料。图片文件存于数据目录 `data/uploads/r<关系id>/`,删除关系时其图片一并清理。
|
|
80
|
+
|
|
68
81
|
## MCP 外部接入(外部 Agent 读写图谱)
|
|
69
82
|
|
|
70
83
|
在 **MCP 页签** 开启"MCP 服务"后,外部 Agent(Claude Desktop、Cursor、Cline、opencode 等)即可通过标准 MCP 协议对本图谱做完整增删改查:
|
|
@@ -75,16 +88,52 @@ kg
|
|
|
75
88
|
- **只读模式**:勾选后外部仅可查询;**令牌管理**:一键重新生成即刻吊销旧令牌
|
|
76
89
|
- **实时同步**:图谱被任一写入方(页面、API、MCP、外部进程)修改后,网页 3 秒内自动刷新
|
|
77
90
|
|
|
91
|
+
## 知识推理:现有功能与方法体系
|
|
92
|
+
|
|
93
|
+
### 本程序已有的推理功能
|
|
94
|
+
|
|
95
|
+
图页签勾选「显示推理关系」后,图谱会叠加展示**隐性关系**(带"推"标记与推导依据链,仅推理展示、数据库中无记录):
|
|
96
|
+
|
|
97
|
+
- **传递推理**:师从(A,B) ∧ 师从(B,C) ⇒ 师从(A,C);位于(A,B) ∧ 位于(B,C) ⇒ 位于(A,C) 等具备传递性的关系自动沿链推导
|
|
98
|
+
- **对称/逆推理**:师生、上下级等方向的自动取反向边
|
|
99
|
+
- **推理限定中心**:可只在某实体为中心的子图范围内推理,配合中心层级视图排查隐性关联
|
|
100
|
+
- **问答证据链**:智能提问的回答附带支撑路径,推理结论可解释、可回溯
|
|
101
|
+
|
|
102
|
+
### 知识推理的科学方法体系(由浅入深)
|
|
103
|
+
|
|
104
|
+
| 方法 | 原理 | 典型代表 | 适合场景 |
|
|
105
|
+
| ---- | ---- | ---- | ---- |
|
|
106
|
+
| 公理/本体推理 | 依据关系性质(传递、对称、逆、子类)做逻辑推导 | OWL/RDF-Schema 推理机 | 本程序已内置;一致性与常识性推导 |
|
|
107
|
+
| 规则推理 | 用户自定义 if-then 规则批量推导 | Datalog、SWRL、Prolog | 领域规则明确的图谱(师承同门、亲属称谓、企业控股) |
|
|
108
|
+
| 图算法/链路预测 | 基于图结构打分:共同邻居、路径相似度、图嵌入向量 | Adamic-Adar、TransE、Node2Vec | 预测"可能存在但未录入"的关系,做补全推荐 |
|
|
109
|
+
| 概率/统计推理 | 从历史边中统计规律,给出候选与置信度 | 路径排序 PRA、规则挖掘 AMIE | 大规模图谱的关系发现与排序 |
|
|
110
|
+
| LLM+图谱协同 | 把子图上下文交给大模型归纳,人审核入库 | GraphRAG、LLM 关系抽取 | 开放域、规则难写尽的场景;本程序文档入图即此思路 |
|
|
111
|
+
|
|
112
|
+
### 与本程序结合的路线
|
|
113
|
+
|
|
114
|
+
1. **已具备**:传递/对称/逆公理推理 + 问答证据链 + MCP 推理查询(外部 Agent 可调 `kg_inference`)
|
|
115
|
+
2. **低成本扩展·共同邻居推荐**:A→B、B→C 有边则提示 A↔C 为候选关系,在实体信息卡列出"推荐关系",一键让 AI 验证或人工确认入库
|
|
116
|
+
3. **AI 推荐关系**:选取两个实体,把二者属性与邻域子图交给 LLM 判断"是否存在关系、是什么关系、依据是什么",经审核界面勾选入库(复用文档入图的审核管线)
|
|
117
|
+
4. **进阶·自定义规则**:设置页开放规则编辑(如 师从(A,B) ∧ 师从(B,C) ⇒ 同门(A,C)),与内置传递/对称推理共用"推"标记展示
|
|
118
|
+
|
|
119
|
+
### 应用场景与效益
|
|
120
|
+
|
|
121
|
+
- **教育与学习**:历史人物、生物分类、概念依赖图谱——学生顺着推理链发现隐性关联,"为什么他俩有瓜葛"有推导依据可看
|
|
122
|
+
- **情报与调研**:碎片信息建图后,推理把散点串成网络(共同雇主、共同事件),链路预测提示值得人工核查的候选线索
|
|
123
|
+
- **企业知识管理**:专家经验、项目关系、人岗技能沉淀为图谱;推理显性化隐性知识("做过 A 类项目且懂 B 技术的人"自动成组),降低对个别专家的依赖
|
|
124
|
+
- **可解释问答**:相比黑盒大模型直答,图谱推理每一步给出证据路径,答案可验证、可追溯
|
|
125
|
+
- **效益本质**:把已录入的显性关系"盘活"——同一份数据,推理可多挖掘出一批隐性关联与候选补全(实践中常见 20% 以上的新边发现),同时暴露矛盾数据(如传递链断裂)反哺数据质量
|
|
126
|
+
|
|
78
127
|
## 功能速览
|
|
79
128
|
|
|
80
129
|
- **AI 对话建图**:右侧输入自然语言指令(联网补全),或上传文档(md/txt/pdf/docx)批量抽取三元组
|
|
81
130
|
- **智能提问**:检索页签一句话找关系(LLM 编译检索计划,本地只读执行,可生成带引用的综述)
|
|
82
131
|
- **语义检索**:配置硅基流动 API Key 后支持语义+关键词混合检索(未配置时自动退化为关键词模式)
|
|
83
|
-
- **最短路径 / 中心子图 /
|
|
132
|
+
- **最短路径 / 中心子图 / 推理**:挖掘实体间关联链路、层级范围与隐性关系(方法体系见上文"知识推理"章节)
|
|
84
133
|
- **撤销**:Ctrl+Z 或按钮,可连续撤销
|
|
85
134
|
- **浅色主题**:视图设置 → 界面主题 → 浅色,整站配色与 3D 画布同步切换,选择本地记忆
|
|
86
135
|
- **保存点与回溯**:版本页签打保存点,随时回滚;每次保存点自动滚动备份到 `data/backups/`
|
|
87
|
-
-
|
|
136
|
+
- **图片绑定**:实体与关系信息卡上传本地图片(缩略图+灯箱,佐证材料留存)
|
|
88
137
|
- **导出**:RDF(Turtle)、整库 .db、单文件只读网页版
|
|
89
138
|
|
|
90
139
|
## 键盘快捷键
|
|
@@ -115,6 +164,7 @@ kg
|
|
|
115
164
|
|
|
116
165
|
## 版本历史摘要
|
|
117
166
|
|
|
167
|
+
- **v1.9.0**:检索配置增强(Base URL 页面可配 + 测试连接按钮,保存前即可验证密钥/地址/模型);关系图片绑定(信息卡上传/缩略图/灯箱,佐证材料与关系绑定,删关系连带清理);帮助文档全面增补(检索配置详解/关系编辑与图片/知识推理方法体系与应用场景)
|
|
118
168
|
- **v1.8.0**:MCP 外部接入——设置新增 MCP 页签(服务开关/只读模式/令牌管理/三类客户端一键复制配置);`/mcp` Streamable HTTP 端点(令牌鉴权+CORS,12 个工具含完整 CRUD);`kg --mcp` 本机 stdio 接入;图谱变更实时同步(SSE 推送+跨进程探测,页面 3 秒内自动刷新)
|
|
119
169
|
- **v1.7.0**:六项图谱能力升级——①实体别名(增删/全局唯一/搜索与消歧联动)②同名冲突检测(创建即提示,可合并可强制)③多路径查找(2-6跳,图上高亮,MCP kg_path)④相似实体推荐(语义+结构双模式)⑤关系置信度三档(确证/推测/存疑,样式区分可过滤)与来源引用标注 ⑥智能问答附证据路径链 ⑦文档批量入图(md/txt/pdf→LLM抽取→审核勾选→来源标记入库,支持跳过审核)
|
|
120
170
|
- **v1.6.1**:修复浅色主题切换导致画布空白的问题;标题栏按钮防挤压(窄屏可横滑);修复3D样式主题刷新后不恢复的问题;新增脚本异常提示
|
package/lib/db.js
CHANGED
|
@@ -78,6 +78,16 @@ function initSchema() {
|
|
|
78
78
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
79
79
|
);
|
|
80
80
|
CREATE INDEX IF NOT EXISTS idx_alias_entity ON aliases(entity_id);
|
|
81
|
+
CREATE TABLE IF NOT EXISTS relation_images (
|
|
82
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
83
|
+
relation_id INTEGER NOT NULL REFERENCES relations(id) ON DELETE CASCADE,
|
|
84
|
+
filename TEXT NOT NULL,
|
|
85
|
+
stored_path TEXT NOT NULL,
|
|
86
|
+
thumb_path TEXT NOT NULL DEFAULT '',
|
|
87
|
+
caption TEXT NOT NULL DEFAULT '',
|
|
88
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
89
|
+
);
|
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_rimg_relation ON relation_images(relation_id);
|
|
81
91
|
`);
|
|
82
92
|
migrateEvidence();
|
|
83
93
|
// 旧库迁移:entity_embeddings 已拆分至独立 vectors.db,主库内残留表直接清除
|
|
@@ -484,26 +494,46 @@ function entNameIn(id) {
|
|
|
484
494
|
|
|
485
495
|
// 批量应用OpenCode操作(单事务原子提交,任一违规整体回滚,保持上一个合规版本)
|
|
486
496
|
// ref占位符解析:add_entity可带ref,后续关系用ref引用新实体
|
|
497
|
+
// add_relation 支持按名称引用(source_name/target_name):主名或别名精确匹配,事务内可见同批新建实体
|
|
487
498
|
function applyAgentOps(ops) {
|
|
488
499
|
const applied = [];
|
|
489
500
|
const refMap = {};
|
|
490
501
|
return tx(() => {
|
|
491
|
-
for (
|
|
492
|
-
|
|
502
|
+
for (let opIdx = 0; opIdx < ops.length; opIdx++) {
|
|
503
|
+
const op = ops[opIdx];
|
|
504
|
+
try {
|
|
505
|
+
if (!op || typeof op !== 'object') throw badOp('每个操作必须为JSON对象');
|
|
493
506
|
switch (op.op) {
|
|
494
507
|
case 'add_entity': {
|
|
495
508
|
const row = addEntityCore({ name: op.name, category: op.category, attributes: op.attributes }, 'OpenCode');
|
|
509
|
+
if (Array.isArray(op.aliases)) {
|
|
510
|
+
// 同事务内联写别名(addAlias 内部自带事务,嵌套会失败)
|
|
511
|
+
for (const raw of op.aliases) {
|
|
512
|
+
const n = cleanAlias(raw);
|
|
513
|
+
if (!n || n.length > 200 || n === row.name) continue;
|
|
514
|
+
const dup = db.prepare('SELECT id FROM aliases WHERE entity_id = ? AND alias = ?').get(row.id, n);
|
|
515
|
+
if (dup) continue;
|
|
516
|
+
if (aliasConflict(n, row.id)) continue;
|
|
517
|
+
const info = db.prepare('INSERT INTO aliases (entity_id, alias) VALUES (?, ?)').run(row.id, n);
|
|
518
|
+
const arow = db.prepare('SELECT id, entity_id, alias, created_at FROM aliases WHERE id = ?').get(Number(info.lastInsertRowid));
|
|
519
|
+
logOp('ADD_ALIAS', { alias: arow }, 'OpenCode');
|
|
520
|
+
}
|
|
521
|
+
}
|
|
496
522
|
if (op.ref !== undefined) {
|
|
497
523
|
if (typeof op.ref !== 'string' || !op.ref.trim()) throw badOp('ref必须为非空字符串');
|
|
498
524
|
refMap[op.ref.trim()] = row.id;
|
|
499
525
|
}
|
|
500
|
-
applied.push({ op: 'add_entity', id: row.id, name: row.name });
|
|
526
|
+
applied.push({ op: 'add_entity', id: row.id, name: row.name, aliases_added: Array.isArray(op.aliases) ? op.aliases.length : 0 });
|
|
501
527
|
break;
|
|
502
528
|
}
|
|
503
529
|
case 'add_relation': {
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
530
|
+
let sid;
|
|
531
|
+
let tid;
|
|
532
|
+
if (op.source_name !== undefined && op.source_id === undefined && op.source_ref === undefined) sid = resolveEntityName(op.source_name);
|
|
533
|
+
else sid = resolveRef(op.source_id, op.source_ref, refMap);
|
|
534
|
+
if (op.target_name !== undefined && op.target_id === undefined && op.target_ref === undefined) tid = resolveEntityName(op.target_name);
|
|
535
|
+
else tid = resolveRef(op.target_id, op.target_ref, refMap);
|
|
536
|
+
const row = addRelationCore({ source_id: sid, target_id: tid, name: op.name, category: op.category, confidence: op.confidence, source_ref: op.evidence_ref }, 'OpenCode');
|
|
507
537
|
applied.push({ op: 'add_relation', id: row.id, name: row.name });
|
|
508
538
|
break;
|
|
509
539
|
}
|
|
@@ -521,7 +551,7 @@ function applyAgentOps(ops) {
|
|
|
521
551
|
}
|
|
522
552
|
case 'update_relation': {
|
|
523
553
|
const patch = {};
|
|
524
|
-
for (const k of ['source_id', 'target_id', 'name', 'category']) if (op[k] !== undefined) patch[k] = op[k];
|
|
554
|
+
for (const k of ['source_id', 'target_id', 'name', 'category', 'confidence', 'source_ref']) if (op[k] !== undefined) patch[k] = op[k];
|
|
525
555
|
const row = updateRelationCore(Number(op.id), patch, 'OpenCode');
|
|
526
556
|
applied.push({ op: 'update_relation', id: row.id, name: row.name });
|
|
527
557
|
break;
|
|
@@ -534,6 +564,11 @@ function applyAgentOps(ops) {
|
|
|
534
564
|
default:
|
|
535
565
|
throw badOp(`未知操作类型"${op.op}",仅支持 add_entity/add_relation/update_entity/delete_entity/update_relation/delete_relation`);
|
|
536
566
|
}
|
|
567
|
+
} catch (e) {
|
|
568
|
+
const opKind = (op && op.op) || '未知';
|
|
569
|
+
e.message = `第${opIdx + 1}条操作(${opKind})失败: ${e.message}`;
|
|
570
|
+
throw e;
|
|
571
|
+
}
|
|
537
572
|
}
|
|
538
573
|
return applied;
|
|
539
574
|
});
|
|
@@ -548,6 +583,17 @@ function applyAgentOps(ops) {
|
|
|
548
583
|
if (!Number.isInteger(n) || n <= 0) throw badOp(`source_id/target_id必须为正整数或使用ref占位符`);
|
|
549
584
|
return n;
|
|
550
585
|
}
|
|
586
|
+
// 按名称解析实体id:主名或别名精确匹配;重名返回候选
|
|
587
|
+
function resolveEntityName(name) {
|
|
588
|
+
const hits = findNameConflicts(String(name).trim());
|
|
589
|
+
if (hits.length === 1) return hits[0].id;
|
|
590
|
+
if (hits.length > 1) {
|
|
591
|
+
const e = badOp(`实体名 "${name}" 存在${hits.length}个候选(${hits.map((h) => `id=${h.id}`).join(', ')}),请改用 source_id/target_id`);
|
|
592
|
+
e.candidates = hits.map((h) => ({ id: h.id, name: h.name, category: h.category, via: h.via }));
|
|
593
|
+
throw e;
|
|
594
|
+
}
|
|
595
|
+
throw badOp(`实体 "${name}" 不存在(本批或库中均未找到)`);
|
|
596
|
+
}
|
|
551
597
|
function badOp(msg) { const e = new Error('OpenCode操作校验未通过: ' + msg); e.status = 400; return e; }
|
|
552
598
|
}
|
|
553
599
|
|
|
@@ -578,6 +624,34 @@ function deleteEntityImage(id) {
|
|
|
578
624
|
return row;
|
|
579
625
|
}
|
|
580
626
|
|
|
627
|
+
// ---- 关系图片绑定(结构与实体图片一致;文件本体由 server 层存取于 data/uploads/)----
|
|
628
|
+
function addRelationImage(relationId, item) {
|
|
629
|
+
if (!getRelation(relationId)) { const e = new Error(`关系id=${relationId} 不存在`); e.status = 404; throw e; }
|
|
630
|
+
if (typeof item.filename !== 'string' || !item.filename.trim()) { const e = new Error('filename必须为非空字符串'); e.status = 400; throw e; }
|
|
631
|
+
if (typeof item.stored_path !== 'string' || !item.stored_path.trim()) { const e = new Error('stored_path必须为非空字符串'); e.status = 400; throw e; }
|
|
632
|
+
const caption = typeof item.caption === 'string' ? item.caption.trim().slice(0, 300) : '';
|
|
633
|
+
const thumbPath = typeof item.thumb_path === 'string' ? item.thumb_path.trim() : '';
|
|
634
|
+
const info = db.prepare('INSERT INTO relation_images (relation_id, filename, stored_path, caption, thumb_path) VALUES (?, ?, ?, ?, ?)')
|
|
635
|
+
.run(relationId, item.filename.trim().slice(0, 200), item.stored_path.trim(), caption, thumbPath);
|
|
636
|
+
logOp('ADD_RELATION_IMAGE', { relation_id: relationId, image_id: Number(info.lastInsertRowid), filename: item.filename }, '手工');
|
|
637
|
+
return getRelationImage(Number(info.lastInsertRowid));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function getRelationImage(id) {
|
|
641
|
+
return db.prepare('SELECT * FROM relation_images WHERE id = ?').get(id);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function listRelationImages(relationId) {
|
|
645
|
+
return db.prepare('SELECT * FROM relation_images WHERE relation_id = ? ORDER BY id').all(relationId);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function deleteRelationImage(id) {
|
|
649
|
+
const row = getRelationImage(id);
|
|
650
|
+
if (!row) { const e = new Error(`关系图片绑定id=${id} 不存在`); e.status = 404; throw e; }
|
|
651
|
+
db.prepare('DELETE FROM relation_images WHERE id = ?').run(id);
|
|
652
|
+
return row;
|
|
653
|
+
}
|
|
654
|
+
|
|
581
655
|
// 剪除悬空图片行:.db 文件可迁移,uploads/ 资产不随库走,导入后行指向的文件可能不存在
|
|
582
656
|
function pruneMissingImages() {
|
|
583
657
|
const rows = db.prepare('SELECT id, stored_path FROM entity_images').all();
|
|
@@ -799,6 +873,7 @@ module.exports = {
|
|
|
799
873
|
addEntity, updateEntity, deleteEntity,
|
|
800
874
|
addRelation, updateRelation, deleteRelation,
|
|
801
875
|
addEntityImage, getEntityImage, listEntityImages, deleteEntityImage, imageCounts, pruneMissingImages,
|
|
876
|
+
addRelationImage, getRelationImage, listRelationImages, deleteRelationImage,
|
|
802
877
|
egoSubgraph, findPath, findPaths, resolveKey,
|
|
803
878
|
listAliases, addAlias, removeAlias, aliasMap, aliasRecords, findNameConflicts,
|
|
804
879
|
undoLast,
|
package/mcp/core.js
CHANGED
|
@@ -32,7 +32,7 @@ const TOOLS = [
|
|
|
32
32
|
},
|
|
33
33
|
{
|
|
34
34
|
name: 'kg_get_entity',
|
|
35
|
-
description: '按id
|
|
35
|
+
description: '按id或名称获取单个实体详情(含其全部关系与别名)',
|
|
36
36
|
inputSchema: {
|
|
37
37
|
type: 'object',
|
|
38
38
|
properties: { id: { type: 'number' }, name: { type: 'string' } },
|
|
@@ -115,13 +115,22 @@ const TOOLS = [
|
|
|
115
115
|
},
|
|
116
116
|
{
|
|
117
117
|
name: 'kg_apply_ops',
|
|
118
|
-
description: '写入图谱操作(kg-ops协议JSON数组):add_entity/add_relation/update_entity/update_relation/delete_entity/delete_relation
|
|
118
|
+
description: '写入图谱操作(kg-ops协议JSON数组):add_entity/add_relation/update_entity/update_relation/delete_entity/delete_relation。add_relation 支持按名称引用(source_name/target_name,含别名,重名时返回候选要求改用id);实体间同批引用可用 ref 占位符(add_entity 传 ref,add_relation 传 source_ref/target_ref,仅同一次调用内有效);关系可带 confidence(确证/推测/存疑)与 evidence_ref(来源引用文本)。add_entity 支持 aliases 数组。所有写入均记入operation_logs并自动git保存点',
|
|
119
119
|
inputSchema: {
|
|
120
120
|
type: 'object',
|
|
121
121
|
properties: { ops: { type: 'array', items: { type: 'object' } } },
|
|
122
122
|
required: ['ops'],
|
|
123
123
|
},
|
|
124
124
|
},
|
|
125
|
+
{
|
|
126
|
+
name: 'kg_reset',
|
|
127
|
+
description: '清空图谱全部实体与关系(级联删除,自动保存点)。仅用于重建场景,必须显式传 confirm:true 才会执行',
|
|
128
|
+
inputSchema: {
|
|
129
|
+
type: 'object',
|
|
130
|
+
properties: { confirm: { type: 'boolean', description: '必须为true才执行清空' } },
|
|
131
|
+
required: ['confirm'],
|
|
132
|
+
},
|
|
133
|
+
},
|
|
125
134
|
];
|
|
126
135
|
|
|
127
136
|
function createCore({ readonly = false, source = 'MCP' } = {}) {
|
|
@@ -133,7 +142,17 @@ function createCore({ readonly = false, source = 'MCP' } = {}) {
|
|
|
133
142
|
throw new Error(`实体id "${key}" 不存在`);
|
|
134
143
|
}
|
|
135
144
|
const hits = ents.filter((e) => e.name === key);
|
|
136
|
-
if (hits.length === 0)
|
|
145
|
+
if (hits.length === 0) {
|
|
146
|
+
const aliasMap = db.aliasMap();
|
|
147
|
+
const aliasHits = ents.filter((e) => (aliasMap[e.id] || []).some((a) => a === key));
|
|
148
|
+
if (aliasHits.length === 1) return { id: aliasHits[0].id };
|
|
149
|
+
if (aliasHits.length > 1) {
|
|
150
|
+
const e = new Error(`别名 "${key}" 对应${aliasHits.length}个实体,请改用id`);
|
|
151
|
+
e.candidates = aliasHits.map((h) => ({ id: h.id, name: h.name, category: h.category }));
|
|
152
|
+
throw e;
|
|
153
|
+
}
|
|
154
|
+
throw new Error(`实体 "${key}" 不存在`);
|
|
155
|
+
}
|
|
137
156
|
if (hits.length > 1) {
|
|
138
157
|
const e = new Error(`实体名 "${key}" 存在${hits.length}个候选,请改用id`);
|
|
139
158
|
e.candidates = hits.map((h) => ({ id: h.id, name: h.name, category: h.category }));
|
|
@@ -142,6 +161,10 @@ function createCore({ readonly = false, source = 'MCP' } = {}) {
|
|
|
142
161
|
return { id: hits[0].id };
|
|
143
162
|
}
|
|
144
163
|
|
|
164
|
+
// add_relation 的名称引用解析已下沉到 db.applyAgentOps(事务内可见同批新建实体)
|
|
165
|
+
|
|
166
|
+
const aliasById = () => db.aliasMap();
|
|
167
|
+
|
|
145
168
|
const HANDLERS = {
|
|
146
169
|
kg_stats: async () => {
|
|
147
170
|
const c = db.counts();
|
|
@@ -155,7 +178,9 @@ function createCore({ readonly = false, source = 'MCP' } = {}) {
|
|
|
155
178
|
const total = ents.length;
|
|
156
179
|
const limit = Math.max(1, Math.min(Number(args.limit) || 100, 500));
|
|
157
180
|
const offset = Math.max(0, Number(args.offset) || 0);
|
|
158
|
-
|
|
181
|
+
const amap = aliasById();
|
|
182
|
+
const page = ents.slice(offset, offset + limit).map((e) => ({ ...e, aliases: amap[e.id] || [] }));
|
|
183
|
+
return { total, count: Math.min(limit, total - offset), offset, entities: page };
|
|
159
184
|
},
|
|
160
185
|
kg_get_entity: async (args) => {
|
|
161
186
|
let e = null;
|
|
@@ -167,7 +192,7 @@ function createCore({ readonly = false, source = 'MCP' } = {}) {
|
|
|
167
192
|
}
|
|
168
193
|
if (!e) throw new Error('实体不存在(请提供id或name)');
|
|
169
194
|
const rels = db.listRelations().filter((r) => r.source_id === e.id || r.target_id === e.id);
|
|
170
|
-
return { entity: e, relations: rels };
|
|
195
|
+
return { entity: { ...e, aliases: aliasById()[e.id] || [] }, relations: rels };
|
|
171
196
|
},
|
|
172
197
|
kg_get_graph: async (args) => {
|
|
173
198
|
const g = db.getGraph();
|
|
@@ -219,6 +244,15 @@ function createCore({ readonly = false, source = 'MCP' } = {}) {
|
|
|
219
244
|
try { git.savepoint(`${source}写入: ${applied.length} 项操作`, source); } catch (_) { /* 数据目录无git时忽略 */ }
|
|
220
245
|
return { applied_count: applied.length, applied };
|
|
221
246
|
},
|
|
247
|
+
kg_reset: async (args) => {
|
|
248
|
+
if (readonly) throw new Error('MCP运行于只读模式,清空被拒绝(可在应用设置中关闭只读)');
|
|
249
|
+
if (args.confirm !== true) throw new Error('清空图谱是危险操作,必须显式传 confirm:true 执行');
|
|
250
|
+
const ids = db.listEntities().map((e) => e.id);
|
|
251
|
+
if (!ids.length) return { deleted_entities: 0, message: '图谱已为空' };
|
|
252
|
+
db.applyAgentOps(ids.map((id) => ({ op: 'delete_entity', id })));
|
|
253
|
+
try { git.savepoint(`${source}清空: 删除${ids.length}个实体`, source); } catch (_) { /* 忽略 */ }
|
|
254
|
+
return { deleted_entities: ids.length, message: '图谱已清空,可通过保存点回溯恢复' };
|
|
255
|
+
},
|
|
222
256
|
};
|
|
223
257
|
|
|
224
258
|
return { TOOLS, HANDLERS };
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -12,6 +12,7 @@ const state = {
|
|
|
12
12
|
inferredData: null, // /api/inference 缓存 {inferred, entities, ontology}
|
|
13
13
|
imageCounts: new Map(), // entityId -> 图片数量
|
|
14
14
|
entityImages: new Map(), // entityId -> [图片行]
|
|
15
|
+
relationImages: new Map(), // relationId -> [图片行]
|
|
15
16
|
meta: null, // /api/meta 缓存(图例与下拉框用)
|
|
16
17
|
aliases: {}, // entityId -> [别名](/api/graph 附带)
|
|
17
18
|
confFilter: '', // 关系置信度过滤:''=全部 | 确证 | 推测 | 存疑
|
|
@@ -623,6 +624,10 @@ function renderInfoCard() {
|
|
|
623
624
|
const r = state.relations.find((x) => x.id === rid);
|
|
624
625
|
if (!r) { card.style.display = 'none'; return; }
|
|
625
626
|
const s = state.entityMap.get(r.source_id), t = state.entityMap.get(r.target_id);
|
|
627
|
+
const rimgs = state.relationImages.get(rid);
|
|
628
|
+
const rimgHtml = `<div id="rimg-section"><div class="kv"><b>图片</b>:${rimgs ? (rimgs.length ? rimgs.length + ' 张' : '(无)') : '加载中…'}</div>` +
|
|
629
|
+
(rimgs && rimgs.length ? `<div id="img-grid">` + rimgs.map((im, i) => `<img src="${escapeHtml(im.thumb_url || im.url)}" title="${escapeHtml(im.caption || im.filename)}" onclick="openRelLightbox(${rid},${i})">`).join('') + `</div>` : '') +
|
|
630
|
+
`</div>`;
|
|
626
631
|
card.innerHTML = `
|
|
627
632
|
<h4>${escapeHtml(r.name)} <span class="tag" style="color:${RELATION_STYLE[r.category].css};border-color:${RELATION_STYLE[r.category].css}55">${r.category}关系</span> ${confBadge(r)}</h4>
|
|
628
633
|
<div class="kv"><b>${s ? escapeHtml(s.entity.name) : '?'}</b> --> <b>${t ? escapeHtml(t.entity.name) : '?'}</b></div>
|
|
@@ -632,8 +637,10 @@ function renderInfoCard() {
|
|
|
632
637
|
${['确证', '推测', '存疑'].map((c) => `<option value="${c}"${(r.confidence || '确证') === c ? ' selected' : ''}>${c}</option>`).join('')}
|
|
633
638
|
</select></div>
|
|
634
639
|
<div class="kv"><b>来源引用</b>:<input id="rel-sref" value="${escapeHtml(r.source_ref || '')}" placeholder="URL/文献+页码" style="width:150px"></div>
|
|
635
|
-
|
|
640
|
+
${rimgHtml}
|
|
641
|
+
<div class="btns"><button onclick="saveRelMeta(${r.id})">保存标注</button><button onclick="$('entity-img-input').click()">绑图片</button><button class="danger" onclick="delRelation(${r.id})">删除</button></div>`;
|
|
636
642
|
card.style.display = 'block';
|
|
643
|
+
if (!rimgs) loadRelationImages(rid);
|
|
637
644
|
}
|
|
638
645
|
}
|
|
639
646
|
function escapeHtml(s) {
|
|
@@ -740,10 +747,13 @@ document.body.appendChild(imgInput);
|
|
|
740
747
|
window.$ = $; // 信息卡内联onclick需访问$
|
|
741
748
|
|
|
742
749
|
imgInput.addEventListener('change', async () => {
|
|
743
|
-
const
|
|
750
|
+
const sel = state.selected;
|
|
751
|
+
const isRel = sel && sel.type === 'relation';
|
|
752
|
+
const id = sel && (sel.type === 'entity' || sel.type === 'relation') ? sel.id : null;
|
|
744
753
|
const files = [...imgInput.files];
|
|
745
754
|
imgInput.value = '';
|
|
746
755
|
if (!id || !files.length) return;
|
|
756
|
+
const postUrl = isRel ? `/api/relations/${id}/images` : `/api/entities/${id}/images`;
|
|
747
757
|
for (const f of files) {
|
|
748
758
|
try {
|
|
749
759
|
if (f.size > 10 * 1024 * 1024) throw new Error('超过10MB上限');
|
|
@@ -751,15 +761,20 @@ imgInput.addEventListener('change', async () => {
|
|
|
751
761
|
const thumb = await makeThumbB64(f);
|
|
752
762
|
let caption = '';
|
|
753
763
|
if (files.length === 1) caption = prompt('图片备注(可留空)', '') || '';
|
|
754
|
-
await api(
|
|
764
|
+
await api(postUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: f.name, content_b64: b64, caption, thumb_b64: thumb }) });
|
|
755
765
|
toast(`已绑定图片 ${f.name}`);
|
|
756
766
|
} catch (e) { toast(`图片 ${f.name} 绑定失败: ${e.message}`, true); }
|
|
757
767
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
768
|
+
if (isRel) {
|
|
769
|
+
const rows = await api(`/api/relations/${id}/images`);
|
|
770
|
+
state.relationImages.set(id, rows);
|
|
771
|
+
} else {
|
|
772
|
+
state.entityImages.delete(id);
|
|
773
|
+
const cnt = await api(`/api/entities/${id}/images`);
|
|
774
|
+
state.entityImages.set(id, cnt);
|
|
775
|
+
state.imageCounts.set(id, cnt.length);
|
|
776
|
+
await refreshAll(false);
|
|
777
|
+
}
|
|
763
778
|
if (state.selected && state.selected.id === id) renderInfoCard();
|
|
764
779
|
});
|
|
765
780
|
|
|
@@ -786,22 +801,39 @@ async function loadEntityImages(entityId) {
|
|
|
786
801
|
} catch (e) { toast(e.message, true); }
|
|
787
802
|
}
|
|
788
803
|
|
|
804
|
+
async function loadRelationImages(relationId) {
|
|
805
|
+
try {
|
|
806
|
+
const rows = await api(`/api/relations/${relationId}/images`);
|
|
807
|
+
state.relationImages.set(relationId, rows);
|
|
808
|
+
if (state.selected && state.selected.type === 'relation' && state.selected.id === relationId) renderInfoCard();
|
|
809
|
+
} catch (e) { toast(e.message, true); }
|
|
810
|
+
}
|
|
811
|
+
|
|
789
812
|
/* ---- 灯箱 ---- */
|
|
790
|
-
let lbState = null; // { list, idx }
|
|
813
|
+
let lbState = null; // { kind:'entity'|'relation', list, idx }
|
|
791
814
|
function openLightbox(entityId, idx) {
|
|
792
815
|
const list = state.entityImages.get(entityId) || [];
|
|
793
816
|
if (!list.length) return;
|
|
794
|
-
lbState = { list, idx };
|
|
817
|
+
lbState = { kind: 'entity', list, idx };
|
|
795
818
|
renderLightbox();
|
|
796
819
|
$('lightbox').classList.add('show');
|
|
797
820
|
}
|
|
798
821
|
window.openLightbox = openLightbox;
|
|
799
822
|
|
|
823
|
+
function openRelLightbox(relationId, idx) {
|
|
824
|
+
const list = state.relationImages.get(relationId) || [];
|
|
825
|
+
if (!list.length) return;
|
|
826
|
+
lbState = { kind: 'relation', list, idx };
|
|
827
|
+
renderLightbox();
|
|
828
|
+
$('lightbox').classList.add('show');
|
|
829
|
+
}
|
|
830
|
+
window.openRelLightbox = openRelLightbox;
|
|
831
|
+
|
|
800
832
|
function renderLightbox() {
|
|
801
833
|
if (!lbState) return;
|
|
802
834
|
const im = lbState.list[lbState.idx];
|
|
803
835
|
$('lb-img').src = im.url;
|
|
804
|
-
$('lb-cap').textContent = (im.caption || im.filename) +
|
|
836
|
+
$('lb-cap').textContent = (im.caption || im.filename) + `(${lbState.kind === 'relation' ? '关系' : '实体'}#${lbState.kind === 'relation' ? im.relation_id : im.entity_id})`;
|
|
805
837
|
$('lb-pos').textContent = `${lbState.idx + 1} / ${lbState.list.length}`;
|
|
806
838
|
}
|
|
807
839
|
|
|
@@ -822,6 +854,16 @@ async function delImage() {
|
|
|
822
854
|
const im = lbState.list[lbState.idx];
|
|
823
855
|
if (!confirm(`删除图片绑定「${im.filename}」?文件将从磁盘移除。`)) return;
|
|
824
856
|
try {
|
|
857
|
+
if (lbState.kind === 'relation') {
|
|
858
|
+
await api(`/api/relation-images/${im.id}`, { method: 'DELETE' });
|
|
859
|
+
const rid = im.relation_id;
|
|
860
|
+
lbState.list.splice(lbState.idx, 1);
|
|
861
|
+
state.relationImages.set(rid, lbState.list);
|
|
862
|
+
if (!lbState.list.length) closeLightbox(); else renderLightbox();
|
|
863
|
+
toast('图片绑定已删除');
|
|
864
|
+
if (state.selected && state.selected.type === 'relation' && state.selected.id === rid) renderInfoCard();
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
825
867
|
await api(`/api/images/${im.id}`, { method: 'DELETE' });
|
|
826
868
|
const eid = im.entity_id;
|
|
827
869
|
lbState.list.splice(lbState.idx, 1);
|
|
@@ -1043,9 +1085,10 @@ async function loadSimilar(id) {
|
|
|
1043
1085
|
window.loadSimilar = loadSimilar;
|
|
1044
1086
|
|
|
1045
1087
|
async function delRelation(id) {
|
|
1046
|
-
if (!confirm(`删除关系 #${id}
|
|
1088
|
+
if (!confirm(`删除关系 #${id}?其绑定的图片文件将一并移除。`)) return;
|
|
1047
1089
|
try {
|
|
1048
1090
|
await api(`/api/relations/${id}`, { method: 'DELETE' });
|
|
1091
|
+
state.relationImages.delete(id);
|
|
1049
1092
|
if (state.selected && state.selected.id === id) state.selected = null;
|
|
1050
1093
|
toast('关系已删除');
|
|
1051
1094
|
await refreshAll();
|
|
@@ -2020,6 +2063,7 @@ async function loadSearchStatus() {
|
|
|
2020
2063
|
const [st, cfg] = await Promise.all([api('/api/embeddings/status'), api('/api/embeddings/settings')]);
|
|
2021
2064
|
$('s-status').textContent = `已索引 ${st.indexed}/${st.total_entities}`;
|
|
2022
2065
|
if (!$('s-model').value) $('s-model').value = cfg.model;
|
|
2066
|
+
if (!$('s-baseurl').value) $('s-baseurl').value = cfg.base_url || 'https://api.siliconflow.cn/v1';
|
|
2023
2067
|
$('s-key').placeholder = cfg.api_key === '已配置' ? '已配置(输入新值可更换)' : '仅存本地 data/settings.json';
|
|
2024
2068
|
} catch (_) { /* 服务未就绪时静默 */ }
|
|
2025
2069
|
}
|
|
@@ -2110,16 +2154,38 @@ try {
|
|
|
2110
2154
|
} catch (_) { /* 浏览器不支持时依赖手动刷新 */ }
|
|
2111
2155
|
|
|
2112
2156
|
$('s-save').addEventListener('click', async () => {
|
|
2113
|
-
const patch = {
|
|
2157
|
+
const patch = {
|
|
2158
|
+
model: $('s-model').value.trim() || 'BAAI/bge-m3',
|
|
2159
|
+
base_url: $('s-baseurl').value.trim() || 'https://api.siliconflow.cn/v1',
|
|
2160
|
+
};
|
|
2114
2161
|
const key = $('s-key').value.trim();
|
|
2115
2162
|
if (key) patch.api_key = key;
|
|
2116
2163
|
try {
|
|
2117
2164
|
await api('/api/embeddings/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) });
|
|
2118
2165
|
$('s-key').value = '';
|
|
2166
|
+
$('s-status').textContent = '配置已保存';
|
|
2119
2167
|
loadSearchStatus();
|
|
2120
2168
|
} catch (e) { $('s-status').textContent = e.message; }
|
|
2121
2169
|
});
|
|
2122
2170
|
|
|
2171
|
+
// 测试连接:用页面上当前填写的配置(未保存的也生效)向服务方发一次真实请求
|
|
2172
|
+
$('s-test').addEventListener('click', async () => {
|
|
2173
|
+
const btn = $('s-test');
|
|
2174
|
+
btn.disabled = true;
|
|
2175
|
+
$('s-status').textContent = '测试中…';
|
|
2176
|
+
const patch = {
|
|
2177
|
+
model: $('s-model').value.trim() || 'BAAI/bge-m3',
|
|
2178
|
+
base_url: $('s-baseurl').value.trim() || 'https://api.siliconflow.cn/v1',
|
|
2179
|
+
};
|
|
2180
|
+
const key = $('s-key').value.trim();
|
|
2181
|
+
if (key) patch.api_key = key;
|
|
2182
|
+
try {
|
|
2183
|
+
const r = await api('/api/embeddings/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) });
|
|
2184
|
+
$('s-status').textContent = `连接成功:${r.model} 维度${r.dim} 耗时${r.ms}ms`;
|
|
2185
|
+
} catch (e) { $('s-status').textContent = `连接失败:${e.message}`; }
|
|
2186
|
+
btn.disabled = false;
|
|
2187
|
+
});
|
|
2188
|
+
|
|
2123
2189
|
$('s-build').addEventListener('click', async () => {
|
|
2124
2190
|
const btn = $('s-build');
|
|
2125
2191
|
btn.disabled = true;
|
package/public/index.html
CHANGED
|
@@ -101,11 +101,14 @@
|
|
|
101
101
|
</div>
|
|
102
102
|
<div class="card">
|
|
103
103
|
<h3>语义检索配置(硅基流动默认)</h3>
|
|
104
|
+
<div class="row">
|
|
105
|
+
<div style="flex:1"><label>API Base URL</label><input id="s-baseurl" placeholder="https://api.siliconflow.cn/v1"></div>
|
|
106
|
+
</div>
|
|
104
107
|
<div class="row">
|
|
105
108
|
<div style="flex:1"><label>API Key</label><input id="s-key" type="password" placeholder="仅存本地 data/settings.json"></div>
|
|
106
109
|
<div style="width:110px"><label>模型</label><input id="s-model"></div>
|
|
107
110
|
</div>
|
|
108
|
-
<div class="row"><button class="primary" id="s-save">保存配置</button><button id="s-build">构建全量向量</button><span id="s-status" style="font-size:11px;color:#8fa3c0;align-self:center"></span></div>
|
|
111
|
+
<div class="row"><button class="primary" id="s-save">保存配置</button><button id="s-test">测试连接</button><button id="s-build">构建全量向量</button><span id="s-status" style="font-size:11px;color:#8fa3c0;align-self:center"></span></div>
|
|
109
112
|
</div>
|
|
110
113
|
<div class="card">
|
|
111
114
|
<h3>混合检索(语义+关键词 RRF 融合)</h3>
|
|
@@ -246,12 +249,12 @@
|
|
|
246
249
|
<h3>快速上手</h3>
|
|
247
250
|
<ul class="hp-list">
|
|
248
251
|
<li><b>AI 对话建图</b>:右侧输入自然语言(如"联网查一下珠穆朗玛峰的海拔并建入图谱"),或上传文档(md/txt/pdf/docx)批量抽取</li>
|
|
249
|
-
<li><b>检索页签</b
|
|
252
|
+
<li><b>检索页签</b>:智能提问(一句话找关系)、语义/关键词混合检索;语义配置卡可填 Base URL/Key/模型并一键测试连接</li>
|
|
250
253
|
<li><b>最短路径</b>:搜索或列表选中实体后,用"路径"功能查两实体关联链路</li>
|
|
251
254
|
<li><b>撤销</b>:按钮或 Ctrl+Z,可连续撤销;删除实体仅恢复实体本身,关联关系可在日志页看到级联记录</li>
|
|
252
255
|
<li><b>推理</b>:开关叠加虚线显示的隐性关系(传递/对称/逆规则),点击可见推导依据</li>
|
|
253
256
|
<li><b>保存点与回溯</b>:版本页签打保存点,随时回滚到任一历史状态</li>
|
|
254
|
-
<li><b>图片绑定</b
|
|
257
|
+
<li><b>图片绑定</b>:实体与关系信息卡上传本地图片(缩略图+灯箱),佐证材料随图谱留存</li>
|
|
255
258
|
<li><b>导出</b>:RDF(Turtle)、整库 .db、单文件只读网页版</li>
|
|
256
259
|
</ul>
|
|
257
260
|
<h3>键盘快捷键</h3>
|
package/server.js
CHANGED
|
@@ -42,7 +42,9 @@ const mcpHandler = createMcpHandler({
|
|
|
42
42
|
const PORT = Number(process.env.PORT || 3000);
|
|
43
43
|
const app = express();
|
|
44
44
|
app.use(express.json({ limit: '30mb' }));
|
|
45
|
-
app.use(express.static(path.join(__dirname, 'public')
|
|
45
|
+
app.use(express.static(path.join(__dirname, 'public'), {
|
|
46
|
+
setHeaders: (res) => { res.setHeader('Cache-Control', 'no-cache, must-revalidate'); }
|
|
47
|
+
}));
|
|
46
48
|
app.use('/uploads', express.static(path.join(git.DATA_DIR, 'uploads')));
|
|
47
49
|
|
|
48
50
|
const api = express.Router();
|
|
@@ -315,11 +317,70 @@ api.delete('/images/:imgId', (req, res) => {
|
|
|
315
317
|
res.json({ deleted: row });
|
|
316
318
|
});
|
|
317
319
|
|
|
320
|
+
// ---------- 关系图片绑定(与实体图片同模式,目录 r<id>)----------
|
|
321
|
+
api.get('/relations/:id/images', (req, res) => {
|
|
322
|
+
const id = Number(req.params.id);
|
|
323
|
+
if (!db.getRelation(id)) return res.status(404).json({ error: `关系id=${id} 不存在` });
|
|
324
|
+
const rel = (p) => (p ? '/uploads/' + p.replace(/^uploads[\\/]/, '') : null);
|
|
325
|
+
res.json(db.listRelationImages(id).map((r) => ({ ...r, url: rel(r.stored_path), thumb_url: rel(r.thumb_path) || rel(r.stored_path) })));
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
api.post('/relations/:id/images', (req, res) => {
|
|
329
|
+
const id = Number(req.params.id);
|
|
330
|
+
const { filename, content_b64, caption, thumb_b64 } = req.body || {};
|
|
331
|
+
if (!filename || !content_b64) return res.status(400).json({ error: '必须提供文件名与内容(content_b64)' });
|
|
332
|
+
const ext = path.extname(String(filename)).toLowerCase();
|
|
333
|
+
if (!IMAGE_EXTS.includes(ext)) return res.status(400).json({ error: `仅支持图片格式: ${IMAGE_EXTS.join(' ')}` });
|
|
334
|
+
let buf;
|
|
335
|
+
try { buf = Buffer.from(content_b64, 'base64'); } catch (_) { return res.status(400).json({ error: 'content_b64不是合法的base64' }); }
|
|
336
|
+
if (!buf.length) return res.status(400).json({ error: '文件内容为空' });
|
|
337
|
+
if (buf.length > MAX_IMAGE_BYTES) return res.status(400).json({ error: '单张图片不得超过10MB' });
|
|
338
|
+
|
|
339
|
+
const dir = path.join(git.DATA_DIR, 'uploads', `r${id}`);
|
|
340
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
341
|
+
const safe = path.basename(String(filename)).replace(/[^\w.\-\u4e00-\u9fa5]/g, '_').slice(0, 80) || 'image';
|
|
342
|
+
const storedPath = path.join('uploads', `r${id}`, `${Date.now()}_${safe}`);
|
|
343
|
+
fs.writeFileSync(path.join(git.DATA_DIR, storedPath), buf);
|
|
344
|
+
let thumbPath = '';
|
|
345
|
+
if (thumb_b64) {
|
|
346
|
+
try {
|
|
347
|
+
const tbuf = Buffer.from(thumb_b64, 'base64');
|
|
348
|
+
if (tbuf.length > 0 && tbuf.length <= 2 * 1024 * 1024) {
|
|
349
|
+
thumbPath = path.join('uploads', `r${id}`, `${Date.now()}_thumb_${safe}`);
|
|
350
|
+
fs.writeFileSync(path.join(git.DATA_DIR, thumbPath), tbuf);
|
|
351
|
+
}
|
|
352
|
+
} catch (_) { thumbPath = ''; }
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
const row = db.addRelationImage(id, { filename, stored_path: storedPath, caption, thumb_path: thumbPath });
|
|
356
|
+
const rel = (p) => (p ? '/uploads/' + p.replace(/^uploads[\\/]/, '') : null);
|
|
357
|
+
res.json({ ...row, url: rel(row.stored_path), thumb_url: rel(row.thumb_path) || rel(row.stored_path) });
|
|
358
|
+
} catch (e) {
|
|
359
|
+
cleanupImageFiles([storedPath, thumbPath]);
|
|
360
|
+
throw e;
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
api.delete('/relation-images/:imgId', (req, res) => {
|
|
365
|
+
const row = db.deleteRelationImage(Number(req.params.imgId));
|
|
366
|
+
cleanupImageFiles([row.stored_path, row.thumb_path].filter(Boolean));
|
|
367
|
+
res.json({ deleted: row });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// 删除关系时连带清理其图片文件(表行由外键级联清除)
|
|
371
|
+
api.delete('/relations/:id', (req, res) => {
|
|
372
|
+
const id = Number(req.params.id);
|
|
373
|
+
const files = db.listRelationImages(id).flatMap((r) => [r.stored_path, r.thumb_path].filter(Boolean));
|
|
374
|
+
const out = db.deleteRelation(id, '手工');
|
|
375
|
+
cleanupImageFiles(files);
|
|
376
|
+
res.json(out);
|
|
377
|
+
});
|
|
378
|
+
|
|
318
379
|
// ---------- 关系 ----------
|
|
319
380
|
api.get('/relations', (req, res) => res.json(db.listRelations()));
|
|
320
381
|
api.post('/relations', (req, res) => res.json(db.addRelation(req.body, '手工')));
|
|
321
382
|
api.put('/relations/:id', (req, res) => res.json(db.updateRelation(Number(req.params.id), req.body, '手工')));
|
|
322
|
-
|
|
383
|
+
// DELETE /relations/:id 已在上方"关系图片"块定义(连带清理图片文件)
|
|
323
384
|
|
|
324
385
|
// ---------- 图谱 / 日志 ----------
|
|
325
386
|
api.get('/graph', (req, res) => res.json(db.getGraph()));
|
|
@@ -369,6 +430,21 @@ api.put('/embeddings/settings', (req, res) => {
|
|
|
369
430
|
} catch (e) { res.status(400).json({ error: e.message }); }
|
|
370
431
|
});
|
|
371
432
|
|
|
433
|
+
// 测试连接:用当前(或请求体内临时覆盖的)配置向服务方发一次真实embedding请求
|
|
434
|
+
api.post('/embeddings/test', async (req, res) => {
|
|
435
|
+
const overrides = req.body || {};
|
|
436
|
+
if (overrides.api_key === '已配置') delete overrides.api_key; // 占位=沿用已存密钥
|
|
437
|
+
const s = { ...embeddings.loadSettings(), ...overrides };
|
|
438
|
+
const started = Date.now();
|
|
439
|
+
try {
|
|
440
|
+
const vec = await embeddings.embed(['连接测试'], s);
|
|
441
|
+
const dim = Array.isArray(vec) && Array.isArray(vec[0]) ? vec[0].length : (vec && vec.length) || 0;
|
|
442
|
+
res.json({ ok: true, model: s.model, base_url: s.base_url, dim, ms: Date.now() - started });
|
|
443
|
+
} catch (e) {
|
|
444
|
+
res.status(400).json({ ok: false, error: e.message, ms: Date.now() - started });
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
372
448
|
api.post('/embeddings/build', async (req, res) => {
|
|
373
449
|
try {
|
|
374
450
|
const result = await embeddings.build();
|