local-knowledge-graph 1.10.4 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/index.html CHANGED
@@ -18,13 +18,10 @@
18
18
  <button id="m-right" class="m-btn" title="打开AI对话">AI</button>
19
19
  <button id="btn-savepoint" title="将当前合规数据提交为Git保存点(仅存差异)">打保存点</button>
20
20
  <button id="btn-undo" title="撤销最近一次操作(按快照还原,快捷键Ctrl+Z)">撤销</button>
21
- <button id="btn-export" title="导出标准RDF(Turtle)格式文件">导出RDF</button>
22
- <button id="btn-relayout" title="重新进行力导向布局">重新布局</button>
23
- <button id="btn-resetview" title="重置相机视角">重置视角</button>
24
- <button id="btn-style" title="自定义实体/关系的颜色、大小与线型">视图设置</button>
21
+ <button id="btn-multisel" title="多选模式:点击节点加入/移出选区,然后提取子图或导出">多选</button>
25
22
  <button id="btn-help" title="帮助:查看版本现状、检查更新与使用指南">帮助</button>
26
- <button id="btn-inference" title="叠加显示推理得到的隐性关系(虚线,OWL传递/对称/逆规则),点击推理边可查看推导依据">推理</button>
27
- <button id="m-file" class="m-btn" title="文件:打开/保存/另存">文件</button>
23
+ <button id="m-view" class="m-btn" title="视图:布局/视角/主题/推理/截图">视图</button>
24
+ <button id="m-file" class="m-btn" title="文件:打开/保存/另存/导入导出">文件</button>
28
25
  </header>
29
26
 
30
27
  <main>
@@ -33,6 +30,7 @@
33
30
  <div data-tab="entity" class="active">实体</div>
34
31
  <div data-tab="relation">关系</div>
35
32
  <div data-tab="search">检索</div>
33
+ <div data-tab="stats">统计</div>
36
34
  <div data-tab="mcp">MCP</div>
37
35
  <div data-tab="log">日志</div>
38
36
  <div data-tab="version">版本</div>
@@ -88,6 +86,23 @@
88
86
  </div>
89
87
 
90
88
  <div class="tabbody" id="tab-search">
89
+ <div class="card">
90
+ <h3>属性过滤(按实体属性筛选图上显示)</h3>
91
+ <div class="row">
92
+ <div style="flex:1.4"><label>属性键</label><input id="af-key" placeholder="例如:朝代"></div>
93
+ <div style="width:100px"><label>条件</label>
94
+ <select id="af-op">
95
+ <option value="eq">等于</option>
96
+ <option value="contains">包含</option>
97
+ <option value="gt">大于</option>
98
+ <option value="lt">小于</option>
99
+ <option value="exists">存在该键</option>
100
+ </select>
101
+ </div>
102
+ <div style="flex:1"><label>值</label><input id="af-value" placeholder="大于/小于需填数字"></div>
103
+ </div>
104
+ <div class="row"><button class="primary" id="af-apply">应用到图上</button><button id="af-clear">清除过滤</button><span id="af-status" style="font-size:11px;color:#8fa3c0;align-self:center"></span></div>
105
+ </div>
91
106
  <div class="card">
92
107
  <h3>智能提问(自然语言关系查找)</h3>
93
108
  <label>用一句话描述要找的关系</label>
@@ -110,6 +125,15 @@
110
125
  </div>
111
126
  <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>
112
127
  </div>
128
+ <div class="card" id="oc-install-card" style="display:none">
129
+ <h3>OpenCode 一键安装(AI 对话建图依赖)</h3>
130
+ <div class="row">
131
+ <button class="primary" id="oc-install-btn">下载并安装 OpenCode</button>
132
+ <span id="oc-install-status" style="font-size:11px;color:#8fa3c0;align-self:center">未检测到 opencode,点击按钮自动完成安装</span>
133
+ </div>
134
+ <pre id="oc-install-log" class="mcp-snippet" style="display:none;max-height:160px;overflow:auto"></pre>
135
+ <div class="hint-text">将通过 npm 全局安装 opencode-ai(约几十MB,需 1-3 分钟)。安装后自动配置模型:优先接入本机 Ollama / LM Studio 已有模型,否则使用 OpenCode Zen 免费模型(无需登录)。完成后立即可在右侧 AI 对话使用,无需重启本应用。</div>
136
+ </div>
113
137
  <div class="card">
