dsh-model 0.18.0 → 0.19.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.
@@ -5,7 +5,7 @@
5
5
  // 用户自己从市场 / GitHub 装的(依赖写法不是我们的)就不动它。卸载时只移除 dsh-model 装的那份。
6
6
  import { lstat, realpath, unlink } from 'node:fs/promises';
7
7
  import { join } from 'node:path';
8
- import { dshPlugin, profileHasDependency } from '../dsh/cli.js';
8
+ import { dshPlugin, dshPluginTee, profileHasDependency } from '../dsh/cli.js';
9
9
  import { resolveProfile } from '../dsh/locate.js';
10
10
  import { DshModelError } from '../errors.js';
11
11
  import { L } from '../i18n.js';
@@ -65,10 +65,15 @@ export async function installDshPlugin(ctx, all, opts = {}) {
65
65
  catch {
66
66
  // 不存在
67
67
  }
68
- const code = await dshPlugin(ctx, profile, ['add', want.install, '--config.strict-dep-builds=false'], all.config.proxy);
68
+ const { code, output } = await dshPluginTee(ctx, profile, ['add', want.install, '--config.strict-dep-builds=false'], all.config.proxy);
69
69
  const installed = await profileHasDependency(ctx, profile, PLUGIN_NAME);
70
70
  if (code !== 0 || !installed) {
71
- throw new DshModelError('plugin_install_failed', L(`把 dsh-model 装进 dsh 失败(退出码 ${code})`, `Failed to install dsh-model into dsh (exit code ${code})`), L('可以在 dsh 的插件市场搜索 dsh-model 安装', 'You can install it from the dsh plugin marketplace (search dsh-model)'));
71
+ const blocked = releaseAgeBlocked(output);
72
+ if (blocked.length) {
73
+ // dsh 的 pnpm 不装"发布不满一天"的版本,而且整份插件清单一起查:别的插件刚更新也会把这次拦下
74
+ throw new DshModelError('plugin_release_age', L(`dsh 的插件安全规则(新发布的版本要等一天)拦下了这次安装:${blocked.join('、')}`, `dsh's plugin safety rule (new versions wait a day) blocked this install: ${blocked.join(', ')}`), L('这条规则检查 dsh 里的全部插件,不只是 dsh-model。等上面列出的版本发布满一天后,再执行一次 dsh-model setup 即可', 'It checks every plugin in dsh, not just dsh-model. Run dsh-model setup again once the versions above are a day old'));
75
+ }
76
+ throw new DshModelError('plugin_install_failed', L(`把 dsh-model 装进 dsh 失败(退出码 ${code})`, `Failed to install dsh-model into dsh (exit code ${code})`), L('可以在 dsh 的「添加插件」里填 dsh-model@<版本> 安装', 'You can add it in dsh with "Add plugin": dsh-model@<version>'));
72
77
  }
73
78
  all.state.plugins = [...(all.state.plugins ?? []).filter((p) => p.name !== PLUGIN_NAME), { name: PLUGIN_NAME, version: installed, installedByUs: true, installedAt: new Date().toISOString() }];
74
79
  ok(L(`dsh 插件已安装(${installed}):重启一次 dsh,在「设置 → dsh-model」里管理来源、用量和 key`, `dsh plugin installed (${installed}): restart dsh once, then manage sources, usage and keys in Settings → dsh-model`));
@@ -87,3 +92,9 @@ export async function removeDshPlugin(ctx, all, opts = {}) {
87
92
  all.state.plugins = (all.state.plugins ?? []).filter((p) => p.name !== PLUGIN_NAME);
88
93
  return true;
89
94
  }
95
+ /** pnpm 的 ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION:哪些包、什么时候发布的。导出给测试 */
96
+ export function releaseAgeBlocked(output) {
97
+ if (!/MINIMUM_RELEASE_AGE_VIOLATION/.test(output))
98
+ return [];
99
+ return [...output.matchAll(/^\s*(\S+@\S+) was published at (\S+?),/gm)].map((m) => `${m[1]}(${m[2].replace('T', ' ').slice(0, 16)} 发布)`);
100
+ }
package/dist/ops.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // ops.ts — 各命令共用的流程:读全部状态、应用引擎配置、同步模型到 dsh
2
+ import { COMPAT_PREFIXES } from './sources.js';
2
3
  import { connectDshGroups } from './dsh/connect.js';
3
4
  import { listModels, waitHealthy, waitModelsChange } from './engine/client.js';
4
5
  import { compatModelIndex, loadCompatUpstreams } from './engine/compat.js';
@@ -140,7 +141,7 @@ export async function dshCandidates(ctx, all, ids) {
140
141
  /** 引擎里带前缀(opencode/ workbuddy/ workbuddy-ai/)的模型是否正好等于期望:不能缺,也不能多(关掉的来源要消失) */
141
142
  function aliasesMatch(models, expected) {
142
143
  const want = new Set(expected);
143
- const prefixes = ['opencode/', 'workbuddy/', 'workbuddy-ai/', 'ollama/', 'lmstudio/'];
144
+ const prefixes = COMPAT_PREFIXES;
144
145
  const have = models.map((m) => m.id).filter((id) => prefixes.some((p) => id.startsWith(p)));
145
146
  return have.length === want.size && have.every((id) => want.has(id));
146
147
  }
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // 守护进程以 dsh 用户运行,而更新(npm 全局目录、系统服务)和卸载(Caddy、防火墙、系统服务)要 root。
4
4
  // 所以 setup(root)装一个 systemd path 单元:守护进程在 <home>/admin/request 写一张请求单,
5
- // systemd 以 root 运行 `dsh-model admin-run`,它只认两件固定的事:更新到 npm 最新版、卸载。别的一律拒绝。
5
+ // systemd 以 root 运行 `dsh-model admin-run`,它只认三件固定的事:更新到 npm 最新版、卸载、重启 dsh(插件更新后要重启才生效)。别的一律拒绝。
6
6
  import { chown, mkdir, readFile, rm } from 'node:fs/promises';
7
7
  import { join } from 'node:path';
8
8
  import { run } from '../util/exec.js';
@@ -76,7 +76,7 @@ export async function takeAdminRequest(ctx) {
76
76
  await rm(file, { force: true });
77
77
  try {
78
78
  const r = JSON.parse(raw);
79
- if (r.action !== 'update' && r.action !== 'uninstall')
79
+ if (r.action !== 'update' && r.action !== 'uninstall' && r.action !== 'restart-dsh')
80
80
  return null;
81
81
  return { action: r.action, keepAuth: r.keepAuth === true, lang: r.lang === 'en' ? 'en' : 'zh' };
82
82
  }
package/dist/sources.js CHANGED
@@ -15,6 +15,13 @@ export const SOURCES = [
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
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
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)' } },
18
+ // 自带 API key 的来源(2026-10 核实,见设计 §14.11):免费的都有每日 / 每分钟上限
19
+ { id: 'gemini', label: 'Gemini(Google AI Studio)', kind: 'apikey', login: 'key', subscribeUrl: 'https://aistudio.google.com/apikey', pricing: { tier: 'limited', zh: '免费 key,每天有次数上限;免费档的对话可能被用来训练模型', en: 'Free key with daily limits; free-tier prompts may be used for training' } },
20
+ { id: 'groq', label: 'Groq', kind: 'apikey', login: 'key', subscribeUrl: 'https://console.groq.com/keys', pricing: { tier: 'limited', zh: '免费 key,每个模型每分钟 / 每天有上限', en: 'Free key with per-model per-minute / per-day limits' } },
21
+ { id: 'openrouter', label: 'OpenRouter', kind: 'apikey', login: 'key', subscribeUrl: 'https://openrouter.ai/keys', pricing: { tier: 'limited', zh: '名字带 :free 的模型免费(每天约 50 次,充值 10 美元后约 1000 次);其余按量付费', en: ':free models are free (about 50/day, about 1,000/day after a $10 top-up); others pay as you go' } },
22
+ { id: 'siliconflow', label: '硅基流动 SiliconFlow', kind: 'apikey', login: 'key', subscribeUrl: 'https://cloud.siliconflow.cn/account/ak', pricing: { tier: 'limited', zh: '实名认证后部分小模型免费(限速);其余按量付费', en: 'Some small models are free after identity verification (rate limited); others pay as you go' } },
23
+ { id: 'deepseek', label: 'DeepSeek 官方', kind: 'apikey', login: 'key', subscribeUrl: 'https://platform.deepseek.com/api_keys', pricing: { tier: 'paid', zh: '按量付费(价格低)', en: 'Pay as you go (low prices)' } },
24
+ { id: 'custom', label: '其他 OpenAI 兼容服务', kind: 'apikey', login: 'key', pricing: { tier: 'paid', zh: '填地址和 key,接入任意 OpenAI 兼容服务', en: 'Enter a URL and a key for any OpenAI-compatible service' } },
18
25
  { 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' } },
19
26
  ];
20
27
  const ALIASES = { grok: 'xai', muse: 'meta', chatgpt: 'codex', openai: 'codex', 'workbuddy-cn': 'workbuddy', wb: 'workbuddy', 'wb-ai': 'workbuddy-ai' };
@@ -36,3 +43,5 @@ export function paymentRequired(cred) {
36
43
  const cooldowns = cred.cooldowns;
37
44
  return Array.isArray(cooldowns) && cooldowns.some((c) => c?.reason === 'payment_required');
38
45
  }
46
+ /** 引擎里作为 openai-compatibility 上游的来源:模型名前缀就是来源 id(同步时据此核对引擎是否已热重载) */
47
+ export const COMPAT_PREFIXES = [...SOURCES.filter((s) => s.kind !== 'engine').map((s) => `${s.variant ?? s.id}/`), 'auto/'];
package/dist/util/exec.js CHANGED
@@ -51,6 +51,17 @@ export function runInherit(cmd, args, options = {}) {
51
51
  child.on('close', (code) => resolve(code ?? 1));
52
52
  });
53
53
  }
54
+ /** 像 runInherit 一样把输出原样给用户看,同时留一份(要从输出里认出具体错误时用) */
55
+ export function runTee(cmd, args, options = {}) {
56
+ return new Promise((resolve) => {
57
+ let output = '';
58
+ const child = spawn(...spawnArgs(cmd, args, { cwd: options.cwd, env: options.env ?? process.env, stdio: ['inherit', 'pipe', 'pipe'] }));
59
+ child.stdout?.on('data', (d) => { process.stdout.write(d); output = (output + d.toString()).slice(-20000); });
60
+ child.stderr?.on('data', (d) => { process.stderr.write(d); output = (output + d.toString()).slice(-20000); });
61
+ child.on('error', () => resolve({ code: 127, output }));
62
+ child.on('close', (code) => resolve({ code: code ?? 1, output }));
63
+ });
64
+ }
54
65
  /** 在 PATH 里找命令 */
55
66
  export async function which(cmd) {
56
67
  if (WIN) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model",
3
- "version": "0.18.0",
3
+ "version": "0.19.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",
@@ -56,6 +56,9 @@
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/node": "^20.19.43",
59
+ "jsdom": "^25.0.1",
60
+ "react": "^18.3.1",
61
+ "react-dom": "^18.3.1",
59
62
  "typescript": "^5.9.3",
60
63
  "vitest": "^3.2.7"
61
64
  },
package/plugin/client.js CHANGED
@@ -289,20 +289,40 @@ window.__ModuleLoader__.load({
289
289
  s.status === 'pending' ? (msg || L('等待授权中…', 'Waiting for approval…')) : s.status === 'ok' ? L('已登录', 'Signed in') : `${L('登录没有完成', 'Login did not complete')}:${s.error || s.status}`))
290
290
  }
