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/lib/diff.js ADDED
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ // 版本 Diff:对比两个保存点快照,输出结构化差异(Archify Before/Delta/After 式对比回执)
4
+ // attributes 在库内为 JSON 文本,规范化解析后比较,键序差异不误报
5
+
6
+ function normAttrs(a) {
7
+ if (a === null || a === undefined || a === '') return {};
8
+ if (typeof a === 'object') return a;
9
+ try { const v = JSON.parse(a); return v && typeof v === 'object' ? v : { '值': v }; } catch (_) { return { '原文': String(a) }; }
10
+ }
11
+
12
+ function stable(v) {
13
+ if (v === null || v === undefined) return '';
14
+ if (typeof v !== 'object') return String(v);
15
+ const keys = Object.keys(v).sort();
16
+ return JSON.stringify(keys.map((k) => [k, v[k]]));
17
+ }
18
+
19
+ // 实体比较字段:name/category/attributes;关系比较字段:source_id/target_id/name/category/confidence/source_ref/attributes
20
+ function diffRows(oldRows, newRows, fields, label) {
21
+ const oldById = new Map(oldRows.map((r) => [r.id, r]));
22
+ const newById = new Map(newRows.map((r) => [r.id, r]));
23
+ const added = [], removed = [], changed = [];
24
+ for (const [id, row] of newById) {
25
+ if (!oldById.has(id)) { added.push(pick(row, fields)); continue; }
26
+ const prev = oldById.get(id);
27
+ const attrPrev = normAttrs(prev.attributes);
28
+ const attrNow = normAttrs(row.attributes);
29
+ const changedFields = [];
30
+ const detail = { id, fields: changedFields, from: {}, to: {} };
31
+ for (const f of fields) {
32
+ const a = f === 'attributes' ? stable(attrPrev) : String(prev[f] === null || prev[f] === undefined ? '' : prev[f]);
33
+ const b = f === 'attributes' ? stable(attrNow) : String(row[f] === null || row[f] === undefined ? '' : row[f]);
34
+ if (a !== b) {
35
+ changedFields.push(f);
36
+ detail.from[f] = f === 'attributes' ? attrPrev : (prev[f] === null || prev[f] === undefined ? '' : prev[f]);
37
+ detail.to[f] = f === 'attributes' ? attrNow : (row[f] === null || row[f] === undefined ? '' : row[f]);
38
+ }
39
+ }
40
+ if (changedFields.length) changed.push(detail);
41
+ }
42
+ for (const [id, row] of oldById) if (!newById.has(id)) removed.push(pick(row, fields));
43
+ return { added, removed, changed, summary: { [label + '_added']: added.length, [label + '_removed']: removed.length, [label + '_changed']: changed.length } };
44
+ }
45
+
46
+ function pick(row, fields) {
47
+ const out = { id: row.id };
48
+ for (const f of fields) out[f] = f === 'attributes' ? normAttrs(row.attributes) : (row[f] === null || row[f] === undefined ? '' : row[f]);
49
+ return out;
50
+ }
51
+
52
+ // 对比两个快照 {entities, relations};from=旧 to=新
53
+ function diffSnapshots(fromSnap, toSnap) {
54
+ const ent = diffRows(fromSnap.entities, toSnap.entities, ['name', 'category', 'attributes'], 'entities');
55
+ const rel = diffRows(fromSnap.relations, toSnap.relations, ['source_id', 'target_id', 'name', 'category', 'confidence', 'source_ref', 'attributes'], 'relations');
56
+ return {
57
+ entities: { added: ent.added, removed: ent.removed, changed: ent.changed },
58
+ relations: { added: rel.added, removed: rel.removed, changed: rel.changed },
59
+ summary: { ...ent.summary, ...rel.summary },
60
+ };
61
+ }
62
+
63
+ module.exports = { diffSnapshots, normAttrs };
package/lib/git.js CHANGED
@@ -172,4 +172,27 @@ function restore(hash) {
172
172
  return { restored: hash, backup: backup && backup.hash ? backup.hash : null, counts };
173
173
  }
174
174
 