114
138
  <h3>混合检索(语义+关键词 RRF 融合)</h3>
115
139
  <label>查询</label>
@@ -143,6 +167,19 @@
143
167
  </div>
144
168
  </div>
145
169
 
170
+ <div class="tabbody" id="tab-stats">
171
+ <div class="card">
172
+ <h3>图统计(度分布与枢纽节点)</h3>
173
+ <div class="row"><button class="primary" id="st-refresh">刷新统计</button><span id="st-status" style="font-size:11px;color:#8fa3c0;align-self:center"></span></div>
174
+ <div id="st-overview"></div>
175
+ <h3 style="margin-top:14px">度分布</h3>
176
+ <div id="st-dist"></div>
177
+ <h3 style="margin-top:14px">枢纽节点 Top10(点击飞行定位)</h3>
178
+ <div id="st-hubs"></div>
179
+ <h3 style="margin-top:14px">实体大类分布</h3>
180
+ <div id="st-cats"></div>
181
+ </div>
182
+ </div>
146
183
  <div class="tabbody" id="tab-log">
147
184
  <div class="card">
148
185
  <h3>操作日志(手工与OpenCode共用)</h3>
@@ -172,7 +209,16 @@
172
209
  <div class="row"><button class="primary" id="v-save">打保存点</button></div>
173
210
  <div class="hint-text">数据文件 data/kg.db 由本地Git仓库托管,仅存差异版本;恢复前会自动备份当前状态。</div>
174
211
  </div>
175
- <div class="card"><h3>提交历史(与操作日志双向绑定)</h3><div id="v-list"></div></div>
212
+ <div class="card"><h3>提交历史(与操作日志双向绑定)</h3>
213
+ <div class="hint-text">勾选两个保存点可对比版本差异(Diff)。</div>
214
+ <div id="diff-bar" style="display:none;align-items:center;gap:8px;flex-wrap:wrap;margin:8px 0">
215
+ <span id="diff-bar-text" style="font-size:12px;color:#8fa3c0"></span>
216
+ <button class="primary" id="btn-diff-run" style="padding:4px 10px;font-size:12px">对比所选版本</button>
217
+ <button id="btn-diff-clear" style="padding:4px 10px;font-size:12px">清除选择</button>
218
+ </div>
219
+ <div id="diff-result" style="display:none"></div>
220
+ <div id="v-list"></div>
221
+ </div>
176
222
  </div>
177
223
 
178
224
  <div class="tabbody" id="tab-mcp">
@@ -240,6 +286,22 @@
240
286
  <button id="ego-exit">退出中心</button>
241
287
  <div class="ego-hint">清空层数输入框 = 显示全部层级;层级光圈颜色随跳数递进</div>
242
288
  </div>
289
+ <div id="ms-bar">
290
+ <span>多选:</span><span class="ego-name" id="ms-count">已选 0 个</span>
291
+ <button id="ms-extract">提取子图</button>
292
+ <button id="ms-export-json">导出JSON</button>
293
+ <button id="ms-export-csv">导出CSV</button>
294
+ <button id="ms-clear">清空</button>
295
+ <button id="ms-exit">退出多选</button>
296
+ <div class="ego-hint">点击图上节点加入/移出选区(金色光圈标记),完成后提取子图或直接导出</div>
297
+ </div>
298
+ <div id="sub-bar">
299
+ <span>子图:</span><span class="ego-name" id="sub-name"></span>
300
+ <button id="sub-export-json">导出JSON</button>
301
+ <button id="sub-export-csv">导出CSV</button>
302
+ <button id="sub-exit">退出子图</button>
303
+ <div class="ego-hint">子图模式:仅显示所选实体及其之间的关联关系,可随时导出</div>
304
+ </div>
243
305
  <div id="info-card"></div>
