dsh-model 0.16.0 → 0.18.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/README.en.md +5 -3
- package/README.md +5 -3
- package/dist/cli.js +4 -0
- package/dist/commands/engine.js +1 -1
- package/dist/commands/self.js +5 -3
- package/dist/commands/sources.js +1 -1
- package/dist/commands/status.js +2 -1
- package/dist/commands/uninstall.js +2 -1
- package/dist/context.js +2 -2
- package/dist/daemon/core.js +65 -19
- package/dist/daemon/usage.js +1 -1
- package/dist/dsh/cli.js +60 -14
- package/dist/dsh/locate.js +6 -0
- package/dist/dsh/pick.js +1 -1
- package/dist/engine/compat.js +16 -0
- package/dist/engine/install.js +35 -10
- package/dist/integrations/local.js +50 -0
- package/dist/ops.js +1 -1
- package/dist/service/index.js +5 -0
- package/dist/service/windows.js +139 -0
- package/dist/sources.js +2 -0
- package/dist/util/detach.js +28 -0
- package/dist/util/exec.js +22 -2
- package/dist/util/fs.js +18 -1
- package/dist/util/proxy.js +27 -1
- package/engine-manifest.json +10 -0
- package/package.json +1 -1
- package/plugin/client.js +13 -5
- package/plugin/index.js +1 -0
package/README.en.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<a href="LICENSE"><img src="https://img.shields.io/github/license/AIcivilization/dsh-model" alt="MIT license"></a>
|
|
9
9
|
<a href="https://www.npmjs.com/package/dsh-model"><img src="https://img.shields.io/npm/v/dsh-model?color=cb3837&logo=npm" alt="npm version"></a>
|
|
10
10
|
<img src="https://img.shields.io/badge/DeepSeek%20Harness-%E2%89%A5%200.2.0--rc.2-4176E6" alt="DeepSeek Harness ≥ 0.2.0-rc.2">
|
|
11
|
-
<img src="https://img.shields.io/badge/platform-
|
|
11
|
+
<img src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-blue" alt="Platform: Windows / macOS / Linux">
|
|
12
12
|
<img src="https://img.shields.io/badge/node-%E2%89%A5%2020-339933?logo=node.js&logoColor=white" alt="Node ≥ 20">
|
|
13
13
|
<a href="https://github.com/router-for-me/CLIProxyAPI"><img src="https://img.shields.io/badge/engine-CLIProxyAPI%208.0.13-555" alt="Engine: CLIProxyAPI 8.0.13"></a>
|
|
14
14
|
</p>
|
|
@@ -37,7 +37,7 @@ npm install -g dsh-model
|
|
|
37
37
|
dsh-model setup
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
On a dsh-vps server, use `sudo dsh-model setup`.
|
|
40
|
+
On a dsh-vps server, use `sudo dsh-model setup`. On Windows, run it in a normal PowerShell or Command Prompt; no administrator rights needed.
|
|
41
41
|
|
|
42
42
|
`setup` is safe to re-run. It:
|
|
43
43
|
|
|
@@ -73,6 +73,7 @@ If a source is signed in but has no usable plan (say, a Kimi account without Kim
|
|
|
73
73
|
| Source | Cost | Notes |
|
|
74
74
|
|---|---|---|
|
|
75
75
|
| WorkBuddy / WorkBuddy AI | Free | Credits come with the account |
|
|
76
|
+
| Ollama / LM Studio | Free | Models running on this machine; they appear once installed and started, no sign-in |
|
|
76
77
|
| Codex | Paid | ChatGPT Plus / Pro |
|
|
77
78
|
| Kimi | Paid | Kimi Code plan |
|
|
78
79
|
| Grok | Paid | SuperGrok / X Premium+ |
|
|
@@ -120,7 +121,8 @@ See the [design doc](docs/DESIGN.md) (Chinese).
|
|
|
120
121
|
## Notes
|
|
121
122
|
|
|
122
123
|
- Using subscription and WorkBuddy credentials outside the official clients may break the providers' terms and put the account at risk. Decide for yourself.
|
|
123
|
-
- macOS and Linux, Node ≥ 20.
|
|
124
|
+
- Windows 10 / 11 (x64, ARM64), macOS and Linux, Node ≥ 20.
|
|
125
|
+
- Windows: the background services start at sign-in with no window. If an antivirus blocks them (the program is gone or will not start), trust `%USERPROFILE%\.dsh-model` and run `dsh-model repair`.
|
|
124
126
|
|
|
125
127
|
## License
|
|
126
128
|
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<a href="LICENSE"><img src="https://img.shields.io/github/license/AIcivilization/dsh-model" alt="MIT license"></a>
|
|
9
9
|
<a href="https://www.npmjs.com/package/dsh-model"><img src="https://img.shields.io/npm/v/dsh-model?color=cb3837&logo=npm" alt="npm version"></a>
|
|
10
10
|
<img src="https://img.shields.io/badge/DeepSeek%20Harness-%E2%89%A5%200.2.0--rc.2-4176E6" alt="DeepSeek Harness ≥ 0.2.0-rc.2">
|
|
11
|
-
<img src="https://img.shields.io/badge/platform-
|
|
11
|
+
<img src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-blue" alt="Platform: Windows / macOS / Linux">
|
|
12
12
|
<img src="https://img.shields.io/badge/node-%E2%89%A5%2020-339933?logo=node.js&logoColor=white" alt="Node ≥ 20">
|
|
13
13
|
<a href="https://github.com/router-for-me/CLIProxyAPI"><img src="https://img.shields.io/badge/engine-CLIProxyAPI%208.0.13-555" alt="Engine: CLIProxyAPI 8.0.13"></a>
|
|
14
14
|
</p>
|
|
@@ -37,7 +37,7 @@ npm install -g dsh-model
|
|
|
37
37
|
dsh-model setup
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
服务器上(装了 dsh-vps 的 VPS)用 `sudo dsh-model setup`。
|
|
40
|
+
服务器上(装了 dsh-vps 的 VPS)用 `sudo dsh-model setup`。Windows 上在普通的 PowerShell 或命令提示符里执行即可,不需要管理员。
|
|
41
41
|
|
|
42
42
|
`setup` 可以重复执行,它会依次:
|
|
43
43
|
|
|
@@ -73,6 +73,7 @@ dsh-model setup
|
|
|
73
73
|
| 来源 | 费用 | 说明 |
|
|
74
74
|
|---|---|---|
|
|
75
75
|
| WorkBuddy / WorkBuddy AI | 免费 | 账号自带积分 |
|
|
76
|
+
| Ollama / LM Studio | 免费 | 本机运行的模型。装好并启动后自动出现,不用登录 |
|
|
76
77
|
| Codex | 需付费 | ChatGPT Plus / Pro |
|
|
77
78
|
| Kimi | 需付费 | Kimi Code 套餐 |
|
|
78
79
|
| Grok | 需付费 | SuperGrok / X Premium+ |
|
|
@@ -120,7 +121,8 @@ dsh · 编辑器 · 脚本 ──Bearer key──▶ 引擎 127.0.0.1:8317/v1(
|
|
|
120
121
|
## 注意
|
|
121
122
|
|
|
122
123
|
- 订阅和 WorkBuddy 的凭据在官方客户端之外使用,可能不符合服务商的条款,账号有风险,请自行判断。
|
|
123
|
-
- 支持
|
|
124
|
+
- 支持 Windows 10 / 11(x64、ARM64)、macOS、Linux,需要 Node ≥ 20。
|
|
125
|
+
- Windows:后台服务登录后自动运行、不弹窗口。如果被杀毒软件拦下(程序不见了或启动不了),把 `%USERPROFILE%\.dsh-model` 加入信任,再执行 `dsh-model repair`。
|
|
124
126
|
|
|
125
127
|
## License
|
|
126
128
|
|
package/dist/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import { setup } from './commands/setup.js';
|
|
|
10
10
|
import { doctor, status } from './commands/status.js';
|
|
11
11
|
import { uninstall } from './commands/uninstall.js';
|
|
12
12
|
import { adminRun, selfUpdate } from './commands/self.js';
|
|
13
|
+
import { supervise } from './service/windows.js';
|
|
13
14
|
import { opencode } from './commands/opencode.js';
|
|
14
15
|
import { workbuddy } from './commands/workbuddy.js';
|
|
15
16
|
import { bridge } from './commands/bridge.js';
|
|
@@ -183,6 +184,9 @@ export async function main(argv) {
|
|
|
183
184
|
case 'engine':
|
|
184
185
|
return await engine(ctx, a1, a2);
|
|
185
186
|
case 'service':
|
|
187
|
+
// Windows 后台服务的常驻进程(由隐藏窗口脚本启动,不是给人用的)
|
|
188
|
+
if (a1 === 'supervise' && a2)
|
|
189
|
+
return await supervise(a2);
|
|
186
190
|
return await service(ctx, a1);
|
|
187
191
|
case 'repair':
|
|
188
192
|
return await repair(ctx);
|
package/dist/commands/engine.js
CHANGED
|
@@ -103,7 +103,7 @@ async function trialRun(ctx, all, bin) {
|
|
|
103
103
|
doc.oauth['auth-dir'] = join(dir, 'auth');
|
|
104
104
|
const cfg = join(dir, 'engine.yaml');
|
|
105
105
|
await atomicWrite(cfg, YAML.stringify(doc));
|
|
106
|
-
const child = spawn(bin, ['-config', cfg], { cwd: dir, stdio: 'ignore' });
|
|
106
|
+
const child = spawn(bin, ['-config', cfg], { cwd: dir, stdio: 'ignore', windowsHide: true });
|
|
107
107
|
try {
|
|
108
108
|
if (!(await waitHealthy(port, 15_000)))
|
|
109
109
|
throw new DshModelError('engine_trial_failed', L('新版本试运行没有启动', 'New version did not start in trial run'));
|
package/dist/commands/self.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
7
|
import { closeSync, openSync } from 'node:fs';
|
|
8
8
|
import { chown, realpath } from 'node:fs/promises';
|
|
9
|
-
import { dirname, join } from 'node:path';
|
|
9
|
+
import { delimiter, dirname, join } from 'node:path';
|
|
10
10
|
import { requireRootInVps } from '../context.js';
|
|
11
11
|
import { DshModelError, isDshModelError } from '../errors.js';
|
|
12
12
|
import { L, lang } from '../i18n.js';
|
|
@@ -44,10 +44,11 @@ async function npmBeside() {
|
|
|
44
44
|
if (process.versions.electron)
|
|
45
45
|
return null;
|
|
46
46
|
const dir = dirname(process.execPath);
|
|
47
|
-
|
|
47
|
+
// Windows 上 npm 是 npm.cmd(exec.ts 会经 cmd.exe 调它);PATH 分隔符也不同
|
|
48
|
+
const npm = join(dir, process.platform === 'win32' ? 'npm.cmd' : 'npm');
|
|
48
49
|
if (!(await exists(npm)))
|
|
49
50
|
return null;
|
|
50
|
-
return { npm, env: { ...process.env, PATH: `${dir}
|
|
51
|
+
return { npm, env: { ...process.env, PATH: `${dir}${delimiter}${process.env.PATH ?? ''}` } };
|
|
51
52
|
}
|
|
52
53
|
/** 当前程序是不是 npm 全局装的(是就能用 npm install -g 原地更新) */
|
|
53
54
|
async function npmGlobalInstall() {
|
|
@@ -136,6 +137,7 @@ export async function adminRun(ctx) {
|
|
|
136
137
|
const code = await new Promise((resolve) => {
|
|
137
138
|
const child = spawn(process.execPath, [join(PKG_ROOT, 'bin', 'dsh-model.js'), ...args, '--lang', req.lang ?? 'zh'], {
|
|
138
139
|
stdio: ['ignore', out, out],
|
|
140
|
+
windowsHide: true,
|
|
139
141
|
env: { ...process.env, NO_COLOR: '1', NODE_NO_WARNINGS: '1' },
|
|
140
142
|
});
|
|
141
143
|
child.on('error', () => resolve(-1));
|
package/dist/commands/sources.js
CHANGED
|
@@ -69,7 +69,7 @@ export async function sources(ctx, opts = {}) {
|
|
|
69
69
|
s.enabled ? green('●') : dim('○'),
|
|
70
70
|
s.id,
|
|
71
71
|
pricingTag(s),
|
|
72
|
-
s.loggedIn ? (s.usage?.noAccess ? yellow(L('已登录·无订阅', 'signed in · no plan')) : s.enabled ? green(L('已接入', 'connected')) : yellow(L('已登录·关闭', 'signed in · off'))) : dim(L('未登录', 'signed out')),
|
|
72
|
+
s.loggedIn ? (s.usage?.noAccess ? yellow(L('已登录·无订阅', 'signed in · no plan')) : s.enabled ? green(L('已接入', 'connected')) : yellow(L('已登录·关闭', 'signed in · off'))) : dim(s.kind === 'local' ? L('未运行', 'not running') : L('未登录', 'signed out')),
|
|
73
73
|
s.account ?? (s.detail ? dim(s.detail.slice(0, 40)) : '-'),
|
|
74
74
|
s.models ? String(s.models) : '-',
|
|
75
75
|
]),
|
package/dist/commands/status.js
CHANGED
|
@@ -173,7 +173,8 @@ export async function doctor(ctx, opts) {
|
|
|
173
173
|
const credText = await readText(d.credFile);
|
|
174
174
|
add('dsh-key', readRef(credText) === key ? 'ok' : 'fail', readRef(credText) === key ? L(`${KEY_REF} 与 key 一致`, `${KEY_REF} matches`) : L(`${KEY_REF} 与 key 不一致(dsh-model repair)`, `${KEY_REF} does not match (dsh-model repair)`));
|
|
175
175
|
const cm = await fileMode(d.credFile);
|
|
176
|
-
|
|
176
|
+
// Windows 没有 Unix 权限位(读出来总是 666),权限靠用户目录本身的访问控制,不查
|
|
177
|
+
if (cm != null && ctx.platform !== 'win32')
|
|
177
178
|
add('dsh-cred-perms', cm === 0o600 ? 'ok' : 'fail', L(`.credentials.yaml 权限 ${cm.toString(8)}(dsh 要求 600)`, `.credentials.yaml mode ${cm.toString(8)} (dsh requires 600)`));
|
|
178
179
|
const patchText = await readText(d.patchFile);
|
|
179
180
|
if (patchText != null && d.writtenPatchSha && sha256(patchText) !== d.writtenPatchSha) {
|
|
@@ -95,7 +95,8 @@ export async function uninstall(ctx, opts) {
|
|
|
95
95
|
warn(L(`有 ${errors.length} 步失败,保留了 ${ctx.paths.home} 以便重试(再次执行 uninstall)`, `${errors.length} step(s) failed; kept ${ctx.paths.home} so you can retry (run uninstall again)`));
|
|
96
96
|
return 1;
|
|
97
97
|
}
|
|
98
|
-
|
|
98
|
+
// Windows:刚结束的进程可能还没完全退出、还占着目录,删除会短暂报 EBUSY / EPERM,重试一会儿
|
|
99
|
+
await rm(ctx.paths.home, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 });
|
|
99
100
|
ok(L(`已删除 ${ctx.paths.home},卸载完成`, `Deleted ${ctx.paths.home}; uninstall complete`));
|
|
100
101
|
info(L('最后可执行:npm uninstall -g dsh-model', 'Finally you can run: npm uninstall -g dsh-model'));
|
|
101
102
|
return 0;
|
package/dist/context.js
CHANGED
|
@@ -41,8 +41,8 @@ export function makePaths(home) {
|
|
|
41
41
|
}
|
|
42
42
|
export async function createContext(env = process.env) {
|
|
43
43
|
const platform = process.platform;
|
|
44
|
-
if (platform !== 'darwin' && platform !== 'linux') {
|
|
45
|
-
throw new DshModelError('unsupported_platform', L(`暂不支持这个平台:${platform}
|
|
44
|
+
if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win32') {
|
|
45
|
+
throw new DshModelError('unsupported_platform', L(`暂不支持这个平台:${platform}(支持 macOS、Linux、Windows)`, `Unsupported platform: ${platform} (macOS, Linux and Windows are supported)`));
|
|
46
46
|
}
|
|
47
47
|
const mode = detectMode(env, platform);
|
|
48
48
|
const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
|
package/dist/daemon/core.js
CHANGED
|
@@ -5,14 +5,15 @@
|
|
|
5
5
|
// 职责:来源状态与开关、登录会话(引擎 OAuth / WorkBuddy 自有登录 / OpenCode key)、key 管理、用量统计。
|
|
6
6
|
// CLI 与 dsh 插件都只通过 /control/* 调这里,逻辑只有一份。
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
|
-
import { closeSync, openSync } from 'node:fs';
|
|
9
8
|
import { readFile } from 'node:fs/promises';
|
|
10
9
|
import { join } from 'node:path';
|
|
11
10
|
import { latestVersion, selfJobLog } from '../commands/self.js';
|
|
11
|
+
import { LOCAL_SERVERS, loadLocalCatalog, refreshLocalCatalog } from '../integrations/local.js';
|
|
12
12
|
import { adminInstalled, adminRequestPath } from '../service/admin.js';
|
|
13
13
|
import { atomicWrite } from '../util/fs.js';
|
|
14
14
|
import { lang } from '../i18n.js';
|
|
15
15
|
import { which } from '../util/exec.js';
|
|
16
|
+
import { spawnDetached } from '../util/detach.js';
|
|
16
17
|
import { PKG_ROOT, pkgVersion } from '../util/pkg.js';
|
|
17
18
|
import { randomBytes } from 'node:crypto';
|
|
18
19
|
import { currentBinary } from '../engine/install.js';
|
|
@@ -47,6 +48,16 @@ const USAGE_REFRESH_MS = 5 * 60 * 1000;
|
|
|
47
48
|
/** 查不了用量的来源多久重新实测一次 */
|
|
48
49
|
const PROBE_MS = 6 * 60 * 60 * 1000;
|
|
49
50
|
const log_ = (m) => log(m);
|
|
51
|
+
/** 进程还在不在(signal 0 只做检查) */
|
|
52
|
+
const alive = (pid) => {
|
|
53
|
+
try {
|
|
54
|
+
process.kill(pid, 0);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
50
61
|
const log = (m) => console.error(`[${new Date().toISOString()}] ${m}`);
|
|
51
62
|
export class Daemon {
|
|
52
63
|
ctx;
|
|
@@ -71,6 +82,8 @@ export class Daemon {
|
|
|
71
82
|
if (await this.refreshCatalogs())
|
|
72
83
|
void this.resync();
|
|
73
84
|
this.timers.push(setInterval(() => void this.refreshCatalogs().then((c) => (c ? this.resync() : undefined)), CATALOG_REFRESH_MS));
|
|
85
|
+
// 本机模型服务(Ollama、LM Studio)随时可能启动或退出:两分钟问一次,变了就同步
|
|
86
|
+
this.timers.push(setInterval(() => void refreshLocalCatalog(this.ctx.paths.home, this.ctx.owner).then((c) => (c ? this.resync() : undefined), () => { }), 2 * 60 * 1000));
|
|
74
87
|
this.timers.push(setInterval(() => void this.pollUsage(), USAGE_POLL_MS));
|
|
75
88
|
this.timers.push(setInterval(() => void this.stats.save().catch(() => { }), STATS_SAVE_MS));
|
|
76
89
|
this.timers.push(setInterval(() => void this.reloadKeyNames(), STATS_SAVE_MS));
|
|
@@ -105,7 +118,7 @@ export class Daemon {
|
|
|
105
118
|
}
|
|
106
119
|
}
|
|
107
120
|
async refreshCatalogs() {
|
|
108
|
-
let changed = false;
|
|
121
|
+
let changed = await refreshLocalCatalog(this.ctx.paths.home, this.ctx.owner).catch(() => false);
|
|
109
122
|
for (const rt of this.runtimes) {
|
|
110
123
|
try {
|
|
111
124
|
if (await refreshCatalog(this.ctx.paths.home, rt, this.ctx.owner))
|
|
@@ -141,7 +154,7 @@ export class Daemon {
|
|
|
141
154
|
const u = this.usageCache.get(s.id);
|
|
142
155
|
// 引擎来源:实测结果出来前不显示用量行;OpenCode 的 key 设置时已实测
|
|
143
156
|
if (USAGE_UNSUPPORTED.has(s.id) && s.loggedIn) {
|
|
144
|
-
const stub = s.kind
|
|
157
|
+
const stub = s.kind !== 'opencode' ? undefined : { source: s.id, windows: [], fetchedAt: new Date().toISOString(), unsupported: true };
|
|
145
158
|
const usage = u ?? stub;
|
|
146
159
|
return usage ? { ...s, usage } : s;
|
|
147
160
|
}
|
|
@@ -163,6 +176,7 @@ export class Daemon {
|
|
|
163
176
|
}
|
|
164
177
|
const cats = await loadCatalogs(this.ctx.paths.home);
|
|
165
178
|
const secrets = await loadSecrets(this.ctx);
|
|
179
|
+
const local = await loadLocalCatalog(this.ctx.paths.home);
|
|
166
180
|
// 订阅来源能服务的模型数:管理接口按凭据列模型(只数没停用的)
|
|
167
181
|
const engineModels = new Map();
|
|
168
182
|
try {
|
|
@@ -208,6 +222,19 @@ export class Daemon {
|
|
|
208
222
|
models: c?.signedIn ? c.models.length : 0,
|
|
209
223
|
};
|
|
210
224
|
}
|
|
225
|
+
if (def.kind === 'local') {
|
|
226
|
+
const st = local[def.id];
|
|
227
|
+
const srv = LOCAL_SERVERS.find((x) => x.id === def.id);
|
|
228
|
+
const up = Boolean(st?.reachable);
|
|
229
|
+
return {
|
|
230
|
+
...base,
|
|
231
|
+
loggedIn: up,
|
|
232
|
+
enabled: up && (st?.models.length ?? 0) > 0 && !disabled.has(def.id),
|
|
233
|
+
...(up ? { account: srv.base.replace(/\/v1$/, '') } : { detail: L(`没检测到 ${srv.label} 在运行(${srv.base.replace(/\/v1$/, '')})`, `${srv.label} is not running (${srv.base.replace(/\/v1$/, '')})`) }),
|
|
234
|
+
...(up && !st?.models.length ? { detail: L('在运行,但还没有模型', 'Running, but has no models yet') } : {}),
|
|
235
|
+
models: st?.models.length ?? 0,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
211
238
|
const has = Boolean(secrets.opencode?.key);
|
|
212
239
|
return { ...base, loggedIn: has, enabled: has && !disabled.has('opencode'), models: has ? modelCount('opencode') : 0 };
|
|
213
240
|
});
|
|
@@ -231,6 +258,12 @@ export class Daemon {
|
|
|
231
258
|
async enable(id, opts = {}) {
|
|
232
259
|
const def = mustSource(id);
|
|
233
260
|
const state = (await this.sources()).find((s) => s.id === def.id);
|
|
261
|
+
if (def.kind === 'local') {
|
|
262
|
+
await refreshLocalCatalog(this.ctx.paths.home, this.ctx.owner);
|
|
263
|
+
const now = (await this.sourcesRaw()).find((s) => s.id === def.id);
|
|
264
|
+
if (!now.loggedIn || !now.models)
|
|
265
|
+
throw new DshModelError('local_not_running', now.detail ?? L(`${def.label} 没在运行`, `${def.label} is not running`), L(`先装好并启动它:${def.subscribeUrl}`, `Install and start it first: ${def.subscribeUrl}`));
|
|
266
|
+
}
|
|
234
267
|
if (def.kind === 'opencode' && !state.loggedIn) {
|
|
235
268
|
throw new DshModelError('needs_key', L('OpenCode Zen 需要 API key:用 opencode/key 提交', 'OpenCode Zen needs an API key: submit it via opencode/key'));
|
|
236
269
|
}
|
|
@@ -275,6 +308,9 @@ export class Daemon {
|
|
|
275
308
|
for (const c of credsFor(await m.credentials(), def))
|
|
276
309
|
await m.deleteCredential(c.name);
|
|
277
310
|
}
|
|
311
|
+
else if (def.kind === 'local') {
|
|
312
|
+
throw new DshModelError('no_logout', L(`${def.label} 不需要登录;不想用就关掉开关`, `${def.label} has no sign-in; turn the switch off instead`));
|
|
313
|
+
}
|
|
278
314
|
else if (def.kind === 'workbuddy') {
|
|
279
315
|
const v = WORKBUDDY_VARIANTS.find((x) => x.id === def.variant);
|
|
280
316
|
await workbuddyLogout(v, this.ctx.paths.home);
|
|
@@ -425,7 +461,7 @@ export class Daemon {
|
|
|
425
461
|
const m = await this.mgmt();
|
|
426
462
|
const before = credsFor(await m.credentials(), def).map((c) => c.name);
|
|
427
463
|
const abort = new AbortController();
|
|
428
|
-
const child = spawn(currentBinary(this.ctx), ['-config', this.ctx.paths.engineYaml, def.deviceCliFlag, '-no-browser'], { cwd: this.ctx.paths.home, stdio: ['ignore', 'pipe', 'pipe'], signal: abort.signal });
|
|
464
|
+
const child = spawn(currentBinary(this.ctx), ['-config', this.ctx.paths.engineYaml, def.deviceCliFlag, '-no-browser'], { cwd: this.ctx.paths.home, stdio: ['ignore', 'pipe', 'pipe'], signal: abort.signal, windowsHide: true });
|
|
429
465
|
let out = '';
|
|
430
466
|
const s = {
|
|
431
467
|
id: randomBytes(12).toString('hex'),
|
|
@@ -655,6 +691,13 @@ export class Daemon {
|
|
|
655
691
|
* 用 detached 让它脱离守护进程的进程组(launchd 重启服务时不会被一起杀掉)。
|
|
656
692
|
*/
|
|
657
693
|
async startSelfJob(action, opts = {}) {
|
|
694
|
+
// 同一时间只跑一个(实测:连点两次会起两个更新、日志互相覆盖)
|
|
695
|
+
if (this.selfJob && Date.now() - Date.parse(this.selfJob.startedAt) < 10 * 60 * 1000) {
|
|
696
|
+
if (this.selfJob.pid && !alive(this.selfJob.pid))
|
|
697
|
+
this.selfJob = undefined;
|
|
698
|
+
else
|
|
699
|
+
return { started: false, log: this.selfJob.log };
|
|
700
|
+
}
|
|
658
701
|
const log = selfJobLog(this.ctx, action);
|
|
659
702
|
if (this.ctx.mode === 'vps') {
|
|
660
703
|
// 守护进程是 dsh 用户:写请求单,由 dsh-model-admin(root)执行
|
|
@@ -667,21 +710,20 @@ export class Daemon {
|
|
|
667
710
|
return { started: true, log };
|
|
668
711
|
}
|
|
669
712
|
const args = action === 'update' ? ['update'] : ['uninstall', '--yes', '--remove-plugin', ...(opts.keepAuth ? ['--keep-auth'] : [])];
|
|
670
|
-
const out = openSync(log, 'w', 0o600);
|
|
671
713
|
const cmd = [process.execPath, join(PKG_ROOT, 'bin', 'dsh-model.js'), ...args, '--lang', lang()];
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
stdio:
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
this.selfJob = { action, startedAt: new Date().toISOString(), log };
|
|
684
|
-
log_(`daemon: started ${action} (pid ${
|
|
714
|
+
const env = { ...process.env, NO_COLOR: '1', NODE_NO_WARNINGS: '1', ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}) };
|
|
715
|
+
let pid;
|
|
716
|
+
if (this.ctx.platform === 'linux' && (await which('systemd-run'))) {
|
|
717
|
+
// Linux 的 systemd --user 重启服务会杀掉整个 cgroup:交给 systemd-run 另起一个
|
|
718
|
+
const keepEnv = ['PATH', 'HOME', 'LANG', 'DSH_HOME', 'DSH_MODEL_HOME', 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY'].filter((k) => process.env[k]).map((k) => `--setenv=${k}=${process.env[k]}`);
|
|
719
|
+
const viaSystemd = ['--user', '--collect', '--quiet', ...keepEnv, '--setenv=NO_COLOR=1', '/bin/sh', '-c', 'log="$1"; shift; exec "$@" >"$log" 2>&1', 'sh', log, ...cmd];
|
|
720
|
+
spawn('systemd-run', viaSystemd, { detached: true, stdio: 'ignore', env }).unref();
|
|
721
|
+
}
|
|
722
|
+
else {
|
|
723
|
+
pid = spawnDetached(cmd[0], cmd.slice(1), { log, env });
|
|
724
|
+
}
|
|
725
|
+
this.selfJob = { action, startedAt: new Date().toISOString(), log, ...(pid ? { pid } : {}) };
|
|
726
|
+
log_(`daemon: started ${action}${pid ? ` (pid ${pid})` : ''}`);
|
|
685
727
|
return { started: true, log };
|
|
686
728
|
}
|
|
687
729
|
/** 这个来源的全部模型,以及哪些显示在 dsh 里 */
|
|
@@ -773,8 +815,12 @@ export class Daemon {
|
|
|
773
815
|
return ok({ sources: await this.sources(), keys: await this.keys(), stats: this.statsSnapshot(), endpoints: await this.endpoints() });
|
|
774
816
|
}
|
|
775
817
|
if (method === 'GET' && path === '/sources') {
|
|
776
|
-
if (query.get('refresh') === '1')
|
|
818
|
+
if (query.get('refresh') === '1') {
|
|
819
|
+
// 本机模型服务开没开:页面打开时顺手问一下(1.5 秒超时),变了就同步进 dsh
|
|
820
|
+
if (await refreshLocalCatalog(this.ctx.paths.home, this.ctx.owner).catch(() => false))
|
|
821
|
+
void this.resync();
|
|
777
822
|
await this.refreshUsage();
|
|
823
|
+
}
|
|
778
824
|
return ok(await this.sources());
|
|
779
825
|
}
|
|
780
826
|
if (seg[0] === 'sources' && seg[1] && method === 'POST') {
|
package/dist/daemon/usage.js
CHANGED
|
@@ -12,7 +12,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
const TIMEOUT_MS = 15_000;
|
|
14
14
|
/** 这几家暂不支持(见设计 §14.6) */
|
|
15
|
-
export const USAGE_UNSUPPORTED = new Set(['antigravity', 'devin', 'meta', 'opencode']);
|
|
15
|
+
export const USAGE_UNSUPPORTED = new Set(['antigravity', 'devin', 'meta', 'opencode', 'ollama', 'lmstudio']);
|
|
16
16
|
async function getJson(url, headers) {
|
|
17
17
|
const res = await fetch(url, { headers: { Accept: 'application/json', ...headers }, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
18
18
|
const text = await res.text();
|
package/dist/dsh/cli.js
CHANGED
|
@@ -1,22 +1,55 @@
|
|
|
1
1
|
// dsh/cli.ts — 找到并调用 dsh 自己的命令行(用它的 `dsh plugin` 装 / 卸插件:带兼容性检查,会自动登记 bundles)
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { join, win32 } from 'node:path';
|
|
5
5
|
import { VPS_ROOT } from '../context.js';
|
|
6
6
|
import { DshModelError } from '../errors.js';
|
|
7
7
|
import { L } from '../i18n.js';
|
|
8
8
|
import { run, runInherit, which } from '../util/exec.js';
|
|
9
9
|
import { exists } from '../util/fs.js';
|
|
10
10
|
const MAC_APPS = ['/Applications/DeepSeek Harness.app', join(homedir(), 'Applications/DeepSeek Harness.app')];
|
|
11
|
+
/** Windows 上 DeepSeek Harness 桌面版可能装在的目录 */
|
|
12
|
+
export function windowsAppDirs(env, execPath = process.execPath, electron = Boolean(process.versions.electron)) {
|
|
13
|
+
const dirs = [];
|
|
14
|
+
// 从 dsh 管理页一键安装时,本进程就是 DeepSeek Harness.exe(Electron):它所在的目录最准
|
|
15
|
+
// 只在 Windows 上用:一律按 Windows 的路径规则拼(在别的平台上跑测试也对)
|
|
16
|
+
if (electron)
|
|
17
|
+
dirs.push(win32.dirname(execPath));
|
|
18
|
+
for (const base of [env.LOCALAPPDATA && win32.join(env.LOCALAPPDATA, 'Programs'), env.ProgramFiles, env['ProgramFiles(x86)']]) {
|
|
19
|
+
if (base)
|
|
20
|
+
dirs.push(win32.join(base, 'DeepSeek Harness'));
|
|
21
|
+
}
|
|
22
|
+
return [...new Set(dirs)];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Windows:桌面版的命令行就是 DeepSeek Harness.exe 以 Node 模式(ELECTRON_RUN_AS_NODE=1)运行
|
|
26
|
+
* app.asar 里的 dsh-desktop-host/lib/cli.js(Mac 上 runtime/cli/bin/dsh 这个启动脚本做的正是这件事)。
|
|
27
|
+
* 直接这样调,不依赖启动脚本叫什么名字。
|
|
28
|
+
*/
|
|
29
|
+
async function windowsDshCli(ctx) {
|
|
30
|
+
for (const dir of windowsAppDirs(ctx.env)) {
|
|
31
|
+
for (const name of ['dsh.cmd', 'dsh.exe']) {
|
|
32
|
+
const bin = join(dir, 'resources', 'runtime', 'cli', 'bin', name);
|
|
33
|
+
if (await exists(bin))
|
|
34
|
+
return { cmd: bin, pre: [] };
|
|
35
|
+
}
|
|
36
|
+
const exe = join(dir, 'DeepSeek Harness.exe');
|
|
37
|
+
const asar = join(dir, 'resources', 'app.asar');
|
|
38
|
+
if ((await exists(exe)) && (await exists(asar))) {
|
|
39
|
+
return { cmd: exe, pre: ['--expose-internals', join(asar, 'dsh', 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'cli.js')], env: { ELECTRON_RUN_AS_NODE: '1' } };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
11
44
|
export async function locateDshCli(ctx) {
|
|
12
45
|
if (ctx.env.DSH_MODEL_DSH_BIN)
|
|
13
|
-
return ctx.env.DSH_MODEL_DSH_BIN;
|
|
46
|
+
return { cmd: ctx.env.DSH_MODEL_DSH_BIN, pre: [] };
|
|
14
47
|
if (ctx.mode === 'vps') {
|
|
15
48
|
try {
|
|
16
49
|
const env = await readFile(join(VPS_ROOT, 'state/gate.env'), 'utf8');
|
|
17
50
|
const bin = env.match(/^DSH_BIN=(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '');
|
|
18
51
|
if (bin && (await exists(bin)))
|
|
19
|
-
return bin;
|
|
52
|
+
return { cmd: bin, pre: [] };
|
|
20
53
|
}
|
|
21
54
|
catch {
|
|
22
55
|
// 往下找
|
|
@@ -26,24 +59,29 @@ export async function locateDshCli(ctx) {
|
|
|
26
59
|
for (const app of MAC_APPS) {
|
|
27
60
|
const bin = join(app, 'Contents/Resources/runtime/cli/bin/dsh');
|
|
28
61
|
if (await exists(bin))
|
|
29
|
-
return bin;
|
|
62
|
+
return { cmd: bin, pre: [] };
|
|
30
63
|
}
|
|
31
64
|
}
|
|
65
|
+
if (ctx.platform === 'win32') {
|
|
66
|
+
const w = await windowsDshCli(ctx);
|
|
67
|
+
if (w)
|
|
68
|
+
return w;
|
|
69
|
+
}
|
|
32
70
|
const onPath = await which('dsh');
|
|
33
71
|
if (onPath)
|
|
34
|
-
return onPath;
|
|
72
|
+
return { cmd: onPath, pre: [] };
|
|
35
73
|
throw new DshModelError('dsh_cli_not_found', L('找不到 dsh 的命令行', 'Could not find the dsh command line'), L('请确认 dsh 已安装;或设置 DSH_MODEL_DSH_BIN 指向 dsh 可执行文件', 'Make sure dsh is installed, or set DSH_MODEL_DSH_BIN to the dsh executable'));
|
|
36
74
|
}
|
|
37
75
|
function dshEnv(ctx, proxy) {
|
|
38
76
|
return { ...ctx.env, DSH_HOME: ctx.dshHome, ...(proxy ? { HTTPS_PROXY: proxy, HTTP_PROXY: proxy } : {}) };
|
|
39
77
|
}
|
|
40
78
|
/** vps 模式下 dsh 的文件归 dsh 用户,插件操作要以它的身份跑 */
|
|
41
|
-
function wrap(ctx,
|
|
79
|
+
function wrap(ctx, cli, args, proxy) {
|
|
42
80
|
if (ctx.runAs && ctx.isRoot) {
|
|
43
81
|
const keep = ['DSH_HOME', ...(proxy ? ['HTTPS_PROXY', 'HTTP_PROXY'] : [])].join(',');
|
|
44
|
-
return { cmd: 'sudo', args: ['-u', ctx.runAs, '-H', `--preserve-env=${keep}`,
|
|
82
|
+
return { cmd: 'sudo', args: ['-u', ctx.runAs, '-H', `--preserve-env=${keep}`, cli.cmd, ...cli.pre, ...args] };
|
|
45
83
|
}
|
|
46
|
-
return { cmd:
|
|
84
|
+
return { cmd: cli.cmd, args: [...cli.pre, ...args] };
|
|
47
85
|
}
|
|
48
86
|
/**
|
|
49
87
|
* 这个 profile 的 node_modules 当初链接的 pnpm 仓库:沿用它。
|
|
@@ -52,11 +90,18 @@ function wrap(ctx, bin, args, proxy) {
|
|
|
52
90
|
async function profileStoreDir(ctx, profile) {
|
|
53
91
|
try {
|
|
54
92
|
const text = await readFile(join(ctx.dshHome, 'profiles', profile, 'node_modules', '.modules.yaml'), 'utf8');
|
|
55
|
-
|
|
93
|
+
// 这个文件实际是 JSON(Windows 路径里的反斜杠是转义过的),解析不了再退回正则
|
|
94
|
+
let dir;
|
|
95
|
+
try {
|
|
96
|
+
dir = JSON.parse(text).storeDir;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
dir = /"?storeDir"?\s*:\s*"?([^"\n,]+)"?/.exec(text)?.[1]?.trim();
|
|
100
|
+
}
|
|
56
101
|
if (!dir)
|
|
57
102
|
return null;
|
|
58
103
|
// 记录的是带版本的子目录(…/store/v11),store-dir 要它的上一级
|
|
59
|
-
return dir.replace(
|
|
104
|
+
return dir.replace(/[\\/]v\d+$/, '');
|
|
60
105
|
}
|
|
61
106
|
catch {
|
|
62
107
|
return null;
|
|
@@ -64,10 +109,10 @@ async function profileStoreDir(ctx, profile) {
|
|
|
64
109
|
}
|
|
65
110
|
/** dsh plugin --profile <p> <pnpm-args...>,输出直接给用户看(pnpm 进度) */
|
|
66
111
|
export async function dshPlugin(ctx, profile, pnpmArgs, proxy) {
|
|
67
|
-
const
|
|
112
|
+
const cli = await locateDshCli(ctx);
|
|
68
113
|
const store = await profileStoreDir(ctx, profile);
|
|
69
|
-
const w = wrap(ctx,
|
|
70
|
-
return runInherit(w.cmd, w.args, { env: dshEnv(ctx, proxy) });
|
|
114
|
+
const w = wrap(ctx, cli, ['plugin', '--profile', profile, ...pnpmArgs, ...(store ? [`--config.store-dir=${store}`] : [])], proxy);
|
|
115
|
+
return runInherit(w.cmd, w.args, { env: { ...dshEnv(ctx, proxy), ...cli.env } });
|
|
71
116
|
}
|
|
72
117
|
/** profile 的 package.json 里有没有这个依赖(有就是用户或 dsh 已经装了) */
|
|
73
118
|
export async function profileHasDependency(ctx, profile, name) {
|
|
@@ -81,7 +126,8 @@ export async function profileHasDependency(ctx, profile, name) {
|
|
|
81
126
|
}
|
|
82
127
|
export async function dshCliVersion(ctx) {
|
|
83
128
|
try {
|
|
84
|
-
const
|
|
129
|
+
const cli = await locateDshCli(ctx);
|
|
130
|
+
const r = await run(cli.cmd, [...cli.pre, '--version'], { timeoutMs: 20_000, env: { ...process.env, ...cli.env } });
|
|
85
131
|
return r.code === 0 ? r.stdout.trim() || null : null;
|
|
86
132
|
}
|
|
87
133
|
catch {
|
package/dist/dsh/locate.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// dsh/locate.ts — 找到 dsh:DSH_HOME、profile、版本
|
|
2
2
|
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { dshCliVersion } from './cli.js';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
import { VPS_ROOT } from '../context.js';
|
|
@@ -52,6 +53,11 @@ export async function dshVersion(ctx) {
|
|
|
52
53
|
return r.stdout.trim();
|
|
53
54
|
}
|
|
54
55
|
}
|
|
56
|
+
// Windows 没有 Info.plist:问 dsh 命令行自己(输出可能是 "dsh 0.2.0-rc.2",取其中的版本号)
|
|
57
|
+
if (ctx.platform === 'win32') {
|
|
58
|
+
const out = await dshCliVersion(ctx);
|
|
59
|
+
return out?.match(/\d+\.\d+\.\d+(?:-[\w.]+)?/)?.[0] ?? null;
|
|
60
|
+
}
|
|
55
61
|
return null;
|
|
56
62
|
}
|
|
57
63
|
export async function locateDsh(ctx, preferredProfile) {
|
package/dist/dsh/pick.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// 去掉画图 / 审查 / 自动路由这类不是对话用的 → 免费的先放(最多 2 个)→ 各系列轮流取最新版本,凑满 5 个。
|
|
6
6
|
export const DEFAULT_PICK = 5;
|
|
7
7
|
const MAX_FREE = 2;
|
|
8
|
-
const NON_CHAT = /image|embed|tts|whisper|audio|review|(^|\/)(auto|default-model)$/i;
|
|
8
|
+
const NON_CHAT = /image|embed|bge-|rerank|tts|whisper|audio|review|(^|\/)(auto|default-model)$/i;
|
|
9
9
|
/** 系列的先后:常用的大模型在前 */
|
|
10
10
|
const FAMILY_ORDER = ['gpt', 'claude', 'gemini', 'grok', 'kimi', 'glm', 'deepseek', 'qwen', 'minimax', 'hy'];
|
|
11
11
|
const bare = (id) => id.slice(id.lastIndexOf('/') + 1).toLowerCase();
|
package/dist/engine/compat.js
CHANGED
|
@@ -6,6 +6,7 @@ import { join } from 'node:path';
|
|
|
6
6
|
import { bridgeConfigPath, loadCatalogs } from '../bridge/runtime.js';
|
|
7
7
|
import { loadSecrets } from '../secrets.js';
|
|
8
8
|
import { readJson } from '../util/fs.js';
|
|
9
|
+
import { LOCAL_SERVERS, loadLocalCatalog } from '../integrations/local.js';
|
|
9
10
|
export const OPENCODE_BASE = 'https://opencode.ai/zen/v1';
|
|
10
11
|
export const OPENCODE_PREFIX = 'opencode';
|
|
11
12
|
export function opencodeModelsPath(ctx) {
|
|
@@ -67,6 +68,21 @@ export async function loadCompatUpstreams(ctx) {
|
|
|
67
68
|
});
|
|
68
69
|
}
|
|
69
70
|
}
|
|
71
|
+
// 本机模型服务(Ollama、LM Studio):在运行、有模型、没被关掉就接进来
|
|
72
|
+
const local = await loadLocalCatalog(ctx.paths.home);
|
|
73
|
+
for (const s of LOCAL_SERVERS) {
|
|
74
|
+
const st = local[s.id];
|
|
75
|
+
if (!st?.reachable || !st.models.length || disabled.has(s.id))
|
|
76
|
+
continue;
|
|
77
|
+
out.push({
|
|
78
|
+
name: s.id,
|
|
79
|
+
label: s.label,
|
|
80
|
+
baseUrl: s.base,
|
|
81
|
+
apiKey: 'local',
|
|
82
|
+
direct: true,
|
|
83
|
+
models: st.models.map((id) => ({ name: id, alias: `${s.id}/${id}`, displayName: `${s.label} · ${id}`, dshName: `${id} · 本地免费`, rate: 0, group: s.id, groupLabel: `${s.label}(本地)` })),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
70
86
|
return out;
|
|
71
87
|
}
|
|
72
88
|
/** 渲染成引擎 v8 的 api-keys.openai-compatibility 段 */
|
package/dist/engine/install.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 只信任仓库里 engine-manifest.json 的 sha256;校验不过直接失败。
|
|
4
4
|
// 版本目录 engine/versions/<v>/,current 软链指向在用版本,升级与回滚只切软链。
|
|
5
|
+
// Windows:发布包是 zip、程序叫 cli-proxy-api.exe;建软链要管理员,所以 current 用目录联接(junction),不需要权限。
|
|
5
6
|
import { createWriteStream } from 'node:fs';
|
|
6
|
-
import { chmod, mkdir, readFile, readlink, rename, rm, symlink } from 'node:fs/promises';
|
|
7
|
-
import { join } from 'node:path';
|
|
7
|
+
import { chmod, mkdir, readFile, readlink, rename, rm, symlink, unlink } from 'node:fs/promises';
|
|
8
|
+
import { join, resolve } from 'node:path';
|
|
8
9
|
import { Readable } from 'node:stream';
|
|
9
10
|
import { pipeline } from 'node:stream/promises';
|
|
10
11
|
import { DshModelError } from '../errors.js';
|
|
@@ -12,14 +13,19 @@ import { L } from '../i18n.js';
|
|
|
12
13
|
import { run } from '../util/exec.js';
|
|
13
14
|
import { ensureDir, exists, sha256 } from '../util/fs.js';
|
|
14
15
|
import { PKG_ROOT } from '../util/pkg.js';
|
|
15
|
-
export const ENGINE_BIN = 'cli-proxy-api';
|
|
16
|
+
export const ENGINE_BIN = process.platform === 'win32' ? 'cli-proxy-api.exe' : 'cli-proxy-api';
|
|
16
17
|
export async function loadManifest() {
|
|
17
18
|
return JSON.parse(await readFile(join(PKG_ROOT, 'engine-manifest.json'), 'utf8'));
|
|
18
19
|
}
|
|
19
|
-
/** darwin-aarch64 / linux-amd64 …(与上游发布包命名一致) */
|
|
20
|
+
/** darwin-aarch64 / linux-amd64 / windows-amd64 …(与上游发布包命名一致) */
|
|
20
21
|
export function platformTarget(platform = process.platform, arch = process.arch) {
|
|
21
22
|
const a = arch === 'arm64' ? 'aarch64' : arch === 'x64' ? 'amd64' : arch;
|
|
22
|
-
return `${platform}-${a}`;
|
|
23
|
+
return `${platform === 'win32' ? 'windows' : platform}-${a}`;
|
|
24
|
+
}
|
|
25
|
+
/** 上游发布包的文件名:Windows 是 zip,其余 tar.gz */
|
|
26
|
+
export function assetFileName(version, target) {
|
|
27
|
+
const [os, arch] = target.split('-');
|
|
28
|
+
return `CLIProxyAPI_${version}_${os}_${arch}.${os === 'windows' ? 'zip' : 'tar.gz'}`;
|
|
23
29
|
}
|
|
24
30
|
export function versionBinary(ctx, version) {
|
|
25
31
|
return join(ctx.paths.versionsDir, version, ENGINE_BIN);
|
|
@@ -29,8 +35,9 @@ export function currentBinary(ctx) {
|
|
|
29
35
|
}
|
|
30
36
|
export async function currentVersion(ctx) {
|
|
31
37
|
try {
|
|
38
|
+
// 软链是相对路径 versions/<v>;Windows 的目录联接读出来是绝对路径、反斜杠
|
|
32
39
|
const target = await readlink(ctx.paths.current);
|
|
33
|
-
return target.split(
|
|
40
|
+
return target.split(/[\\/]/).filter(Boolean).pop() ?? null;
|
|
34
41
|
}
|
|
35
42
|
catch {
|
|
36
43
|
return null;
|
|
@@ -38,9 +45,8 @@ export async function currentVersion(ctx) {
|
|
|
38
45
|
}
|
|
39
46
|
/** 从上游发布的 checksums.txt 取某版本的资产(engine upgrade <ver> 指定了清单外版本时用) */
|
|
40
47
|
export async function fetchUpstreamAsset(manifest, version, target) {
|
|
41
|
-
const [os, arch] = target.split('-');
|
|
42
48
|
const base = `https://github.com/${manifest.repo}/releases/download/v${version}`;
|
|
43
|
-
const file =
|
|
49
|
+
const file = assetFileName(version, target);
|
|
44
50
|
const res = await fetch(`${base}/checksums.txt`);
|
|
45
51
|
if (!res.ok)
|
|
46
52
|
throw new DshModelError('engine_version_not_found', L(`找不到引擎版本 v${version}(HTTP ${res.status})`, `Engine version v${version} not found (HTTP ${res.status})`));
|
|
@@ -85,12 +91,13 @@ export async function installVersion(ctx, version, asset) {
|
|
|
85
91
|
}
|
|
86
92
|
await rm(partial, { recursive: true, force: true });
|
|
87
93
|
await mkdir(partial, { recursive: true });
|
|
88
|
-
const tar = await
|
|
94
|
+
const tar = await extract(archive, partial);
|
|
89
95
|
if (tar.code !== 0)
|
|
90
96
|
throw new DshModelError('engine_extract_failed', L(`解压引擎失败:${tar.stderr.trim()}`, `Failed to extract engine: ${tar.stderr.trim()}`));
|
|
91
97
|
if (!(await exists(join(partial, ENGINE_BIN))))
|
|
92
98
|
throw new DshModelError('engine_extract_failed', L(`压缩包里没有 ${ENGINE_BIN}`, `${ENGINE_BIN} not found in archive`));
|
|
93
|
-
|
|
99
|
+
if (process.platform !== 'win32')
|
|
100
|
+
await chmod(join(partial, ENGINE_BIN), 0o755);
|
|
94
101
|
await rename(partial, join(ctx.paths.versionsDir, version));
|
|
95
102
|
if (ctx.owner)
|
|
96
103
|
await run('chown', ['-R', `${ctx.owner.uid}:${ctx.owner.gid}`, ctx.paths.engineDir]);
|
|
@@ -101,8 +108,26 @@ export async function installVersion(ctx, version, asset) {
|
|
|
101
108
|
await rm(partial, { recursive: true, force: true });
|
|
102
109
|
}
|
|
103
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* 解压。tar.gz 用系统 tar;Windows 的 zip 也用系统自带的 tar(Windows 10 1803 起有 System32\\tar.exe,bsdtar,认 zip),
|
|
113
|
+
* 写全路径,免得 PATH 里先找到 Git Bash 的 GNU tar(不认 zip)。
|
|
114
|
+
*/
|
|
115
|
+
async function extract(archive, dest) {
|
|
116
|
+
if (archive.endsWith('.zip')) {
|
|
117
|
+
const tar = process.platform === 'win32' ? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe') : 'tar';
|
|
118
|
+
return run(tar, ['-xf', archive, '-C', dest]);
|
|
119
|
+
}
|
|
120
|
+
return run('tar', ['-xzf', archive, '-C', dest]);
|
|
121
|
+
}
|
|
104
122
|
/** 原子切换 current 软链:先建临时软链再 rename 覆盖 */
|
|
105
123
|
export async function activateVersion(ctx, version) {
|
|
124
|
+
if (process.platform === 'win32') {
|
|
125
|
+
// 目录联接要绝对路径;rename 不能覆盖已有的目录,所以先删旧的再建(瞬间切换,不是原子的)
|
|
126
|
+
// unlink 只删联接本身,不会动到它指向的版本目录
|
|
127
|
+
await unlink(ctx.paths.current).catch(() => { });
|
|
128
|
+
await symlink(resolve(ctx.paths.versionsDir, version), ctx.paths.current, 'junction');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
106
131
|
const tmp = `${ctx.paths.current}.tmp`;
|
|
107
132
|
await rm(tmp, { force: true });
|
|
108
133
|
await symlink(join('versions', version), tmp);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// integrations/local.ts — 本机模型服务(Ollama、LM Studio)作为来源(设计 §14.10)
|
|
2
|
+
//
|
|
3
|
+
// 两者都自带 OpenAI 兼容接口(/v1/models、/v1/chat/completions),本机运行、免费、不用登录。
|
|
4
|
+
// 检测到在运行就当作"已接入",模型清单直接问它;引擎把它们作为 openai-compatibility 上游,前缀 ollama/ 与 lmstudio/。
|
|
5
|
+
// 思路来自 dsh-plugin-cli-hub 的 ollama adapter(那边是调命令行,这里直接走它的 HTTP 接口)。
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { readJson, writeJson } from '../util/fs.js';
|
|
8
|
+
export const LOCAL_SERVERS = [
|
|
9
|
+
{ id: 'ollama', label: 'Ollama', base: `${(process.env.OLLAMA_HOST ? normalizeHost(process.env.OLLAMA_HOST) : 'http://127.0.0.1:11434')}/v1` },
|
|
10
|
+
{ id: 'lmstudio', label: 'LM Studio', base: 'http://127.0.0.1:1234/v1' },
|
|
11
|
+
];
|
|
12
|
+
/** OLLAMA_HOST 可能写成 0.0.0.0:11434 / localhost / http://host:port */
|
|
13
|
+
function normalizeHost(h) {
|
|
14
|
+
let s = h.trim().replace(/\/+$/, '');
|
|
15
|
+
if (!/^https?:\/\//.test(s))
|
|
16
|
+
s = `http://${s}`;
|
|
17
|
+
s = s.replace('://0.0.0.0', '://127.0.0.1');
|
|
18
|
+
return /:\d+$/.test(s) ? s : `${s}:11434`;
|
|
19
|
+
}
|
|
20
|
+
export const localCatalogPath = (home) => join(home, 'local-models.json');
|
|
21
|
+
export async function loadLocalCatalog(home) {
|
|
22
|
+
return (await readJson(localCatalogPath(home))) ?? {};
|
|
23
|
+
}
|
|
24
|
+
async function probe(s) {
|
|
25
|
+
const updatedAt = new Date().toISOString();
|
|
26
|
+
// 测试环境不看本机真装了什么(与不发现桌面 App 同一个开关)
|
|
27
|
+
if (process.env.DSH_MODEL_NO_APP_DISCOVERY)
|
|
28
|
+
return { reachable: false, models: [], updatedAt };
|
|
29
|
+
try {
|
|
30
|
+
const r = await fetch(`${s.base}/models`, { signal: AbortSignal.timeout(1500) });
|
|
31
|
+
if (!r.ok)
|
|
32
|
+
return { reachable: false, models: [], updatedAt };
|
|
33
|
+
const data = (await r.json()).data ?? [];
|
|
34
|
+
return { reachable: true, models: data.map((m) => String(m.id ?? '')).filter(Boolean).sort(), updatedAt };
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return { reachable: false, models: [], updatedAt };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** 问一遍本机的模型服务;在不在运行、模型有没有变,变了返回 true */
|
|
41
|
+
export async function refreshLocalCatalog(home, owner) {
|
|
42
|
+
const before = await loadLocalCatalog(home);
|
|
43
|
+
const next = {};
|
|
44
|
+
for (const s of LOCAL_SERVERS)
|
|
45
|
+
next[s.id] = await probe(s);
|
|
46
|
+
const key = (c) => JSON.stringify(LOCAL_SERVERS.map((s) => [c[s.id]?.reachable ?? false, c[s.id]?.models ?? []]));
|
|
47
|
+
const changed = key(before) !== key(next);
|
|
48
|
+
await writeJson(localCatalogPath(home), next, { owner });
|
|
49
|
+
return changed;
|
|
50
|
+
}
|
package/dist/ops.js
CHANGED
|
@@ -140,7 +140,7 @@ export async function dshCandidates(ctx, all, ids) {
|
|
|
140
140
|
/** 引擎里带前缀(opencode/ workbuddy/ workbuddy-ai/)的模型是否正好等于期望:不能缺,也不能多(关掉的来源要消失) */
|
|
141
141
|
function aliasesMatch(models, expected) {
|
|
142
142
|
const want = new Set(expected);
|
|
143
|
-
const prefixes = ['opencode/', 'workbuddy/', 'workbuddy-ai/'];
|
|
143
|
+
const prefixes = ['opencode/', 'workbuddy/', 'workbuddy-ai/', 'ollama/', 'lmstudio/'];
|
|
144
144
|
const have = models.map((m) => m.id).filter((id) => prefixes.some((p) => id.startsWith(p)));
|
|
145
145
|
return have.length === want.size && have.every((id) => want.has(id));
|
|
146
146
|
}
|
package/dist/service/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { currentBinary } from '../engine/install.js';
|
|
|
5
5
|
import { PKG_ROOT } from '../util/pkg.js';
|
|
6
6
|
import { launchd } from './launchd.js';
|
|
7
7
|
import { systemd } from './systemd.js';
|
|
8
|
+
import { windowsService } from './windows.js';
|
|
8
9
|
export const SERVICE_LABEL = 'com.dsh-model.engine';
|
|
9
10
|
export const UNIT_NAME = 'dsh-model-engine.service';
|
|
10
11
|
export const BRIDGE_LABEL = 'com.dsh-model.bridge';
|
|
@@ -12,6 +13,8 @@ export const BRIDGE_UNIT = 'dsh-model-bridge.service';
|
|
|
12
13
|
export function serviceKind(ctx) {
|
|
13
14
|
if (ctx.mode === 'vps')
|
|
14
15
|
return 'systemd-system';
|
|
16
|
+
if (ctx.platform === 'win32')
|
|
17
|
+
return 'windows';
|
|
15
18
|
return ctx.platform === 'darwin' ? 'launchd' : 'systemd-user';
|
|
16
19
|
}
|
|
17
20
|
/** bridge 是 Node 进程:用当前 node 的绝对路径,加上本包的入口 */
|
|
@@ -40,6 +43,8 @@ export function serviceFor(ctx, name = 'engine', proxy) {
|
|
|
40
43
|
home: ctx.paths.home,
|
|
41
44
|
...(name === 'bridge' ? { env: bridgeEnv(ctx, proxy) } : {}),
|
|
42
45
|
};
|
|
46
|
+
if (kind === 'windows')
|
|
47
|
+
return windowsService({ ...common, label: name === 'engine' ? 'dsh-model-engine' : 'dsh-model-bridge', file: join(ctx.paths.home, 'services', `${name === 'engine' ? 'dsh-model-engine' : 'dsh-model-bridge'}.json`) });
|
|
43
48
|
if (kind === 'launchd')
|
|
44
49
|
return launchd({ ...common, label, file: join(homedir(), 'Library/LaunchAgents', `${label}.plist`) });
|
|
45
50
|
if (kind === 'systemd-user')
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// service/windows.ts — Windows:开机登录后自动运行、不弹窗口、崩溃自动重启,都不需要管理员(设计 §16)
|
|
2
|
+
//
|
|
3
|
+
// - 自启:写进当前用户的 Run 键(HKCU\Software\Microsoft\Windows\CurrentVersion\Run);
|
|
4
|
+
// - 不弹黑窗口:引擎和 node 都是控制台程序,直接运行会弹窗。用 wscript 跑一个隐藏窗口的小脚本(.vbs,style 0)启动;
|
|
5
|
+
// - 崩溃重启:小脚本启动的是 `dsh-model service supervise <spec>`,它用 windowsHide 拉起真正的程序,退出了就隔几秒再拉;
|
|
6
|
+
// - 停止:supervise 把自己的 pid 写进 <label>.pid,taskkill /T /F 连同子进程一起结束。
|
|
7
|
+
// wscript 启动完就退出,所以 supervise 不挂在调用者的进程树下(taskkill /T 调用者时不会被连带杀掉)。
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { DshModelError } from '../errors.js';
|
|
12
|
+
import { L } from '../i18n.js';
|
|
13
|
+
import { run } from '../util/exec.js';
|
|
14
|
+
import { atomicWrite, exists } from '../util/fs.js';
|
|
15
|
+
import { PKG_ROOT } from '../util/pkg.js';
|
|
16
|
+
export const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
|
|
17
|
+
const paths = (spec) => {
|
|
18
|
+
const base = spec.file.replace(/\.json$/i, '');
|
|
19
|
+
return { json: spec.file, vbs: `${base}.vbs`, pid: `${base}.pid` };
|
|
20
|
+
};
|
|
21
|
+
/** VBScript 字符串里的双引号要写两遍 */
|
|
22
|
+
const vbq = (s) => `"${s.replace(/"/g, '""')}"`;
|
|
23
|
+
/** 隐藏窗口启动 supervise 的小脚本。存成 UTF-16LE(带 BOM),路径里有中文也不乱码 */
|
|
24
|
+
export function renderVbs(spec, node = process.execPath, electron = Boolean(process.versions.electron)) {
|
|
25
|
+
const cmd = [node, join(PKG_ROOT, 'bin', 'dsh-model.js'), 'service', 'supervise', spec.file].map((a) => `""${a.replace(/"/g, '""""')}""`).join(' ');
|
|
26
|
+
return [
|
|
27
|
+
"' Generated by dsh-model. 由 dsh-model 生成:隐藏窗口启动后台服务,卸载请执行 dsh-model uninstall",
|
|
28
|
+
'Set sh = CreateObject("WScript.Shell")',
|
|
29
|
+
// 从 dsh 桌面版一键安装时 node 就是 DeepSeek Harness.exe:让它当 Node 跑
|
|
30
|
+
...(electron ? ['sh.Environment("Process")("ELECTRON_RUN_AS_NODE") = "1"'] : []),
|
|
31
|
+
`sh.Run "${cmd}", 0, False`,
|
|
32
|
+
'',
|
|
33
|
+
].join('\r\n');
|
|
34
|
+
}
|
|
35
|
+
export function renderSupervise(spec) {
|
|
36
|
+
return {
|
|
37
|
+
label: spec.label,
|
|
38
|
+
program: spec.program,
|
|
39
|
+
cwd: spec.workingDirectory,
|
|
40
|
+
env: { ...spec.env, ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}) },
|
|
41
|
+
stdout: spec.stdoutPath,
|
|
42
|
+
pidFile: paths(spec).pid,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function alive(pid) {
|
|
46
|
+
try {
|
|
47
|
+
process.kill(pid, 0);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function readPid(file) {
|
|
55
|
+
const n = Number((await readFile(file, 'utf8').catch(() => '')).trim());
|
|
56
|
+
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
57
|
+
}
|
|
58
|
+
export function windowsService(spec) {
|
|
59
|
+
const p = paths(spec);
|
|
60
|
+
const runValue = `wscript.exe //B //Nologo "${p.vbs}"`;
|
|
61
|
+
const running = async () => {
|
|
62
|
+
const pid = await readPid(p.pid);
|
|
63
|
+
return pid && alive(pid) ? pid : undefined;
|
|
64
|
+
};
|
|
65
|
+
const start = async () => {
|
|
66
|
+
if (await running())
|
|
67
|
+
return;
|
|
68
|
+
const child = spawn('wscript.exe', ['//B', '//Nologo', p.vbs], { detached: true, stdio: 'ignore', windowsHide: true });
|
|
69
|
+
child.unref();
|
|
70
|
+
const deadline = Date.now() + 10_000;
|
|
71
|
+
while (Date.now() < deadline && !(await running()))
|
|
72
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
73
|
+
if (!(await running()))
|
|
74
|
+
throw new DshModelError('service_failed', L(`后台服务 ${spec.label} 没有启动(看日志:${spec.stdoutPath})`, `Background service ${spec.label} did not start (log: ${spec.stdoutPath})`), L('可能被杀毒软件拦截:把 dsh-model 的目录加入信任后执行 dsh-model repair', 'An antivirus may have blocked it: trust the dsh-model folder, then run dsh-model repair'));
|
|
75
|
+
};
|
|
76
|
+
const stop = async () => {
|
|
77
|
+
const pid = await readPid(p.pid);
|
|
78
|
+
if (pid && alive(pid))
|
|
79
|
+
await run('taskkill', ['/PID', String(pid), '/T', '/F']);
|
|
80
|
+
await rm(p.pid, { force: true });
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
kind: 'windows',
|
|
84
|
+
spec,
|
|
85
|
+
render: () => `${JSON.stringify(renderSupervise(spec), null, 2)}\n\n${renderVbs(spec)}`,
|
|
86
|
+
async install() {
|
|
87
|
+
await atomicWrite(p.json, JSON.stringify(renderSupervise(spec), null, 2), { mode: 0o600 });
|
|
88
|
+
await writeFile(p.vbs, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(renderVbs(spec), 'utf16le')]));
|
|
89
|
+
const r = await run('reg', ['add', RUN_KEY, '/v', spec.label, '/t', 'REG_SZ', '/d', runValue, '/f']);
|
|
90
|
+
if (r.code !== 0)
|
|
91
|
+
throw new DshModelError('service_failed', L(`写开机自启失败:${r.stderr.trim()}`, `Failed to register autostart: ${r.stderr.trim()}`));
|
|
92
|
+
await stop();
|
|
93
|
+
await start();
|
|
94
|
+
},
|
|
95
|
+
start,
|
|
96
|
+
stop,
|
|
97
|
+
async restart() {
|
|
98
|
+
await stop();
|
|
99
|
+
await start();
|
|
100
|
+
},
|
|
101
|
+
async status() {
|
|
102
|
+
const installed = await exists(p.json);
|
|
103
|
+
const pid = await running();
|
|
104
|
+
return { installed, running: Boolean(pid), ...(pid ? { pid } : {}), detail: pid ? 'running' : installed ? 'stopped' : 'not installed' };
|
|
105
|
+
},
|
|
106
|
+
async uninstall() {
|
|
107
|
+
await stop();
|
|
108
|
+
await run('reg', ['delete', RUN_KEY, '/v', spec.label, '/f']);
|
|
109
|
+
await rm(p.json, { force: true });
|
|
110
|
+
await rm(p.vbs, { force: true });
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* `dsh-model service supervise <spec.json>`:由隐藏窗口脚本启动,常驻。
|
|
116
|
+
* 拉起程序(不弹窗,输出追加到日志);程序退出就隔几秒重拉(太频繁就拉长间隔)。被 taskkill /T 时连同子进程一起结束。
|
|
117
|
+
*/
|
|
118
|
+
export async function supervise(specFile) {
|
|
119
|
+
const s = JSON.parse(await readFile(specFile, 'utf8'));
|
|
120
|
+
const { closeSync, openSync } = await import('node:fs');
|
|
121
|
+
await atomicWrite(s.pidFile, String(process.pid), { mode: 0o600 });
|
|
122
|
+
let backoff = 2_000;
|
|
123
|
+
for (;;) {
|
|
124
|
+
const startedAt = Date.now();
|
|
125
|
+
const out = openSync(s.stdout, 'a');
|
|
126
|
+
const code = await new Promise((resolve) => {
|
|
127
|
+
const child = spawn(s.program[0], s.program.slice(1), { cwd: s.cwd, env: { ...process.env, ...s.env }, stdio: ['ignore', out, out], windowsHide: true });
|
|
128
|
+
child.on('error', () => resolve(-1));
|
|
129
|
+
child.on('exit', (c) => resolve(c ?? -1));
|
|
130
|
+
});
|
|
131
|
+
closeSync(out);
|
|
132
|
+
await writeFile(s.stdout, `[${new Date().toISOString()}] dsh-model supervise: ${s.label} exited (${code}); restarting\n`, { flag: 'a' }).catch(() => { });
|
|
133
|
+
// 跑了一分钟以上才退出的算正常崩溃,间隔恢复到 2 秒;一启动就退出的逐步拉长到 60 秒,免得空转
|
|
134
|
+
backoff = Date.now() - startedAt > 60_000 ? 2_000 : Math.min(backoff * 2, 60_000);
|
|
135
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
136
|
+
if (!(await exists(dirname(s.pidFile))))
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
}
|
package/dist/sources.js
CHANGED
|
@@ -13,6 +13,8 @@ export const SOURCES = [
|
|
|
13
13
|
{ id: 'meta', label: 'Muse (Meta)', kind: 'engine', login: 'device', engineProvider: 'meta', filePrefix: 'meta-', pricing: { tier: 'paid', zh: '需要 Muse Code 套餐($5 / 月起),meta.ai 的免费版接不进来', en: 'Needs a Muse Code plan (from $5/mo); the free meta.ai tier cannot be used here' } },
|
|
14
14
|
{ id: 'antigravity', label: 'Antigravity', kind: 'engine', login: 'paste', engineProvider: 'antigravity', filePrefix: 'antigravity-', riskAck: true, risky: true, pricing: { tier: 'limited', zh: 'Google 账号有免费额度,额度有限', en: 'Free quota with a Google account, limited' } },
|
|
15
15
|
{ id: 'devin', label: 'Devin', kind: 'engine', login: 'paste', engineProvider: 'devin', filePrefix: 'devin-', subscribeUrl: 'https://devin.ai/pricing', probeModel: 'devin/swe-1-7-lightning', pricing: { tier: 'paid', zh: '需要 Devin Pro($20 / 月);免费档实测调用被拒(insufficient_quota)', en: 'Needs Devin Pro ($20/mo); the free tier is refused in testing (insufficient_quota)' } },
|
|
16
|
+
{ id: 'ollama', label: 'Ollama(本地)', kind: 'local', login: 'none', subscribeUrl: 'https://ollama.com/download', pricing: { tier: 'free', zh: '本机运行,免费;装好并启动 Ollama、拉一个模型后自动出现', en: 'Runs on this machine, free; appears once Ollama is installed, running and has a model' } },
|
|
17
|
+
{ id: 'lmstudio', label: 'LM Studio(本地)', kind: 'local', login: 'none', subscribeUrl: 'https://lmstudio.ai', pricing: { tier: 'free', zh: '本机运行,免费;在 LM Studio 里打开本地服务(Developer → Start Server)后自动出现', en: 'Runs on this machine, free; appears once the LM Studio local server is started (Developer → Start Server)' } },
|
|
16
18
|
{ id: 'opencode', label: 'OpenCode Zen', kind: 'opencode', login: 'key', subscribeUrl: 'https://opencode.ai/zen', pricing: { tier: 'paid', zh: '要先充值拿 key 按量计费;它的免费模型只能在 OpenCode 软件里用', en: 'Top up for a pay-as-you-go key; its free models only work inside the OpenCode app' } },
|
|
17
19
|
];
|
|
18
20
|
const ALIASES = { grok: 'xai', muse: 'meta', chatgpt: 'codex', openai: 'codex', 'workbuddy-cn': 'workbuddy', wb: 'workbuddy', 'wb-ai': 'workbuddy-ai' };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// util/detach.ts — 起一个和自己脱钩的后台进程(更新、卸载这类会重启或移除守护进程本身的任务)
|
|
2
|
+
//
|
|
3
|
+
// Windows 上 taskkill /T 按"父进程"找整棵进程树:直接 spawn 出来的子进程,在守护进程被重启时会被连带杀掉。
|
|
4
|
+
// 所以 Windows 上先起一个只活几毫秒的中转进程,由它再拉起真正的任务并立刻退出;任务的父进程不在了,就不在任何人的树下。
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { closeSync, openSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
const RELAY = `
|
|
9
|
+
const { spawn } = require('node:child_process');
|
|
10
|
+
const { openSync } = require('node:fs');
|
|
11
|
+
const job = JSON.parse(process.argv[1]);
|
|
12
|
+
const out = openSync(job.log, 'w');
|
|
13
|
+
spawn(job.cmd, job.args, { detached: true, windowsHide: true, cwd: job.cwd, stdio: ['ignore', out, out] }).unref();
|
|
14
|
+
`;
|
|
15
|
+
export function spawnDetached(cmd, args, options) {
|
|
16
|
+
if (process.platform === 'win32') {
|
|
17
|
+
// 中转进程用同一个 node(dsh 桌面版里是 Electron,env 里已带 ELECTRON_RUN_AS_NODE)
|
|
18
|
+
// 工作目录放在用户主目录:卸载要删 ~/.dsh-model,Windows 上进程的工作目录在里面时删不掉
|
|
19
|
+
const relay = spawn(process.execPath, ['-e', RELAY, JSON.stringify({ cmd, args, log: options.log, cwd: homedir() })], { detached: true, windowsHide: true, cwd: homedir(), stdio: 'ignore', env: options.env });
|
|
20
|
+
relay.unref();
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
const out = openSync(options.log, 'w', 0o600);
|
|
24
|
+
const child = spawn(cmd, args, { detached: true, cwd: homedir(), stdio: ['ignore', out, out], env: options.env });
|
|
25
|
+
child.unref();
|
|
26
|
+
closeSync(out);
|
|
27
|
+
return child.pid;
|
|
28
|
+
}
|
package/dist/util/exec.js
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
// util/exec.ts — 子进程
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
+
const WIN = process.platform === 'win32';
|
|
4
|
+
/** cmd.exe 的参数加引号:整段包在双引号里,里面的双引号写两遍 */
|
|
5
|
+
export function quoteWinArg(a) {
|
|
6
|
+
return /^[\w@%+=:,./\\-]+$/.test(a) ? a : `"${a.replace(/"/g, '""')}"`;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* 统一处理 Windows 的两个坑:
|
|
10
|
+
* - .cmd / .bat(npm.cmd、dsh.cmd)不能直接 spawn(Node 18.20 / 20.12 起直接报 EINVAL),要经 cmd.exe;
|
|
11
|
+
* - 子进程默认会弹一个黑色控制台窗口:一律 windowsHide。
|
|
12
|
+
*/
|
|
13
|
+
export function spawnArgs(cmd, args, options) {
|
|
14
|
+
if (WIN && /\.(cmd|bat)$/i.test(cmd)) {
|
|
15
|
+
return [[cmd, ...args].map(quoteWinArg).join(' '), [], { ...options, shell: true, windowsHide: true }];
|
|
16
|
+
}
|
|
17
|
+
return [cmd, args, WIN ? { ...options, windowsHide: true } : options];
|
|
18
|
+
}
|
|
3
19
|
/** 跑一个命令并收集输出;不抛错,调用方看 code */
|
|
4
20
|
export function run(cmd, args, options = {}) {
|
|
5
21
|
return new Promise((resolve) => {
|
|
6
22
|
// 不需要输入时 stdin 直接 ignore;需要时子进程可能不读就退出(或命令不存在),写入会 EPIPE,必须吞掉(服务器上实测会崩)
|
|
7
23
|
const hasInput = options.input !== undefined;
|
|
8
|
-
const child = spawn(cmd, args, { cwd: options.cwd, env: options.env ?? process.env, stdio: [hasInput ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
|
|
24
|
+
const child = spawn(...spawnArgs(cmd, args, { cwd: options.cwd, env: options.env ?? process.env, stdio: [hasInput ? 'pipe' : 'ignore', 'pipe', 'pipe'] }));
|
|
9
25
|
let stdout = '';
|
|
10
26
|
let stderr = '';
|
|
11
27
|
const timer = options.timeoutMs ? setTimeout(() => child.kill('SIGKILL'), options.timeoutMs) : null;
|
|
@@ -30,13 +46,17 @@ export function run(cmd, args, options = {}) {
|
|
|
30
46
|
/** 交互式运行:stdio 直接交给用户(登录流程要用户看 URL、粘贴回调) */
|
|
31
47
|
export function runInherit(cmd, args, options = {}) {
|
|
32
48
|
return new Promise((resolve) => {
|
|
33
|
-
const child = spawn(cmd, args, { cwd: options.cwd, env: options.env ?? process.env, stdio: 'inherit' });
|
|
49
|
+
const child = spawn(...spawnArgs(cmd, args, { cwd: options.cwd, env: options.env ?? process.env, stdio: 'inherit' }));
|
|
34
50
|
child.on('error', () => resolve(127));
|
|
35
51
|
child.on('close', (code) => resolve(code ?? 1));
|
|
36
52
|
});
|
|
37
53
|
}
|
|
38
54
|
/** 在 PATH 里找命令 */
|
|
39
55
|
export async function which(cmd) {
|
|
56
|
+
if (WIN) {
|
|
57
|
+
const r = await run('where', [cmd]);
|
|
58
|
+
return r.code === 0 ? r.stdout.split(/\r?\n/)[0]?.trim() || null : null;
|
|
59
|
+
}
|
|
40
60
|
const r = await run('sh', ['-c', `command -v ${cmd}`]);
|
|
41
61
|
return r.code === 0 ? r.stdout.trim() || null : null;
|
|
42
62
|
}
|
package/dist/util/fs.js
CHANGED
|
@@ -14,13 +14,30 @@ export async function atomicWrite(file, data, options = {}) {
|
|
|
14
14
|
await chmod(tmp, mode); // umask 可能削掉 writeFile 的 mode
|
|
15
15
|
if (options.owner)
|
|
16
16
|
await chown(tmp, options.owner.uid, options.owner.gid);
|
|
17
|
-
await
|
|
17
|
+
await renameRetry(tmp, file);
|
|
18
18
|
}
|
|
19
19
|
catch (error) {
|
|
20
20
|
await rm(tmp, { force: true });
|
|
21
21
|
throw error;
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Windows 上目标文件正被别的进程打开(杀毒扫描、引擎在读配置)时,rename 会短暂报 EACCES / EBUSY / EPERM:
|
|
26
|
+
* 退避重试一会儿(dsh 自己的 atomic-write 也这么做)。其他平台直接 rename。
|
|
27
|
+
*/
|
|
28
|
+
async function renameRetry(from, to) {
|
|
29
|
+
for (let i = 0;; i++) {
|
|
30
|
+
try {
|
|
31
|
+
return await rename(from, to);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
const code = error.code;
|
|
35
|
+
if (process.platform !== 'win32' || i >= 8 || !['EACCES', 'EBUSY', 'EPERM'].includes(code ?? ''))
|
|
36
|
+
throw error;
|
|
37
|
+
await new Promise((r) => setTimeout(r, 25 * 2 ** i));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
24
41
|
/**
|
|
25
42
|
* 原地覆盖写(保留 inode):给"被别的进程用 fsnotify 盯着的文件"用。
|
|
26
43
|
* atomicWrite 的 rename 会换掉 inode,Linux 上盯着旧 inode 的监听就再也收不到变化(引擎热重载实测失效)。
|
package/dist/util/proxy.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// util/proxy.ts — 出站代理:引擎作为后台服务运行时拿不到终端里的 HTTPS_PROXY,
|
|
2
2
|
// 所以 setup 时把代理写进 engine.yaml 的 requests.proxy-url。
|
|
3
|
-
// 检测顺序:HTTPS_PROXY / ALL_PROXY 环境变量 → macOS
|
|
3
|
+
// 检测顺序:HTTPS_PROXY / ALL_PROXY 环境变量 → 系统代理(macOS:scutil --proxy;Windows:注册表 Internet Settings,Clash 等"系统代理"写在这里)
|
|
4
4
|
import { connect } from 'node:net';
|
|
5
5
|
import { DshModelError } from '../errors.js';
|
|
6
6
|
import { L } from '../i18n.js';
|
|
@@ -41,8 +41,34 @@ export async function detectProxy(env = process.env, platform = process.platform
|
|
|
41
41
|
return { url: `socks5://${get('SOCKSProxy')}:${get('SOCKSPort')}`, source: 'macOS system proxy' };
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
if (platform === 'win32') {
|
|
45
|
+
const key = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings';
|
|
46
|
+
const [en, srv] = await Promise.all([run('reg', ['query', key, '/v', 'ProxyEnable']), run('reg', ['query', key, '/v', 'ProxyServer'])]);
|
|
47
|
+
const url = en.code === 0 && srv.code === 0 ? parseWindowsProxy(en.stdout, srv.stdout) : null;
|
|
48
|
+
if (url)
|
|
49
|
+
return { url, source: 'Windows system proxy' };
|
|
50
|
+
}
|
|
44
51
|
return null;
|
|
45
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* 解析 reg query 的输出。ProxyServer 两种写法:
|
|
55
|
+
* "127.0.0.1:7890"(所有协议共用)或 "http=127.0.0.1:7890;https=127.0.0.1:7890;socks=127.0.0.1:7891"
|
|
56
|
+
*/
|
|
57
|
+
export function parseWindowsProxy(enableOut, serverOut) {
|
|
58
|
+
if (!/ProxyEnable\s+REG_DWORD\s+0x0*1\b/i.test(enableOut))
|
|
59
|
+
return null;
|
|
60
|
+
const raw = /ProxyServer\s+REG_SZ\s+(\S+)/i.exec(serverOut)?.[1];
|
|
61
|
+
if (!raw)
|
|
62
|
+
return null;
|
|
63
|
+
if (!raw.includes('='))
|
|
64
|
+
return /^[a-z]+:\/\//i.test(raw) ? raw : `http://${raw}`;
|
|
65
|
+
const parts = new Map(raw.split(';').map((p) => p.split('=')).filter(([k, v]) => k && v).map(([k, v]) => [k.toLowerCase(), v]));
|
|
66
|
+
const pick = parts.get('https') ?? parts.get('http');
|
|
67
|
+
if (pick)
|
|
68
|
+
return /^[a-z]+:\/\//i.test(pick) ? pick : `http://${pick}`;
|
|
69
|
+
const socks = parts.get('socks');
|
|
70
|
+
return socks ? `socks5://${socks.replace(/^[a-z0-9]+:\/\//i, '')}` : null;
|
|
71
|
+
}
|
|
46
72
|
/** 代理端口能不能连上(doctor 用) */
|
|
47
73
|
export function proxyReachable(url, timeoutMs = 2000) {
|
|
48
74
|
return new Promise((resolve) => {
|
package/engine-manifest.json
CHANGED
|
@@ -23,6 +23,16 @@
|
|
|
23
23
|
"file": "CLIProxyAPI_8.0.13_linux_amd64.tar.gz",
|
|
24
24
|
"url": "https://github.com/router-for-me/CLIProxyAPI/releases/download/v8.0.13/CLIProxyAPI_8.0.13_linux_amd64.tar.gz",
|
|
25
25
|
"sha256": "50ecffb47fdd81c8c5a9825a73a7a905ab66342337e274f39c4276b92d3533f3"
|
|
26
|
+
},
|
|
27
|
+
"windows-aarch64": {
|
|
28
|
+
"file": "CLIProxyAPI_8.0.13_windows_aarch64.zip",
|
|
29
|
+
"url": "https://github.com/router-for-me/CLIProxyAPI/releases/download/v8.0.13/CLIProxyAPI_8.0.13_windows_aarch64.zip",
|
|
30
|
+
"sha256": "ec1ea1cc76964d0a9d68fd0c989a9d9f579911f08fa902663259adb1e9e6df4c"
|
|
31
|
+
},
|
|
32
|
+
"windows-amd64": {
|
|
33
|
+
"file": "CLIProxyAPI_8.0.13_windows_amd64.zip",
|
|
34
|
+
"url": "https://github.com/router-for-me/CLIProxyAPI/releases/download/v8.0.13/CLIProxyAPI_8.0.13_windows_amd64.zip",
|
|
35
|
+
"sha256": "7064ec8150511d0ece145a492968b476aa5abae3e1c7ef385852631918086bdd"
|
|
26
36
|
}
|
|
27
37
|
}
|
|
28
38
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-model",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Many model sources behind one authenticated OpenAI-compatible endpoint for DeepSeek Harness (dsh) and your tools: WorkBuddy, OpenCode Zen and your CLI subscriptions (Codex, Kimi…), with a management page inside dsh. 多家模型汇成一个 OpenAI 兼容地址,dsh 和其他软件都能直接用。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh-plugin",
|
package/plugin/client.js
CHANGED
|
@@ -416,15 +416,16 @@ window.__ModuleLoader__.load({
|
|
|
416
416
|
h(PriceTag, { pricing: s.pricing }),
|
|
417
417
|
s.risky ? h('span', { style: { fontSize: 11, color: C.err, marginLeft: 6, border: `1px solid ${C.err}`, borderRadius: 4, padding: '0 4px' } }, L('高风险', 'High risk')) : null,
|
|
418
418
|
h('span', { style: { ...S.meta, marginLeft: 8 } },
|
|
419
|
-
s.loggedIn ? (s.account || '') : L('未登录', 'Signed out'),
|
|
419
|
+
s.loggedIn ? (s.account || '') : s.kind === 'local' ? L('未运行', 'Not running') : L('未登录', 'Signed out'),
|
|
420
420
|
s.models ? ` · ${L(`${s.models} 个模型`, `${s.models} models`)}` : '')),
|
|
421
421
|
s.loggedIn && s.enabled && s.models && !s.usage?.noAccess ? h('button', { type: 'button', style: S.btn, onClick: () => setPicking(picking === s.id ? '' : s.id) }, L('选模型', 'Models')) : null,
|
|
422
|
-
s.loggedIn && s.kind !== 'opencode' ? h('button', { type: 'button', style: S.btn, onClick: () => logout(s) }, L('退出登录', 'Sign out')) : null,
|
|
422
|
+
s.loggedIn && s.kind !== 'opencode' && s.kind !== 'local' ? h('button', { type: 'button', style: S.btn, onClick: () => logout(s) }, L('退出登录', 'Sign out')) : null,
|
|
423
423
|
s.kind === 'opencode' && s.loggedIn ? h('button', { type: 'button', style: S.btn, onClick: onKey }, L('换 key', 'Change key')) : null),
|
|
424
424
|
picking === s.id ? h(ModelPicker, { source: s, onClose: (saved) => { setPicking(''); if (saved) void reload() } }) : null,
|
|
425
425
|
s.enabled ? h(Usage, { usage: s.usage, subscribeUrl: s.subscribeUrl }) : null,
|
|
426
426
|
!s.loggedIn && s.pricing ? h('div', { style: { ...S.meta, paddingLeft: 46, marginTop: 4 } },
|
|
427
427
|
L(s.pricing.zh, s.pricing.en),
|
|
428
|
+
s.subscribeUrl && s.kind === 'local' ? h('a', { href: s.subscribeUrl, target: '_blank', rel: 'noopener noreferrer', style: { color: C.accent, marginLeft: 8 } }, L('去下载 ↗', 'Download ↗')) : null,
|
|
428
429
|
s.subscribeUrl && s.pricing.tier !== 'free' ? h('a', { href: s.subscribeUrl, target: '_blank', rel: 'noopener noreferrer', style: { color: C.accent, marginLeft: 8 } }, s.kind === 'opencode' ? L('去充值 ↗', 'Top up ↗') : L('看套餐 ↗', 'Plans ↗')) : null) : null,
|
|
429
430
|
!s.loggedIn && s.detail ? h('div', { style: { ...S.meta, paddingLeft: 46, marginTop: 4 } }, s.detail) : null))))
|
|
430
431
|
}
|
|
@@ -563,6 +564,7 @@ window.__ModuleLoader__.load({
|
|
|
563
564
|
const [confirming, setConfirming] = useState(false)
|
|
564
565
|
const [keepAuth, setKeepAuth] = useState(false)
|
|
565
566
|
const [err, setErr] = useState('')
|
|
567
|
+
const [starting, setStarting] = useState(false)
|
|
566
568
|
useEffect(() => {
|
|
567
569
|
let alive = true
|
|
568
570
|
// 服务比页面旧(没有 /self):也要给出办法,不能整块不显示
|
|
@@ -582,12 +584,18 @@ window.__ModuleLoader__.load({
|
|
|
582
584
|
}
|
|
583
585
|
const hasUpdate = info.latest && newer(info.latest, info.version)
|
|
584
586
|
const start = async (action) => {
|
|
587
|
+
if (starting) return
|
|
585
588
|
setErr('')
|
|
589
|
+
setStarting(true)
|
|
586
590
|
try {
|
|
587
591
|
await api('POST', `/self/${action}`, action === 'uninstall' ? { keepAuth } : {})
|
|
588
592
|
onJob({ action, from: info.version, target: info.latest })
|
|
589
593
|
} catch (e) {
|
|
590
|
-
|
|
594
|
+
// 连不上守护进程多半是它已经开始重启(任务已经在跑):照样进进度页,等它回来
|
|
595
|
+
if (e.code === 'daemon_unreachable') onJob({ action, from: info.version, target: info.latest })
|
|
596
|
+
else setErr(e.message)
|
|
597
|
+
} finally {
|
|
598
|
+
setStarting(false)
|
|
591
599
|
}
|
|
592
600
|
}
|
|
593
601
|
return h('div', { style: S.section },
|
|
@@ -596,7 +604,7 @@ window.__ModuleLoader__.load({
|
|
|
596
604
|
h('div', { style: S.line },
|
|
597
605
|
h('span', { style: { ...S.label, ...S.grow } }, `dsh-model ${info.version}`,
|
|
598
606
|
h('span', { style: { ...S.meta, marginLeft: 8 } }, info.latest ? (hasUpdate ? L(`有新版本 ${info.latest}`, `${info.latest} available`) : L('已是最新', 'Up to date')) : L('查不到最新版本', 'Latest version unknown'))),
|
|
599
|
-
info.canManage && hasUpdate ? h('button', { type: 'button', style: S.btnPrimary, onClick: () => void start('update') }, L(`更新到 ${info.latest}`, `Update to ${info.latest}`)) : null,
|
|
607
|
+
info.canManage && hasUpdate ? h('button', { type: 'button', style: S.btnPrimary, disabled: starting, onClick: () => void start('update') }, starting ? L('正在开始…', 'Starting…') : L(`更新到 ${info.latest}`, `Update to ${info.latest}`)) : null,
|
|
600
608
|
info.canManage ? h('button', { type: 'button', style: { ...S.btn, color: C.err }, onClick: () => setConfirming(true) }, L('卸载', 'Uninstall')) : null),
|
|
601
609
|
!info.canManage ? h('div', { style: { marginTop: 8 } },
|
|
602
610
|
h('div', { style: S.meta }, L('服务器上更新和卸载要 root 权限,请在服务器终端里执行:', 'On the server, update and uninstall need root; run in a server terminal:')),
|
|
@@ -608,7 +616,7 @@ window.__ModuleLoader__.load({
|
|
|
608
616
|
h('input', { type: 'checkbox', checked: keepAuth, onChange: (e) => setKeepAuth(e.target.checked) }),
|
|
609
617
|
L('先把各家登录凭据备份到主目录(以后重装可以拷回来)', 'Back up provider sign-ins to the home folder first (to reuse after reinstalling)')),
|
|
610
618
|
h('div', { style: { ...S.line, marginTop: 8 } },
|
|
611
|
-
h('button', { type: 'button', style: { ...S.btnPrimary, background: C.err }, onClick: () => void start('uninstall') }, L('确认卸载', 'Uninstall')),
|
|
619
|
+
h('button', { type: 'button', style: { ...S.btnPrimary, background: C.err }, disabled: starting, onClick: () => void start('uninstall') }, starting ? L('正在开始…', 'Starting…') : L('确认卸载', 'Uninstall')),
|
|
612
620
|
h('button', { type: 'button', style: S.btn, onClick: () => setConfirming(false) }, L('取消', 'Cancel')))) : null,
|
|
613
621
|
err ? h('div', { style: { fontSize: 12, color: C.err, marginTop: 6 } }, err) : null))
|
|
614
622
|
}
|
package/plugin/index.js
CHANGED
|
@@ -104,6 +104,7 @@ function runCli(args) {
|
|
|
104
104
|
running = new Promise((resolve) => {
|
|
105
105
|
let out = ''
|
|
106
106
|
const child = spawn(process.execPath, [join(PKG_ROOT, 'bin/dsh-model.js'), ...args], {
|
|
107
|
+
windowsHide: true,
|
|
107
108
|
// dsh 桌面版可能跑在 Electron 里:让它当普通 Node 用
|
|
108
109
|
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', NO_COLOR: '1', FORCE_COLOR: '0' },
|
|
109
110
|
stdio: ['ignore', 'pipe', 'pipe'],
|