175
- module.exports = { ensureRepo, savepoint, history, restore, readMeta, writeMeta, backupCopy, DATA_DIR };
175
+ // 导出任意保存点的库快照(只读):供版本 Diff 对比,不动主库
176
+ function exportSnapshot(hash) {
177
+ ensureRepo();
178
+ if (!hasCommits()) { const e = new Error('仓库中还没有任何保存点'); e.status = 400; throw e; }
179
+ try { git(['cat-file', '-e', `${hash}^{commit}`]); } catch (_) {
180
+ const e = new Error(`保存点 ${hash} 不存在`); e.status = 404; throw e;
181
+ }
182
+ const buf = gitBuffer(['show', `${hash}:${DB_FILE}`]);
183
+ const tmp = path.join(os.tmpdir(), `kg_snap_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.db`);
184
+ fs.writeFileSync(tmp, buf);
185
+ const { DatabaseSync } = require('node:sqlite');
186
+ let probe;
187
+ try {
188
+ probe = new DatabaseSync(tmp, { readOnly: true });
189
+ const entities = probe.prepare('SELECT id, name, category, attributes FROM entities ORDER BY id').all();
190
+ const relations = probe.prepare('SELECT id, source_id, target_id, name, category, confidence, source_ref, attributes FROM relations ORDER BY id').all();
191
+ return { entities, relations };
192
+ } finally {
193
+ try { if (probe) probe.close(); } catch (_) {}
194
+ try { fs.unlinkSync(tmp); } catch (_) {}
195
+ }
196
+ }
197
+
198
+ module.exports = { ensureRepo, savepoint, history, restore, exportSnapshot, readMeta, writeMeta, backupCopy, DATA_DIR };
package/lib/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 };
@@ -0,0 +1,229 @@
1
+ 'use strict';
2
+
3
+ // OpenCode 一键安装与模型配置:
4
+ // - 安装:npm 全局安装 opencode-ai。本应用本身即 npm 包,npm 必然可用,
5
+ // 且全程无交互,Windows/macOS/Linux 通用(curl 脚本在 Windows 不可用)。
6
+ // - 模型配置(写入 opencode 全局配置 ~/.config/opencode/opencode.json):
7
+ // 1) 已有认证(auth.json)或已设默认模型 → 尊重现有配置,不改动
8
+ // 2) 本机 Ollama (127.0.0.1:11434) → 自动写入 provider 与默认模型
9
+ // 3) 本机 LM Studio (127.0.0.1:1234) → 同上
10
+ // 4) 兜底:OpenCode Zen 免费模型 opencode/grok-code(官方源码确认免费模型无需登录)
11
+ // - 完成后通过 onDone 钩子通知调用方重新检测,无需重启应用即可使用。
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const os = require('os');
16
+ const { spawn } = require('child_process');
17
+
18
+ const NPM_PKG = 'opencode-ai';
19
+ const FREE_MODEL = 'opencode/grok-code';
20
+ const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
21
+ const LOG_KEEP = 300;
22
+
23
+ // phase: idle | installing | configuring | done | error
24
+ const state = {
25
+ phase: 'idle',
26
+ step: '',
27
+ log: [],
28
+ error: null,
29
+ model: null, // { source: existing|ollama|lmstudio|free|error, model, detail }
30
+ started_at: null,
31
+ ended_at: null,
32
+ };
33
+ let running = false;
34
+ let onDone = null; // 安装完成后的回调(server 用它重新 checkAgent)
35
+
36
+ function pushLog(line) {
37
+ const t = new Date().toISOString().slice(11, 19);
38
+ state.log.push(`[${t}] ${line}`);
39
+ if (state.log.length > LOG_KEEP) state.log.splice(0, state.log.length - LOG_KEEP);
40
+ }
41
+
42
+ // npm 与 node 同目录(官方安装/nvm 均如此),GUI 启动 PATH 缺失时也能找到
43
+ function npmCommand() {
44
+ const dir = path.dirname(process.execPath);
45
+ if (process.platform === 'win32') {
46
+ const local = path.join(dir, 'npm.cmd');
47
+ return { file: fs.existsSync(local) ? local : 'npm.cmd', shell: true };
48
+ }
49
+ const local = path.join(dir, 'npm');
50
+ return { file: fs.existsSync(local) ? local : 'npm', shell: false };
51
+ }
52
+
53
+ function runNpmInstall() {
54
+ return new Promise((resolve) => {
55
+ const npm = npmCommand();
56
+ let child;
57
+ try {
58
+ // --no-audit/--no-fund:跳过审计与赞助提示,大包安装明显提速
59
+ child = spawn(npm.file, ['install', '-g', NPM_PKG, '--no-audit', '--no-fund'], { env: process.env, stdio: ['ignore', 'pipe', 'pipe'], shell: npm.shell });
60
+ } catch (e) {
61
+ return resolve({ ok: false, error: `无法启动 npm: ${e.message}` });
62
+ }
63
+ // npm 非 TTY 运行时几乎没有中间输出,用心跳日志证明进程仍在下载
64
+ const t0 = Date.now();
65
+ const heartbeat = setInterval(() => pushLog(`下载安装中… 已耗时 ${Math.round((Date.now() - t0) / 1000)}s(首次需下载较大二进制,请耐心等待)`), 15000);
66
+ const timer = setTimeout(() => {
67
+ try { child.kill('SIGKILL'); } catch (_) { /* 已退出 */ }
68
+ resolve({ ok: false, error: `npm 安装超时(${INSTALL_TIMEOUT_MS / 60000}分钟),已终止` });
69
+ }, INSTALL_TIMEOUT_MS);
70
+ const finish = (r) => { clearInterval(heartbeat); clearTimeout(timer); resolve(r); };
71
+ child.stdout.on('data', (d) => String(d).split('\n').forEach((l) => { if (l.trim()) pushLog(l.trim()); }));
72
+ child.stderr.on('data', (d) => String(d).split('\n').forEach((l) => { if (l.trim()) pushLog(l.trim()); }));
73
+ child.on('error', (e) => finish({ ok: false, error: `npm 启动失败: ${e.message}` }));
74
+ child.on('close', (code) => {
75
+ finish(code === 0 ? { ok: true } : { ok: false, error: `npm 退出码 ${code}(详见日志,可检查网络后重试)` });
76
+ });
77
+ });
78
+ }
79
+
80
+ // ---- opencode 配置文件 ----
81
+
82
+ function xdgConfigHome() { return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); }
83
+ function xdgDataHome() { return process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); }
84
+ function globalConfigPath() { return path.join(xdgConfigHome(), 'opencode', 'opencode.json'); }
85
+ function authFilePath() { return path.join(xdgDataHome(), 'opencode', 'auth.json'); }
86
+
87
+ function readJsonSafe(p) {
88
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { return null; }
89
+ }
90
+
91
+ function existingSetup() {
92
+ const auth = readJsonSafe(authFilePath());
93
+ if (auth && typeof auth === 'object' && Object.keys(auth).length) return { configured: true, where: 'auth' };
94
+ const cfg = readJsonSafe(globalConfigPath());
95
+ if (cfg && typeof cfg === 'object' && cfg.model) return { configured: true, where: 'config' };
96
+ return { configured: false, where: null };
97
+ }
98
+
99
+ // 合并写入全局配置;原文件不可解析(如 jsonc 带注释)时先备份再重写
100
+ function writeGlobalConfig(mutate) {
101
+ const p = globalConfigPath();
102
+ fs.mkdirSync(path.dirname(p), { recursive: true });
103
+ let cfg = readJsonSafe(p);
104
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
105
+ if (fs.existsSync(p)) {
106
+ try { fs.copyFileSync(p, `${p}.bak-${Date.now()}`); } catch (_) { /* 备份失败也继续 */ }
107
+ }
108
+ cfg = {};
109
+ }
110
+ mutate(cfg);
111
+ if (!cfg.$schema) cfg.$schema = 'https://opencode.ai/config.json';
112
+ fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n');
113
+ return p;
114
+ }
115
+
116
+ function fetchJson(url, timeoutMs = 2000) {
117
+ return fetch(url, { signal: AbortSignal.timeout(timeoutMs) })
118
+ .then((r) => (r.ok ? r.json() : null))
119
+ .catch(() => null);
120
+ }
121
+
122
+ const EMBED_RE = /embed|bge|clip|rerank|minilm|nomic|e5[-_]|jina|gte/i;
123
+
124
+ async function detectLocalModels() {
125
+ const ollama = await fetchJson('http://127.0.0.1:11434/api/tags');
126
+ const oModels = ((ollama && Array.isArray(ollama.models)) ? ollama.models : [])
127
+ .map((m) => m && m.name).filter((n) => n && !EMBED_RE.test(n));
128
+ if (oModels.length) {
129
+ return { provider: 'ollama', baseURL: 'http://localhost:11434/v1', label: 'Ollama', models: oModels, pick: oModels[0] };
130
+ }
131
+ const lms = await fetchJson('http://127.0.0.1:1234/v1/models');
132
+ const lModels = ((lms && Array.isArray(lms.data)) ? lms.data : [])
133
+ .map((m) => m && m.id).filter((n) => n && !EMBED_RE.test(n));
134
+ if (lModels.length) {
135
+ return { provider: 'lmstudio', baseURL: 'http://127.0.0.1:1234/v1', label: 'LM Studio', models: lModels, pick: lModels[0] };
136
+ }
137
+ return null;
138
+ }
139
+
140
+ function applyLocalProvider(local) {
141
+ const models = {};
142
+ for (const id of local.models) models[id] = { name: id };
143
+ writeGlobalConfig((cfg) => {
144
+ cfg.provider = cfg.provider || {};
145
+ cfg.provider[local.provider] = {
146
+ npm: '@ai-sdk/openai-compatible',
147
+ name: `${local.label} (local)`,
148
+ options: { baseURL: local.baseURL },
149
+ models,
150
+ };
151
+ cfg.model = `${local.provider}/${local.pick}`;
152
+ });
153
+ return { source: local.provider, model: `${local.provider}/${local.pick}`, detail: `检测到本机 ${local.label},已接入 ${local.models.length} 个模型,默认使用 ${local.pick}` };
154
+ }
155
+
156
+ function applyFreeModel() {
157
+ writeGlobalConfig((cfg) => { cfg.model = FREE_MODEL; });
158
+ return { source: 'free', model: FREE_MODEL, detail: '未检测到本机模型,已设置 OpenCode Zen 免费模型(无需登录,可在 opencode 配置中更换)' };
159
+ }
160
+
161
+ async function configureModel() {
162
+ const existing = existingSetup();
163
+ if (existing.configured) {
164
+ return { source: 'existing', model: null, detail: existing.where === 'auth' ? '检测到已有 opencode 认证,保留现有模型配置' : '检测到已设置默认模型,保留现有配置' };
165
+ }
166
+ const local = await detectLocalModels();
167
+ return local ? applyLocalProvider(local) : applyFreeModel();
168
+ }
169
+
170
+ // ---- 主流程 ----
171
+
172
+ async function installFlow() {
173
+ running = true;
174
+ Object.assign(state, { phase: 'installing', step: `正在通过 npm 安装 ${NPM_PKG}…`, error: null, model: null, started_at: Date.now(), ended_at: null });
175
+ pushLog(`开始安装 ${NPM_PKG}`);
176
+
177
+ const inst = await runNpmInstall();
178
+ if (!inst.ok) {
179
+ Object.assign(state, { phase: 'error', step: '', error: inst.error, ended_at: Date.now() });
180
+ pushLog(`安装失败: ${inst.error}`);
181
+ running = false;
182
+ return;
183
+ }
184
+ pushLog('npm 安装完成,正在识别 opencode…');
185
+ state.phase = 'configuring';
186
+ state.step = '正在检测安装结果与配置模型…';
187
+
188
+ const ocbin = require('./ocbin');
189
+ const bin = ocbin.findOpenCodeBin({ refresh: true });
190
+ const version = bin && ocbin.probeVersion(bin);
191
+ if (!version) {
192
+ const msg = `安装完成但未能识别 opencode${bin ? `(${bin.path} 探测失败)` : ''},可用环境变量 KG_OPENCODE_PATH 指定可执行文件路径后重试`;
193
+ Object.assign(state, { phase: 'error', step: '', error: msg, ended_at: Date.now() });
194
+ pushLog(msg);
195
+ running = false;
196
+ return;
197
+ }
198
+ pushLog(`已识别 opencode: ${bin.path}(${version})`);
199
+
200
+ try {
201
+ state.model = await configureModel();
202
+ pushLog(`模型配置: ${state.model.detail}${state.model.model ? `,默认模型 ${state.model.model}` : ''}`);
203
+ } catch (e) {
204
+ state.model = { source: 'error', model: null, detail: `模型配置失败: ${e.message}(opencode 本体已可用,可手动在其配置中设置模型)` };
205
+ pushLog(state.model.detail);
206
+ }
207
+
208
+ Object.assign(state, { phase: 'done', step: '', ended_at: Date.now() });
209
+ pushLog('全部完成,OpenCode 已就绪');
210
+ running = false;
211
+ if (typeof onDone === 'function') { try { onDone(); } catch (_) { /* 回调异常不影响状态 */ } }
212
+ }
213
+
214
+ function startInstall(force = false) {
215
+ if (running) return { started: false, reason: '安装正在进行中' };
216
+ if (state.phase === 'done' && !force) return { started: false, reason: '已安装完成,无需重复安装(强制重装请带 force)' };
217
+ if (force) Object.assign(state, { phase: 'idle', log: [], error: null, model: null });
218
+ installFlow().catch((e) => {
219
+ Object.assign(state, { phase: 'error', step: '', error: e.message, ended_at: Date.now() });
220
+ running = false;
221
+ });
222
+ return { started: true };
223
+ }
224
+
225
+ function status() {
226
+ return { ...state, running, log: state.log.slice(-40) };
227
+ }
228
+
229
+ module.exports = { startInstall, status, FREE_MODEL, set onDone(fn) { onDone = fn; }, _internal: { configureModel, existingSetup, detectLocalModels, globalConfigPath, authFilePath } };