local-knowledge-graph 1.8.1 → 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 +39 -0
- package/package.json +1 -1
- package/public/app.js +79 -13
- package/public/index.html +6 -3
- package/server.js +75 -1
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,主库内残留表直接清除
|
|
@@ -614,6 +624,34 @@ function deleteEntityImage(id) {
|
|
|
614
624
|
return row;
|
|
615
625
|
}
|
|
616
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
|
+
|
|
617
655
|
// 剪除悬空图片行:.db 文件可迁移,uploads/ 资产不随库走,导入后行指向的文件可能不存在
|
|
618
656
|
function pruneMissingImages() {
|
|
619
657
|
const rows = db.prepare('SELECT id, stored_path FROM entity_images').all();
|
|
@@ -835,6 +873,7 @@ module.exports = {
|
|
|
835
873
|
addEntity, updateEntity, deleteEntity,
|
|
836
874
|
addRelation, updateRelation, deleteRelation,
|
|
837
875
|
addEntityImage, getEntityImage, listEntityImages, deleteEntityImage, imageCounts, pruneMissingImages,
|
|
876
|
+
addRelationImage, getRelationImage, listRelationImages, deleteRelationImage,
|
|
838
877
|
egoSubgraph, findPath, findPaths, resolveKey,
|
|
839
878
|
listAliases, addAlias, removeAlias, aliasMap, aliasRecords, findNameConflicts,
|
|
840
879
|
undoLast,
|
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
|
@@ -317,11 +317,70 @@ api.delete('/images/:imgId', (req, res) => {
|
|
|
317
317
|
res.json({ deleted: row });
|
|
318
318
|
});
|
|
319
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
|
+
|
|
320
379
|
// ---------- 关系 ----------
|
|
321
380
|
api.get('/relations', (req, res) => res.json(db.listRelations()));
|
|
322
381
|
api.post('/relations', (req, res) => res.json(db.addRelation(req.body, '手工')));
|
|
323
382
|
api.put('/relations/:id', (req, res) => res.json(db.updateRelation(Number(req.params.id), req.body, '手工')));
|
|
324
|
-
|
|
383
|
+
// DELETE /relations/:id 已在上方"关系图片"块定义(连带清理图片文件)
|
|
325
384
|
|
|
326
385
|
// ---------- 图谱 / 日志 ----------
|
|
327
386
|
api.get('/graph', (req, res) => res.json(db.getGraph()));
|
|
@@ -371,6 +430,21 @@ api.put('/embeddings/settings', (req, res) => {
|
|
|
371
430
|
} catch (e) { res.status(400).json({ error: e.message }); }
|
|
372
431
|
});
|
|
373
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
|
+
|
|
374
448
|
api.post('/embeddings/build', async (req, res) => {
|
|
375
449
|
try {
|
|
376
450
|
const result = await embeddings.build();
|