dsh-model 0.16.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 +1 -0
- package/README.md +1 -0
- package/dist/commands/sources.js +1 -1
- package/dist/daemon/core.js +51 -4
- package/dist/daemon/usage.js +1 -1
- package/dist/dsh/pick.js +1 -1
- package/dist/engine/compat.js +16 -0
- package/dist/integrations/local.js +50 -0
- package/dist/ops.js +1 -1
- package/dist/sources.js +2 -0
- package/package.json +1 -1
- package/plugin/client.js +13 -5
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/commands/sources.js
CHANGED
|
@@ -69,7 +69,7 @@ export async function sources(ctx, opts = {}) {
|
|
|
69
69
|
s.enabled ? green('●') : dim('○'),
|
|
70
70
|
s.id,
|
|
71
71
|
pricingTag(s),
|
|
72
|
-
s.loggedIn ? (s.usage?.noAccess ? yellow(L('已登录·无订阅', 'signed in · no plan')) : s.enabled ? green(L('已接入', 'connected')) : yellow(L('已登录·关闭', 'signed in · off'))) : dim(L('未登录', 'signed out')),
|
|
72
|
+
s.loggedIn ? (s.usage?.noAccess ? yellow(L('已登录·无订阅', 'signed in · no plan')) : s.enabled ? green(L('已接入', 'connected')) : yellow(L('已登录·关闭', 'signed in · off'))) : dim(s.kind === 'local' ? L('未运行', 'not running') : L('未登录', 'signed out')),
|
|
73
73
|
s.account ?? (s.detail ? dim(s.detail.slice(0, 40)) : '-'),
|
|
74
74
|
s.models ? String(s.models) : '-',
|
|
75
75
|
]),
|
package/dist/daemon/core.js
CHANGED
|
@@ -9,6 +9,7 @@ import { closeSync, openSync } from 'node:fs';
|
|
|
9
9
|
import { readFile } from 'node:fs/promises';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { latestVersion, selfJobLog } from '../commands/self.js';
|
|
12
|
+
import { LOCAL_SERVERS, loadLocalCatalog, refreshLocalCatalog } from '../integrations/local.js';
|
|
12
13
|
import { adminInstalled, adminRequestPath } from '../service/admin.js';
|
|
13
14
|
import { atomicWrite } from '../util/fs.js';
|
|
14
15
|
import { lang } from '../i18n.js';
|
|
@@ -47,6 +48,16 @@ const USAGE_REFRESH_MS = 5 * 60 * 1000;
|
|
|
47
48
|
/** 查不了用量的来源多久重新实测一次 */
|
|
48
49
|
const PROBE_MS = 6 * 60 * 60 * 1000;
|
|
49
50
|
const log_ = (m) => log(m);
|
|
51
|
+
/** 进程还在不在(signal 0 只做检查) */
|
|
52
|
+
const alive = (pid) => {
|
|
53
|
+
try {
|
|
54
|
+
process.kill(pid, 0);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
50
61
|
const log = (m) => console.error(`[${new Date().toISOString()}] ${m}`);
|
|
51
62
|
export class Daemon {
|
|
52
63
|
ctx;
|
|
@@ -71,6 +82,8 @@ export class Daemon {
|
|
|
71
82
|
if (await this.refreshCatalogs())
|
|
72
83
|
void this.resync();
|
|
73
84
|
this.timers.push(setInterval(() => void this.refreshCatalogs().then((c) => (c ? this.resync() : undefined)), CATALOG_REFRESH_MS));
|
|
85
|
+
// 本机模型服务(Ollama、LM Studio)随时可能启动或退出:两分钟问一次,变了就同步
|
|
86
|
+
this.timers.push(setInterval(() => void refreshLocalCatalog(this.ctx.paths.home, this.ctx.owner).then((c) => (c ? this.resync() : undefined), () => { }), 2 * 60 * 1000));
|
|
74
87
|
this.timers.push(setInterval(() => void this.pollUsage(), USAGE_POLL_MS));
|
|
75
88
|
this.timers.push(setInterval(() => void this.stats.save().catch(() => { }), STATS_SAVE_MS));
|
|
76
89
|
this.timers.push(setInterval(() => void this.reloadKeyNames(), STATS_SAVE_MS));
|
|
@@ -105,7 +118,7 @@ export class Daemon {
|
|
|
105
118
|
}
|
|
106
119
|
}
|
|
107
120
|
async refreshCatalogs() {
|
|
108
|
-
let changed = false;
|
|
121
|
+
let changed = await refreshLocalCatalog(this.ctx.paths.home, this.ctx.owner).catch(() => false);
|
|
109
122
|
for (const rt of this.runtimes) {
|
|
110
123
|
try {
|
|
111
124
|
if (await refreshCatalog(this.ctx.paths.home, rt, this.ctx.owner))
|
|
@@ -141,7 +154,7 @@ export class Daemon {
|
|
|
141
154
|
const u = this.usageCache.get(s.id);
|
|
142
155
|
// 引擎来源:实测结果出来前不显示用量行;OpenCode 的 key 设置时已实测
|
|
143
156
|
if (USAGE_UNSUPPORTED.has(s.id) && s.loggedIn) {
|
|
144
|
-
const stub = s.kind
|
|
157
|
+
const stub = s.kind !== 'opencode' ? undefined : { source: s.id, windows: [], fetchedAt: new Date().toISOString(), unsupported: true };
|
|
145
158
|
const usage = u ?? stub;
|
|
146
159
|
return usage ? { ...s, usage } : s;
|
|
147
160
|
}
|
|
@@ -163,6 +176,7 @@ export class Daemon {
|
|
|
163
176
|
}
|
|
164
177
|
const cats = await loadCatalogs(this.ctx.paths.home);
|
|
165
178
|
const secrets = await loadSecrets(this.ctx);
|
|
179
|
+
const local = await loadLocalCatalog(this.ctx.paths.home);
|
|
166
180
|
// 订阅来源能服务的模型数:管理接口按凭据列模型(只数没停用的)
|
|
167
181
|
const engineModels = new Map();
|
|
168
182
|
try {
|
|
@@ -208,6 +222,19 @@ export class Daemon {
|
|
|
208
222
|
models: c?.signedIn ? c.models.length : 0,
|
|
209
223
|
};
|
|
210
224
|
}
|
|
225
|
+
if (def.kind === 'local') {
|
|
226
|
+
const st = local[def.id];
|
|
227
|
+
const srv = LOCAL_SERVERS.find((x) => x.id === def.id);
|
|
228
|
+
const up = Boolean(st?.reachable);
|
|
229
|
+
return {
|
|
230
|
+
...base,
|
|
231
|
+
loggedIn: up,
|
|
232
|
+
enabled: up && (st?.models.length ?? 0) > 0 && !disabled.has(def.id),
|
|
233
|
+
...(up ? { account: srv.base.replace(/\/v1$/, '') } : { detail: L(`没检测到 ${srv.label} 在运行(${srv.base.replace(/\/v1$/, '')})`, `${srv.label} is not running (${srv.base.replace(/\/v1$/, '')})`) }),
|
|
234
|
+
...(up && !st?.models.length ? { detail: L('在运行,但还没有模型', 'Running, but has no models yet') } : {}),
|
|
235
|
+
models: st?.models.length ?? 0,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
211
238
|
const has = Boolean(secrets.opencode?.key);
|
|
212
239
|
return { ...base, loggedIn: has, enabled: has && !disabled.has('opencode'), models: has ? modelCount('opencode') : 0 };
|
|
213
240
|
});
|
|
@@ -231,6 +258,12 @@ export class Daemon {
|
|
|
231
258
|
async enable(id, opts = {}) {
|
|
232
259
|
const def = mustSource(id);
|
|
233
260
|
const state = (await this.sources()).find((s) => s.id === def.id);
|
|
261
|
+
if (def.kind === 'local') {
|
|
262
|
+
await refreshLocalCatalog(this.ctx.paths.home, this.ctx.owner);
|
|
263
|
+
const now = (await this.sourcesRaw()).find((s) => s.id === def.id);
|
|
264
|
+
if (!now.loggedIn || !now.models)
|
|
265
|
+
throw new DshModelError('local_not_running', now.detail ?? L(`${def.label} 没在运行`, `${def.label} is not running`), L(`先装好并启动它:${def.subscribeUrl}`, `Install and start it first: ${def.subscribeUrl}`));
|
|
266
|
+
}
|
|
234
267
|
if (def.kind === 'opencode' && !state.loggedIn) {
|
|
235
268
|
throw new DshModelError('needs_key', L('OpenCode Zen 需要 API key:用 opencode/key 提交', 'OpenCode Zen needs an API key: submit it via opencode/key'));
|
|
236
269
|
}
|
|
@@ -275,6 +308,9 @@ export class Daemon {
|
|
|
275
308
|
for (const c of credsFor(await m.credentials(), def))
|
|
276
309
|
await m.deleteCredential(c.name);
|
|
277
310
|
}
|
|
311
|
+
else if (def.kind === 'local') {
|
|
312
|
+
throw new DshModelError('no_logout', L(`${def.label} 不需要登录;不想用就关掉开关`, `${def.label} has no sign-in; turn the switch off instead`));
|
|
313
|
+
}
|
|
278
314
|
else if (def.kind === 'workbuddy') {
|
|
279
315
|
const v = WORKBUDDY_VARIANTS.find((x) => x.id === def.variant);
|
|
280
316
|
await workbuddyLogout(v, this.ctx.paths.home);
|
|
@@ -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)执行
|
|
@@ -680,7 +723,7 @@ export class Daemon {
|
|
|
680
723
|
});
|
|
681
724
|
child.unref();
|
|
682
725
|
closeSync(out);
|
|
683
|
-
this.selfJob = { action, startedAt: new Date().toISOString(), log };
|
|
726
|
+
this.selfJob = { action, startedAt: new Date().toISOString(), log, ...(child.pid ? { pid: child.pid } : {}) };
|
|
684
727
|
log_(`daemon: started ${action} (pid ${child.pid})`);
|
|
685
728
|
return { started: true, log };
|
|
686
729
|
}
|
|
@@ -773,8 +816,12 @@ export class Daemon {
|
|
|
773
816
|
return ok({ sources: await this.sources(), keys: await this.keys(), stats: this.statsSnapshot(), endpoints: await this.endpoints() });
|
|
774
817
|
}
|
|
775
818
|
if (method === 'GET' && path === '/sources') {
|
|
776
|
-
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();
|
|
777
823
|
await this.refreshUsage();
|
|
824
|
+
}
|
|
778
825
|
return ok(await this.sources());
|
|
779
826
|
}
|
|
780
827
|
if (seg[0] === 'sources' && seg[1] && method === 'POST') {
|
package/dist/daemon/usage.js
CHANGED
|
@@ -12,7 +12,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
const TIMEOUT_MS = 15_000;
|
|
14
14
|
/** 这几家暂不支持(见设计 §14.6) */
|
|
15
|
-
export const USAGE_UNSUPPORTED = new Set(['antigravity', 'devin', 'meta', 'opencode']);
|
|
15
|
+
export const USAGE_UNSUPPORTED = new Set(['antigravity', 'devin', 'meta', 'opencode', 'ollama', 'lmstudio']);
|
|
16
16
|
async function getJson(url, headers) {
|
|
17
17
|
const res = await fetch(url, { headers: { Accept: 'application/json', ...headers }, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
18
18
|
const text = await res.text();
|
package/dist/dsh/pick.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// 去掉画图 / 审查 / 自动路由这类不是对话用的 → 免费的先放(最多 2 个)→ 各系列轮流取最新版本,凑满 5 个。
|
|
6
6
|
export const DEFAULT_PICK = 5;
|
|
7
7
|
const MAX_FREE = 2;
|
|
8
|
-
const NON_CHAT = /image|embed|tts|whisper|audio|review|(^|\/)(auto|default-model)$/i;
|
|
8
|
+
const NON_CHAT = /image|embed|bge-|rerank|tts|whisper|audio|review|(^|\/)(auto|default-model)$/i;
|
|
9
9
|
/** 系列的先后:常用的大模型在前 */
|
|
10
10
|
const FAMILY_ORDER = ['gpt', 'claude', 'gemini', 'grok', 'kimi', 'glm', 'deepseek', 'qwen', 'minimax', 'hy'];
|
|
11
11
|
const bare = (id) => id.slice(id.lastIndexOf('/') + 1).toLowerCase();
|
package/dist/engine/compat.js
CHANGED
|
@@ -6,6 +6,7 @@ import { join } from 'node:path';
|
|
|
6
6
|
import { bridgeConfigPath, loadCatalogs } from '../bridge/runtime.js';
|
|
7
7
|
import { loadSecrets } from '../secrets.js';
|
|
8
8
|
import { readJson } from '../util/fs.js';
|
|
9
|
+
import { LOCAL_SERVERS, loadLocalCatalog } from '../integrations/local.js';
|
|
9
10
|
export const OPENCODE_BASE = 'https://opencode.ai/zen/v1';
|
|
10
11
|
export const OPENCODE_PREFIX = 'opencode';
|
|
11
12
|
export function opencodeModelsPath(ctx) {
|
|
@@ -67,6 +68,21 @@ export async function loadCompatUpstreams(ctx) {
|
|
|
67
68
|
});
|
|
68
69
|
}
|
|
69
70
|
}
|
|
71
|
+
// 本机模型服务(Ollama、LM Studio):在运行、有模型、没被关掉就接进来
|
|
72
|
+
const local = await loadLocalCatalog(ctx.paths.home);
|
|
73
|
+
for (const s of LOCAL_SERVERS) {
|
|
74
|
+
const st = local[s.id];
|
|
75
|
+
if (!st?.reachable || !st.models.length || disabled.has(s.id))
|
|
76
|
+
continue;
|
|
77
|
+
out.push({
|
|
78
|
+
name: s.id,
|
|
79
|
+
label: s.label,
|
|
80
|
+
baseUrl: s.base,
|
|
81
|
+
apiKey: 'local',
|
|
82
|
+
direct: true,
|
|
83
|
+
models: st.models.map((id) => ({ name: id, alias: `${s.id}/${id}`, displayName: `${s.label} · ${id}`, dshName: `${id} · 本地免费`, rate: 0, group: s.id, groupLabel: `${s.label}(本地)` })),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
70
86
|
return out;
|
|
71
87
|
}
|
|
72
88
|
/** 渲染成引擎 v8 的 api-keys.openai-compatibility 段 */
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// integrations/local.ts — 本机模型服务(Ollama、LM Studio)作为来源(设计 §14.10)
|
|
2
|
+
//
|
|
3
|
+
// 两者都自带 OpenAI 兼容接口(/v1/models、/v1/chat/completions),本机运行、免费、不用登录。
|
|
4
|
+
// 检测到在运行就当作"已接入",模型清单直接问它;引擎把它们作为 openai-compatibility 上游,前缀 ollama/ 与 lmstudio/。
|
|
5
|
+
// 思路来自 dsh-plugin-cli-hub 的 ollama adapter(那边是调命令行,这里直接走它的 HTTP 接口)。
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { readJson, writeJson } from '../util/fs.js';
|
|
8
|
+
export const LOCAL_SERVERS = [
|
|
9
|
+
{ id: 'ollama', label: 'Ollama', base: `${(process.env.OLLAMA_HOST ? normalizeHost(process.env.OLLAMA_HOST) : 'http://127.0.0.1:11434')}/v1` },
|
|
10
|
+
{ id: 'lmstudio', label: 'LM Studio', base: 'http://127.0.0.1:1234/v1' },
|
|
11
|
+
];
|
|
12
|
+
/** OLLAMA_HOST 可能写成 0.0.0.0:11434 / localhost / http://host:port */
|
|
13
|
+
function normalizeHost(h) {
|
|
14
|
+
let s = h.trim().replace(/\/+$/, '');
|
|
15
|
+
if (!/^https?:\/\//.test(s))
|
|
16
|
+
s = `http://${s}`;
|
|
17
|
+
s = s.replace('://0.0.0.0', '://127.0.0.1');
|
|
18
|
+
return /:\d+$/.test(s) ? s : `${s}:11434`;
|
|
19
|
+
}
|
|
20
|
+
export const localCatalogPath = (home) => join(home, 'local-models.json');
|
|
21
|
+
export async function loadLocalCatalog(home) {
|
|
22
|
+
return (await readJson(localCatalogPath(home))) ?? {};
|
|
23
|
+
}
|
|
24
|
+
async function probe(s) {
|
|
25
|
+
const updatedAt = new Date().toISOString();
|
|
26
|
+
// 测试环境不看本机真装了什么(与不发现桌面 App 同一个开关)
|
|
27
|
+
if (process.env.DSH_MODEL_NO_APP_DISCOVERY)
|
|
28
|
+
return { reachable: false, models: [], updatedAt };
|
|
29
|
+
try {
|
|
30
|
+
const r = await fetch(`${s.base}/models`, { signal: AbortSignal.timeout(1500) });
|
|
31
|
+
if (!r.ok)
|
|
32
|
+
return { reachable: false, models: [], updatedAt };
|
|
33
|
+
const data = (await r.json()).data ?? [];
|
|
34
|
+
return { reachable: true, models: data.map((m) => String(m.id ?? '')).filter(Boolean).sort(), updatedAt };
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return { reachable: false, models: [], updatedAt };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** 问一遍本机的模型服务;在不在运行、模型有没有变,变了返回 true */
|
|
41
|
+
export async function refreshLocalCatalog(home, owner) {
|
|
42
|
+
const before = await loadLocalCatalog(home);
|
|
43
|
+
const next = {};
|
|
44
|
+
for (const s of LOCAL_SERVERS)
|
|
45
|
+
next[s.id] = await probe(s);
|
|
46
|
+
const key = (c) => JSON.stringify(LOCAL_SERVERS.map((s) => [c[s.id]?.reachable ?? false, c[s.id]?.models ?? []]));
|
|
47
|
+
const changed = key(before) !== key(next);
|
|
48
|
+
await writeJson(localCatalogPath(home), next, { owner });
|
|
49
|
+
return changed;
|
|
50
|
+
}
|
package/dist/ops.js
CHANGED
|
@@ -140,7 +140,7 @@ export async function dshCandidates(ctx, all, ids) {
|
|
|
140
140
|
/** 引擎里带前缀(opencode/ workbuddy/ workbuddy-ai/)的模型是否正好等于期望:不能缺,也不能多(关掉的来源要消失) */
|
|
141
141
|
function aliasesMatch(models, expected) {
|
|
142
142
|
const want = new Set(expected);
|
|
143
|
-
const prefixes = ['opencode/', 'workbuddy/', 'workbuddy-ai/'];
|
|
143
|
+
const prefixes = ['opencode/', 'workbuddy/', 'workbuddy-ai/', 'ollama/', 'lmstudio/'];
|
|
144
144
|
const have = models.map((m) => m.id).filter((id) => prefixes.some((p) => id.startsWith(p)));
|
|
145
145
|
return have.length === want.size && have.every((id) => want.has(id));
|
|
146
146
|
}
|
package/dist/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.
|
|
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,6 +564,7 @@ window.__ModuleLoader__.load({
|
|
|
563
564
|
const [confirming, setConfirming] = useState(false)
|
|
564
565
|
const [keepAuth, setKeepAuth] = useState(false)
|
|
565
566
|
const [err, setErr] = useState('')
|
|
567
|
+
const [starting, setStarting] = useState(false)
|
|
566
568
|
useEffect(() => {
|
|
567
569
|
let alive = true
|
|
568
570
|
// 服务比页面旧(没有 /self):也要给出办法,不能整块不显示
|
|
@@ -582,12 +584,18 @@ window.__ModuleLoader__.load({
|
|
|
582
584
|
}
|
|
583
585
|
const hasUpdate = info.latest && newer(info.latest, info.version)
|
|
584
586
|
const start = async (action) => {
|
|
587
|
+
if (starting) return
|
|
585
588
|
setErr('')
|
|
589
|
+
setStarting(true)
|
|
586
590
|
try {
|
|
587
591
|
await api('POST', `/self/${action}`, action === 'uninstall' ? { keepAuth } : {})
|
|
588
592
|
onJob({ action, from: info.version, target: info.latest })
|
|
589
593
|
} catch (e) {
|
|
590
|
-
|
|
594
|
+
// 连不上守护进程多半是它已经开始重启(任务已经在跑):照样进进度页,等它回来
|
|
595
|
+
if (e.code === 'daemon_unreachable') onJob({ action, from: info.version, target: info.latest })
|
|
596
|
+
else setErr(e.message)
|
|
597
|
+
} finally {
|
|
598
|
+
setStarting(false)
|
|
591
599
|
}
|
|
592
600
|
}
|
|
593
601
|
return h('div', { style: S.section },
|
|
@@ -596,7 +604,7 @@ window.__ModuleLoader__.load({
|
|
|
596
604
|
h('div', { style: S.line },
|
|
597
605
|
h('span', { style: { ...S.label, ...S.grow } }, `dsh-model ${info.version}`,
|
|
598
606
|
h('span', { style: { ...S.meta, marginLeft: 8 } }, info.latest ? (hasUpdate ? L(`有新版本 ${info.latest}`, `${info.latest} available`) : L('已是最新', 'Up to date')) : L('查不到最新版本', 'Latest version unknown'))),
|
|
599
|
-
info.canManage && hasUpdate ? h('button', { type: 'button', style: S.btnPrimary, onClick: () => void start('update') }, L(`更新到 ${info.latest}`, `Update to ${info.latest}`)) : null,
|
|
607
|
+
info.canManage && hasUpdate ? h('button', { type: 'button', style: S.btnPrimary, disabled: starting, onClick: () => void start('update') }, starting ? L('正在开始…', 'Starting…') : L(`更新到 ${info.latest}`, `Update to ${info.latest}`)) : null,
|
|
600
608
|
info.canManage ? h('button', { type: 'button', style: { ...S.btn, color: C.err }, onClick: () => setConfirming(true) }, L('卸载', 'Uninstall')) : null),
|
|
601
609
|
!info.canManage ? h('div', { style: { marginTop: 8 } },
|
|
602
610
|
h('div', { style: S.meta }, L('服务器上更新和卸载要 root 权限,请在服务器终端里执行:', 'On the server, update and uninstall need root; run in a server terminal:')),
|
|
@@ -608,7 +616,7 @@ window.__ModuleLoader__.load({
|
|
|
608
616
|
h('input', { type: 'checkbox', checked: keepAuth, onChange: (e) => setKeepAuth(e.target.checked) }),
|
|
609
617
|
L('先把各家登录凭据备份到主目录(以后重装可以拷回来)', 'Back up provider sign-ins to the home folder first (to reuse after reinstalling)')),
|
|
610
618
|
h('div', { style: { ...S.line, marginTop: 8 } },
|
|
611
|
-
h('button', { type: 'button', style: { ...S.btnPrimary, background: C.err }, onClick: () => void start('uninstall') }, L('确认卸载', 'Uninstall')),
|
|
619
|
+
h('button', { type: 'button', style: { ...S.btnPrimary, background: C.err }, disabled: starting, onClick: () => void start('uninstall') }, starting ? L('正在开始…', 'Starting…') : L('确认卸载', 'Uninstall')),
|
|
612
620
|
h('button', { type: 'button', style: S.btn, onClick: () => setConfirming(false) }, L('取消', 'Cancel')))) : null,
|
|
613
621
|
err ? h('div', { style: { fontSize: 12, color: C.err, marginTop: 6 } }, err) : null))
|
|
614
622
|
}
|