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.
- package/README.en.md +9 -2
- package/README.md +9 -2
- package/dist/cli.js +5 -1
- package/dist/commands/self.js +5 -0
- package/dist/commands/sources.js +19 -0
- package/dist/daemon/core.js +89 -1
- package/dist/daemon/failures.js +80 -0
- package/dist/daemon/usage.js +1 -1
- package/dist/dsh/cli.js +6 -2
- package/dist/engine/compat.js +51 -1
- package/dist/integrations/apikeys.js +124 -0
- package/dist/integrations/dshplugin.js +14 -3
- package/dist/ops.js +2 -1
- package/dist/service/admin.js +2 -2
- package/dist/sources.js +9 -0
- package/dist/util/exec.js +11 -0
- package/package.json +4 -1
- package/plugin/client.js +259 -28
- package/plugin/index.js +77 -22
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|
package/dist/service/admin.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
})
|