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 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-macOS%20%7C%20Linux-blue" alt="Platform: macOS / Linux">
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-macOS%20%7C%20Linux-blue" alt="Platform: macOS / Linux">
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
- - 支持 macOS 和 Linux,需要 Node ≥ 20。
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);
@@ -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'));
@@ -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
- const npm = join(dir, 'npm');
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}:${process.env.PATH ?? ''}` } };
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));
@@ -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
  ]),
@@ -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
- if (cm != null)
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
- await rm(ctx.paths.home, { recursive: true, force: true });
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}(只支持 macOS 和 Linux)`, `Unsupported platform: ${platform} (macOS and Linux only)`));
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;
@@ -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 === 'engine' ? undefined : { source: s.id, windows: [], fetchedAt: new Date().toISOString(), unsupported: true };
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
- // Linux 的 systemd --user 重启服务会杀掉整个 cgroup:交给 systemd-run 另起一个
673
- const useSystemdRun = this.ctx.platform === 'linux' && (await which('systemd-run'));
674
- 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]}`);
675
- const viaSystemd = ['--user', '--collect', '--quiet', ...keepEnv, '--setenv=NO_COLOR=1', '/bin/sh', '-c', 'log="$1"; shift; exec "$@" >"$log" 2>&1', 'sh', log, ...cmd];
676
- const child = spawn(useSystemdRun ? 'systemd-run' : cmd[0], useSystemdRun ? viaSystemd : cmd.slice(1), {
677
- detached: true,
678
- stdio: ['ignore', out, out],
679
- env: { ...process.env, NO_COLOR: '1', NODE_NO_WARNINGS: '1', ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}) },
680
- });
681
- child.unref();
682
- closeSync(out);
683
- this.selfJob = { action, startedAt: new Date().toISOString(), log };
684
- log_(`daemon: started ${action} (pid ${child.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') {
@@ -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, bin, args, proxy) {
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}`, bin, ...args] };
82
+ return { cmd: 'sudo', args: ['-u', ctx.runAs, '-H', `--preserve-env=${keep}`, cli.cmd, ...cli.pre, ...args] };
45
83
  }
46
- return { cmd: bin, args };
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
- const dir = /"?storeDir"?\s*:\s*"?([^"\n,]+)"?/.exec(text)?.[1]?.trim();
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(/\/v\d+$/, '');
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 bin = await locateDshCli(ctx);
112
+ const cli = await locateDshCli(ctx);
68
113
  const store = await profileStoreDir(ctx, profile);
69
- const w = wrap(ctx, bin, ['plugin', '--profile', profile, ...pnpmArgs, ...(store ? [`--config.store-dir=${store}`] : [])], proxy);
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 r = await run(await locateDshCli(ctx), ['--version'], { timeoutMs: 20_000 });
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 {
@@ -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();
@@ -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 段 */
@@ -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('/').filter(Boolean).pop() ?? null;
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 = `CLIProxyAPI_${version}_${os}_${arch}.tar.gz`;
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 run('tar', ['-xzf', archive, '-C', partial]);
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
- await chmod(join(partial, ENGINE_BIN), 0o755);
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
  }
@@ -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 rename(tmp, file);
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 的监听就再也收不到变化(引擎热重载实测失效)。
@@ -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 系统代理(scutil --proxy)
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) => {
@@ -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.16.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
- setErr(e.message)
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'],