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.
- 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 +291 -38
- 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.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
|
-
|
|
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
|
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
})
|