244
306
  <div id="style-panel"></div>
245
307
  <div id="help-panel">
@@ -249,6 +311,7 @@
249
311
  <p class="hp-p">本地知识图谱整合器 —— 把文档与联网信息整理成可导航的 3D 知识图谱。全流程本地运行,数据存于本机 data/ 目录(独立 Git 仓库托管,可回溯)。</p>
250
312
  <h3>版本与更新</h3>
251
313
  <div id="help-upd"></div>
314
+ <div class="hp-line">想先看看效果?<button id="btn-load-demo" class="ghost" style="padding:3px 10px;font-size:12px;margin:0 4px">载入演示数据</button>(太阳系示例,现有数据会先自动打保存点备份)</div>
252
315
  <div class="hp-line">详细帮助文档:项目目录下 <b>HELP.md</b></div>
253
316
  <div class="hp-line">发布记录:<a href="https://github.com/iamsamyiok/local-knowledge-graph/releases" target="_blank" rel="noopener">GitHub Releases</a></div>
254
317
  <h3>快速上手</h3>
@@ -308,8 +371,20 @@
308
371
  <button id="fm-saveas">另存为图谱</button>
309
372
  <button id="fm-savehtml">另存为图谱网页</button>
310
373
  <button id="fm-rdf">导出 RDF</button>
374
+ <button id="fm-export-json">导出三元组 JSON</button>
375
+ <button id="fm-export-csv">导出三元组 CSV</button>
376
+ <button id="fm-import-triples">导入三元组(JSON/CSV)</button>
377
+ </div>
378
+ <div id="view-menu">
379
+ <button id="btn-relayout">重新布局</button>
380
+ <button id="btn-resetview">重置视角</button>
381
+ <button id="btn-style">视图设置(颜色/线型/主题)</button>
382
+ <button id="btn-inference">推理关系叠加</button>
383
+ <button id="btn-share-link" title="复制当前视图的URL链接,打开即恢复同一视图">复制视图链接</button>
384
+ <button id="btn-screenshot">截图(PNG)</button>
311
385
  </div>
312
386
  <input type="file" id="db-file-input" accept=".db,.sqlite,.sqlite3" style="display:none">
387
+ <input type="file" id="triples-file-input" accept=".json,.csv" style="display:none">
313
388
  <div id="lightbox">
314
389
  <button class="lb-close" id="lb-close" title="关闭">x</button>
315
390
  <figure>
package/public/style.css CHANGED
@@ -133,10 +133,23 @@
133
133
  #ego-bar label { margin: 0; color: #8fa3c0; }
134
134
  #ego-bar input { width: 76px; }
135
135
  #ego-bar .ego-hint { color: #5c6f92; font-size: 10.5px; width: 100%; text-align: center; }
136
+ /* 多选条与子图条:布局/配色与中心条一致 */
137
+ #ms-bar, #sub-bar {
138
+ position: absolute; top: 10px; left: 50%; transform: translateX(-50%);
139
+ display: none; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: center;
140
+ background: rgba(13,20,36,.92); border: 1px solid #2a3a5c; border-radius: 8px;
141
+ padding: 7px 12px; z-index: 12; max-width: calc(100% - 24px); font-size: 12px;
142
+ }
143
+ #ms-bar.show, #sub-bar.show { display: flex; }
144
+ #ms-bar .ego-name, #sub-bar .ego-name { color: #ffd75f; font-weight: 600; }
145
+ #ms-bar .ego-hint, #sub-bar .ego-hint { color: #5c6f92; font-size: 10.5px; width: 100%; text-align: center; }
146
+ #btn-multisel.active { border-color: #ffd75f; color: #ffd75f; }
136
147
  @media (max-width: 768px) {
137
148
  #ego-bar { top: 8px; padding: 6px 8px; gap: 6px; }
138
149
  #ego-bar .ego-name { max-width: 110px; }
139
150
  #ego-bar button { min-height: 30px; padding: 5px 9px; font-size: 11px; }
151
+ #ms-bar, #sub-bar { top: 8px; padding: 6px 8px; gap: 6px; }
152
+ #ms-bar button, #sub-bar button { min-height: 30px; padding: 5px 9px; font-size: 11px; }
140
153
  }
