local-knowledge-graph 1.10.3 → 1.11.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 +24 -1
- package/README.md +10 -4
- package/lib/agent.js +22 -6
- package/lib/db.js +133 -11
- package/lib/ocbin.js +194 -0
- package/lib/ocinstall.js +229 -0
- package/lib/triples_io.js +229 -0
- package/lib/validator.js +35 -8
- package/lib/viewer_template.html +44 -6
- package/package.json +1 -1
- package/public/app.js +457 -23
- package/public/index.html +62 -0
- package/public/style.css +13 -0
- package/server.js +82 -6
package/HELP.md
CHANGED
|
@@ -68,6 +68,10 @@ kg
|
|
|
68
68
|
|
|
69
69
|
Key 与 Base URL 仅存本地数据目录的 settings.json,服务端永不回传真实值。不配置则自动退化为关键词检索,其余功能不受影响。改配置后需重新「构建全量向量」才能让新模型生效于语义检索。
|
|
70
70
|
- **OpenCode(AI 对话建图 / 智能提问编译)**:使用 opencode CLI 自身的认证(`opencode auth login`),应用不经手密钥。未安装或未认证时仅这两个功能降级。
|
|
71
|
+
- **一键安装**:未检测到 opencode 时,「检索」页签会出现"OpenCode 一键安装"卡片(点击顶部"OpenCode 未安装"徽章也可直达),一键通过 npm 全局安装 opencode-ai(首次需下载较大二进制,约 1-6 分钟,安装过程有实时日志与耗时显示)。安装中右侧 AI 对话会友好提示等待,完成后立即可用,无需重启本应用。
|
|
72
|
+
- **自动模型配置**:安装完成后自动写入 opencode 全局配置(`~/.config/opencode/opencode.json`)——优先保留已有认证/已设模型;其次接入本机 Ollama(127.0.0.1:11434)或 LM Studio(127.0.0.1:1234)已拉起的模型(自动过滤 embedding 模型);都没有时默认使用 OpenCode Zen 免费模型 `opencode/grok-code`(无需登录,可随时在 opencode 配置中更换)。
|
|
73
|
+
- 启动时会自动识别 opencode 的安装位置:PATH 扫描(兼容 Windows 的 npm `.cmd` 垫片,自动还原真实 exe)、`~/.opencode/bin`(官方脚本安装)、Homebrew / WinGet / Scoop、npm 全局目录。识别结果在启动日志与 `/api/meta` 的 `agent_path` 中可见。
|
|
74
|
+
- 特殊安装位置可用环境变量 `KG_OPENCODE_PATH` 直接指定可执行文件完整路径(优先级最高)。
|
|
71
75
|
- 更换数据目录(`kg --data <目录>`)会使用该目录下的独立配置,互不串扰。
|
|
72
76
|
|
|
73
77
|
## 关系编辑与关系图片
|
|
@@ -78,6 +82,24 @@ kg
|
|
|
78
82
|
- **AI 编辑**:外部 Agent 通过 MCP 的 `kg_apply_ops` 可对关系做增删改(支持 `confidence` 置信度、`evidence_ref` 来源引用、按名称引用端点实体);文档批量入图抽取出的关系同样带置信度与来源,经审核勾选后入库。AI 写入与人工修改互相实时可见。
|
|
79
83
|
- **关系图片**:选中关系后点「绑图片」上传本地图片(支持多选,jpg/png/gif/webp/bmp,单张 ≤10MB,自动生成缩略图)。图片在关系信息卡缩略图网格展示,点击进入灯箱翻看,灯箱内可删除。典型用途:史料扫描件、聊天记录截图、地图/图表、新闻照片等佐证材料。图片文件存于数据目录 `data/uploads/r<关系id>/`,删除关系时其图片一并清理。
|
|
80
84
|
|
|
85
|
+
## 关系属性与重复三元组
|
|
86
|
+
|
|
87
|
+
- **关系属性**:点选图上关系,信息卡"关系属性"框内以 JSON 编辑(如 `{"开始时间":"2024-01","weight":4}`),与置信度/来源引用一起保存;AI 建图(kg-ops 的 add_relation / update_relation)与三元组导入均支持 attributes
|
|
88
|
+
- **weight 明暗映射**:关系属性中的数值 `weight` 会映射到线的明暗(1 最暗,5 最亮),用于表达关系强弱;WebGL 线宽不可靠,故用明暗表达
|
|
89
|
+
- **方向箭头**:关系线在终点端画箭头指示方向;A→B 与 B→A 存在同名关系(互惠对)时两端各画一个箭头
|
|
90
|
+
- **重复三元组**:同一起点、同一终点、同一关系名的组合只能存在一条,重复添加返回 409;同名不同向(A→B 与 B→A)允许并存;导入时遇到重复自动跳过并在结果中计数
|
|
91
|
+
|
|
92
|
+
## 三元组导入导出(JSON / CSV)
|
|
93
|
+
|
|
94
|
+
- **导出**:文件菜单 →「导出三元组 JSON / CSV」;CSV 为中文表头 `种类,名称,大类,起点,终点,关系,关系大类,置信度,来源引用,属性`,属性列为 JSON 对象文本(UTF-8 BOM,Excel 直接打开不乱码)
|
|
95
|
+
- **导入**:文件菜单 →「导入三元组」,选 .json 或 .csv;同名实体自动复用(不重复创建),重复关系自动跳过;缺失的起点/终点实体若在文件内有"实体"行定义则自动建好,否则该条关系报错跳过(其余行继续);CSV 可省略表头(按默认列序解析)
|
|
96
|
+
- **子图导出**:工具栏「多选」→ 点选节点 →「导出JSON/CSV」直接导出选区子图;或「提取子图」后用子图条上的导出按钮。API 方式:`GET /api/export/json?ids=1,2,3`
|
|
97
|
+
- **导入 API**:`POST /api/import/triples`,body `{content, format, dry_run}`,dry_run=true 只校验返回计数不写库
|
|
98
|
+
|
|
99
|
+
## 图统计(统计页签)
|
|
100
|
+
|
|
101
|
+
- 总量、平均度、最高度;度分布直方图;枢纽节点 Top10(出度/入度、常涉关系,点击飞行定位);实体大类分布条形图
|
|
102
|
+
|
|
81
103
|
## MCP 外部接入(外部 Agent 读写图谱)
|
|
82
104
|
|
|
83
105
|
在 **MCP 页签** 开启"MCP 服务"后,外部 Agent(Claude Desktop、Cursor、Cline、opencode 等)即可通过标准 MCP 协议对本图谱做完整增删改查:
|
|
@@ -152,7 +174,7 @@ kg
|
|
|
152
174
|
用现代浏览器(Chrome/Edge/Safari 最新版);确认服务已启动且端口未被占用。
|
|
153
175
|
|
|
154
176
|
**Q:OpenCode 检测未就绪?**
|
|
155
|
-
运行 `npm i -g opencode-ai` 安装后重启应用;OpenCode
|
|
177
|
+
运行 `npm i -g opencode-ai` 安装后重启应用;OpenCode 仅用于自然语言补全与智能提问的编译环节。也可直接在「检索」页签使用"OpenCode 一键安装"自动完成安装与模型配置。若已安装仍提示未检测到,可用环境变量 `KG_OPENCODE_PATH` 指定 opencode 可执行文件完整路径后重启。
|
|
156
178
|
|
|
157
179
|
**Q:数据会上传到云端吗?**
|
|
158
180
|
图谱数据只存在本机。仅当你在"检索"页签配置了语义检索 API Key 时,检索向量化的文本片段才会发往你配置的模型服务;OpenCode 补全公开知识时按其自身机制联网。其余全部本地完成。
|
|
@@ -165,6 +187,7 @@ kg
|
|
|
165
187
|
|
|
166
188
|
## 版本历史摘要
|
|
167
189
|
|
|
190
|
+
- **v1.11.0**:九项图谱能力升级——①关系属性(JSON 编辑/kg-ops/导入导出全链路,weight 数值映射线明暗)②三元组 JSON/CSV 导入导出(中文表头、同名复用、重复去重、支持子图 ids 参数)③重复三元组校验(同起点+终点+名称返回 409,前端友好提示)④关系方向箭头与互惠对双箭头 ⑤图例点击显隐 + 属性条件过滤 + 检索结果全部高亮 ⑥统计页签(度分布/枢纽 Top10/大类分布)⑦多选模式与子图提取/导出 ⑧PNG 截图(2 倍分辨率)⑨单文件网页导出补齐关系属性/置信度/来源引用展示与平行边标签错开;OpenCode 识别增强(PATH 扫描/常见位置/npm 全局四级定位)与检索页签一键安装(npm 全局安装 + 自动模型配置:本机 Ollama/LM Studio 优先,否则 Zen 免费模型)
|
|
168
191
|
- **v1.10.0**:关系推荐——共同邻居算法(Adamic-Adar 打分,排除已有直接边)全图/中心实体两种扫描;AI 判断候选关系(LLM 给出关系名/大类/置信度/依据,人工确认后入库,不直接写库);检索页签新增"关系推荐"卡,实体信息卡新增"推荐关系"按钮
|
|
169
192
|
- **v1.9.0**:检索配置增强(Base URL 页面可配 + 测试连接按钮,保存前即可验证密钥/地址/模型);关系图片绑定(信息卡上传/缩略图/灯箱,佐证材料与关系绑定,删关系连带清理);帮助文档全面增补(检索配置详解/关系编辑与图片/知识推理方法体系与应用场景)
|
|
170
193
|
- **v1.8.0**:MCP 外部接入——设置新增 MCP 页签(服务开关/只读模式/令牌管理/三类客户端一键复制配置);`/mcp` Streamable HTTP 端点(令牌鉴权+CORS,12 个工具含完整 CRUD);`kg --mcp` 本机 stdio 接入;图谱变更实时同步(SSE 推送+跨进程探测,页面 3 秒内自动刷新)
|
package/README.md
CHANGED
|
@@ -14,12 +14,13 @@
|
|
|
14
14
|
|
|
15
15
|
| 模块 | 说明 |
|
|
16
16
|
|------|------|
|
|
17
|
-
| 3D 可视化 | three.js 力导向布局,实体按 4 大类区分形状/颜色,关系按 5
|
|
17
|
+
| 3D 可视化 | three.js 力导向布局,实体按 4 大类区分形状/颜色,关系按 5 大类区分线型并带方向箭头(互惠关系双箭头、属性 weight 数值映射明暗);支持中心层级视图(可调层数)、多选提取子图、图例点击显隐、属性条件过滤、PNG 截图、视图主题自定义、实体图片绑定与灯箱 |
|
|
18
18
|
| AI 文档导入 | 调用 OpenCode CLI 将 Markdown/PDF/Word 等文档抽取为三元组并自动导入(分块+保存点对齐,防重复导入) |
|
|
19
19
|
| OWL 推理 | 按 传递/对称/逆 三类规则动态推导隐性关系(不入库),可配置 data/ontology.json,前端一键叠加显示并可查看推导链 |
|
|
20
|
-
| 混合检索 | 语义向量(默认硅基流动 BAAI/bge-m3,可换任意 OpenAI 兼容接口)+ 关键词检索,RRF
|
|
20
|
+
| 混合检索 | 语义向量(默认硅基流动 BAAI/bge-m3,可换任意 OpenAI 兼容接口)+ 关键词检索,RRF 融合排序,检索结果可一键全部高亮 |
|
|
21
21
|
| 迷你 Cypher | 只读关系查询:`MATCH (a)-[r:互动]->(b) WHERE a.name contains 郑和 RETURN a.name, r.name LIMIT 10` |
|
|
22
|
-
|
|
|
22
|
+
| 图统计 | 度分布直方图、枢纽节点 Top10(点击飞行定位)、实体大类分布(统计页签) |
|
|
23
|
+
| 数据资产 | 实体图片(本地存储,独立表)、RDF Turtle 导出、SQLite 整库导出/导入(自动备份保存点)、三元组 JSON/CSV 导入导出(中文表头,同名实体复用、重复三元组自动去重)、单文件只读网页另存 |
|
|
23
24
|
| 保存点 | 每次合规写入自动打 Git 保存点,违规写入自动回滚到上个合规版本;可一键回溯任意历史版本 |
|
|
24
25
|
| MCP Server | 零依赖 stdio JSON-RPC,10 个工具可直接接入 Claude Desktop / OpenCode 等支持 MCP 的客户端 |
|
|
25
26
|
| 双端适配 | 桌面端完整面板;手机端单行工具栏+抽屉面板+文件菜单 |
|
|
@@ -59,7 +60,7 @@ npm install
|
|
|
59
60
|
npm start
|
|
60
61
|
```
|
|
61
62
|
|
|
62
|
-
AI 导入功能需要安装 [OpenCode CLI](https://opencode.ai) 并配置可用模型;其余功能(可视化/推理/检索配置/导出/MCP
|
|
63
|
+
AI 导入功能需要安装 [OpenCode CLI](https://opencode.ai) 并配置可用模型;其余功能(可视化/推理/检索配置/导出/MCP)无外部依赖。应用会自动识别 opencode 的安装位置(PATH / `~/.opencode/bin` / Homebrew / WinGet / npm 全局,含 Windows npm 垫片还原);非标准位置可用环境变量 `KG_OPENCODE_PATH` 指定可执行文件完整路径。未安装时可在「检索」页签一键安装,并自动完成模型配置(优先本机 Ollama / LM Studio 已有模型,否则 OpenCode Zen 免费模型,无需登录)。
|
|
63
64
|
|
|
64
65
|
## 目录结构
|
|
65
66
|
|
|
@@ -68,6 +69,8 @@ server.js Express 服务与全部 API
|
|
|
68
69
|
lib/db.js SQLite 三表核心(entities/relations/operation_logs)+ 图片表 + 向量表 + 迷你Cypher
|
|
69
70
|
lib/validator.js 手工/AI 共用的合规校验(违规即整体拒绝,库保持上个合规版本)
|
|
70
71
|
lib/agent.js OpenCode CLI 封装(普通问答与文档导入两种模式)
|
|
72
|
+
lib/ocbin.js opencode 可执行文件多路径定位(PATH/常见位置/npm 垫片还原/KG_OPENCODE_PATH)
|
|
73
|
+
lib/ocinstall.js OpenCode 一键安装与自动模型配置(npm 安装/本机模型接入/免费模型兜底)
|
|
71
74
|
lib/git.js data/ 目录 Git 保存点(差量提交与回溯)
|
|
72
75
|
lib/inference.js OWL 推理引擎(虚拟推导,不写库)
|
|
73
76
|
lib/embeddings.js 向量构建/检索(data/settings.json 存配置,已被 gitignore)
|
|
@@ -111,6 +114,9 @@ GET /export/rdf RDF Turtle
|
|
|
111
114
|
GET /export/db?name= SQLite 整库导出
|
|
112
115
|
POST /graph/import SQLite 整库导入(自动备份+校验)
|
|
113
116
|
GET /export/html 单文件只读网页
|
|
117
|
+
GET /export/json|csv?ids= 三元组 JSON/CSV 导出(ids 可选,传实体id集合即导出子图)
|
|
118
|
+
POST /import/triples 三元组 JSON/CSV 批量导入({content, format, dry_run},同名实体复用、重复关系跳过)
|
|
119
|
+
GET /stats 图统计(度分布、枢纽节点 Top10、大类分布)
|
|
114
120
|
```
|
|
115
121
|
|
|
116
122
|
## kg-ops 写入协议
|
package/lib/agent.js
CHANGED
|
@@ -7,6 +7,7 @@ const { spawn } = require('child_process');
|
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
|
|
10
|
+
const ocbin = require('./ocbin');
|
|
10
11
|
const { APP_ROOT, DATA_DIR } = require('./paths');
|
|
11
12
|
const SESSION_FILE = path.join(DATA_DIR, '.agent_session');
|
|
12
13
|
const TIMEOUT_MS = Number(process.env.AGENT_TIMEOUT_MS || 300000);
|
|
@@ -47,7 +48,7 @@ ${relLines || '(空)'}
|
|
|
47
48
|
【RDF规范(必须严格遵守)】
|
|
48
49
|
- 实体大类只能是: 物理实体 / 抽象实体 / 数值实体 / 时间实体
|
|
49
50
|
- 关系大类只能是: 空间 / 互动 / 归属 / 时间 / 属性
|
|
50
|
-
- 实体属性(attributes)必须是扁平JSON
|
|
51
|
+
- 实体属性(attributes)必须是扁平JSON对象,值为字符串/数值/布尔,禁止嵌套对象或数组;关系同样可选携带扁平attributes(如开始时间、合同编号、权重)
|
|
51
52
|
- 关系的 source/target 只能引用已存在实体id,或用ref占位符引用本次新增的实体
|
|
52
53
|
- 建模准则:数值实体/时间实体仅在"数值或时间作为某条关系的端点"时才创建(如"事件—发生于→744年");仅作描述说明的年份、日期、数量(如成立年份、测量年份、海拔、人口)一律写入所属实体的attributes,禁止为它们单独建节点
|
|
53
54
|
|
|
@@ -65,12 +66,12 @@ ${relLines || '(空)'}
|
|
|
65
66
|
第一步:用简洁中文说明你的分析与结论(若涉及联网信息,注明来源可信度)。
|
|
66
67
|
第二步:如需修改图谱,输出一个 \`\`\`kg-ops 代码块,内容为JSON数组(仅这一种操作格式),元素形如:
|
|
67
68
|
{"op":"add_entity","ref":"A","name":"实体名","category":"物理实体","attributes":{"键":"值"}}
|
|
68
|
-
{"op":"add_relation","source_ref":"A","target_id":3,"name":"关系名","category":"归属"}
|
|
69
|
+
{"op":"add_relation","source_ref":"A","target_id":3,"name":"关系名","category":"归属","attributes":{"开始时间":"2024年","合同编号":"HT-001"}}
|
|
69
70
|
{"op":"update_entity","id":5,"attributes":{"键":"新值"}}
|
|
70
71
|
{"op":"delete_entity","id":7}
|
|
71
|
-
{"op":"update_relation","id":2,"name":"新名"}
|
|
72
|
+
{"op":"update_relation","id":2,"name":"新名","attributes":{"键":"新值"}}
|
|
72
73
|
{"op":"delete_relation","id":4}
|
|
73
|
-
(source_id/target_id用数字id引用已有实体;本次新增的实体用ref占位符,如"ref":"A"
|
|
74
|
+
(source_id/target_id用数字id引用已有实体;本次新增的实体用ref占位符,如"ref":"A"供后续关系引用;关系可选携带扁平attributes,如开始时间、合同编号、权重等)
|
|
74
75
|
第三步:若无任何图谱修改,省略代码块。
|
|
75
76
|
|
|
76
77
|
【用户指令】
|
|
@@ -147,8 +148,23 @@ function runOpenCodeRaw(preArgs, prompt, files, timeoutMs, envExtra) {
|
|
|
147
148
|
|
|
148
149
|
let stdout = '', stderr = '', done = false;
|
|
149
150
|
let child;
|
|
151
|
+
|
|
152
|
+
const bin = ocbin.findOpenCodeBin();
|
|
153
|
+
if (!bin) {
|
|
154
|
+
// 安装进行中时给出等待提示而非"未找到"
|
|
155
|
+
let installing = null;
|
|
156
|
+
try {
|
|
157
|
+
const ocs = require('./ocinstall').status();
|
|
158
|
+
if (ocs.running) {
|
|
159
|
+
const secs = ocs.started_at ? Math.round((Date.now() - ocs.started_at) / 1000) : 0;
|
|
160
|
+
installing = `opencode 正在后台安装中(已进行 ${secs >= 60 ? `${Math.floor(secs / 60)} 分 ${secs % 60} 秒` : `${secs} 秒`}),首次需下载较大二进制文件,完成后即可直接使用,请稍候再试`;
|
|
161
|
+
}
|
|
162
|
+
} catch (_) { /* 安装模块不可用时走通用提示 */ }
|
|
163
|
+
return resolve({ ok: false, error: installing || '未找到 opencode CLI。请安装(npm i -g opencode-ai 或官方安装脚本),或在「检索」页签使用一键安装;已安装但识别失败时可用环境变量 KG_OPENCODE_PATH 指定可执行文件完整路径' });
|
|
164
|
+
}
|
|
165
|
+
const { file, args: spawnArgs, opts: spawnOpts } = ocbin.buildLauncher(bin, args);
|
|
150
166
|
try {
|
|
151
|
-
child = spawn(
|
|
167
|
+
child = spawn(file, spawnArgs, { cwd: APP_ROOT, env: { ...process.env, ...(envExtra || {}) }, stdio: ['ignore', 'pipe', 'pipe'], ...spawnOpts });
|
|
152
168
|
} catch (e) {
|
|
153
169
|
return resolve({ ok: false, error: '无法启动OpenCode进程: ' + e.message });
|
|
154
170
|
}
|
|
@@ -161,7 +177,7 @@ function runOpenCodeRaw(preArgs, prompt, files, timeoutMs, envExtra) {
|
|
|
161
177
|
child.stderr.on('data', (d) => { stderr += d; });
|
|
162
178
|
child.on('error', (e) => {
|
|
163
179
|
if (done) return; done = true; clearTimeout(timer);
|
|
164
|
-
resolve({ ok: false, error: `OpenCode进程启动失败: ${e.message}
|
|
180
|
+
resolve({ ok: false, error: `OpenCode进程启动失败: ${e.message}(尝试执行: ${file},可用环境变量 KG_OPENCODE_PATH 修正路径)` });
|
|
165
181
|
});
|
|
166
182
|
child.on('close', (code) => {
|
|
167
183
|
if (done) return; done = true; clearTimeout(timer);
|
package/lib/db.js
CHANGED
|
@@ -50,6 +50,7 @@ function initSchema() {
|
|
|
50
50
|
category TEXT NOT NULL CHECK(category IN ('空间','互动','归属','时间','属性')),
|
|
51
51
|
confidence TEXT NOT NULL DEFAULT '确证' CHECK(confidence IN ('确证','推测','存疑')),
|
|
52
52
|
source_ref TEXT NOT NULL DEFAULT '',
|
|
53
|
+
attributes TEXT NOT NULL DEFAULT '{}',
|
|
53
54
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
54
55
|
source TEXT NOT NULL CHECK(source IN ('手工','OpenCode','文档'))
|
|
55
56
|
);
|
|
@@ -131,6 +132,9 @@ function migrateEvidence() {
|
|
|
131
132
|
if (relCols.length && !relCols.includes('source_ref')) {
|
|
132
133
|
db.exec("ALTER TABLE relations ADD COLUMN source_ref TEXT NOT NULL DEFAULT ''");
|
|
133
134
|
}
|
|
135
|
+
if (relCols.length && !relCols.includes('attributes')) {
|
|
136
|
+
db.exec("ALTER TABLE relations ADD COLUMN attributes TEXT NOT NULL DEFAULT '{}'");
|
|
137
|
+
}
|
|
134
138
|
// 2) source 枚举扩展"文档":检测建表语句,缺失则整表重建
|
|
135
139
|
const ENTITIES_NEW = `CREATE TABLE entities__rebuild (
|
|
136
140
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -148,6 +152,7 @@ function migrateEvidence() {
|
|
|
148
152
|
category TEXT NOT NULL CHECK(category IN ('空间','互动','归属','时间','属性')),
|
|
149
153
|
confidence TEXT NOT NULL DEFAULT '确证' CHECK(confidence IN ('确证','推测','存疑')),
|
|
150
154
|
source_ref TEXT NOT NULL DEFAULT '',
|
|
155
|
+
attributes TEXT NOT NULL DEFAULT '{}',
|
|
151
156
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
152
157
|
source TEXT NOT NULL CHECK(source IN ('手工','OpenCode','文档'))
|
|
153
158
|
)`;
|
|
@@ -155,7 +160,7 @@ function migrateEvidence() {
|
|
|
155
160
|
rebuildTableWithEnum('entities', ENTITIES_NEW, ['id', 'name', 'category', 'attributes', 'created_at', 'source']);
|
|
156
161
|
}
|
|
157
162
|
if (!tableSql('relations').includes("'文档'")) {
|
|
158
|
-
rebuildTableWithEnum('relations', RELATIONS_NEW, ['id', 'source_id', 'target_id', 'name', 'category', 'confidence', 'source_ref', 'created_at', 'source']);
|
|
163
|
+
rebuildTableWithEnum('relations', RELATIONS_NEW, ['id', 'source_id', 'target_id', 'name', 'category', 'confidence', 'source_ref', 'attributes', 'created_at', 'source']);
|
|
159
164
|
}
|
|
160
165
|
if (!tableSql('operation_logs').includes("'文档'")) {
|
|
161
166
|
const LOGS_NEW = `CREATE TABLE operation_logs__rebuild (
|
|
@@ -261,8 +266,15 @@ function addRelationCore(input, source) {
|
|
|
261
266
|
const check = V.validateRelationInput(input, db);
|
|
262
267
|
if (!check.ok) throw writeErr(check);
|
|
263
268
|
const v = check.value;
|
|
264
|
-
const
|
|
265
|
-
|
|
269
|
+
const dup = V.findDuplicateRelation(v, db);
|
|
270
|
+
if (dup) {
|
|
271
|
+
const e = new Error(`重复三元组:实体${v.source_id} →「${v.name}」→ 实体${v.target_id} 已存在(关系id=${dup.id}),如需修改请使用更新操作`);
|
|
272
|
+
e.status = 409;
|
|
273
|
+
e.duplicate_id = dup.id;
|
|
274
|
+
throw e;
|
|
275
|
+
}
|
|
276
|
+
const info = db.prepare('INSERT INTO relations (source_id, target_id, name, category, confidence, source_ref, attributes, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
|
277
|
+
.run(v.source_id, v.target_id, v.name, v.category, v.confidence, v.source_ref, JSON.stringify(v.attributes || {}), source);
|
|
266
278
|
const id = Number(info.lastInsertRowid);
|
|
267
279
|
const row = getRelation(id);
|
|
268
280
|
logOp('ADD_RELATION', { relation: row }, source);
|
|
@@ -275,8 +287,15 @@ function updateRelationCore(id, patch, source) {
|
|
|
275
287
|
const check = V.validateRelationPatch(patch, existing, db);
|
|
276
288
|
if (!check.ok) throw writeErr(check);
|
|
277
289
|
const v = check.value;
|
|
278
|
-
|
|
279
|
-
|
|
290
|
+
const dup = V.findDuplicateRelation(v, db, id);
|
|
291
|
+
if (dup) {
|
|
292
|
+
const e = new Error(`重复三元组:实体${v.source_id} →「${v.name}」→ 实体${v.target_id} 已存在(关系id=${dup.id})`);
|
|
293
|
+
e.status = 409;
|
|
294
|
+
e.duplicate_id = dup.id;
|
|
295
|
+
throw e;
|
|
296
|
+
}
|
|
297
|
+
db.prepare('UPDATE relations SET source_id = ?, target_id = ?, name = ?, category = ?, confidence = ?, source_ref = ?, attributes = ? WHERE id = ?')
|
|
298
|
+
.run(v.source_id, v.target_id, v.name, v.category, v.confidence, v.source_ref, JSON.stringify(v.attributes || {}), id);
|
|
280
299
|
const row = getRelation(id);
|
|
281
300
|
logOp('UPDATE_RELATION', { before: existing, after: row }, source);
|
|
282
301
|
return row;
|
|
@@ -466,16 +485,16 @@ function undoLast(source) {
|
|
|
466
485
|
case 'UPDATE_RELATION': {
|
|
467
486
|
if (!getRelation(snap.before.id)) { const e = new Error('关系已不存在,无法撤销更新'); e.status = 409; throw e; }
|
|
468
487
|
V.validateRelationInput({ source_id: snap.before.source_id, target_id: snap.before.target_id, name: snap.before.name, category: snap.before.category }, db);
|
|
469
|
-
db.prepare('UPDATE relations SET source_id = ?, target_id = ?, name = ?, category = ? WHERE id = ?')
|
|
470
|
-
.run(snap.before.source_id, snap.before.target_id, snap.before.name, snap.before.category, snap.before.id);
|
|
488
|
+
db.prepare('UPDATE relations SET source_id = ?, target_id = ?, name = ?, category = ?, confidence = ?, source_ref = ?, attributes = ? WHERE id = ?')
|
|
489
|
+
.run(snap.before.source_id, snap.before.target_id, snap.before.name, snap.before.category, snap.before.confidence || '确证', snap.before.source_ref || '', snap.before.attributes || '{}', snap.before.id);
|
|
471
490
|
summary = `已撤销更新:关系#${snap.before.id} [${snap.before.name}] 还原为修改前数据`;
|
|
472
491
|
break;
|
|
473
492
|
}
|
|
474
493
|
case 'DELETE_RELATION': {
|
|
475
494
|
if (getRelation(snap.relation.id)) { summary = `关系#${snap.relation.id}已重新存在,无需撤销`; break; }
|
|
476
495
|
V.validateRelationInput({ source_id: snap.relation.source_id, target_id: snap.relation.target_id, name: snap.relation.name, category: snap.relation.category }, db);
|
|
477
|
-
db.prepare('INSERT INTO relations (id, source_id, target_id, name, category, source, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
478
|
-
.run(snap.relation.id, snap.relation.source_id, snap.relation.target_id, snap.relation.name, snap.relation.category, snap.relation.source, snap.relation.created_at);
|
|
496
|
+
db.prepare('INSERT INTO relations (id, source_id, target_id, name, category, confidence, source_ref, attributes, source, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
497
|
+
.run(snap.relation.id, snap.relation.source_id, snap.relation.target_id, snap.relation.name, snap.relation.category, snap.relation.confidence || '确证', snap.relation.source_ref || '', snap.relation.attributes || '{}', snap.relation.source, snap.relation.created_at);
|
|
479
498
|
summary = `已撤销删除:恢复关系「${entNameIn(snap.relation.source_id)} —[${snap.relation.name}]→ ${entNameIn(snap.relation.target_id)}」`;
|
|
480
499
|
break;
|
|
481
500
|
}
|
|
@@ -533,7 +552,7 @@ function applyAgentOps(ops) {
|
|
|
533
552
|
else sid = resolveRef(op.source_id, op.source_ref, refMap);
|
|
534
553
|
if (op.target_name !== undefined && op.target_id === undefined && op.target_ref === undefined) tid = resolveEntityName(op.target_name);
|
|
535
554
|
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');
|
|
555
|
+
const row = addRelationCore({ source_id: sid, target_id: tid, name: op.name, category: op.category, confidence: op.confidence, source_ref: op.evidence_ref, attributes: op.attributes }, 'OpenCode');
|
|
537
556
|
applied.push({ op: 'add_relation', id: row.id, name: row.name });
|
|
538
557
|
break;
|
|
539
558
|
}
|
|
@@ -551,7 +570,7 @@ function applyAgentOps(ops) {
|
|
|
551
570
|
}
|
|
552
571
|
case 'update_relation': {
|
|
553
572
|
const patch = {};
|
|
554
|
-
for (const k of ['source_id', 'target_id', 'name', 'category', 'confidence', 'source_ref']) if (op[k] !== undefined) patch[k] = op[k];
|
|
573
|
+
for (const k of ['source_id', 'target_id', 'name', 'category', 'confidence', 'source_ref', 'attributes']) if (op[k] !== undefined) patch[k] = op[k];
|
|
555
574
|
const row = updateRelationCore(Number(op.id), patch, 'OpenCode');
|
|
556
575
|
applied.push({ op: 'update_relation', id: row.id, name: row.name });
|
|
557
576
|
break;
|
|
@@ -866,6 +885,107 @@ function miniCypher(q) {
|
|
|
866
885
|
return out;
|
|
867
886
|
}
|
|
868
887
|
|
|
888
|
+
// ---- 三元组批量导入:单事务,逐行容错,同名实体复用、纯数字引用优先按id解析 ----
|
|
889
|
+
function applyTriplesImport(parsed, source) {
|
|
890
|
+
const result = { entities_added: 0, entities_reused: 0, relations_added: 0, relations_skipped_duplicate: 0, errors: [], entity_ids: new Map() };
|
|
891
|
+
return tx(() => {
|
|
892
|
+
// 名称→id 映射:先收已有实体(重名取首个),再补本批新建
|
|
893
|
+
const nameMap = new Map();
|
|
894
|
+
for (const e of listEntities()) if (!nameMap.has(e.name)) nameMap.set(e.name, e.id);
|
|
895
|
+
|
|
896
|
+
for (const ent of parsed.entities) {
|
|
897
|
+
try {
|
|
898
|
+
const existingId = nameMap.get(String(ent.name).trim());
|
|
899
|
+
if (existingId) {
|
|
900
|
+
result.entities_reused++;
|
|
901
|
+
result.entity_ids.set(ent.name, existingId);
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
const row = addEntityCore({ name: ent.name, category: ent.category, attributes: ent.attributes }, source);
|
|
905
|
+
nameMap.set(row.name, row.id);
|
|
906
|
+
if (ent.fromId) result.entity_ids.set('#' + ent.fromId, row.id);
|
|
907
|
+
result.entity_ids.set(row.name, row.id);
|
|
908
|
+
result.entities_added++;
|
|
909
|
+
} catch (e) {
|
|
910
|
+
result.errors.push(`实体"${ent.name}": ${e.message}`);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
const resolveRef = (ref) => {
|
|
914
|
+
const s = String(ref).trim();
|
|
915
|
+
if (/^\d+$/.test(s)) {
|
|
916
|
+
const byId = getEntity(Number(s));
|
|
917
|
+
if (byId) return byId.id;
|
|
918
|
+
}
|
|
919
|
+
return nameMap.get(s) || null;
|
|
920
|
+
};
|
|
921
|
+
for (const rel of parsed.relations) {
|
|
922
|
+
try {
|
|
923
|
+
const sid = resolveRef(rel.source);
|
|
924
|
+
const tid = resolveRef(rel.target);
|
|
925
|
+
if (!sid) { result.errors.push(`关系"${rel.name}": 起点"${rel.source}"不存在且无法自动创建`); continue; }
|
|
926
|
+
if (!tid) { result.errors.push(`关系"${rel.name}": 终点"${rel.target}"不存在且无法自动创建`); continue; }
|
|
927
|
+
try {
|
|
928
|
+
addRelationCore({ source_id: sid, target_id: tid, name: rel.name, category: rel.category, confidence: rel.confidence, source_ref: rel.source_ref, attributes: rel.attributes }, source);
|
|
929
|
+
result.relations_added++;
|
|
930
|
+
} catch (e) {
|
|
931
|
+
if (e.status === 409) result.relations_skipped_duplicate++;
|
|
932
|
+
else result.errors.push(`关系"${rel.name}"(${rel.source}→${rel.target}): ${e.message}`);
|
|
933
|
+
}
|
|
934
|
+
} catch (e) {
|
|
935
|
+
result.errors.push(`关系"${rel.name}": ${e.message}`);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
bumpVersion();
|
|
939
|
+
return result;
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// ---- 图统计:度分布与核心枢纽节点(需求:统计指标)----
|
|
944
|
+
function graphStats() {
|
|
945
|
+
const ents = listEntities();
|
|
946
|
+
const rels = listRelations();
|
|
947
|
+
const degree = new Map(); // id → {out, in, total}
|
|
948
|
+
for (const e of ents) degree.set(e.id, { out: 0, in: 0, total: 0 });
|
|
949
|
+
for (const r of rels) {
|
|
950
|
+
const d = degree.get(r.source_id);
|
|
951
|
+
if (d) { d.out++; d.total++; }
|
|
952
|
+
const t = degree.get(r.target_id);
|
|
953
|
+
if (t) { t.in++; t.total++; }
|
|
954
|
+
}
|
|
955
|
+
const dist = new Map();
|
|
956
|
+
let maxDegree = 0;
|
|
957
|
+
for (const d of degree.values()) {
|
|
958
|
+
dist.set(d.total, (dist.get(d.total) || 0) + 1);
|
|
959
|
+
if (d.total > maxDegree) maxDegree = d.total;
|
|
960
|
+
}
|
|
961
|
+
const hubs = ents
|
|
962
|
+
.map((e) => {
|
|
963
|
+
const d = degree.get(e.id);
|
|
964
|
+
const relNames = {};
|
|
965
|
+
for (const r of rels) {
|
|
966
|
+
if (r.source_id === e.id || r.target_id === e.id) relNames[r.name] = (relNames[r.name] || 0) + 1;
|
|
967
|
+
}
|
|
968
|
+
return {
|
|
969
|
+
id: e.id, name: e.name, category: e.category,
|
|
970
|
+
degree: d.total, out: d.out, in: d.in,
|
|
971
|
+
relations: Object.entries(relNames).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([n]) => n),
|
|
972
|
+
};
|
|
973
|
+
})
|
|
974
|
+
.sort((a, b) => b.degree - a.degree)
|
|
975
|
+
.slice(0, 10);
|
|
976
|
+
const catCount = {};
|
|
977
|
+
for (const e of ents) catCount[e.category] = (catCount[e.category] || 0) + 1;
|
|
978
|
+
return {
|
|
979
|
+
entities: ents.length,
|
|
980
|
+
relations: rels.length,
|
|
981
|
+
avg_degree: ents.length ? Number((rels.length * 2 / ents.length).toFixed(2)) : 0,
|
|
982
|
+
max_degree: maxDegree,
|
|
983
|
+
degree_distribution: [...dist.entries()].sort((a, b) => a[0] - b[0]).map(([k, v]) => ({ degree: k, count: v })),
|
|
984
|
+
hubs,
|
|
985
|
+
categories: Object.entries(catCount).map(([category, count]) => ({ category, count })),
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
|
|
869
989
|
module.exports = {
|
|
870
990
|
DATA_DIR, DB_PATH,
|
|
871
991
|
open, close, reopen, integrityCheck,
|
|
@@ -879,5 +999,7 @@ module.exports = {
|
|
|
879
999
|
undoLast,
|
|
880
1000
|
miniCypher,
|
|
881
1001
|
applyAgentOps, getGraph, getLogs, maxLogId, counts, logRestore,
|
|
1002
|
+
applyTriplesImport,
|
|
1003
|
+
graphStats,
|
|
882
1004
|
getVersion: () => version,
|
|
883
1005
|
};
|
package/lib/ocbin.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// opencode CLI 可执行文件定位:
|
|
4
|
+
// 1. 环境变量 KG_OPENCODE_PATH 显式指定(优先级最高)
|
|
5
|
+
// 2. PATH 逐目录扫描(Windows 按 PATHEXT 优先级匹配 .exe/.cmd/.bat)
|
|
6
|
+
// 3. 常见安装位置兜底(curl 官方脚本 / Homebrew / npm 全局 / WinGet / Scoop)
|
|
7
|
+
// 4. npm 全局 prefix 兜底(PATH 未包含 npm bin 目录的场景,如 GUI 启动)
|
|
8
|
+
//
|
|
9
|
+
// 背景:npm 安装的 opencode 在 Windows 上是 opencode.cmd 垫片,Node 出于安全策略
|
|
10
|
+
// (CVE-2024-27980)禁止 spawn/execFile 直接执行 .cmd/.bat,导致"已安装却检测不到"。
|
|
11
|
+
// 垫片指向 node_modules/opencode-ai/bin/opencode.exe(postinstall 复制的真实二进制),
|
|
12
|
+
// 本模块会将垫片还原为真实 exe,直接 spawn,绕开 cmd.exe 与引号转义问题。
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
|
|
18
|
+
let cached; // undefined=未解析;null=确认不存在;{path,isCmdShim}=解析成功
|
|
19
|
+
|
|
20
|
+
function isExecutable(p) {
|
|
21
|
+
try {
|
|
22
|
+
return fs.statSync(p).isFile() && Boolean(fs.statSync(p).mode & 0o111);
|
|
23
|
+
} catch (_) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fileExists(p) {
|
|
29
|
+
try { return fs.statSync(p).isFile(); } catch (_) { return false; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function pathDirs() {
|
|
33
|
+
const sep = process.platform === 'win32' ? ';' : ':';
|
|
34
|
+
return String(process.env.PATH || '')
|
|
35
|
+
.split(sep)
|
|
36
|
+
.map((d) => d.trim().replace(/^"|"$/g, ''))
|
|
37
|
+
.filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Windows:按可执行优先级生成候选(.exe 可直接 spawn,垫片需还原)
|
|
41
|
+
function candidatesInDir(dir) {
|
|
42
|
+
if (process.platform !== 'win32') return [path.join(dir, 'opencode')];
|
|
43
|
+
const pathext = String(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD')
|
|
44
|
+
.split(';').map((e) => e.toLowerCase());
|
|
45
|
+
return ['.exe', '.cmd', '.bat']
|
|
46
|
+
.filter((ext) => pathext.includes(ext))
|
|
47
|
+
.map((ext) => path.join(dir, 'opencode' + ext));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 解析 npm .cmd 垫片的真实目标:优先约定路径,其次解析垫片内容中的 %~dp0 引用
|
|
51
|
+
function cmdShimTarget(shimPath) {
|
|
52
|
+
const dir = path.dirname(shimPath);
|
|
53
|
+
const conventional = path.join(dir, 'node_modules', 'opencode-ai', 'bin', 'opencode.exe');
|
|
54
|
+
if (fileExists(conventional)) return conventional;
|
|
55
|
+
try {
|
|
56
|
+
const text = fs.readFileSync(shimPath, 'utf8');
|
|
57
|
+
// 匹配 "%dp0%\..." 与 "%~dp0\..." 两种 npm/cmd-shim 模板中的 exe 引用
|
|
58
|
+
const m = text.match(/"%(?:~dp0|dp0)%?\\([^"]+\.exe)"/i);
|
|
59
|
+
if (m) {
|
|
60
|
+
const target = path.join(dir, m[1].replace(/\\+/g, '\\'));
|
|
61
|
+
if (fileExists(target)) return target;
|
|
62
|
+
}
|
|
63
|
+
} catch (_) { /* 读不了就放弃 */ }
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function wellKnownCandidates() {
|
|
68
|
+
const home = os.homedir();
|
|
69
|
+
if (process.platform === 'win32') {
|
|
70
|
+
const appdata = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
71
|
+
const local = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
72
|
+
const npmDir = path.join(appdata, 'npm');
|
|
73
|
+
return [
|
|
74
|
+
path.join(npmDir, 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
|
|
75
|
+
path.join(npmDir, 'opencode.exe'),
|
|
76
|
+
path.join(local, 'Microsoft', 'WinGet', 'Links', 'opencode.exe'),
|
|
77
|
+
path.join(local, 'Programs', 'opencode', 'bin', 'opencode.exe'),
|
|
78
|
+
path.join(home, '.opencode', 'bin', 'opencode.exe'),
|
|
79
|
+
path.join(home, 'scoop', 'shims', 'opencode.exe'),
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
return [
|
|
83
|
+
path.join(home, '.opencode', 'bin', 'opencode'), // 官方 curl 安装脚本默认位置
|
|
84
|
+
path.join(home, '.local', 'bin', 'opencode'),
|
|
85
|
+
path.join(path.dirname(process.execPath), 'opencode'), // nvm 等 node 同目录
|
|
86
|
+
'/opt/homebrew/bin/opencode',
|
|
87
|
+
'/usr/local/bin/opencode',
|
|
88
|
+
'/usr/bin/opencode',
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 最后兜底:问 npm 要全局 prefix(子进程调用较慢,仅在前序全部未命中时执行)
|
|
93
|
+
function npmGlobalCandidates() {
|
|
94
|
+
try {
|
|
95
|
+
const { execFileSync } = require('child_process');
|
|
96
|
+
const prefix = execFileSync('npm', ['config', 'get', 'prefix'], {
|
|
97
|
+
encoding: 'utf8',
|
|
98
|
+
timeout: 10000,
|
|
99
|
+
shell: process.platform === 'win32',
|
|
100
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
101
|
+
}).trim();
|
|
102
|
+
if (!prefix) return [];
|
|
103
|
+
if (process.platform === 'win32') {
|
|
104
|
+
return [
|
|
105
|
+
path.join(prefix, 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
|
|
106
|
+
path.join(prefix, 'opencode.exe'),
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
return [path.join(prefix, 'bin', 'opencode')];
|
|
110
|
+
} catch (_) {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function resolveSync() {
|
|
116
|
+
const tried = [];
|
|
117
|
+
|
|
118
|
+
// 1) 环境变量显式指定
|
|
119
|
+
if (process.env.KG_OPENCODE_PATH) {
|
|
120
|
+
const p = process.env.KG_OPENCODE_PATH;
|
|
121
|
+
if (isExecutable(p) || fileExists(p)) {
|
|
122
|
+
return { path: p, isCmdShim: /\.(cmd|bat)$/i.test(p), via: 'env' };
|
|
123
|
+
}
|
|
124
|
+
tried.push(p);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 2) PATH 扫描
|
|
128
|
+
for (const dir of pathDirs()) {
|
|
129
|
+
for (const c of candidatesInDir(dir)) {
|
|
130
|
+
if (!fileExists(c)) continue;
|
|
131
|
+
if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(c)) {
|
|
132
|
+
const target = cmdShimTarget(c);
|
|
133
|
+
if (target) return { path: target, isCmdShim: false, via: 'path-shim' };
|
|
134
|
+
return { path: c, isCmdShim: true, via: 'path-shim' };
|
|
135
|
+
}
|
|
136
|
+
if (isExecutable(c)) return { path: c, isCmdShim: false, via: 'path' };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 3) 常见安装位置
|
|
141
|
+
for (const c of wellKnownCandidates()) {
|
|
142
|
+
if (fileExists(c)) return { path: c, isCmdShim: false, via: 'wellknown' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 4) npm 全局 prefix
|
|
146
|
+
for (const c of npmGlobalCandidates()) {
|
|
147
|
+
if (fileExists(c)) return { path: c, isCmdShim: false, via: 'npm-prefix' };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (tried.length) {
|
|
151
|
+
console.warn(`[OpenCode] KG_OPENCODE_PATH 指定的文件不可用: ${tried.join(', ')}`);
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 定位 opencode CLI。结果缓存在进程内,传 { refresh: true } 强制重新解析。
|
|
158
|
+
* @returns {{path: string, isCmdShim: boolean, via: string} | null}
|
|
159
|
+
*/
|
|
160
|
+
function findOpenCodeBin({ refresh = false } = {}) {
|
|
161
|
+
if (refresh) cached = undefined;
|
|
162
|
+
if (cached === undefined) cached = resolveSync();
|
|
163
|
+
return cached;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 生成 spawn/execFile 所需的启动参数。
|
|
168
|
+
* 极少数场景垫片 exe 缺失只剩 .cmd 时,经 cmd.exe 执行(仅版本探测等安全参数可用)。
|
|
169
|
+
* @returns {{file: string, args: string[], opts: {shell?: boolean}}}
|
|
170
|
+
*/
|
|
171
|
+
function buildLauncher(bin, args) {
|
|
172
|
+
if (!bin.isCmdShim) return { file: bin.path, args, opts: {} };
|
|
173
|
+
const comspec = process.env.comspec || 'cmd.exe';
|
|
174
|
+
return {
|
|
175
|
+
file: comspec,
|
|
176
|
+
args: ['/d', '/s', '/c', `"${bin.path}" ${args.join(' ')}`],
|
|
177
|
+
opts: { windowsVerbatimArguments: true },
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 用已定位的二进制探测版本号;失败返回 null
|
|
183
|
+
*/
|
|
184
|
+
function probeVersion(bin, timeoutMs = 15000) {
|
|
185
|
+
try {
|
|
186
|
+
const { execFileSync } = require('child_process');
|
|
187
|
+
const { file, args, opts } = buildLauncher(bin, ['--version']);
|
|
188
|
+
return String(execFileSync(file, args, { encoding: 'utf8', timeout: timeoutMs, stdio: ['ignore', 'pipe', 'pipe'], ...opts })).trim() || null;
|
|
189
|
+
} catch (_) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = { findOpenCodeBin, buildLauncher, probeVersion };
|