dsh-model 0.18.1 → 0.19.1

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.1",
3
+ "version": "0.19.1",
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,25 +289,55 @@ 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
 
309
329
  // —— 来源 ——
310
330
  const RISKY_KEY = 'dsh-model.showRisky'
331
+ const REST_KEY = 'dsh-model.showSignedOut'
332
+ /** 用户选过就按他选的;没选过返回 null(由是否已有登录的来源决定) */
333
+ const readShowRest = () => {
334
+ try {
335
+ const v = window.localStorage.getItem(REST_KEY)
336
+ return v === '1' ? true : v === '0' ? false : null
337
+ } catch {
338
+ return null
339
+ }
340
+ }
311
341
  const readRisky = () => {
312
342
  try { return window.localStorage.getItem(RISKY_KEY) === '1' } catch { return false }
313
343
  }
@@ -318,6 +348,16 @@ window.__ModuleLoader__.load({
318
348
  const [q, setQ] = useState('')
319
349
  const [msg, setMsg] = useState('')
320
350
  const [saving, setSaving] = useState(false)
351
+ const [tests, setTests] = useState({})
352
+ const test = async (id) => {
353
+ setTests((t) => ({ ...t, [id]: { running: true } }))
354
+ try {
355
+ const r = await api('POST', '/models/test', { model: id })
356
+ setTests((t) => ({ ...t, [id]: r }))
357
+ } catch (e) {
358
+ setTests((t) => ({ ...t, [id]: { ok: false, advice: e.message } }))
359
+ }
360
+ }
321
361
  useEffect(() => {
322
362
  let alive = true
323
363
  api('GET', `/sources/${source.id}/models`).then((r) => {
@@ -349,7 +389,10 @@ window.__ModuleLoader__.load({
349
389
  h('div', { style: { maxHeight: 300, overflowY: 'auto' } }, shown.map((m) => h('label', { key: m.id, style: { ...S.line, minHeight: 26, fontSize: 13, cursor: 'pointer' } },
350
390
  h('input', { type: 'checkbox', checked: sel.has(m.id), onChange: () => flip(m.id) }),
351
391
  h('span', { style: S.grow }, m.name),
352
- m.recommended ? h('span', { style: { fontSize: 11, color: C.accent } }, L('推荐', 'Suggested')) : null))),
392
+ m.recommended ? h('span', { style: { fontSize: 11, color: C.accent } }, L('推荐', 'Suggested')) : null,
393
+ 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 || '' },
394
+ 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,
395
+ 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'))))),
353
396
  msg ? h('div', { style: { fontSize: 12, color: C.err, marginTop: 6 } }, msg) : null,
354
397
  h('div', { style: { ...S.line, marginTop: 8, flexWrap: 'wrap' } },
355
398
  h('button', { type: 'button', style: S.btnPrimary, disabled: saving, onClick: () => save([...sel]) }, saving ? L('保存中…', 'Saving…') : L('保存', 'Save')),
@@ -377,7 +420,7 @@ window.__ModuleLoader__.load({
377
420
  setBusy((b) => ({ ...b, [s.id]: true }))
378
421
  try {
379
422
  if (!on) await api('POST', `/sources/${s.id}/disable`)
380
- else if (s.kind === 'opencode' && !s.loggedIn) onKey()
423
+ else if ((s.kind === 'opencode' || s.kind === 'apikey') && !s.loggedIn) onKey(s)
381
424
  else {
382
425
  let r = await api('POST', `/sources/${s.id}/enable`, {})
383
426
  if (r.riskNotice) {
@@ -394,19 +437,19 @@ window.__ModuleLoader__.load({
394
437
  }
395
438
  }
396
439
  const logout = async (s) => {
397
- if (!window.confirm(L(`退出 ${s.label} 的登录?(它的模型会从 dsh 里消失)`, `Sign out of ${s.label}? (its models disappear from dsh)`))) return
440
+ 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
398
441
  try { await api('POST', `/sources/${s.id}/logout`); await reload() } catch (e) { setErr(e.message) }
399
442
  }
400
- return h('div', { style: S.section },
401
- h('div', { style: S.line },
402
- h('div', { style: { ...S.h, ...S.grow, margin: 0 } }, L('来源', 'Sources')),
403
- h('label', { style: { ...S.meta, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' } },
404
- h('input', { type: 'checkbox', checked: showRisky, onChange: (e) => toggleRisky(e.target.checked) }),
405
- L(`显示高风险来源${!showRisky && hidden ? `(${hidden})` : ''}`, `Show high-risk sources${!showRisky && hidden ? ` (${hidden})` : ''}`))),
406
- h('p', { style: S.note }, L('打开就接入,没登录会弹出登录;关闭只停用,登录保留。标「需付费」的来源,要先在那家开通套餐,登录后才能用。', 'Turn on to connect (signs in if needed); turning off keeps the sign-in. Sources marked Paid need a plan from that provider before they work.')),
407
- err ? h('div', { style: { fontSize: 12, color: C.err, margin: '0 0 8px' } }, err) : null,
408
- showRisky ? h('p', { style: { ...S.note, color: C.warn } }, L('高风险来源(Claude、Antigravity):服务商有封禁第三方使用订阅的先例,账号可能被封。仅在你清楚风险时使用。', 'High-risk sources (Claude, Antigravity): the providers have banned third-party use of subscriptions before; your account may be suspended. Use only if you accept the risk.')) : null,
409
- h('div', { style: S.card }, visible.map((s, i) => h('div', { key: s.id, style: i ? S.row : S.rowFirst },
443
+ // 已登录的照常列出;没登录的收进一个可折叠的列表(默认收起;一个都没登录时默认展开)
444
+ const signedIn = visible.filter((s) => s.loggedIn)
445
+ const rest = visible.filter((s) => !s.loggedIn)
446
+ const [restPref, setRestPref] = useState(readShowRest)
447
+ const openRest = restPref ?? signedIn.length === 0
448
+ const toggleRest = (v) => {
449
+ setRestPref(v)
450
+ try { window.localStorage.setItem(REST_KEY, v ? '1' : '0') } catch { /* 无痕模式:只在本次生效 */ }
451
+ }
452
+ const row = (s, i) => h('div', { key: s.id, style: i ? S.row : S.rowFirst },
410
453
  h('div', { style: S.line },
411
454
  h(Switch, { on: s.enabled, busy: busy[s.id], label: s.label, onChange: (on) => toggle(s, on) }),
412
455
  h('div', { style: S.grow },
@@ -415,17 +458,31 @@ window.__ModuleLoader__.load({
415
458
  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,
416
459
  h('span', { style: { ...S.meta, marginLeft: 8 } },
417
460
  s.loggedIn ? (s.account || '') : s.kind === 'local' ? L('未运行', 'Not running') : L('未登录', 'Signed out'),
418
- s.models ? ` · ${L(`${s.models} 个模型`, `${s.models} models`)}` : '')),
461
+ s.models ? ` · ${L(`${s.models} 个模型`, `${s.models} models`)}` : '',
462
+ s.loggedIn && s.kind === 'apikey' && s.detail ? ` · ${s.detail}` : '')),
419
463
  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,
420
- s.loggedIn && s.kind !== 'opencode' && s.kind !== 'local' ? h('button', { type: 'button', style: S.btn, onClick: () => logout(s) }, L('退出登录', 'Sign out')) : null,
421
- s.kind === 'opencode' && s.loggedIn ? h('button', { type: 'button', style: S.btn, onClick: onKey }, L('换 key', 'Change key')) : null),
464
+ 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,
465
+ (s.kind === 'opencode' || s.kind === 'apikey') && s.loggedIn ? h('button', { type: 'button', style: S.btn, onClick: () => onKey(s) }, L('换 key', 'Change key')) : null),
422
466
  picking === s.id ? h(ModelPicker, { source: s, onClose: (saved) => { setPicking(''); if (saved) void reload() } }) : null,
423
467
  s.enabled ? h(Usage, { usage: s.usage, subscribeUrl: s.subscribeUrl }) : null,
424
468
  !s.loggedIn && s.pricing ? h('div', { style: { ...S.meta, paddingLeft: 46, marginTop: 4 } },
425
469
  L(s.pricing.zh, s.pricing.en),
426
470
  s.subscribeUrl && s.kind === 'local' ? h('a', { href: s.subscribeUrl, target: '_blank', rel: 'noopener noreferrer', style: { color: C.accent, marginLeft: 8 } }, L('去下载 ↗', 'Download ↗')) : null,
427
471
  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,
428
- !s.loggedIn && s.detail ? h('div', { style: { ...S.meta, paddingLeft: 46, marginTop: 4 } }, s.detail) : null))))
472
+ !s.loggedIn && s.detail ? h('div', { style: { ...S.meta, paddingLeft: 46, marginTop: 4 } }, s.detail) : null)
473
+ return h('div', { style: S.section },
474
+ h('div', { style: S.line },
475
+ h('div', { style: { ...S.h, ...S.grow, margin: 0 } }, L('来源', 'Sources')),
476
+ h('label', { style: { ...S.meta, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' } },
477
+ h('input', { type: 'checkbox', checked: showRisky, onChange: (e) => toggleRisky(e.target.checked) }),
478
+ L(`显示高风险来源${!showRisky && hidden ? `(${hidden})` : ''}`, `Show high-risk sources${!showRisky && hidden ? ` (${hidden})` : ''}`))),
479
+ h('p', { style: S.note }, L('打开就接入,没登录会弹出登录;关闭只停用,登录保留。标「需付费」的来源,要先在那家开通套餐,登录后才能用。', 'Turn on to connect (signs in if needed); turning off keeps the sign-in. Sources marked Paid need a plan from that provider before they work.')),
480
+ err ? h('div', { style: { fontSize: 12, color: C.err, margin: '0 0 8px' } }, err) : null,
481
+ showRisky ? h('p', { style: { ...S.note, color: C.warn } }, L('高风险来源(Claude、Antigravity):服务商有封禁第三方使用订阅的先例,账号可能被封。仅在你清楚风险时使用。', 'High-risk sources (Claude, Antigravity): the providers have banned third-party use of subscriptions before; your account may be suspended. Use only if you accept the risk.')) : null,
482
+ signedIn.length ? h('div', { style: S.card }, signedIn.map(row)) : h('div', { style: { ...S.note, margin: '0 0 8px' } }, L('还没有接入任何来源:展开下面的列表挑一个。', 'No source connected yet: open the list below and pick one.')),
483
+ rest.length ? h('button', { type: 'button', style: { ...S.btn, marginTop: 10 }, 'aria-expanded': openRest, onClick: () => toggleRest(!openRest) },
484
+ openRest ? L(`▾ 收起未登录的来源(${rest.length} 个)`, `▾ Hide signed-out sources (${rest.length})`) : L(`▸ 未登录的来源(${rest.length} 个)`, `▸ Signed-out sources (${rest.length})`)) : null,
485
+ openRest && rest.length ? h('div', { style: { ...S.card, marginTop: 8 } }, rest.map(row)) : null)
429
486
  }
430
487
 
431
488
  /** 费用标签:免费 / 免费·额度少 / 需付费 */
@@ -483,6 +540,25 @@ window.__ModuleLoader__.load({
483
540
  h('button', { type: 'button', style: S.btnPrimary, disabled: !name.trim(), onClick: add }, L('新增 key', 'Add key'))))
484
541
  }
485
542
 
543
+ /** 最近 24 小时失败的请求:哪个模型、为什么、该怎么办(只有出过错才显示) */
544
+ function FailuresCard({ failures }) {
545
+ const [all, setAll] = useState(false)
546
+ if (!failures || !failures.length) return null
547
+ const color = (k) => (k === 'rate' || k === 'upstream' ? C.warn : C.err)
548
+ const shown = all ? failures : failures.slice(0, 5)
549
+ return h('div', { style: S.section },
550
+ h('div', { style: S.h }, L(`最近失败(24 小时内 ${failures.length} 次)`, `Recent failures (${failures.length} in 24 hours)`)),
551
+ h('p', { style: S.note }, L('经统一端点失败的请求。只记录模型和报错,不记录对话内容。', 'Requests that failed through the endpoint. Only the model and the error are kept, never the conversation.')),
552
+ h('div', { style: S.card }, shown.map((f, i) => h('div', { key: `${f.at}-${i}`, style: i ? S.row : S.rowFirst },
553
+ h('div', { style: S.line },
554
+ h('span', { style: { ...S.label, ...S.mono, ...S.grow, fontSize: 13 } }, f.model || L('(未知模型)', '(unknown model)')),
555
+ h('span', { style: { fontSize: 12, color: color(f.kind) } }, `HTTP ${f.status}`),
556
+ h('span', { style: S.meta }, ago(f.at))),
557
+ h('div', { style: { fontSize: 13, color: color(f.kind), marginTop: 2 } }, f.advice),
558
+ f.message ? h('div', { style: { ...S.meta, ...S.mono, marginTop: 2, wordBreak: 'break-all' } }, f.message) : null))),
559
+ 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)
560
+ }
561
+
486
562
  function ModelsCard({ stats }) {
487
563
  const rows = Object.entries(stats?.byModel || {}).sort((a, b) => b[1].d1.requests - a[1].d1.requests).slice(0, 30)
488
564
  if (!rows.length) return null
@@ -506,18 +582,29 @@ window.__ModuleLoader__.load({
506
582
  const [res, setRes] = useState(null)
507
583
  const notInstalled = code === 'daemon_not_configured'
508
584
  const action = notInstalled ? 'setup' : 'repair'
585
+ const setupCall = async (body, retried) => {
586
+ const r = await fetch('/api-dsh-model/setup', {
587
+ method: 'POST',
588
+ credentials: 'same-origin',
589
+ headers: { 'content-type': 'application/json', 'x-dsh-model-token': token },
590
+ body: JSON.stringify(body),
591
+ })
592
+ const j = await r.json().catch(() => ({}))
593
+ if (r.status === 403 && !retried && j?.error?.code === 'refused') { await refreshToken(); return setupCall(body, true) }
594
+ return j
595
+ }
509
596
  const run = async () => {
510
597
  setBusy(true)
511
598
  setRes(null)
512
599
  try {
513
- const r = await fetch('/api-dsh-model/setup', {
514
- method: 'POST',
515
- credentials: 'same-origin',
516
- headers: { 'content-type': 'application/json', 'x-dsh-model-token': token },
517
- body: JSON.stringify({ action, lang: isZh() ? 'zh' : 'en' }),
518
- })
519
- const j = await r.json().catch(() => ({}))
520
- if (r.status === 403 && j?.error?.code === 'refused') { await refreshToken(); setBusy(false); return run() }
600
+ let j = await setupCall({ action, lang: isZh() ? 'zh' : 'en' })
601
+ if (j.vps) { setRes(j); return }
602
+ // 每秒取一次新输出,一行一行显示出来
603
+ while (j.running) {
604
+ setRes({ ...j, code: null })
605
+ await new Promise((r) => setTimeout(r, 1000))
606
+ j = await setupCall({ poll: true })
607
+ }
521
608
  setRes(j)
522
609
  if (j.code === 0) setTimeout(onDone, 1500)
523
610
  } catch (e) {
@@ -545,7 +632,7 @@ window.__ModuleLoader__.load({
545
632
  h('p', { style: { ...S.note, marginTop: 10 } }, L('也可以在终端里执行:', 'Or run in a terminal:')),
546
633
  cmdBlock(cmds)),
547
634
  res && !res.vps ? h('div', { style: { marginTop: 12 } },
548
- 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:`)),
635
+ 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:`)),
549
636
  h('pre', { style: { ...S.mono, ...S.card, padding: 10, maxHeight: 260, overflow: 'auto', whiteSpace: 'pre-wrap', margin: 0 } }, res.output || '')) : null))
550
637
  }
551
638
 
@@ -652,19 +739,161 @@ window.__ModuleLoader__.load({
652
739
  state.phase === 'done' && upd ? h('button', { type: 'button', style: { ...S.btn, marginTop: 10 }, onClick: onDone }, L('返回', 'Back')) : null))
653
740
  }
654
741
 
742
+ /** 调宿主自己的接口(不经守护进程):/api-dsh-model/host */
743
+ async function hostCall(body, retried) {
744
+ const r = await fetch('/api-dsh-model/host', {
745
+ method: 'POST',
746
+ credentials: 'same-origin',
747
+ headers: { 'content-type': 'application/json', 'x-dsh-model-token': token },
748
+ body: JSON.stringify(body),
749
+ })
750
+ const j = await r.json().catch(() => ({}))
751
+ if (r.status === 403 && !retried && j?.error?.code === 'refused') {
752
+ await refreshToken()
753
+ return hostCall(body, true)
754
+ }
755
+ if (!r.ok) throw new Error(j?.error?.message || `HTTP ${r.status}`)
756
+ return j
757
+ }
758
+
759
+ /** 插件已更新、但 dsh 还在跑旧代码:顶部提示,VPS 上可一键重启 */
760
+ function RestartBanner({ host }) {
761
+ const [state, setState] = useState('')
762
+ if (!host || !host.installed || !host.running || host.installed === host.running) return null
763
+ const restart = async () => {
764
+ if (!window.confirm(L('重启 dsh?正在进行的对话会中断,大约十几秒后自动恢复。', 'Restart dsh? Ongoing conversations are interrupted; it comes back in about ten seconds.'))) return
765
+ setState('restarting')
766
+ try {
767
+ await hostCall({ action: 'restart' })
768
+ } catch (e) {
769
+ setState(e.message)
770
+ return
771
+ }
772
+ // 等它停下再起来:先连不上,再连得上,就刷新页面
773
+ let wasDown = false
774
+ for (let i = 0; i < 120; i++) {
775
+ await new Promise((r) => setTimeout(r, 1000))
776
+ const up = await fetch('/', { credentials: 'same-origin', cache: 'no-store' }).then((r) => r.ok || r.status === 401 || r.status === 302, () => false)
777
+ if (!up) wasDown = true
778
+ else if (wasDown) { window.location.reload(); return }
779
+ }
780
+ window.location.reload()
781
+ }
782
+ return h('div', { style: { ...S.card, padding: '10px 14px', marginBottom: 16, borderColor: C.warn } },
783
+ h('div', { style: S.line },
784
+ h('span', { style: { ...S.grow, fontSize: 13, color: C.text } },
785
+ 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.`),
786
+ 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,
787
+ host.restart === 'manual' ? h('span', { style: { color: C.sub } }, L('请重启 dsh。', ' Please restart dsh.')) : null),
788
+ 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),
789
+ state && state !== 'restarting' ? h('div', { style: { fontSize: 12, color: C.err, marginTop: 6 } }, state) : null)
790
+ }
791
+
792
+ // —— 额度提醒(dsh 任何页面的右下角)——
793
+ /** 额度快用完的来源:积分剩 10% 以下,或某个窗口用了 90% 以上 */
794
+ function lowQuota(list) {
795
+ const out = []
796
+ for (const u of list || []) {
797
+ if (u.error || u.noAccess) continue
798
+ const label = u.label || u.source
799
+ const c = u.credits
800
+ if (c && !c.unlimited && c.total > 0 && c.remaining / c.total <= 0.1) {
801
+ const pct = Math.max(0, Math.round((c.remaining / c.total) * 100))
802
+ 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()})`) })
803
+ }
804
+ for (const w of u.windows || []) {
805
+ if (w.usedPercent != null && w.usedPercent >= 90) {
806
+ 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)}` : ''}`) })
807
+ }
808
+ }
809
+ }
810
+ return out
811
+ }
812
+ const DISMISS_KEY = 'dsh-model.quotaDismissed'
813
+ const readDismissed = () => {
814
+ try { return JSON.parse(window.localStorage.getItem(DISMISS_KEY) || '{}') } catch { return {} }
815
+ }
816
+ function QuotaNotice() {
817
+ const [alerts, setAlerts] = useState([])
818
+ const [dismissed, setDismissed] = useState(readDismissed)
819
+ useEffect(() => {
820
+ let stop = false
821
+ const tick = () => api('GET', '/usage').then((u) => { if (!stop) setAlerts(lowQuota(u)) }, () => {})
822
+ void tick()
823
+ const t = setInterval(tick, 5 * 60 * 1000)
824
+ return () => { stop = true; clearInterval(t) }
825
+ }, [])
826
+ const now = Date.now()
827
+ const shown = alerts.filter((a) => !(dismissed[a.key] && now - dismissed[a.key] < 24 * 60 * 60 * 1000))
828
+ if (!shown.length) return null
829
+ const dismiss = () => {
830
+ const next = { ...dismissed }
831
+ for (const a of shown) next[a.key] = Date.now()
832
+ setDismissed(next)
833
+ try { window.localStorage.setItem(DISMISS_KEY, JSON.stringify(next)) } catch { /* 无痕模式:只在本次生效 */ }
834
+ }
835
+ 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 } },
836
+ h('div', { style: { fontWeight: 600, marginBottom: 4, color: C.warn } }, L('dsh-model:额度快用完了', 'dsh-model: quota running low')),
837
+ shown.map((a) => h('div', { key: a.key, style: { lineHeight: 1.6 } }, a.text)),
838
+ h('div', { style: { ...S.line, marginTop: 6, justifyContent: 'flex-end' } }, h('button', { type: 'button', style: S.btn, onClick: dismiss }, L('知道了', 'Got it'))))
839
+ }
840
+
841
+ // —— 首次使用向导 ——
842
+ const GUIDE_KEY = 'dsh-model.guideDone'
843
+ const readGuideDone = () => {
844
+ try { return window.localStorage.getItem(GUIDE_KEY) === '1' } catch { return false }
845
+ }
846
+ /** 刚装好、接入的来源少于两个时显示:先接一个免费来源 → 模型已经挑好 → 去 dsh 里用 */
847
+ function Guide({ sources, onConnect }) {
848
+ const [done, setDone] = useState(readGuideDone)
849
+ const connected = sources.filter((s) => s.loggedIn && s.enabled)
850
+ if (done || connected.length >= 2) return null
851
+ const free = sources.filter((s) => !s.risky && !(s.loggedIn && s.enabled) && s.pricing && s.pricing.tier !== 'paid')
852
+ const hide = () => {
853
+ setDone(true)
854
+ try { window.localStorage.setItem(GUIDE_KEY, '1') } catch { /* 无痕模式:只在本次生效 */ }
855
+ }
856
+ const step = (n, title, body) => h('div', { style: { display: 'flex', gap: 10, marginTop: n > 1 ? 12 : 0 } },
857
+ 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)),
858
+ h('div', { style: S.grow }, h('div', { style: { ...S.label, marginBottom: 2 } }, title), body))
859
+ return h('div', { style: { ...S.section } },
860
+ h('div', { style: { ...S.card, padding: 14, borderColor: C.accent } },
861
+ h('div', { style: { ...S.line, marginBottom: 10 } },
862
+ h('div', { style: { ...S.h, ...S.grow, margin: 0 } }, L('开始使用', 'Getting started')),
863
+ h('button', { type: 'button', style: S.btn, onClick: hide }, L('我知道了,不再显示', 'Got it, hide this'))),
864
+ step(1, L('接入一个免费来源', 'Connect a free source'),
865
+ h('div', null,
866
+ h('div', { style: S.meta }, L('先接一个就能用。标「免费·额度少」的要先去对方网站拿一把免费 key。', 'One is enough to start. "Free · limited" ones need a free key from their website first.')),
867
+ h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 } },
868
+ 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') : ''}`))))),
869
+ step(2, L('模型已经替你挑好', 'Models are picked for you'),
870
+ 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.'))),
871
+ step(3, L('去用', 'Use it'),
872
+ 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.')))))
873
+ }
874
+
655
875
  // —— 整页 ——
656
876
  function SettingsSection() {
657
877
  const [data, setData] = useState(null)
658
878
  const [err, setErr] = useState('')
659
879
  const [errCode, setErrCode] = useState('')
660
880
  const [selfJob, setSelfJob] = useState(null)
881
+ const [host, setHost] = useState(null)
882
+ useEffect(() => {
883
+ let stop = false
884
+ const check = () => hostCall({}).then((r) => { if (!stop) setHost(r) }, () => {})
885
+ void check()
886
+ const t = setInterval(check, 60_000)
887
+ return () => { stop = true; clearInterval(t) }
888
+ }, [])
889
+ const banner = h(RestartBanner, { host })
661
890
  const [login, setLogin] = useState(null)
662
891
  const [keyDialog, setKeyDialog] = useState(false)
663
892
  const alive = useRef(true)
664
893
  const load = useCallback(async (refresh) => {
665
894
  try {
666
- 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)])
667
- if (alive.current) { setData({ sources, keys, stats, endpoints }); setErr(''); setErrCode('') }
895
+ 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)])
896
+ if (alive.current) { setData({ sources, keys, stats, endpoints, failures }); setErr(''); setErrCode('') }
668
897
  } catch (e) {
669
898
  if (alive.current) { setErr(e.hint ? `${e.message}(${e.hint})` : e.message); setErrCode(e.code || '') }
670
899
  }
@@ -677,17 +906,35 @@ window.__ModuleLoader__.load({
677
906
  }, [load])
678
907
  // 更新 / 卸载进行中、服务没装或没在运行:整页换成对应的卡片(不能只显示一行错误)
679
908
  if (selfJob) return h(SelfProgress, { job: selfJob, onDone: () => { setSelfJob(null); void load(true) } })
680
- if (errCode === 'daemon_not_configured' || errCode === 'daemon_unreachable') return h(Onboarding, { code: errCode, onDone: () => void load(true) })
909
+ if (errCode === 'daemon_not_configured' || errCode === 'daemon_unreachable') return h('div', null, banner, h(Onboarding, { code: errCode, onDone: () => void load(true) }))
681
910
  if (!data) return h('div', { style: { fontSize: 13, color: err ? C.err : C.sub, padding: '8px 0' } }, err || L('加载中…', 'Loading…'))
911
+ // 向导里的「接入」:和来源开关打开时一样(要 key 的弹 key,要登录的弹登录)
912
+ const connect = async (s) => {
913
+ if ((s.kind === 'opencode' || s.kind === 'apikey') && !s.loggedIn) { setKeyDialog(s); return }
914
+ try {
915
+ let r = await api('POST', `/sources/${s.id}/enable`, {})
916
+ if (r.riskNotice) {
917
+ if (!window.confirm(r.riskNotice)) return
918
+ r = await api('POST', `/sources/${s.id}/enable`, { acceptRisk: true })
919
+ }
920
+ if (r.login) setLogin({ source: s, session: r.login })
921
+ await load(false)
922
+ } catch (e) {
923
+ setErr(`${s.label}:${e.message}${e.hint ? `(${e.hint})` : ''}`)
924
+ }
925
+ }
682
926
  return h('div', { style: { color: C.text, maxWidth: 820 } },
927
+ banner,
928
+ h(Guide, { sources: data.sources, onConnect: (s) => void connect(s) }),
683
929
  err ? h('div', { style: { fontSize: 12, color: C.err, marginBottom: 12 } }, err) : null,
684
930
  h(EndpointCard, { endpoints: data.endpoints }),
685
- h(SourcesCard, { sources: data.sources, reload: () => load(false), onLogin: (s, session) => setLogin({ source: s, session }), onKey: () => setKeyDialog(true) }),
931
+ h(SourcesCard, { sources: data.sources, reload: () => load(false), onLogin: (s, session) => setLogin({ source: s, session }), onKey: (s) => setKeyDialog(s || true) }),
932
+ h(FailuresCard, { failures: data.failures }),
686
933
  h(KeysCard, { keys: data.keys, reload: () => load(false) }),
687
934
  h(ModelsCard, { stats: data.stats }),
688
935
  h(AboutCard, { onJob: setSelfJob }),
689
936
  login ? h(LoginDialog, { source: login.source, session: login.session, onDone: () => { setLogin(null); void load(true) }, onClose: () => { setLogin(null); void load(false) } }) : null,
690
- keyDialog ? h(KeyDialog, { onDone: () => { setKeyDialog(false); void load(true) }, onClose: () => setKeyDialog(false) }) : null)
937
+ keyDialog ? h(KeyDialog, { source: keyDialog === true ? null : keyDialog, onDone: () => { setKeyDialog(false); void load(true) }, onClose: () => setKeyDialog(false) }) : null)
691
938
  }
692
939
 
693
940
  const name = 'dsh-model'
@@ -700,9 +947,15 @@ window.__ModuleLoader__.load({
700
947
  } catch (error) {
701
948
  console.warn('[dsh-model] 设置页注册失败', error)
702
949
  }
950
+ // 额度提醒挂在整个窗口的浮层上(不只设置页);注册失败不影响别的
951
+ try {
952
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'dsh-model-quota' }, QuotaNotice))
953
+ } catch (error) {
954
+ console.warn('[dsh-model] 额度提醒注册失败', error)
955
+ }
703
956
  }
704
957
 
705
- module.exports = { name, inject, apply, __test: { until, pct, ms } }
958
+ module.exports = { name, inject, apply, __test: { until, pct, ms, lowQuota } }
706
959
  return module.exports
707
960
  },
708
961
  })