141
154
 
142
155
  /* ================= 实体图片 ================= */
@@ -258,19 +271,20 @@
258
271
  .log-filter input { flex: 1; }
259
272
 
260
273
  /* ================= 文件菜单(手机端) ================= */
261
- #file-menu {
274
+ /* 文件菜单与视图菜单:同一套下拉样式,视图菜单锚点按钮在其左侧 */
275
+ #file-menu, #view-menu {
262
276
  position: fixed; top: 46px; right: 8px; z-index: 45; display: none;
263
277
  background: #101a30; border: 1px solid #2a3a5c; border-radius: 8px;
264
278
  box-shadow: 0 8px 24px rgba(0,0,0,.5); min-width: 172px; overflow: hidden;
265
279
  }
266
- #file-menu.show { display: block; }
267
- #file-menu button {
280
+ #file-menu.show, #view-menu.show { display: block; }
281
+ #file-menu button, #view-menu button {
268
282
  display: block; width: 100%; text-align: left; background: transparent;
269
283
  border: none; border-bottom: 1px solid #16213a; border-radius: 0;
270
284
  padding: 11px 14px; font-size: 13px; color: #cfe2ff; min-height: 42px;
271
285
  }
272
- #file-menu button:last-child { border-bottom: none; }
273
- #file-menu button:active { background: #1b2b4d; }
286
+ #file-menu button:last-child, #view-menu button:last-child { border-bottom: none; }
287
+ #file-menu button:active, #view-menu button:active { background: #1b2b4d; }
274
288
 
275
289
  /* ================= 平板/小屏桌面适配(769px-1100px) ================= */
