dsh-model 0.15.0 → 0.17.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
@@ -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+ |
package/README.md CHANGED
@@ -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+ |
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ import { repair, service } from './commands/service.js';
9
9
  import { setup } from './commands/setup.js';
10
10
  import { doctor, status } from './commands/status.js';
11
11
  import { uninstall } from './commands/uninstall.js';
12
- import { selfUpdate } from './commands/self.js';
12
+ import { adminRun, selfUpdate } from './commands/self.js';
13
13
  import { opencode } from './commands/opencode.js';
14
14
  import { workbuddy } from './commands/workbuddy.js';
15
15
  import { bridge } from './commands/bridge.js';
@@ -190,6 +190,8 @@ export async function main(argv) {
190
190
  return await logs(ctx, o.lines ? Number(o.lines) : undefined, o.bridge ? 'bridge' : 'engine');
191
191
  case 'uninstall':
192
192
  return await uninstall(ctx, { yes: o.yes, keepAuth: o['keep-auth'], removePlugin: o['remove-plugin'] });
193
+ case 'admin-run':
194
+ return await adminRun(ctx);
193
195
  case 'self-update':
194
196
  case 'update':
195
197
  return await selfUpdate(ctx, { to: o.to });
@@ -3,7 +3,9 @@
3
3
  // 1. 新版本装到原来的位置:npm 全局装的就 npm install -g;从 dsh 管理页一键安装的(程序就在插件目录里)跳过这步;
4
4
  // 2. dsh 里的插件换成同一版本(插件页的新代码要重启一次 dsh 才生效);
5
5
  // 3. 用新版本执行一次 repair:重写服务文件、重启引擎与守护进程、同步模型。
6
- import { realpath } from 'node:fs/promises';
6
+ import { spawn } from 'node:child_process';
7
+ import { closeSync, openSync } from 'node:fs';
8
+ import { chown, realpath } from 'node:fs/promises';
7
9
  import { dirname, join } from 'node:path';
8
10
  import { requireRootInVps } from '../context.js';
9
11
  import { DshModelError, isDshModelError } from '../errors.js';
@@ -15,6 +17,7 @@ import { run, runInherit } from '../util/exec.js';
15
17
  import { exists } from '../util/fs.js';
16
18
  import { info, next, ok, skip, warn } from '../util/output.js';
17
19
  import { PKG_ROOT, pkgVersion } from '../util/pkg.js';
20
+ import { takeAdminRequest } from '../service/admin.js';
18
21
  const REGISTRY = 'https://registry.npmjs.org/dsh-model';
19
22
  /** a 比 b 新(只比 major.minor.patch) */
20
23
  export function isNewer(a, b) {
@@ -114,3 +117,30 @@ export async function selfUpdate(ctx, opts = {}) {
114
117
  next(L('重启一次 dsh,管理页的新代码才会生效', 'Restart dsh once so the management page picks up the new code'));
115
118
  return 0;
116
119
  }
120
+ /** 管理页请求的日志:更新写在 home 里;卸载会删 home,写在它外面 */
121
+ export function selfJobLog(ctx, action) {
122
+ return action === 'uninstall' ? join(dirname(ctx.paths.home), 'dsh-model-uninstall.log') : join(ctx.paths.home, 'update.log');
123
+ }
124
+ /** 由 dsh-model-admin.service(root)执行:取出管理页的请求单,只做 update / uninstall */
125
+ export async function adminRun(ctx) {
126
+ if (!ctx.isRoot)
127
+ throw new DshModelError('needs_root', L('admin-run 只由 dsh-model-admin 服务以 root 执行', 'admin-run is run by the dsh-model-admin service as root'));
128
+ const req = await takeAdminRequest(ctx);
129
+ if (!req)
130
+ return 0;
131
+ const log = selfJobLog(ctx, req.action);
132
+ const out = openSync(log, 'w', 0o600);
133
+ if (ctx.owner)
134
+ await chown(log, ctx.owner.uid, ctx.owner.gid).catch(() => { });
135
+ const args = req.action === 'update' ? ['update'] : ['uninstall', '--yes', '--remove-plugin', ...(req.keepAuth ? ['--keep-auth'] : [])];
136
+ const code = await new Promise((resolve) => {
137
+ const child = spawn(process.execPath, [join(PKG_ROOT, 'bin', 'dsh-model.js'), ...args, '--lang', req.lang ?? 'zh'], {
138
+ stdio: ['ignore', out, out],
139
+ env: { ...process.env, NO_COLOR: '1', NODE_NO_WARNINGS: '1' },
140
+ });
141
+ child.on('error', () => resolve(-1));
142
+ child.on('exit', (c) => resolve(c ?? -1));
143
+ });
144
+ closeSync(out);
145
+ return code;
146
+ }
@@ -1,4 +1,5 @@
1
1
  // commands/service.ts — service install|start|stop|restart|status|uninstall,以及 repair
2
+ import { installAdmin } from '../service/admin.js';
2
3
  import { requireRootInVps } from '../context.js';
3
4
  import { DshModelError } from '../errors.js';
4
5
  import { L } from '../i18n.js';
@@ -65,6 +66,8 @@ export async function repair(ctx) {
65
66
  throw new DshModelError('engine_unhealthy', L('引擎没有启动,查看 dsh-model logs', 'Engine did not start; see dsh-model logs'));
66
67
  ok(L('服务已重装并重启', 'Service reinstalled and restarted'));
67
68
  await ensureDaemon(ctx, all);
69
+ if (ctx.mode === 'vps')
70
+ await installAdmin(ctx);
68
71
  }
69
72
  await syncAll(ctx, all);
70
73
  return 0;
@@ -22,6 +22,7 @@ import { opencodeConfigured } from '../integrations/opencode.js';
22
22
  import { ensureDaemon } from '../daemon/service.js';
23
23
  import { installDshPlugin } from '../integrations/dshplugin.js';
24
24
  import { enableCaddy } from './remote.js';
25
+ import { installAdmin } from '../service/admin.js';
25
26
  import { DEFAULT_PUBLIC_PORT, publicHost } from '../remote/public.js';
26
27
  export async function setup(ctx, opts) {
27
28
  requireRootInVps(ctx);
@@ -91,6 +92,9 @@ export async function setup(ctx, opts) {
91
92
  skip(L('已跳过(--skip-dsh-plugin)', 'Skipped (--skip-dsh-plugin)'));
92
93
  else
93
94
  await step('dsh-plugin', () => installDshPlugin(ctx, all));
95
+ // vps:让 dsh 管理页能直接点「更新 / 卸载」(root 执行,只认这两件事)
96
+ if (ctx.mode === 'vps' && !ctx.serviceDisabled)
97
+ await step('admin', () => installAdmin(ctx));
94
98
  // vps:自动在 dsh 的域名上开对外端点(没有域名就用 IP),自己的其他设备直接能用
95
99
  if (ctx.mode === 'vps') {
96
100
  info('');
@@ -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
  ]),
@@ -14,6 +14,7 @@ import { fail, info, ok, warn } from '../util/output.js';
14
14
  import { disable as disableRemote } from './remote.js';
15
15
  import { disableWorkbuddy, removeWorkbuddyPlugin } from '../integrations/workbuddy.js';
16
16
  import { removeDshPlugin } from '../integrations/dshplugin.js';
17
+ import { removeAdmin } from '../service/admin.js';
17
18
  async function confirm(question) {
18
19
  if (!process.stdin.isTTY)
19
20
  return false;
@@ -73,6 +74,10 @@ export async function uninstall(ctx, opts) {
73
74
  ok(L(`dsh 配置已还原(patch:${r.patch},凭据:${r.cred})`, `dsh config restored (patch: ${r.patch}, credentials: ${r.cred})`));
74
75
  });
75
76
  await saveAll(ctx, all).catch(() => { });
77
+ await step('admin', async () => {
78
+ if (ctx.mode === 'vps')
79
+ await removeAdmin();
80
+ });
76
81
  await step('service', async () => {
77
82
  if (ctx.serviceDisabled)
78
83
  return;
@@ -7,9 +7,11 @@
7
7
  import { spawn } from 'node:child_process';
8
8
  import { closeSync, openSync } from 'node:fs';
9
9
  import { readFile } from 'node:fs/promises';
10
- import { homedir } from 'node:os';
11
10
  import { join } from 'node:path';
12
- import { latestVersion } from '../commands/self.js';
11
+ import { latestVersion, selfJobLog } from '../commands/self.js';
12
+ import { LOCAL_SERVERS, loadLocalCatalog, refreshLocalCatalog } from '../integrations/local.js';
13
+ import { adminInstalled, adminRequestPath } from '../service/admin.js';
14
+ import { atomicWrite } from '../util/fs.js';
13
15
  import { lang } from '../i18n.js';
14
16
  import { which } from '../util/exec.js';
15
17
  import { PKG_ROOT, pkgVersion } from '../util/pkg.js';
@@ -46,6 +48,16 @@ const USAGE_REFRESH_MS = 5 * 60 * 1000;
46
48
  /** 查不了用量的来源多久重新实测一次 */
47
49
  const PROBE_MS = 6 * 60 * 60 * 1000;
48
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
+ };
49
61
  const log = (m) => console.error(`[${new Date().toISOString()}] ${m}`);
50
62
  export class Daemon {
51
63
  ctx;
@@ -70,6 +82,8 @@ export class Daemon {
70
82
  if (await this.refreshCatalogs())
71
83
  void this.resync();
72
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));
73
87
  this.timers.push(setInterval(() => void this.pollUsage(), USAGE_POLL_MS));
74
88
  this.timers.push(setInterval(() => void this.stats.save().catch(() => { }), STATS_SAVE_MS));
75
89
  this.timers.push(setInterval(() => void this.reloadKeyNames(), STATS_SAVE_MS));
@@ -104,7 +118,7 @@ export class Daemon {
104
118
  }
105
119
  }
106
120
  async refreshCatalogs() {
107
- let changed = false;
121
+ let changed = await refreshLocalCatalog(this.ctx.paths.home, this.ctx.owner).catch(() => false);
108
122
  for (const rt of this.runtimes) {
109
123
  try {
110
124
  if (await refreshCatalog(this.ctx.paths.home, rt, this.ctx.owner))
@@ -140,7 +154,7 @@ export class Daemon {
140
154
  const u = this.usageCache.get(s.id);
141
155
  // 引擎来源:实测结果出来前不显示用量行;OpenCode 的 key 设置时已实测
142
156
  if (USAGE_UNSUPPORTED.has(s.id) && s.loggedIn) {
143
- 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 };
144
158
  const usage = u ?? stub;
145
159
  return usage ? { ...s, usage } : s;
146
160
  }
@@ -162,6 +176,7 @@ export class Daemon {
162
176
  }
163
177
  const cats = await loadCatalogs(this.ctx.paths.home);
164
178
  const secrets = await loadSecrets(this.ctx);
179
+ const local = await loadLocalCatalog(this.ctx.paths.home);
165
180
  // 订阅来源能服务的模型数:管理接口按凭据列模型(只数没停用的)
166
181
  const engineModels = new Map();
167
182
  try {
@@ -207,6 +222,19 @@ export class Daemon {
207
222
  models: c?.signedIn ? c.models.length : 0,
208
223
  };
209
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
+ }
210
238
  const has = Boolean(secrets.opencode?.key);
211
239
  return { ...base, loggedIn: has, enabled: has && !disabled.has('opencode'), models: has ? modelCount('opencode') : 0 };
212
240
  });
@@ -230,6 +258,12 @@ export class Daemon {
230
258
  async enable(id, opts = {}) {
231
259
  const def = mustSource(id);
232
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
+ }
233
267
  if (def.kind === 'opencode' && !state.loggedIn) {
234
268
  throw new DshModelError('needs_key', L('OpenCode Zen 需要 API key:用 opencode/key 提交', 'OpenCode Zen needs an API key: submit it via opencode/key'));
235
269
  }
@@ -274,6 +308,9 @@ export class Daemon {
274
308
  for (const c of credsFor(await m.credentials(), def))
275
309
  await m.deleteCredential(c.name);
276
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
+ }
277
314
  else if (def.kind === 'workbuddy') {
278
315
  const v = WORKBUDDY_VARIANTS.find((x) => x.id === def.variant);
279
316
  await workbuddyLogout(v, this.ctx.paths.home);
@@ -642,7 +679,7 @@ export class Daemon {
642
679
  return {
643
680
  version: pkgVersion(),
644
681
  latest: await latestVersion(),
645
- canManage: !vps,
682
+ canManage: !vps || (await adminInstalled()),
646
683
  ...(this.selfJob ? { job: { action: this.selfJob.action, startedAt: this.selfJob.startedAt, log: log.replace(/\x1b\[[0-9;]*m/g, '').slice(-6000) } } : {}),
647
684
  commands: vps
648
685
  ? { update: 'sudo npm install -g dsh-model@latest && sudo dsh-model setup', uninstall: 'sudo dsh-model uninstall' }
@@ -654,10 +691,24 @@ export class Daemon {
654
691
  * 用 detached 让它脱离守护进程的进程组(launchd 重启服务时不会被一起杀掉)。
655
692
  */
656
693
  async startSelfJob(action, opts = {}) {
657
- if (this.ctx.mode === 'vps')
658
- throw new DshModelError('needs_root', L('服务器上要 root 权限,请在终端里执行命令', 'On the server this needs root; run the command in a terminal'));
659
- // 卸载会删掉 home,日志放在外面
660
- const log = action === 'uninstall' ? join(homedir(), 'dsh-model-uninstall.log') : join(this.ctx.paths.home, 'update.log');
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
+ }
701
+ const log = selfJobLog(this.ctx, action);
702
+ if (this.ctx.mode === 'vps') {
703
+ // 守护进程是 dsh 用户:写请求单,由 dsh-model-admin(root)执行
704
+ if (!(await adminInstalled()))
705
+ throw new DshModelError('needs_root', L('服务器上要 root 权限,请在终端里执行命令(重跑一次 sudo dsh-model setup 之后就能在页面上点)', 'On the server this needs root; run the command in a terminal (after re-running sudo dsh-model setup, the buttons work here)'));
706
+ const req = { action, keepAuth: opts.keepAuth === true, lang: lang() };
707
+ await atomicWrite(adminRequestPath(this.ctx), JSON.stringify(req), { mode: 0o600 });
708
+ this.selfJob = { action, startedAt: new Date().toISOString(), log };
709
+ log_(`daemon: requested ${action} from dsh-model-admin`);
710
+ return { started: true, log };
711
+ }
661
712
  const args = action === 'update' ? ['update'] : ['uninstall', '--yes', '--remove-plugin', ...(opts.keepAuth ? ['--keep-auth'] : [])];
662
713
  const out = openSync(log, 'w', 0o600);
663
714
  const cmd = [process.execPath, join(PKG_ROOT, 'bin', 'dsh-model.js'), ...args, '--lang', lang()];
@@ -672,7 +723,7 @@ export class Daemon {
672
723
  });
673
724
  child.unref();
674
725
  closeSync(out);
675
- this.selfJob = { action, startedAt: new Date().toISOString(), log };
726
+ this.selfJob = { action, startedAt: new Date().toISOString(), log, ...(child.pid ? { pid: child.pid } : {}) };
676
727
  log_(`daemon: started ${action} (pid ${child.pid})`);
677
728
  return { started: true, log };
678
729
  }
@@ -765,8 +816,12 @@ export class Daemon {
765
816
  return ok({ sources: await this.sources(), keys: await this.keys(), stats: this.statsSnapshot(), endpoints: await this.endpoints() });
766
817
  }
767
818
  if (method === 'GET' && path === '/sources') {
768
- if (query.get('refresh') === '1')
819
+ if (query.get('refresh') === '1') {
820
+ // 本机模型服务开没开:页面打开时顺手问一下(1.5 秒超时),变了就同步进 dsh
821
+ if (await refreshLocalCatalog(this.ctx.paths.home, this.ctx.owner).catch(() => false))
822
+ void this.resync();
769
823
  await this.refreshUsage();
824
+ }
770
825
  return ok(await this.sources());
771
826
  }
772
827
  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/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 段 */
@@ -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
  }
@@ -0,0 +1,86 @@
1
+ // service/admin.ts — VPS 上让 dsh 管理页能「更新 / 卸载」(设计 §14.9)
2
+ //
3
+ // 守护进程以 dsh 用户运行,而更新(npm 全局目录、系统服务)和卸载(Caddy、防火墙、系统服务)要 root。
4
+ // 所以 setup(root)装一个 systemd path 单元:守护进程在 <home>/admin/request 写一张请求单,
5
+ // systemd 以 root 运行 `dsh-model admin-run`,它只认两件固定的事:更新到 npm 最新版、卸载。别的一律拒绝。
6
+ import { chown, mkdir, readFile, rm } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { run } from '../util/exec.js';
9
+ import { atomicWrite, exists } from '../util/fs.js';
10
+ import { PKG_ROOT } from '../util/pkg.js';
11
+ export const ADMIN_PATH_UNIT = '/etc/systemd/system/dsh-model-admin.path';
12
+ export const ADMIN_SERVICE_UNIT = '/etc/systemd/system/dsh-model-admin.service';
13
+ export const adminDir = (ctx) => join(ctx.paths.home, 'admin');
14
+ export const adminRequestPath = (ctx) => join(adminDir(ctx), 'request');
15
+ export async function adminInstalled() {
16
+ return exists(ADMIN_PATH_UNIT);
17
+ }
18
+ /** setup / repair(root)时装上;幂等 */
19
+ export async function installAdmin(ctx) {
20
+ const dir = adminDir(ctx);
21
+ await mkdir(dir, { recursive: true, mode: 0o700 });
22
+ if (ctx.owner)
23
+ await chown(dir, ctx.owner.uid, ctx.owner.gid);
24
+ const path = [
25
+ '# Generated by dsh-model. 由 dsh-model 生成:dsh 管理页的「更新 / 卸载」请求',
26
+ '[Unit]',
27
+ 'Description=dsh-model admin requests from the dsh page',
28
+ '',
29
+ '[Path]',
30
+ `PathExists=${adminRequestPath(ctx)}`,
31
+ 'Unit=dsh-model-admin.service',
32
+ '',
33
+ '[Install]',
34
+ 'WantedBy=multi-user.target',
35
+ '',
36
+ ].join('\n');
37
+ const service = [
38
+ '# Generated by dsh-model. 由 dsh-model 生成:以 root 执行管理页的请求(只认 update / uninstall)',
39
+ '[Unit]',
40
+ 'Description=dsh-model admin (update / uninstall from the dsh page)',
41
+ '',
42
+ '[Service]',
43
+ 'Type=oneshot',
44
+ `ExecStart=${process.execPath} ${join(PKG_ROOT, 'bin', 'dsh-model.js')} admin-run`,
45
+ `Environment=DSH_MODEL_HOME=${ctx.paths.home}`,
46
+ // 系统服务默认没有 HOME,npm 要用它放缓存
47
+ 'Environment=HOME=/root',
48
+ '',
49
+ ].join('\n');
50
+ const before = (await readFile(ADMIN_PATH_UNIT, 'utf8').catch(() => '')) + (await readFile(ADMIN_SERVICE_UNIT, 'utf8').catch(() => ''));
51
+ if (before === path + service)
52
+ return;
53
+ await atomicWrite(ADMIN_PATH_UNIT, path, { mode: 0o644 });
54
+ await atomicWrite(ADMIN_SERVICE_UNIT, service, { mode: 0o644 });
55
+ await run('systemctl', ['daemon-reload']);
56
+ await run('systemctl', ['enable', '--now', 'dsh-model-admin.path']);
57
+ }
58
+ export async function removeAdmin() {
59
+ if (!(await exists(ADMIN_PATH_UNIT)) && !(await exists(ADMIN_SERVICE_UNIT)))
60
+ return;
61
+ await run('systemctl', ['disable', '--now', 'dsh-model-admin.path']);
62
+ await rm(ADMIN_PATH_UNIT, { force: true });
63
+ await rm(ADMIN_SERVICE_UNIT, { force: true });
64
+ await run('systemctl', ['daemon-reload']);
65
+ }
66
+ /** 读并删掉请求单(先删,免得 path 单元反复触发);不合规的返回 null */
67
+ export async function takeAdminRequest(ctx) {
68
+ const file = adminRequestPath(ctx);
69
+ let raw = '';
70
+ try {
71
+ raw = await readFile(file, 'utf8');
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ await rm(file, { force: true });
77
+ try {
78
+ const r = JSON.parse(raw);
79
+ if (r.action !== 'update' && r.action !== 'uninstall')
80
+ return null;
81
+ return { action: r.action, keepAuth: r.keepAuth === true, lang: r.lang === 'en' ? 'en' : 'zh' };
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
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' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model",
3
- "version": "0.15.0",
3
+ "version": "0.17.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,20 +564,38 @@ 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
- api('GET', '/self').then((r) => alive && setInfo(r), () => {})
570
+ // 服务比页面旧(没有 /self):也要给出办法,不能整块不显示
571
+ api('GET', '/self').then((r) => alive && setInfo(r), (e) => alive && setInfo({ old: e.code === 'not_found' }))
569
572
  return () => { alive = false }
570
573
  }, [])
571
574
  if (!info) return null
575
+ if (!info.version) {
576
+ const cmd = 'npm install -g dsh-model && dsh-model setup'
577
+ return h('div', { style: S.section },
578
+ h('div', { style: S.h }, L('关于', 'About')),
579
+ h('div', { style: { ...S.card, padding: '12px 14px' } },
580
+ h('div', { style: S.meta }, info.old
581
+ ? L('这台机器上的 dsh-model 服务比这个页面旧,不支持在页面上更新。在终端执行一次(服务器上加 sudo),之后就能在这里更新和卸载:', 'The dsh-model service on this machine is older than this page and cannot update from here. Run this once in a terminal (with sudo on a server); after that you can update and uninstall here:')
582
+ : L('暂时查不到版本信息。可以在终端执行(服务器上加 sudo):', 'Version info is unavailable right now. You can run in a terminal (with sudo on a server):')),
583
+ h('div', { style: { ...S.line, minHeight: 30, marginTop: 6 } }, h('code', { style: { ...S.mono, ...S.grow } }, cmd), h(CopyButton, { get: cmd }))))
584
+ }
572
585
  const hasUpdate = info.latest && newer(info.latest, info.version)
573
586
  const start = async (action) => {
587
+ if (starting) return
574
588
  setErr('')
589
+ setStarting(true)
575
590
  try {
576
591
  await api('POST', `/self/${action}`, action === 'uninstall' ? { keepAuth } : {})
577
592
  onJob({ action, from: info.version, target: info.latest })
578
593
  } catch (e) {
579
- 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)
580
599
  }
581
600
  }
582
601
  return h('div', { style: S.section },
@@ -585,7 +604,7 @@ window.__ModuleLoader__.load({
585
604
  h('div', { style: S.line },
586
605
  h('span', { style: { ...S.label, ...S.grow } }, `dsh-model ${info.version}`,
587
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'))),
588
- 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,
589
608
  info.canManage ? h('button', { type: 'button', style: { ...S.btn, color: C.err }, onClick: () => setConfirming(true) }, L('卸载', 'Uninstall')) : null),
590
609
  !info.canManage ? h('div', { style: { marginTop: 8 } },
591
610
  h('div', { style: S.meta }, L('服务器上更新和卸载要 root 权限,请在服务器终端里执行:', 'On the server, update and uninstall need root; run in a server terminal:')),
@@ -597,7 +616,7 @@ window.__ModuleLoader__.load({
597
616
  h('input', { type: 'checkbox', checked: keepAuth, onChange: (e) => setKeepAuth(e.target.checked) }),
598
617
  L('先把各家登录凭据备份到主目录(以后重装可以拷回来)', 'Back up provider sign-ins to the home folder first (to reuse after reinstalling)')),
599
618
  h('div', { style: { ...S.line, marginTop: 8 } },
600
- 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')),
601
620
  h('button', { type: 'button', style: S.btn, onClick: () => setConfirming(false) }, L('取消', 'Cancel')))) : null,
602
621
  err ? h('div', { style: { fontSize: 12, color: C.err, marginTop: 6 } }, err) : null))
603
622
  }