291
291
 
292
- function KeyDialog({ onDone, onClose }) {
292
+ /** 填 API key:OpenCode Zen,以及 Gemini / Groq / OpenRouter / 硅基流动 / DeepSeek / 其他 OpenAI 兼容服务 */
293
+ function KeyDialog({ source, onDone, onClose }) {
294
+ const opencode = !source || source.kind === 'opencode'
295
+ const custom = source && source.id === 'custom'
293
296
  const [key, setKey] = useState('')
297
+ const [base, setBase] = useState('')
294
298
  const [busy, setBusy] = useState(false)
295
299
  const [msg, setMsg] = useState('')
296
300
  const submit = async () => {
297
301
  setBusy(true)
298
- setMsg(L('正在用一个免费模型实测这把 key…', 'Testing the key against a free model…'))
299
- try { await api('POST', '/opencode/key', { key: key.trim() }); onDone() } catch (e) { setMsg(e.message); setBusy(false) }
302
+ setMsg(opencode ? L('正在用一个免费模型实测这把 key…', 'Testing the key against a free model…') : L('正在用这把 key 读取模型列表…', 'Reading the model list with this key…'))
303
+ try {
304
+ if (opencode) await api('POST', '/opencode/key', { key: key.trim() })
305
+ else await api('POST', `/apikey/${source.id}`, { key: key.trim(), ...(custom ? { baseUrl: base.trim() } : {}) })
306
+ onDone()
307
+ } catch (e) {
308
+ setMsg(e.hint ? `${e.message}(${e.hint})` : e.message)
309
+ setBusy(false)
310
+ }
300
311
  }
301
- return h(Modal, { title: 'OpenCode Zen', onClose },
302
- h('p', { style: S.note }, L('需要你的 OpenCode Zen API key(在 opencode.ai 免费注册)。写入前会先用一个免费模型实测。', 'Needs your OpenCode Zen API key (free sign-up at opencode.ai). It is tested against a free model first.')),
312
+ const label = opencode ? 'OpenCode Zen' : source.label
313
+ const link = opencode ? 'https://opencode.ai/auth' : source.subscribeUrl
314
+ return h(Modal, { title: label, onClose },
315
+ h('p', { style: S.note },
316
+ opencode
317
+ ? L('需要你的 OpenCode Zen API key(要先充值)。写入前会先用一个免费模型实测。', 'Needs your OpenCode Zen API key (top-up required). It is tested against a free model first.')
318
+ : custom
319
+ ? L('填服务的地址(以 /v1 结尾)和 API key。保存前会用它读取一次模型列表,读得到才保存。', 'Enter the service URL (ending in /v1) and the API key. It reads the model list once before saving.')
320
+ : L(`填 ${label} 的 API key。保存前会用它读取一次模型列表(不花额度),读得到才保存。${source.pricing ? source.pricing.zh + '。' : ''}`, `Enter your ${label} API key. It reads the model list once before saving (no quota used).${source.pricing ? ' ' + source.pricing.en + '.' : ''}`),
321
+ link ? h('a', { href: link, target: '_blank', rel: 'noopener noreferrer', style: { color: C.accent, marginLeft: 6 } }, L('去创建 key ↗', 'Create a key ↗')) : null),
322
+ custom ? h('div', { style: { ...S.line, marginBottom: 8 } }, h('input', { style: { ...S.input, ...S.grow }, placeholder: 'https://api.example.com/v1', value: base, onChange: (e) => setBase(e.target.value) })) : null,
303
323
  h('div', { style: S.line },
304
324
  h('input', { type: 'password', style: { ...S.input, ...S.grow }, placeholder: 'API key', value: key, onChange: (e) => setKey(e.target.value) }),
305
- h('button', { type: 'button', style: S.btnPrimary, disabled: busy || !key.trim(), onClick: submit }, L('保存', 'Save'))),
325
+ h('button', { type: 'button', style: S.btnPrimary, disabled: busy || !key.trim() || (custom && !base.trim()), onClick: submit }, L('保存', 'Save'))),
306
326
  msg ? h('div', { style: { ...S.meta, marginTop: 8 } }, msg) : null)
307
327
  }
308
328
 
@@ -318,6 +338,16 @@ window.__ModuleLoader__.load({
318
338
  const [q, setQ] = useState('')
319
339
  const [msg, setMsg] = useState('')
320
340
  const [saving, setSaving] = useState(false)
341
+ const [tests, setTests] = useState({})
342
+ const test = async (id) => {
343
+ setTests((t) => ({ ...t, [id]: { running: true } }))
344
+ try {
345
+ const r = await api('POST', '/models/test', { model: id })
346
+ setTests((t) => ({ ...t, [id]: r }))
347
+ } catch (e) {
348
+ setTests((t) => ({ ...t, [id]: { ok: false, advice: e.message } }))
349
+ }
350
+ }
321
351
  useEffect(() => {
322
352
  let alive = true
323
353
  api('GET', `/sources/${source.id}/models`).then((r) => {
@@ -327,8 +357,6 @@ window.__ModuleLoader__.load({
327
357
  }, (e) => alive && setMsg(e.message))
328
358
  return () => { alive = false }
329
359
  }, [source.id])
330
- if (selfJob) return h(SelfProgress, { job: selfJob, onDone: () => { setSelfJob(null); void load(true) } })
331
- if (errCode === 'daemon_not_configured' || errCode === 'daemon_unreachable') return h(Onboarding, { code: errCode, onDone: () => void load(true) })
332
360
  if (!data) return h('div', { style: { ...S.meta, paddingLeft: 46, marginTop: 8 } }, msg || L('加载中…', 'Loading…'))
333
361
  const flip = (id) => setSel((x) => { const n = new Set(x); n.has(id) ? n.delete(id) : n.add(id); return n })
334
362
  const save = async (models) => {
@@ -351,7 +379,10 @@ window.__ModuleLoader__.load({
351
379
  h('div', { style: { maxHeight: 300, overflowY: 'auto' } }, shown.map((m) => h('label', { key: m.id, style: { ...S.line, minHeight: 26, fontSize: 13, cursor: 'pointer' } },
352
380
  h('input', { type: 'checkbox', checked: sel.has(m.id), onChange: () => flip(m.id) }),
353
381
  h('span', { style: S.grow }, m.name),
354
- m.recommended ? h('span', { style: { fontSize: 11, color: C.accent } }, L('推荐', 'Suggested')) : null))),
382
+ m.recommended ? h('span', { style: { fontSize: 11, color: C.accent } }, L('推荐', 'Suggested')) : null,
383
+ tests[m.id] ? h('span', { style: { fontSize: 11, color: tests[m.id].running ? C.sub : tests[m.id].ok ? C.ok : C.err, maxWidth: 260, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: tests[m.id].message || tests[m.id].reply || '' },
384
+ tests[m.id].running ? L('测试中…', 'Testing…') : tests[m.id].ok ? L(`可用 · ${(tests[m.id].ms / 1000).toFixed(1)} 秒${tests[m.id].reply ? ` · ${tests[m.id].reply}` : ''}`, `OK · ${(tests[m.id].ms / 1000).toFixed(1)}s${tests[m.id].reply ? ` · ${tests[m.id].reply}` : ''}`) : (tests[m.id].advice || L('不可用', 'Not working'))) : null,
385
+ h('button', { type: 'button', style: { ...S.btn, padding: '2px 8px' }, disabled: tests[m.id]?.running, onClick: (e) => { e.preventDefault(); void test(m.id) } }, L('测一下', 'Test'))))),
355
386
  msg ? h('div', { style: { fontSize: 12, color: C.err, marginTop: 6 } }, msg) : null,
356
387
  h('div', { style: { ...S.line, marginTop: 8, flexWrap: 'wrap' } },
357
388
  h('button', { type: 'button', style: S.btnPrimary, disabled: saving, onClick: () => save([...sel]) }, saving ? L('保存中…', 'Saving…') : L('保存', 'Save')),
@@ -379,7 +410,7 @@ window.__ModuleLoader__.load({
379
410
  setBusy((b) => ({ ...b, [s.id]: true }))
380
411
  try {
381
412
  if (!on) await api('POST', `/sources/${s.id}/disable`)
382
- else if (s.kind === 'opencode' && !s.loggedIn) onKey()
413
+ else if ((s.kind === 'opencode' || s.kind === 'apikey') && !s.loggedIn) onKey(s)
383
414
  else {
384
415
  let r = await api('POST', `/sources/${s.id}/enable`, {})
385
416
  if (r.riskNotice) {
@@ -396,7 +427,7 @@ window.__ModuleLoader__.load({
396
427
  }
397
428
  }
398
429
  const logout = async (s) => {
399
- if (!window.confirm(L(`退出 ${s.label} 的登录?(它的模型会从 dsh 里消失)`, `Sign out of ${s.label}? (its models disappear from dsh)`))) return
430
+ if (!window.confirm(s.kind === 'apikey' ? L(`删除 ${s.label} 的 key?(它的模型会从 dsh 里消失)`, `Remove the ${s.label} key? (its models disappear from dsh)`) : L(`退出 ${s.label} 的登录?(它的模型会从 dsh 里消失)`, `Sign out of ${s.label}? (its models disappear from dsh)`))) return
400
431
  try { await api('POST', `/sources/${s.id}/logout`); await reload() } catch (e) { setErr(e.message) }
401
432
  }
402
433
  return h('div', { style: S.section },
@@ -417,10 +448,11 @@ window.__ModuleLoader__.load({
417
448
  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
449
  h('span', { style: { ...S.meta, marginLeft: 8 } },
419
450
  s.loggedIn ? (s.account || '') : s.kind === 'local' ? L('未运行', 'Not running') : L('未登录', 'Signed out'),
420
- s.models ? ` · ${L(`${s.models} 个模型`, `${s.models} models`)}` : '')),
451
+ s.models ? ` · ${L(`${s.models} 个模型`, `${s.models} models`)}` : '',
452
+ s.loggedIn && s.kind === 'apikey' && s.detail ? ` · ${s.detail}` : '')),
421
453
  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' && s.kind !== 'local' ? h('button', { type: 'button', style: S.btn, onClick: () => logout(s) }, L('退出登录', 'Sign out')) : null,
423
- s.kind === 'opencode' && s.loggedIn ? h('button', { type: 'button', style: S.btn, onClick: onKey }, L('换 key', 'Change key')) : null),
454
+ s.loggedIn && s.kind !== 'opencode' && s.kind !== 'local' ? h('button', { type: 'button', style: S.btn, onClick: () => logout(s) }, s.kind === 'apikey' ? L('删除 key', 'Remove key') : L('退出登录', 'Sign out')) : null,
455
+ (s.kind === 'opencode' || s.kind === 'apikey') && s.loggedIn ? h('button', { type: 'button', style: S.btn, onClick: () => onKey(s) }, L('换 key', 'Change key')) : null),
424
456
  picking === s.id ? h(ModelPicker, { source: s, onClose: (saved) => { setPicking(''); if (saved) void reload() } }) : null,
425
457
  s.enabled ? h(Usage, { usage: s.usage, subscribeUrl: s.subscribeUrl }) : null,
426
458
  !s.loggedIn && s.pricing ? h('div', { style: { ...S.meta, paddingLeft: 46, marginTop: 4 } },
@@ -485,6 +517,25 @@ window.__ModuleLoader__.load({
485
517
  h('button', { type: 'button', style: S.btnPrimary, disabled: !name.trim(), onClick: add }, L('新增 key', 'Add key'))))
486
518
  }
487
519
 
520
+ /** 最近 24 小时失败的请求:哪个模型、为什么、该怎么办(只有出过错才显示) */
521
+ function FailuresCard({ failures }) {
522
+ const [all, setAll] = useState(false)
523
+ if (!failures || !failures.length) return null
524
+ const color = (k) => (k === 'rate' || k === 'upstream' ? C.warn : C.err)
525
+ const shown = all ? failures : failures.slice(0, 5)
526
+ return h('div', { style: S.section },
527
+ h('div', { style: S.h }, L(`最近失败(24 小时内 ${failures.length} 次)`, `Recent failures (${failures.length} in 24 hours)`)),
528
+ h('p', { style: S.note }, L('经统一端点失败的请求。只记录模型和报错,不记录对话内容。', 'Requests that failed through the endpoint. Only the model and the error are kept, never the conversation.')),
529
+ h('div', { style: S.card }, shown.map((f, i) => h('div', { key: `${f.at}-${i}`, style: i ? S.row : S.rowFirst },
530
+ h('div', { style: S.line },
531
+ h('span', { style: { ...S.label, ...S.mono, ...S.grow, fontSize: 13 } }, f.model || L('(未知模型)', '(unknown model)')),
532
+ h('span', { style: { fontSize: 12, color: color(f.kind) } }, `HTTP ${f.status}`),
533
+ h('span', { style: S.meta }, ago(f.at))),
534
+ h('div', { style: { fontSize: 13, color: color(f.kind), marginTop: 2 } }, f.advice),
535
+ f.message ? h('div', { style: { ...S.meta, ...S.mono, marginTop: 2, wordBreak: 'break-all' } }, f.message) : null))),
536
+ failures.length > 5 ? h('button', { type: 'button', style: { ...S.btn, marginTop: 8 }, onClick: () => setAll(!all) }, all ? L('收起', 'Show less') : L(`全部 ${failures.length} 条`, `All ${failures.length}`)) : null)
537
+ }
538
+
488
539
  function ModelsCard({ stats }) {
489
540
  const rows = Object.entries(stats?.byModel || {}).sort((a, b) => b[1].d1.requests - a[1].d1.requests).slice(0, 30)
490
541
  if (!rows.length) return null
@@ -508,18 +559,29 @@ window.__ModuleLoader__.load({
508
559
  const [res, setRes] = useState(null)
509
560
  const notInstalled = code === 'daemon_not_configured'
510
561
  const action = notInstalled ? 'setup' : 'repair'
562
+ const setupCall = async (body, retried) => {
563
+ const r = await fetch('/api-dsh-model/setup', {
564
+ method: 'POST',
565
+ credentials: 'same-origin',
566
+ headers: { 'content-type': 'application/json', 'x-dsh-model-token': token },
567
+ body: JSON.stringify(body),
568
+ })
569
+ const j = await r.json().catch(() => ({}))
570
+ if (r.status === 403 && !retried && j?.error?.code === 'refused') { await refreshToken(); return setupCall(body, true) }
571
+ return j
572
+ }
511
573
  const run = async () => {
512
574
  setBusy(true)
513
575
  setRes(null)
514
576
  try {
515
- const r = await fetch('/api-dsh-model/setup', {
516
- method: 'POST',
517
- credentials: 'same-origin',
518
- headers: { 'content-type': 'application/json', 'x-dsh-model-token': token },
519
- body: JSON.stringify({ action, lang: isZh() ? 'zh' : 'en' }),
520
- })
521
- const j = await r.json().catch(() => ({}))
522
- if (r.status === 403 && j?.error?.code === 'refused') { await refreshToken(); setBusy(false); return run() }
577
+ let j = await setupCall({ action, lang: isZh() ? 'zh' : 'en' })
578
+ if (j.vps) { setRes(j); return }
579
+ // 每秒取一次新输出,一行一行显示出来
580
+ while (j.running) {
581
+ setRes({ ...j, code: null })
582
+ await new Promise((r) => setTimeout(r, 1000))
583
+ j = await setupCall({ poll: true })
584
+ }
523
585
  setRes(j)
524
586
  if (j.code === 0) setTimeout(onDone, 1500)
525
587
  } catch (e) {
@@ -547,7 +609,7 @@ window.__ModuleLoader__.load({
547
609
  h('p', { style: { ...S.note, marginTop: 10 } }, L('也可以在终端里执行:', 'Or run in a terminal:')),
548
610
  cmdBlock(cmds)),
549
611
  res && !res.vps ? h('div', { style: { marginTop: 12 } },
550
- h('div', { style: { fontSize: 12, color: res.code === 0 ? C.ok : C.err, marginBottom: 6 } }, res.code === 0 ? L('完成,正在加载…', 'Done, loading…') : L(`没有完成(退出码 ${res.code}),输出如下:`, `Did not finish (exit code ${res.code}); output:`)),
612
+ h('div', { style: { fontSize: 12, color: res.code === null ? C.sub : res.code === 0 ? C.ok : C.err, marginBottom: 6 } }, res.code === null ? L('进行中…', 'Working…') : res.code === 0 ? L('完成,正在加载…', 'Done, loading…') : L(`没有完成(退出码 ${res.code}),输出如下:`, `Did not finish (exit code ${res.code}); output:`)),
551
613
  h('pre', { style: { ...S.mono, ...S.card, padding: 10, maxHeight: 260, overflow: 'auto', whiteSpace: 'pre-wrap', margin: 0 } }, res.output || '')) : null))
552
614
  }
553
615
 
@@ -654,19 +716,161 @@ window.__ModuleLoader__.load({
654
716
  state.phase === 'done' && upd ? h('button', { type: 'button', style: { ...S.btn, marginTop: 10 }, onClick: onDone }, L('返回', 'Back')) : null))
655
717
  }
656
718
 
719
+ /** 调宿主自己的接口(不经守护进程):/api-dsh-model/host */
720
+ async function hostCall(body, retried) {
721
+ const r = await fetch('/api-dsh-model/host', {
722
+ method: 'POST',
723
+ credentials: 'same-origin',
724
+ headers: { 'content-type': 'application/json', 'x-dsh-model-token': token },
725
+ body: JSON.stringify(body),
726
+ })
727
+ const j = await r.json().catch(() => ({}))
728
+ if (r.status === 403 && !retried && j?.error?.code === 'refused') {
729
+ await refreshToken()
730
+ return hostCall(body, true)
731
+ }
732
+ if (!r.ok) throw new Error(j?.error?.message || `HTTP ${r.status}`)
733
+ return j
734
+ }
735
+
736
+ /** 插件已更新、但 dsh 还在跑旧代码:顶部提示,VPS 上可一键重启 */
737
+ function RestartBanner({ host }) {
738
+ const [state, setState] = useState('')
739
+ if (!host || !host.installed || !host.running || host.installed === host.running) return null
740
+ const restart = async () => {
741
+ if (!window.confirm(L('重启 dsh?正在进行的对话会中断,大约十几秒后自动恢复。', 'Restart dsh? Ongoing conversations are interrupted; it comes back in about ten seconds.'))) return
742
+ setState('restarting')
743
+ try {
744
+ await hostCall({ action: 'restart' })
745
+ } catch (e) {
746
+ setState(e.message)
747
+ return
748
+ }
749
+ // 等它停下再起来:先连不上,再连得上,就刷新页面
750
+ let wasDown = false
751
+ for (let i = 0; i < 120; i++) {
752
+ await new Promise((r) => setTimeout(r, 1000))
753
+ const up = await fetch('/', { credentials: 'same-origin', cache: 'no-store' }).then((r) => r.ok || r.status === 401 || r.status === 302, () => false)
754
+ if (!up) wasDown = true
755
+ else if (wasDown) { window.location.reload(); return }
756
+ }
757
+ window.location.reload()
758
+ }
759
+ return h('div', { style: { ...S.card, padding: '10px 14px', marginBottom: 16, borderColor: C.warn } },
760
+ h('div', { style: S.line },
761
+ h('span', { style: { ...S.grow, fontSize: 13, color: C.text } },
762
+ L(`dsh-model 已更新到 ${host.installed},但 dsh 还在运行 ${host.running} 的代码,重启 dsh 后生效。`, `dsh-model was updated to ${host.installed}, but dsh is still running ${host.running}; restart dsh to apply it.`),
763
+ host.restart === 'desktop' ? h('span', { style: { color: C.sub } }, L('请完全退出 dsh(Windows 上连同右下角托盘的图标)再打开。', ' Quit dsh completely (on Windows, the tray icon too) and open it again.')) : null,
764
+ host.restart === 'manual' ? h('span', { style: { color: C.sub } }, L('请重启 dsh。', ' Please restart dsh.')) : null),
765
+ host.restart === 'vps' ? h('button', { type: 'button', style: S.btnPrimary, disabled: state === 'restarting', onClick: () => void restart() }, state === 'restarting' ? L('正在重启…', 'Restarting…') : L('重启 dsh', 'Restart dsh')) : null),
766
+ state && state !== 'restarting' ? h('div', { style: { fontSize: 12, color: C.err, marginTop: 6 } }, state) : null)
767
+ }
768
+
769
+ // —— 额度提醒(dsh 任何页面的右下角)——
770
+ /** 额度快用完的来源:积分剩 10% 以下,或某个窗口用了 90% 以上 */
771
+ function lowQuota(list) {
772
+ const out = []
773
+ for (const u of list || []) {
774
+ if (u.error || u.noAccess) continue
775
+ const label = u.label || u.source
776
+ const c = u.credits
777
+ if (c && !c.unlimited && c.total > 0 && c.remaining / c.total <= 0.1) {
778
+ const pct = Math.max(0, Math.round((c.remaining / c.total) * 100))
779
+ out.push({ key: `${u.source}:credits:${pct <= 0 ? 0 : 10}`, text: L(`${label} 积分只剩 ${pct}%(${c.remaining.toLocaleString()} / ${c.total.toLocaleString()})`, `${label} credits are down to ${pct}% (${c.remaining.toLocaleString()} / ${c.total.toLocaleString()})`) })
780
+ }
781
+ for (const w of u.windows || []) {
782
+ if (w.usedPercent != null && w.usedPercent >= 90) {
783
+ out.push({ key: `${u.source}:${w.id}:${w.usedPercent >= 100 ? 100 : 90}`, text: L(`${label} 的 ${w.label} 额度已用 ${Math.round(w.usedPercent)}%${w.resetAt ? `,${until(w.resetAt)}` : ''}`, `${label} ${w.label} quota is ${Math.round(w.usedPercent)}% used${w.resetAt ? `, ${until(w.resetAt)}` : ''}`) })
784
+ }
785
+ }
786
+ }
787
+ return out
788
+ }
789
+ const DISMISS_KEY = 'dsh-model.quotaDismissed'
790
+ const readDismissed = () => {
791
+ try { return JSON.parse(window.localStorage.getItem(DISMISS_KEY) || '{}') } catch { return {} }
792
+ }
793
+ function QuotaNotice() {
794
+ const [alerts, setAlerts] = useState([])
795
+ const [dismissed, setDismissed] = useState(readDismissed)
796
+ useEffect(() => {
797
+ let stop = false
798
+ const tick = () => api('GET', '/usage').then((u) => { if (!stop) setAlerts(lowQuota(u)) }, () => {})
799
+ void tick()
800
+ const t = setInterval(tick, 5 * 60 * 1000)
801
+ return () => { stop = true; clearInterval(t) }
802
+ }, [])
803
+ const now = Date.now()
804
+ const shown = alerts.filter((a) => !(dismissed[a.key] && now - dismissed[a.key] < 24 * 60 * 60 * 1000))
805
+ if (!shown.length) return null
806
+ const dismiss = () => {
807
+ const next = { ...dismissed }
808
+ for (const a of shown) next[a.key] = Date.now()
809
+ setDismissed(next)
810
+ try { window.localStorage.setItem(DISMISS_KEY, JSON.stringify(next)) } catch { /* 无痕模式:只在本次生效 */ }
811
+ }
812
+ return h('div', { role: 'status', style: { position: 'fixed', right: 16, bottom: 16, zIndex: 900, maxWidth: 360, background: C.base, color: C.text, border: `1px solid ${C.warn}`, borderRadius: 10, padding: '10px 12px', boxShadow: 'var(--dsw-elevation-soft, 0 8px 30px rgba(0,0,0,.2))', fontSize: 13 } },
813
+ h('div', { style: { fontWeight: 600, marginBottom: 4, color: C.warn } }, L('dsh-model:额度快用完了', 'dsh-model: quota running low')),
814
+ shown.map((a) => h('div', { key: a.key, style: { lineHeight: 1.6 } }, a.text)),
815
+ h('div', { style: { ...S.line, marginTop: 6, justifyContent: 'flex-end' } }, h('button', { type: 'button', style: S.btn, onClick: dismiss }, L('知道了', 'Got it'))))
816
+ }
817
+
818
+ // —— 首次使用向导 ——
819
+ const GUIDE_KEY = 'dsh-model.guideDone'
820
+ const readGuideDone = () => {
821
+ try { return window.localStorage.getItem(GUIDE_KEY) === '1' } catch { return false }
822
+ }
823
+ /** 刚装好、接入的来源少于两个时显示:先接一个免费来源 → 模型已经挑好 → 去 dsh 里用 */
824
+ function Guide({ sources, onConnect }) {
825
+ const [done, setDone] = useState(readGuideDone)
826
+ const connected = sources.filter((s) => s.loggedIn && s.enabled)
827
+ if (done || connected.length >= 2) return null
828
+ const free = sources.filter((s) => !s.risky && !(s.loggedIn && s.enabled) && s.pricing && s.pricing.tier !== 'paid')
829
+ const hide = () => {
830
+ setDone(true)
831
+ try { window.localStorage.setItem(GUIDE_KEY, '1') } catch { /* 无痕模式:只在本次生效 */ }
832
+ }
833
+ const step = (n, title, body) => h('div', { style: { display: 'flex', gap: 10, marginTop: n > 1 ? 12 : 0 } },
834
+ h('div', { style: { width: 22, height: 22, borderRadius: 11, background: C.accent, color: C.accentFg, fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 'none' } }, String(n)),
835
+ h('div', { style: S.grow }, h('div', { style: { ...S.label, marginBottom: 2 } }, title), body))
836
+ return h('div', { style: { ...S.section } },
837
+ h('div', { style: { ...S.card, padding: 14, borderColor: C.accent } },
838
+ h('div', { style: { ...S.line, marginBottom: 10 } },
839
+ h('div', { style: { ...S.h, ...S.grow, margin: 0 } }, L('开始使用', 'Getting started')),
840
+ h('button', { type: 'button', style: S.btn, onClick: hide }, L('我知道了,不再显示', 'Got it, hide this'))),
841
+ step(1, L('接入一个免费来源', 'Connect a free source'),
842
+ h('div', null,
843
+ h('div', { style: S.meta }, L('先接一个就能用。标「免费·额度少」的要先去对方网站拿一把免费 key。', 'One is enough to start. "Free · limited" ones need a free key from their website first.')),
844
+ h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 } },
845
+ free.slice(0, 8).map((s) => h('button', { key: s.id, type: 'button', style: S.btn, onClick: () => onConnect(s) }, `${s.label}${s.pricing.tier === 'free' ? L(' · 免费', ' · free') : ''}`))))),
846
+ step(2, L('模型已经替你挑好', 'Models are picked for you'),
847
+ h('div', { style: S.meta }, L('每个来源默认挑 5 个放进 dsh(免费的优先)。想换就在下面「来源」里点「选模型」。', 'Each source puts 5 suggested models into dsh (free ones first). Change them with "Models" under Sources below.'))),
848
+ step(3, L('去用', 'Use it'),
849
+ h('div', { style: S.meta }, L('在 dsh 的模型列表里,选来源分组下的任意模型就能开始对话。其他软件用下面的「访问地址」和 API key。', 'In dsh\'s model list, pick any model under a source group. Other tools use the endpoint and an API key below.')))))
850
+ }
851
+
657
852
  // —— 整页 ——
658
853
  function SettingsSection() {
659
854
  const [data, setData] = useState(null)
660
855
  const [err, setErr] = useState('')
661
856
  const [errCode, setErrCode] = useState('')
662
857
  const [selfJob, setSelfJob] = useState(null)
858
+ const [host, setHost] = useState(null)
859
+ useEffect(() => {
860
+ let stop = false
861
+ const check = () => hostCall({}).then((r) => { if (!stop) setHost(r) }, () => {})
862
+ void check()
863
+ const t = setInterval(check, 60_000)
864
+ return () => { stop = true; clearInterval(t) }
865
+ }, [])
866
+ const banner = h(RestartBanner, { host })
663
867
  const [login, setLogin] = useState(null)
664
868
  const [keyDialog, setKeyDialog] = useState(false)
665
869
  const alive = useRef(true)
666
870
  const load = useCallback(async (refresh) => {
667
871
  try {
668
- const [sources, keys, stats, endpoints] = await Promise.all([api('GET', refresh ? '/sources?refresh=1' : '/sources'), api('GET', '/keys'), api('GET', '/stats'), api('GET', '/endpoints').catch(() => null)])
669
- if (alive.current) { setData({ sources, keys, stats, endpoints }); setErr(''); setErrCode('') }
872
+ const [sources, keys, stats, endpoints, failures] = await Promise.all([api('GET', refresh ? '/sources?refresh=1' : '/sources'), api('GET', '/keys'), api('GET', '/stats'), api('GET', '/endpoints').catch(() => null), api('GET', '/failures').catch(() => null)])
873
+ if (alive.current) { setData({ sources, keys, stats, endpoints, failures }); setErr(''); setErrCode('') }
670
874
  } catch (e) {
671
875
  if (alive.current) { setErr(e.hint ? `${e.message}(${e.hint})` : e.message); setErrCode(e.code || '') }
672
876
  }
@@ -677,16 +881,37 @@ window.__ModuleLoader__.load({
677
881
  const t = setInterval(() => void load(false), 15000)
678
882
  return () => { alive.current = false; clearInterval(t) }
679
883
  }, [load])
884
+ // 更新 / 卸载进行中、服务没装或没在运行:整页换成对应的卡片(不能只显示一行错误)
885
+ if (selfJob) return h(SelfProgress, { job: selfJob, onDone: () => { setSelfJob(null); void load(true) } })
886
+ if (errCode === 'daemon_not_configured' || errCode === 'daemon_unreachable') return h('div', null, banner, h(Onboarding, { code: errCode, onDone: () => void load(true) }))
680
887
  if (!data) return h('div', { style: { fontSize: 13, color: err ? C.err : C.sub, padding: '8px 0' } }, err || L('加载中…', 'Loading…'))
888
+ // 向导里的「接入」:和来源开关打开时一样(要 key 的弹 key,要登录的弹登录)
889
+ const connect = async (s) => {
890
+ if ((s.kind === 'opencode' || s.kind === 'apikey') && !s.loggedIn) { setKeyDialog(s); return }
891
+ try {
892
+ let r = await api('POST', `/sources/${s.id}/enable`, {})
893
+ if (r.riskNotice) {
894
+ if (!window.confirm(r.riskNotice)) return
895
+ r = await api('POST', `/sources/${s.id}/enable`, { acceptRisk: true })
896
+ }
897
+ if (r.login) setLogin({ source: s, session: r.login })
898
+ await load(false)
899
+ } catch (e) {
900
+ setErr(`${s.label}:${e.message}${e.hint ? `(${e.hint})` : ''}`)
901
+ }
902
+ }
681
903
  return h('div', { style: { color: C.text, maxWidth: 820 } },
904
+ banner,
905
+ h(Guide, { sources: data.sources, onConnect: (s) => void connect(s) }),
682
906
  err ? h('div', { style: { fontSize: 12, color: C.err, marginBottom: 12 } }, err) : null,
683
907
  h(EndpointCard, { endpoints: data.endpoints }),
684
- h(SourcesCard, { sources: data.sources, reload: () => load(false), onLogin: (s, session) => setLogin({ source: s, session }), onKey: () => setKeyDialog(true) }),
908
+ h(SourcesCard, { sources: data.sources, reload: () => load(false), onLogin: (s, session) => setLogin({ source: s, session }), onKey: (s) => setKeyDialog(s || true) }),
909
+ h(FailuresCard, { failures: data.failures }),
685
910
  h(KeysCard, { keys: data.keys, reload: () => load(false) }),
686
911
  h(ModelsCard, { stats: data.stats }),
687
912
  h(AboutCard, { onJob: setSelfJob }),
688
913
  login ? h(LoginDialog, { source: login.source, session: login.session, onDone: () => { setLogin(null); void load(true) }, onClose: () => { setLogin(null); void load(false) } }) : null,
689
- keyDialog ? h(KeyDialog, { onDone: () => { setKeyDialog(false); void load(true) }, onClose: () => setKeyDialog(false) }) : null)
914
+ keyDialog ? h(KeyDialog, { source: keyDialog === true ? null : keyDialog, onDone: () => { setKeyDialog(false); void load(true) }, onClose: () => setKeyDialog(false) }) : null)
690
915
  }
691
916
 
692
917
  const name = 'dsh-model'
@@ -699,9 +924,15 @@ window.__ModuleLoader__.load({
699
924
  } catch (error) {
700
925
  console.warn('[dsh-model] 设置页注册失败', error)
701
926
  }
927
+ // 额度提醒挂在整个窗口的浮层上(不只设置页);注册失败不影响别的
928
+ try {
929
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'dsh-model-quota' }, QuotaNotice))
930
+ } catch (error) {
931
+ console.warn('[dsh-model] 额度提醒注册失败', error)
932
+ }
702
933
  }
703
934
 
704
- module.exports = { name, inject, apply, __test: { until, pct, ms } }
935
+ module.exports = { name, inject, apply, __test: { until, pct, ms, lowQuota } }
705
936
  return module.exports
706
937
  },
707
938
  })