276
290
  @media (min-width: 769px) and (max-width: 1100px) {
@@ -286,7 +300,6 @@
286
300
  /* ================= 手机端适配 ================= */
287
301
  @media (max-width: 768px) {
288
302
  .m-btn { display: inline-block; background: #16233f; }
289
- #btn-inference { display: none; }
290
303
  /* 主题设置移动端可达:按钮保留,面板收窄为屏幕内浮层 */
291
304
  #style-panel { max-width: calc(100vw - 16px); width: 288px; top: 8px; right: 8px; }
292
305
 
@@ -299,7 +312,6 @@
299
312
  header::-webkit-scrollbar { display: none; }
300
313
  header h1 { display: none; }
301
314
  header button { padding: 5px 6px; font-size: 11px; flex-shrink: 0; min-width: 0; }
302
- #btn-export { display: none; } /* 导出RDF移入文件菜单 */
303
315
  #stat-badge, #agent-badge { display: none; }
304
316
 
305
317
  /* 左右面板变为抽屉 */
@@ -491,9 +503,9 @@
491
503
  html[data-theme="light"] .log-filter select, html[data-theme="light"] .log-filter input {
492
504
  background: #ffffff; border-color: #c9d6ea; color: #22304c;
493
505
  }
494
- html[data-theme="light"] #file-menu { background: #ffffff; border-color: #c9d6ea; box-shadow: 0 8px 24px rgba(30, 50, 80, 0.18); }
495
- html[data-theme="light"] #file-menu button { color: #22304c; border-bottom-color: #edf2f9; }
496
- html[data-theme="light"] #file-menu button:active { background: #eaf3ff; }
506
+ html[data-theme="light"] #file-menu, html[data-theme="light"] #view-menu { background: #ffffff; border-color: #c9d6ea; box-shadow: 0 8px 24px rgba(30, 50, 80, 0.18); }
507
+ html[data-theme="light"] #file-menu button, html[data-theme="light"] #view-menu button { color: #22304c; border-bottom-color: #edf2f9; }
508
+ html[data-theme="light"] #file-menu button:active, html[data-theme="light"] #view-menu button:active { background: #eaf3ff; }
497
509
  html[data-theme="light"] .m-btn { background: #eaf3ff; }
498
510
  html[data-theme="light"] #m-legend { background: rgba(255, 255, 255, 0.92); }
499
511
 
package/server.js CHANGED
@@ -8,6 +8,8 @@ const db = require('./lib/db');
8
8
  const git = require('./lib/git');
9
9
  const rdf = require('./lib/rdf');
10
10
  const agent = require('./lib/agent');
11
+ const ocbin = require('./lib/ocbin');
12
+ const ocinstall = require('./lib/ocinstall');
11
13
  const V = require('./lib/validator');
12
14
  const inference = require('./lib/inference');
13
15
  const recommend = require('./lib/recommend');
@@ -58,6 +60,7 @@ api.get('/meta', (req, res) => {
58
60
  counts: db.counts(),
59
61
  version: db.getVersion(),
60
62
  agent_available: agentAvailable,
63
+ agent_path: agentBin ? agentBin.path : null,
61
64
  agent_session: agentSessionInfo(),
62
65
  });
63
66
  });
@@ -572,6 +575,75 @@ api.get('/export/html', (req, res) => {
572
575
  res.send(html);
573
576
  });
574
577
 
578
+ // ---------- 三元组 JSON/CSV 导出(可选 ids 参数用于子图导出) ----------
579
+ function filteredGraph(idsParam) {
580
+ if (!idsParam) return db.getGraph();
581
+ const ids = new Set(String(idsParam).split(',').map((s) => Number(s.trim())).filter((n) => Number.isInteger(n) && n > 0));
582
+ const g = db.getGraph();
583
+ return {
584
+ entities: g.entities.filter((e) => ids.has(e.id)),
585
+ relations: g.relations.filter((r) => ids.has(r.source_id) && ids.has(r.target_id)),
586
+ image_counts: g.image_counts,
587
+ aliases: g.aliases,
588
+ };
589
+ }
590
+
591
+ api.get('/export/json', (req, res) => {
592
+ const triples = require('./lib/triples_io');
593
+ const body = triples.exportJson(filteredGraph(req.query.ids));
594
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
595
+ res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(req.query.name || 'kg-backup.json')}`);
596
+ res.send(body);
597
+ });
598
+
599
+ api.get('/export/csv', (req, res) => {
600
+ const triples = require('./lib/triples_io');
601
+ const body = '\uFEFF' + triples.exportCsv(filteredGraph(req.query.ids));
602
+ res.setHeader('Content-Type', 'text/csv; charset=utf-8');
603
+ res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(req.query.name || 'kg-backup.csv')}`);
604
+ res.send(body);
605
+ });
606
+
607
+ // ---------- 三元组 CSV/JSON 批量导入 ----------
608
+ // ---------- 演示数据一键载入(先打保存点保护现有数据,再灌入太阳系示例) ----------
609
+ api.post('/demo/load', (req, res) => {
610
+ try {
611
+ const parsed = require('./lib/demo').demoData();
612
+ const sp = git.savepoint(`载入演示数据前自动备份`, '系统');
613
+ const r = db.applyTriplesImport(parsed, '手工');
614
+ r.entity_ids = undefined;
615
+ bus.emit('graph-changed', { via: 'demo' });
616
+ git.savepoint(`载入演示数据:实体+${r.entities_added} 关系+${r.relations_added}`, '系统');
617
+ res.json({ ok: true, backup: sp && sp.hash ? sp.hash : null, ...r, counts: db.counts(), version: db.getVersion() });
618
+ } catch (e) {
619
+ res.status(e.status || 500).json({ error: e.message });
620
+ }
621
+ });
622
+
623
+ api.post('/import/triples', (req, res) => {
624
+ const { content, format, dry_run } = req.body || {};
625
+ if (!content || typeof content !== 'string') return res.status(400).json({ error: '必须提供 content(CSV/JSON 文本)' });
626
+ if (content.length > 20 * 1024 * 1024) return res.status(400).json({ error: '内容超过20MB上限' });
627
+ const triples = require('./lib/triples_io');
628
+ const parsed = triples.parseImport(content, format || (content.trim().startsWith('{') || content.trim().startsWith('[') ? 'json' : 'csv'));
629
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error });
630
+ if (dry_run) {
631
+ return res.json({ ok: true, dry_run: true, entities: parsed.entities.length, relations: parsed.relations.length, warnings: parsed.warnings.slice(0, 20) });
632
+ }
633
+ try {
634
+ const r = db.applyTriplesImport(parsed, '手工');
635
+ r.entity_ids = undefined;
636
+ bus.emit('graph-changed', { via: 'import' });
637
+ git.savepoint(`三元组导入:实体+${r.entities_added} 关系+${r.relations_added}`, '系统');
638
+ res.json({ ok: true, ...r, warnings: parsed.warnings.slice(0, 20), counts: db.counts(), version: db.getVersion() });
639
+ } catch (e) {
640
+ res.status(e.status || 500).json({ error: e.message });
641
+ }
642
+ });
643
+
644
+ // ---------- 图统计(度分布与枢纽节点) ----------
645
+ api.get('/stats', (req, res) => res.json(db.graphStats()));
646
+
575
647
  // ---------- Git保存点与回溯 ----------
576
648
  api.get('/git/history', (req, res) => res.json(git.history(Number(req.query.limit) || 100)));
577
649
  api.post('/git/savepoint', (req, res) => {
@@ -586,6 +658,22 @@ api.post('/git/restore', (req, res) => {
586
658
  res.json(r);
587
659
  });
588
660
 
661
+ // ---------- 保存点 Diff(Archify Before/Delta/After 式对比) ----------
662
+ api.get('/git/diff', (req, res) => {
663
+ const from = String(req.query.from || '').trim();
664
+ const to = String(req.query.to || '').trim();
665
+ if (!from || !to) return res.status(400).json({ error: '必须提供 from 与 to 两个保存点hash' });
666
+ if (from === to) return res.status(400).json({ error: '两个保存点相同,无差异可比' });
667
+ try {
668
+ const differ = require('./lib/diff');
669
+ const a = git.exportSnapshot(from);
670
+ const b = git.exportSnapshot(to);
671
+ res.json({ from, to, ...differ.diffSnapshots(a, b) });
672
+ } catch (e) {
673
+ res.status(e.status || 500).json({ error: e.message });
674
+ }
675
+ });
676
+
589
677
  // ---------- MCP 外部接入设置 ----------
590
678
  api.get('/mcp/settings', (req, res) => {
591
679
  const s = embeddings.loadSettings();
@@ -650,19 +738,38 @@ api.post('/agent', async (req, res) => {
650
738
 
651
739
  let applied = [];
652
740
  let applyError = null;
653
- if (result.ops && result.ops.length) {
654
- try {
655
- applied = db.applyAgentOps(result.ops);
656
- // Agent批次写入后自动打保存点,与日志双向绑定
657
- const title = `OpenCode: ${String(instruction).slice(0, 60).replace(/\n/g, ' ')}`;
658
- let sp = null;
659
- try { sp = git.savepoint(title, 'OpenCode'); } catch (e) { sp = { committed: false, message: e.message }; }
660
- result.savepoint = sp;
661
- } catch (e) {
662
- applyError = e.message; // 违规操作被拦截,数据库保持上一个合规版本
741
+ let receipts = [];
742
+ let repair_rounds = 0;
743
+ let repaired = false;
744
+ let curOps = result.ops;
745
+ let curReply = result.reply;
746
+ if (curOps && curOps.length) {
747
+ // 修复回路:写入被回执拦截时,带结构化回执让AI修正,最多2轮(Archify式 repair receipt)
748
+ for (let round = 0; round <= 2; round++) {
749
+ try {
750
+ applied = db.applyAgentOps(curOps);
751
+ // Agent批次写入后自动打保存点,与日志双向绑定
752
+ const title = `OpenCode: ${String(instruction).slice(0, 60).replace(/\n/g, ' ')}`;
753
+ let sp = null;
754
+ try { sp = git.savepoint(title, 'OpenCode'); } catch (e) { sp = { committed: false, message: e.message }; }
755
+ result.savepoint = sp;
756
+ if (round > 0) repaired = true;
757
+ applyError = null;
758
+ receipts = [];
759
+ break;
760
+ } catch (e) {
761
+ applyError = e.message;
762
+ receipts = e.receipts || [];
763
+ if (round === 2 || !receipts.length) break;
764
+ const rep = await agent.runRepair(curOps, receipts);
765
+ repair_rounds++;
766
+ if (!rep.ok || !rep.ops || !rep.ops.length) { applyError += '(AI修复未返回有效操作)'; break; }
767
+ curOps = rep.ops;
768
+ curReply = rep.reply;
769
+ }
663
770
  }
664
771
  }
665
- res.json({ reply: result.reply, ops_found: (result.ops || []).length, applied, apply_error: applyError, parse_error: result.parse_error, savepoint: result.savepoint || null, retried: result.retried || false, partial_reply: result.partial_reply || null, session: result.session ? { has_session: true } : null });
772
+ res.json({ reply: curReply, ops_found: (curOps || []).length, applied, apply_error: applyError, receipts, repair_rounds, repaired, parse_error: result.parse_error, savepoint: result.savepoint || null, retried: result.retried || false, partial_reply: result.partial_reply || null, session: result.session ? { has_session: true } : null });
666
773
  } finally { agentBusy = false; }
667
774
  });
668
775
 
@@ -814,16 +921,33 @@ app.use((err, req, res, next) => {
814
921
 
815
922
  // ---------- 启动流程:异常兜底 + 自动拉起OpenCode ----------
816
923
  let agentAvailable = false;
924
+ let agentBin = null;
817
925
 
818
926
  function checkAgent() {
819
- try {
820
- require('child_process').execFileSync('opencode', ['--version'], { encoding: 'utf8', timeout: 15000 });
821
- agentAvailable = true;
822
- } catch (_) {
823
- agentAvailable = false;
927
+ // 多路径解析:PATH 扫描 + 常见安装位置 + npm 垫片还原(详见 lib/ocbin.js)
928
+ const bin = ocbin.findOpenCodeBin({ refresh: true });
929
+ agentBin = bin;
930
+ const version = bin && ocbin.probeVersion(bin);
931
+ agentAvailable = Boolean(version);
932
+ if (bin && !version) {
933
+ console.warn(`[OpenCode] 已定位到 ${bin.path} 但版本探测失败`);
824
934
  }
825
935
  }
826
936
 
937
+ // ---------- OpenCode 一键安装(未检测到时前端可触发) ----------
938
+ app.post('/api/agent/install', (req, res) => {
939
+ const force = !!(req.body && req.body.force);
940
+ const r = ocinstall.startInstall(force);
941
+ if (!r.started) return res.status(409).json({ error: r.reason, install: ocinstall.status() });
942
+ res.json({ ok: true, install: ocinstall.status() });
943
+ });
944
+ app.get('/api/agent/install', (req, res) => {
945
+ res.json({ ...ocinstall.status(), agent_available: agentAvailable });
946
+ });
947
+ ocinstall.onDone = checkAgent;
948
+
949
+ app.use('/api', api);
950
+
827
951
  function bootstrap() {
828
952
  require('./lib/paths').ensureLegacyMigration();
829
953
  git.ensureRepo();
@@ -851,7 +975,9 @@ function bootstrap() {
851
975
  }
852
976
  seedIfEmpty();
853
977
  checkAgent();
854
- console.log(agentAvailable ? '[OpenCode] 进程已就绪,可在前端输入自然语言指令' : '[OpenCode] 未检测到 opencode CLI,自然语言补全不可用(其余功能不受影响)');
978
+ console.log(agentAvailable
979
+ ? `[OpenCode] 进程已就绪(${agentBin.path},来源:${agentBin.via}),可在前端输入自然语言指令`
980
+ : '[OpenCode] 未检测到 opencode CLI,自然语言补全不可用(其余功能不受影响)。已安装时可用环境变量 KG_OPENCODE_PATH 指定可执行文件路径');
855
981
  }
856
982
 
857
983
  function seedIfEmpty() {