dsh-model 0.0.1 → 0.14.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.
Files changed (82) hide show
  1. package/README.en.md +123 -0
  2. package/README.md +92 -43
  3. package/bin/dsh-model.js +8 -0
  4. package/dist/bridge/aggregate.js +91 -0
  5. package/dist/bridge/login.js +127 -0
  6. package/dist/bridge/runtime.js +136 -0
  7. package/dist/bridge/server.js +178 -0
  8. package/dist/bridge/workbuddy/_compat.js +52 -0
  9. package/dist/bridge/workbuddy/app-version.js +141 -0
  10. package/dist/bridge/workbuddy/auth.js +548 -0
  11. package/dist/bridge/workbuddy/catalog.js +154 -0
  12. package/dist/bridge/workbuddy/client-identity.js +234 -0
  13. package/dist/bridge/workbuddy/desktop-credential-protection.js +992 -0
  14. package/dist/bridge/workbuddy/loopback.js +52 -0
  15. package/dist/bridge/workbuddy/probe.js +153 -0
  16. package/dist/bridge/workbuddy/status-paths.js +49 -0
  17. package/dist/bridge/workbuddy/upstream.js +1180 -0
  18. package/dist/bridge/workbuddy/variants.js +101 -0
  19. package/dist/cli.js +207 -0
  20. package/dist/commands/bridge.js +68 -0
  21. package/dist/commands/connect.js +67 -0
  22. package/dist/commands/engine.js +122 -0
  23. package/dist/commands/key.js +75 -0
  24. package/dist/commands/login.js +113 -0
  25. package/dist/commands/logs.js +19 -0
  26. package/dist/commands/opencode.js +77 -0
  27. package/dist/commands/remote.js +129 -0
  28. package/dist/commands/service.js +72 -0
  29. package/dist/commands/setup.js +216 -0
  30. package/dist/commands/sources.js +196 -0
  31. package/dist/commands/status.js +216 -0
  32. package/dist/commands/uninstall.js +97 -0
  33. package/dist/commands/workbuddy.js +117 -0
  34. package/dist/context.js +86 -0
  35. package/dist/daemon/client.js +27 -0
  36. package/dist/daemon/core.js +790 -0
  37. package/dist/daemon/service.js +33 -0
  38. package/dist/daemon/stats.js +126 -0
  39. package/dist/daemon/usage.js +213 -0
  40. package/dist/dsh/cli.js +72 -0
  41. package/dist/dsh/connect.js +257 -0
  42. package/dist/dsh/credentials.js +55 -0
  43. package/dist/dsh/locate.js +129 -0
  44. package/dist/dsh/patch.js +106 -0
  45. package/dist/dsh/pick.js +59 -0
  46. package/dist/engine/auth.js +40 -0
  47. package/dist/engine/client.js +104 -0
  48. package/dist/engine/compat.js +94 -0
  49. package/dist/engine/config.js +47 -0
  50. package/dist/engine/install.js +118 -0
  51. package/dist/engine/mgmt.js +99 -0
  52. package/dist/engine/sourcemap.js +48 -0
  53. package/dist/errors.js +14 -0
  54. package/dist/i18n.js +39 -0
  55. package/dist/integrations/dshplugin.js +42 -0
  56. package/dist/integrations/opencode.js +101 -0
  57. package/dist/integrations/workbuddy.js +114 -0
  58. package/dist/keys.js +54 -0
  59. package/dist/ops.js +178 -0
  60. package/dist/remote/public.js +109 -0
  61. package/dist/secrets.js +13 -0
  62. package/dist/service/index.js +46 -0
  63. package/dist/service/launchd.js +89 -0
  64. package/dist/service/systemd.js +83 -0
  65. package/dist/sources.js +36 -0
  66. package/dist/state.js +90 -0
  67. package/dist/upstreams.js +23 -0
  68. package/dist/util/diff.js +30 -0
  69. package/dist/util/exec.js +42 -0
  70. package/dist/util/fs.js +107 -0
  71. package/dist/util/output.js +50 -0
  72. package/dist/util/pkg.js +13 -0
  73. package/dist/util/port.js +16 -0
  74. package/dist/util/prompt.js +40 -0
  75. package/dist/util/proxy.js +73 -0
  76. package/dist/util/redact.js +7 -0
  77. package/engine-manifest.json +28 -0
  78. package/package.json +77 -7
  79. package/plugin/client.js +549 -0
  80. package/plugin/cordis.patch.yml +4 -0
  81. package/plugin/index.js +177 -0
  82. package/docs/DESIGN.md +0 -610
@@ -0,0 +1,549 @@
1
+ /* global window, document, fetch, navigator, setTimeout, clearTimeout, setInterval, clearInterval */
2
+ // plugin/client.js — dsh-model 的设置页(设计 §14)
3
+ //
4
+ // 手写单文件 bundle,没有构建链:由 dsh web 客户端的 ModuleLoader 注入(写法照 dsh-vps-manager)。
5
+ // 挂在「设置 → dsh-model」:来源开关 + 订阅用量、API key 状态、模型统计。
6
+ // 界面只通过 /api-dsh-model/call 调宿主,宿主再转给 dsh-model 守护进程;页面拿不到任何密钥。
7
+ // 硬约束:界面崩了不能影响 dsh,注册一律包在 try/catch 里。
8
+
9
+ window.__ModuleLoader__.load({
10
+ id: 'dsh-model',
11
+ factory: (require) => {
12
+ const module = { exports: {} }
13
+ const React = require('react')
14
+ const { useCallback, useEffect, useRef, useState } = React
15
+ const h = React.createElement
16
+
17
+ // —— 语言:跟着页面(dsh 只有中文、英文两种)——
18
+ const isZh = () => {
19
+ const l = (document.documentElement.lang || navigator.language || 'zh').toLowerCase()
20
+ return l.startsWith('zh')
21
+ }
22
+ const L = (zh, en) => (isZh() ? zh : en)
23
+
24
+ // —— 调宿主 ——
25
+ let token = window.__DSH_MODEL_TOKEN__ || ''
26
+ async function refreshToken() {
27
+ const r = await fetch('/api-dsh-model/token', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', credentials: 'same-origin' })
28
+ const j = await r.json().catch(() => ({}))
29
+ if (j.token) token = j.token
30
+ }
31
+ async function api(method, path, body, retried) {
32
+ const r = await fetch('/api-dsh-model/call', {
33
+ method: 'POST',
34
+ credentials: 'same-origin',
35
+ headers: { 'content-type': 'application/json', 'x-dsh-model-token': token },
36
+ body: JSON.stringify({ method, path, ...(body !== undefined ? { body } : {}) }),
37
+ })
38
+ const j = await r.json().catch(() => ({}))
39
+ if (r.status === 403 && !retried && j?.error?.code === 'refused') {
40
+ await refreshToken()
41
+ return api(method, path, body, true)
42
+ }
43
+ if (!r.ok || j?.error) {
44
+ const e = new Error(j?.error?.message || `HTTP ${r.status}`)
45
+ e.code = j?.error?.code
46
+ e.hint = j?.error?.hint
47
+ throw e
48
+ }
49
+ return j
50
+ }
51
+
52
+ // —— 样式(用 dsh 的颜色变量,跟随明暗主题)——
53
+ const C = {
54
+ text: 'var(--dsw-alias-label-primary, inherit)',
55
+ sub: 'var(--dsw-alias-label-secondary, #888)',
56
+ faint: 'var(--dsw-alias-label-tertiary, #999)',
57
+ border: 'var(--dsw-alias-border-l3, rgba(127,127,127,.25))',
58
+ layer: 'var(--dsw-alias-bg-layer-1, transparent)',
59
+ base: 'var(--dsw-alias-bg-base, #fff)',
60
+ ok: 'var(--dsw-alias-state-success-primary, #1a7f37)',
61
+ warn: 'var(--dsw-alias-state-warn-primary, #b7791f)',
62
+ err: 'var(--dsw-alias-state-error-primary, #cf222e)',
63
+ accent: 'var(--primary, #4d6bfe)',
64
+ accentFg: 'var(--primary-foreground, #fff)',
65
+ }
66
+ const S = {
67
+ section: { margin: '0 0 28px' },
68
+ h: { fontSize: 15, fontWeight: 600, margin: '0 0 4px', color: C.text },
69
+ note: { fontSize: 12, color: C.sub, margin: '0 0 12px', lineHeight: 1.6 },
70
+ card: { border: `1px solid ${C.border}`, borderRadius: 10, background: C.layer, overflow: 'hidden' },
71
+ row: { padding: '12px 14px', borderTop: `1px solid ${C.border}` },
72
+ rowFirst: { padding: '12px 14px' },
73
+ line: { display: 'flex', alignItems: 'center', gap: 10, minHeight: 28 },
74
+ label: { fontSize: 14, fontWeight: 500, color: C.text },
75
+ meta: { fontSize: 12, color: C.sub },
76
+ grow: { flex: 1, minWidth: 0 },
77
+ btn: { fontSize: 12, padding: '4px 10px', borderRadius: 6, border: `1px solid ${C.border}`, background: 'transparent', color: C.text, cursor: 'pointer' },
78
+ btnPrimary: { fontSize: 12, padding: '5px 12px', borderRadius: 6, border: 'none', background: C.accent, color: C.accentFg, cursor: 'pointer' },
79
+ input: { fontSize: 13, padding: '6px 8px', borderRadius: 6, border: `1px solid ${C.border}`, background: C.base, color: C.text, minWidth: 0 },
80
+ mono: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 12 },
81
+ table: { width: '100%', borderCollapse: 'collapse', fontSize: 12 },
82
+ th: { textAlign: 'left', fontWeight: 500, color: C.sub, padding: '6px 10px', borderBottom: `1px solid ${C.border}` },
83
+ td: { padding: '6px 10px', borderBottom: `1px solid ${C.border}`, color: C.text, whiteSpace: 'nowrap' },
84
+ }
85
+
86
+ // —— 小部件 ——
87
+ function Switch({ on, busy, onChange, label }) {
88
+ return h('button', {
89
+ type: 'button',
90
+ role: 'switch',
91
+ 'aria-checked': on,
92
+ 'aria-label': label,
93
+ disabled: busy,
94
+ onClick: () => onChange(!on),
95
+ style: {
96
+ width: 36, height: 20, borderRadius: 10, border: 'none', padding: 2, cursor: busy ? 'wait' : 'pointer', flex: 'none',
97
+ background: on ? C.ok : 'var(--dsw-alias-border-l4, rgba(127,127,127,.4))', opacity: busy ? 0.6 : 1, transition: 'background .15s',
98
+ },
99
+ }, h('span', { style: { display: 'block', width: 16, height: 16, borderRadius: 8, background: '#fff', transform: on ? 'translateX(16px)' : 'none', transition: 'transform .15s' } }))
100
+ }
101
+
102
+ function Bar({ pct }) {
103
+ const p = Math.max(0, Math.min(100, pct))
104
+ const color = p >= 90 ? C.err : p >= 70 ? C.warn : C.ok
105
+ return h('span', { style: { display: 'inline-block', width: 120, height: 6, borderRadius: 3, background: 'var(--dsw-alias-border-l2, rgba(127,127,127,.2))', verticalAlign: 'middle', overflow: 'hidden' } },
106
+ h('span', { style: { display: 'block', width: `${p}%`, height: '100%', background: color } }))
107
+ }
108
+
109
+ const until = (iso) => {
110
+ if (!iso) return ''
111
+ const ms = Date.parse(iso) - Date.now()
112
+ if (!(ms > 0)) return L('即将重置', 'resetting')
113
+ const m = Math.round(ms / 60000)
114
+ const d = Math.floor(m / 1440)
115
+ const hh = Math.floor((m % 1440) / 60)
116
+ const mm = m % 60
117
+ const t = d ? L(`${d} 天 ${hh} 小时`, `${d}d ${hh}h`) : hh ? L(`${hh} 小时 ${mm} 分`, `${hh}h ${mm}m`) : L(`${mm} 分`, `${mm}m`)
118
+ return L(`${t}后重置`, `resets in ${t}`)
119
+ }
120
+ const winLabel = (w) => ({ '5h': L('5 小时', '5 hours'), '7d': L('每周', 'Weekly'), '30d': L('每月', 'Monthly'), quota: L('总额度', 'Quota'), 'Opus 7d': L('Opus 每周', 'Opus weekly'), 'Sonnet 7d': L('Sonnet 每周', 'Sonnet weekly') }[w.label] || w.label)
121
+ const pct = (x) => (x == null ? '-' : `${Math.round(x * 100)}%`)
122
+ const ms = (x) => (x == null ? '-' : x >= 1000 ? `${(x / 1000).toFixed(1)}s` : `${x}ms`)
123
+ const ago = (iso) => {
124
+ if (!iso) return '-'
125
+ const m = Math.round((Date.now() - Date.parse(iso)) / 60000)
126
+ return m < 1 ? L('刚刚', 'just now') : m < 60 ? L(`${m} 分钟前`, `${m}m ago`) : m < 1440 ? L(`${Math.round(m / 60)} 小时前`, `${Math.round(m / 60)}h ago`) : L(`${Math.round(m / 1440)} 天前`, `${Math.round(m / 1440)}d ago`)
127
+ }
128
+ const rateColor = (x) => (x == null ? C.sub : x >= 0.98 ? C.ok : x >= 0.9 ? C.warn : C.err)
129
+
130
+ function Usage({ usage, subscribeUrl }) {
131
+ if (!usage) return null
132
+ if (usage.noAccess) {
133
+ return h('div', { style: { ...S.line, alignItems: 'flex-start', fontSize: 12, color: C.warn, marginTop: 6, paddingLeft: 46, lineHeight: 1.6 } },
134
+ h('span', { style: S.grow }, L(`当前账号没有可用订阅,调用会被拒绝(不会扣费),所以它的模型已在 dsh 中隐藏。开通后把开关关掉再打开即可恢复。${usage.error ? `(${usage.error})` : ''}`, 'This account has no usable subscription — calls are refused (no charge), so its models are hidden in dsh. After subscribing, turn the switch off and on again.')),
135
+ subscribeUrl ? h('a', { href: subscribeUrl, target: '_blank', rel: 'noopener noreferrer', style: { ...S.btnPrimary, textDecoration: 'none', flex: 'none' } }, L('去开通 ↗', 'Subscribe ↗')) : null)
136
+ }
137
+ if (usage.unsupported) return h('div', { style: { ...S.meta, marginTop: 6, paddingLeft: 46 } }, L('已实测可以调用;用量暂不支持查询', 'Tested OK; usage not available yet'))
138
+ const lines = []
139
+ if (usage.plan) lines.push(h('div', { key: 'plan', style: { ...S.meta, marginTop: 6 } }, `${L('套餐', 'Plan')}:${usage.plan}`))
140
+ for (const w of usage.windows || []) {
141
+ lines.push(h('div', { key: w.id, style: { ...S.line, minHeight: 22, fontSize: 12, color: C.text } },
142
+ h('span', { style: { width: 72, color: C.sub } }, winLabel(w)),
143
+ w.usedPercent != null ? h(Bar, { pct: w.usedPercent }) : null,
144
+ w.usedPercent != null ? h('span', { style: { width: 40, textAlign: 'right' } }, `${Math.round(w.usedPercent)}%`) : null,
145
+ w.used != null && w.limit != null ? h('span', { style: { color: C.sub } }, `${w.used}/${w.limit}`) : null,
146
+ h('span', { style: { color: C.faint } }, until(w.resetAt))))
147
+ }
148
+ if (usage.credits) {
149
+ const c = usage.credits
150
+ lines.push(h('div', { key: 'credits', style: { ...S.line, minHeight: 22, fontSize: 12 } },
151
+ h('span', { style: { width: 72, color: C.sub } }, L('积分', 'Credits')),
152
+ c.total ? h(Bar, { pct: 100 - (c.remaining / c.total) * 100 }) : null,
153
+ h('span', null, c.unlimited ? L('不限量', 'Unlimited') : L(`剩余 ${c.remaining.toLocaleString()}${c.total ? ` / ${c.total.toLocaleString()}` : ''}`, `${c.remaining.toLocaleString()} left${c.total ? ` of ${c.total.toLocaleString()}` : ''}`))))
154
+ }
155
+ if (usage.error) lines.push(h('div', { key: 'err', style: { fontSize: 12, color: C.warn, marginTop: 4 } }, `${L('用量暂不可用', 'Usage unavailable')}:${usage.error}`))
156
+ return h('div', { style: { marginTop: 4, paddingLeft: 46 } }, lines)
157
+ }
158
+
159
+ // —— 登录弹窗 ——
160
+ function Modal({ title, children, onClose }) {
161
+ return h('div', {
162
+ role: 'dialog', 'aria-modal': true,
163
+ style: { position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 },
164
+ onClick: (e) => { if (e.target === e.currentTarget) onClose() },
165
+ }, h('div', { style: { width: 'min(520px, 100%)', background: C.base, color: C.text, borderRadius: 12, padding: 20, boxShadow: 'var(--dsw-elevation-soft, 0 8px 30px rgba(0,0,0,.2))' } },
166
+ h('div', { style: { ...S.line, marginBottom: 12 } }, h('div', { style: { ...S.h, ...S.grow, margin: 0 } }, title), h('button', { type: 'button', style: S.btn, onClick: onClose }, L('关闭', 'Close'))),
167
+ children))
168
+ }
169
+
170
+ async function copy(text) {
171
+ try {
172
+ await navigator.clipboard.writeText(text)
173
+ return true
174
+ } catch {
175
+ // 非安全上下文(http 访问)没有 clipboard API:退回老办法
176
+ try {
177
+ const t = document.createElement('textarea')
178
+ t.value = text
179
+ t.style.position = 'fixed'
180
+ t.style.opacity = '0'
181
+ document.body.appendChild(t)
182
+ t.select()
183
+ const done = document.execCommand('copy')
184
+ t.remove()
185
+ return done
186
+ } catch {
187
+ return false
188
+ }
189
+ }
190
+ }
191
+
192
+ /** 复制按钮:get 可以是异步取值(例如取完整 key);点完显示"已复制" */
193
+ function CopyButton({ get, label, style }) {
194
+ const [state, setState] = useState('')
195
+ const timer = useRef(null)
196
+ useEffect(() => () => clearTimeout(timer.current), [])
197
+ const onClick = async () => {
198
+ let done = false
199
+ try { done = await copy(typeof get === 'function' ? await get() : get) } catch { done = false }
200
+ setState(done ? 'ok' : 'fail')
201
+ clearTimeout(timer.current)
202
+ timer.current = setTimeout(() => setState(''), 1500)
203
+ }
204
+ return h('button', { type: 'button', style: { ...S.btn, ...(style || {}), ...(state === 'ok' ? { color: C.ok } : state === 'fail' ? { color: C.err } : {}) }, onClick },
205
+ state === 'ok' ? L('已复制', 'Copied') : state === 'fail' ? L('复制失败', 'Copy failed') : (label || L('复制', 'Copy')))
206
+ }
207
+
208
+ // —— 访问地址 ——
209
+ function EndpointCard({ endpoints }) {
210
+ if (!endpoints) return null
211
+ const rows = [
212
+ ...(endpoints.public ? [{ id: 'public', name: L('外网地址', 'Public'), url: endpoints.public, note: L('你的电脑、手机或其他软件用这个', 'Use this from your computer, phone or other tools') }] : []),
213
+ { id: 'local', name: L('本机地址', 'Local'), url: endpoints.local, note: endpoints.public ? L('只在这台服务器上能用(dsh 自己用的就是它)', 'Only works on this server (dsh itself uses it)') : L('只在本机能用;要从别的设备访问,在服务器上执行 dsh-model remote enable', 'Only works on this machine; to reach it from other devices run dsh-model remote enable') },
214
+ ]
215
+ return h('div', { style: S.section },
216
+ h('div', { style: S.h }, L('访问地址', 'Endpoint')),
217
+ h('p', { style: S.note }, L('所有模型都经同一个 OpenAI 兼容端点提供:Base URL 填下面的地址,API key 用下方任意一把。', 'Every model is served from one OpenAI-compatible endpoint: use the address below as Base URL and any key below as the API key.')),
218
+ h('div', { style: S.card }, rows.map((r, i) => h('div', { key: r.id, style: i ? S.row : S.rowFirst },
219
+ h('div', { style: S.line },
220
+ h('span', { style: { ...S.label, width: 72, flex: 'none' } }, r.name),
221
+ h('code', { style: { ...S.mono, ...S.grow, wordBreak: 'break-all' } }, r.url),
222
+ h(CopyButton, { get: r.url })),
223
+ h('div', { style: { ...S.meta, paddingLeft: 82 } }, r.note)))))
224
+ }
225
+
226
+ function LoginDialog({ source, session, onDone, onClose }) {
227
+ const [s, setS] = useState(session)
228
+ const [paste, setPaste] = useState('')
229
+ const [msg, setMsg] = useState('')
230
+ useEffect(() => {
231
+ let stop = false
232
+ const tick = async () => {
233
+ if (stop) return
234
+ try {
235
+ const st = await api('GET', `/login/${encodeURIComponent(session.id)}`)
236
+ setS(st)
237
+ if (st.status === 'ok') { onDone(); return }
238
+ if (st.status === 'error' || st.status === 'cancelled') return
239
+ } catch { /* 网络抖动:下一轮 */ }
240
+ setTimeout(tick, 2000)
241
+ }
242
+ const t = setTimeout(tick, 2000)
243
+ return () => { stop = true; clearTimeout(t) }
244
+ }, [session.id])
245
+ const submit = async (value) => {
246
+ const v = String(value ?? paste).trim()
247
+ if (!v) return
248
+ if (!/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/.*[?&]code=/i.test(v)) {
249
+ setMsg(L('这不像授权后的跳转地址:应以 http://localhost 或 http://127.0.0.1 开头,并带 code=', 'This does not look like the redirect address: it should start with http://localhost or http://127.0.0.1 and contain code='))
250
+ return
251
+ }
252
+ setMsg(L('已提交,等待确认…', 'Submitted, waiting…'))
253
+ try { await api('POST', `/login/${encodeURIComponent(session.id)}/callback`, { redirectUrl: v }) } catch (e) { setMsg(e.message) }
254
+ }
255
+ const fromClipboard = async () => {
256
+ try {
257
+ const t = (await navigator.clipboard.readText()).trim()
258
+ setPaste(t)
259
+ await submit(t)
260
+ } catch {
261
+ setMsg(L('浏览器不允许读剪贴板:请手动粘贴到输入框', 'The browser blocked clipboard access: paste into the box manually'))
262
+ }
263
+ }
264
+ const cancel = async () => {
265
+ try { await api('DELETE', `/login/${encodeURIComponent(session.id)}`) } catch { /* ignore */ }
266
+ onClose()
267
+ }
268
+ return h(Modal, { title: L(`登录 ${source.label}`, `Sign in to ${source.label}`), onClose: cancel },
269
+ h('p', { style: S.note }, L('在浏览器里打开下面的链接并授权(这台服务器不需要浏览器,用你自己的电脑或手机都行)。', 'Open this link in a browser and approve (any device works; this server needs no browser).')),
270
+ h('div', { style: { ...S.line, marginBottom: 10 } },
271
+ h('a', { href: s.url, target: '_blank', rel: 'noopener noreferrer', style: { ...S.btnPrimary, textDecoration: 'none' } }, L('打开授权页', 'Open authorization page')),
272
+ h('button', { type: 'button', style: S.btn, onClick: () => copy(s.url) }, L('复制链接', 'Copy link'))),
273
+ s.userCode ? h('div', { style: { ...S.line, marginBottom: 10 } },
274
+ h('span', { style: S.meta }, L('授权页要求输入的码:', 'Code to enter:')),
275
+ h('span', { style: { ...S.mono, fontSize: 18, fontWeight: 600, letterSpacing: 2 } }, s.userCode),
276
+ h('button', { type: 'button', style: S.btn, onClick: () => copy(s.userCode) }, L('复制', 'Copy'))) : null,
277
+ s.needsPaste ? h('div', { style: { marginBottom: 10 } },
278
+ h('div', { style: { ...S.note, color: C.text } },
279
+ h('div', null, L('授权完成后还差一步:', 'One more step after approving:')),
280
+ h('div', null, L('① 浏览器会跳到一个打不开的页面(「无法访问此网站」「拒绝连接」),地址以 http://localhost 或 http://127.0.0.1 开头——这是正常的。', '① The browser lands on a page that cannot load ("This site can\'t be reached"), starting with http://localhost or http://127.0.0.1 — that is expected.')),
281
+ h('div', null, L('② 复制那个页面地址栏里的完整地址,回到这里点「从剪贴板粘贴」(或粘到输入框点提交)。', '② Copy the full address from its address bar, come back and press "Paste from clipboard" (or paste into the box and submit).')),
282
+ h('div', { style: { color: C.sub } }, L(`这个跳转地址是 ${source.label} 写死的,服务商只接受它,没法换成网址;所以要你把它带回来。`, `${source.label} fixes this redirect address and accepts nothing else, so it cannot be a web URL; you carry it back instead.`))),
283
+ h('div', { style: S.line },
284
+ h('input', { style: { ...S.input, ...S.grow }, placeholder: 'http://127.0.0.1:…/callback?code=…', value: paste, onChange: (e) => setPaste(e.target.value), onPaste: (e) => { const t = e.clipboardData?.getData('text'); if (t) setTimeout(() => void submit(t), 0) } }),
285
+ h('button', { type: 'button', style: S.btnPrimary, onClick: fromClipboard }, L('从剪贴板粘贴', 'Paste from clipboard')),
286
+ h('button', { type: 'button', style: S.btn, onClick: () => void submit() }, L('提交', 'Submit')))) : null,
287
+ h('p', { style: { ...S.note, marginTop: 4 } }, L('授权页本身报错(例如 Operation timed out、糟糕出错了)多半是你的浏览器访问该网站的网络问题:换个代理节点,在那个页面点「重试」。', 'If the authorization page itself errors (e.g. "Operation timed out"), it is usually your browser\'s network path to that site: switch proxy nodes and press Retry on that page.')),
288
+ h('div', { style: { fontSize: 12, color: s.status === 'error' ? C.err : C.sub } },
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
+ }
291
+
292
+ function KeyDialog({ onDone, onClose }) {
293
+ const [key, setKey] = useState('')
294
+ const [busy, setBusy] = useState(false)
295
+ const [msg, setMsg] = useState('')
296
+ const submit = async () => {
297
+ 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) }
300
+ }
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.')),
303
+ h('div', { style: S.line },
304
+ 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'))),
306
+ msg ? h('div', { style: { ...S.meta, marginTop: 8 } }, msg) : null)
307
+ }
308
+
309
+ // —— 来源 ——
310
+ const RISKY_KEY = 'dsh-model.showRisky'
311
+ const readRisky = () => {
312
+ try { return window.localStorage.getItem(RISKY_KEY) === '1' } catch { return false }
313
+ }
314
+ /** 勾选这个来源的哪些模型显示在 dsh 里(统一端点照常提供全部) */
315
+ function ModelPicker({ source, onClose }) {
316
+ const [data, setData] = useState(null)
317
+ const [sel, setSel] = useState(new Set())
318
+ const [q, setQ] = useState('')
319
+ const [msg, setMsg] = useState('')
320
+ const [saving, setSaving] = useState(false)
321
+ useEffect(() => {
322
+ let alive = true
323
+ api('GET', `/sources/${source.id}/models`).then((r) => {
324
+ if (!alive) return
325
+ setData(r)
326
+ setSel(new Set(r.models.filter((m) => m.selected).map((m) => m.id)))
327
+ }, (e) => alive && setMsg(e.message))
328
+ return () => { alive = false }
329
+ }, [source.id])
330
+ if (!data) return h('div', { style: { ...S.meta, paddingLeft: 46, marginTop: 8 } }, msg || L('加载中…', 'Loading…'))
331
+ const flip = (id) => setSel((x) => { const n = new Set(x); n.has(id) ? n.delete(id) : n.add(id); return n })
332
+ const save = async (models) => {
333
+ setSaving(true)
334
+ setMsg('')
335
+ try {
336
+ await api('POST', `/sources/${source.id}/models`, { models })
337
+ onClose(true)
338
+ } catch (e) {
339
+ setMsg(e.message)
340
+ } finally {
341
+ setSaving(false)
342
+ }
343
+ }
344
+ const shown = data.models.filter((m) => !q || `${m.name} ${m.id}`.toLowerCase().includes(q.toLowerCase()))
345
+ return h('div', { style: { marginTop: 10, marginLeft: 46, padding: 10, border: `1px solid ${C.border}`, borderRadius: 8 } },
346
+ h('div', { style: { ...S.line, marginBottom: 6, flexWrap: 'wrap' } },
347
+ h('span', { style: { ...S.meta, ...S.grow } }, L(`勾选的模型显示在 dsh 的模型列表里(已选 ${sel.size} / ${data.models.length})。其他软件经统一端点仍可用全部模型。`, `Checked models appear in dsh's model list (${sel.size} / ${data.models.length} selected). Other tools can still use every model through the endpoint.`)),
348
+ data.models.length > 10 ? h('input', { style: { ...S.input, width: 140 }, placeholder: L('搜索', 'Search'), value: q, onChange: (e) => setQ(e.target.value) }) : null),
349
+ 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
+ h('input', { type: 'checkbox', checked: sel.has(m.id), onChange: () => flip(m.id) }),
351
+ h('span', { style: S.grow }, m.name),
352
+ m.recommended ? h('span', { style: { fontSize: 11, color: C.accent } }, L('推荐', 'Suggested')) : null))),
353
+ msg ? h('div', { style: { fontSize: 12, color: C.err, marginTop: 6 } }, msg) : null,
354
+ h('div', { style: { ...S.line, marginTop: 8, flexWrap: 'wrap' } },
355
+ h('button', { type: 'button', style: S.btnPrimary, disabled: saving, onClick: () => save([...sel]) }, saving ? L('保存中…', 'Saving…') : L('保存', 'Save')),
356
+ h('button', { type: 'button', style: S.btn, disabled: saving, onClick: () => save(null) }, L('恢复推荐', 'Use suggested')),
357
+ h('button', { type: 'button', style: S.btn, onClick: () => setSel(new Set(data.models.map((m) => m.id))) }, L('全选', 'All')),
358
+ h('button', { type: 'button', style: S.btn, onClick: () => setSel(new Set()) }, L('全不选', 'None')),
359
+ h('span', { style: S.grow }),
360
+ h('button', { type: 'button', style: S.btn, onClick: () => onClose(false) }, L('取消', 'Cancel'))))
361
+ }
362
+
363
+ function SourcesCard({ sources, reload, onLogin, onKey }) {
364
+ const [picking, setPicking] = useState('')
365
+ const [busy, setBusy] = useState({})
366
+ const [err, setErr] = useState('')
367
+ const [showRisky, setShowRisky] = useState(readRisky)
368
+ const toggleRisky = (v) => {
369
+ setShowRisky(v)
370
+ try { window.localStorage.setItem(RISKY_KEY, v ? '1' : '0') } catch { /* 无痕模式等:只在本次生效 */ }
371
+ }
372
+ // 高风险来源默认隐藏;已登录的照常显示,方便退出登录
373
+ const visible = sources.filter((s) => showRisky || !s.risky || s.loggedIn)
374
+ const hidden = sources.length - visible.length
375
+ const toggle = async (s, on) => {
376
+ setErr('')
377
+ setBusy((b) => ({ ...b, [s.id]: true }))
378
+ try {
379
+ if (!on) await api('POST', `/sources/${s.id}/disable`)
380
+ else if (s.kind === 'opencode' && !s.loggedIn) onKey()
381
+ else {
382
+ let r = await api('POST', `/sources/${s.id}/enable`, {})
383
+ if (r.riskNotice) {
384
+ if (!window.confirm(r.riskNotice)) return
385
+ r = await api('POST', `/sources/${s.id}/enable`, { acceptRisk: true })
386
+ }
387
+ if (r.login) onLogin(s, r.login)
388
+ }
389
+ await reload()
390
+ } catch (e) {
391
+ setErr(`${s.label}:${e.message}`)
392
+ } finally {
393
+ setBusy((b) => ({ ...b, [s.id]: false }))
394
+ }
395
+ }
396
+ const logout = async (s) => {
397
+ if (!window.confirm(L(`退出 ${s.label} 的登录?(它的模型会从 dsh 里消失)`, `Sign out of ${s.label}? (its models disappear from dsh)`))) return
398
+ try { await api('POST', `/sources/${s.id}/logout`); await reload() } catch (e) { setErr(e.message) }
399
+ }
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 },
410
+ h('div', { style: S.line },
411
+ h(Switch, { on: s.enabled, busy: busy[s.id], label: s.label, onChange: (on) => toggle(s, on) }),
412
+ h('div', { style: S.grow },
413
+ h('span', { style: S.label }, s.label),
414
+ h(PriceTag, { pricing: s.pricing }),
415
+ 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
+ h('span', { style: { ...S.meta, marginLeft: 8 } },
417
+ s.loggedIn ? (s.account || '') : L('未登录', 'Signed out'),
418
+ s.models ? ` · ${L(`${s.models} 个模型`, `${s.models} models`)}` : '')),
419
+ 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' ? 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),
422
+ picking === s.id ? h(ModelPicker, { source: s, onClose: (saved) => { setPicking(''); if (saved) void reload() } }) : null,
423
+ s.enabled ? h(Usage, { usage: s.usage, subscribeUrl: s.subscribeUrl }) : null,
424
+ !s.loggedIn && s.pricing ? h('div', { style: { ...S.meta, paddingLeft: 46, marginTop: 4 } },
425
+ L(s.pricing.zh, s.pricing.en),
426
+ 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,
427
+ !s.loggedIn && s.detail ? h('div', { style: { ...S.meta, paddingLeft: 46, marginTop: 4 } }, s.detail) : null))))
428
+ }
429
+
430
+ /** 费用标签:免费 / 免费·额度少 / 需付费 */
431
+ function PriceTag({ pricing }) {
432
+ if (!pricing) return null
433
+ const color = pricing.tier === 'free' ? C.ok : pricing.tier === 'limited' ? C.warn : C.err
434
+ const text = pricing.tier === 'free' ? L('免费', 'Free') : pricing.tier === 'limited' ? L('免费·额度少', 'Free · limited') : L('需付费', 'Paid')
435
+ return h('span', { title: L(pricing.zh, pricing.en), style: { fontSize: 11, color, marginLeft: 6, border: `1px solid ${color}`, borderRadius: 4, padding: '0 4px', whiteSpace: 'nowrap' } }, text)
436
+ }
437
+
438
+ // —— key ——
439
+ function KeysCard({ keys, reload }) {
440
+ const [name, setName] = useState('')
441
+ const [fresh, setFresh] = useState(null)
442
+ const [err, setErr] = useState('')
443
+ const add = async () => {
444
+ setErr('')
445
+ try { const r = await api('POST', '/keys', { name: name.trim() }); setFresh(r); setName(''); await reload() } catch (e) { setErr(e.message) }
446
+ }
447
+ const rotate = async (k) => {
448
+ if (!window.confirm(L(`轮换 ${k.name}?旧 key 立即失效。`, `Rotate ${k.name}? The old key stops working immediately.`))) return
449
+ try { const r = await api('POST', `/keys/${encodeURIComponent(k.name)}/rotate`); if (r.key) setFresh(r); await reload() } catch (e) { setErr(e.message) }
450
+ }
451
+ const revoke = async (k) => {
452
+ if (!window.confirm(L(`吊销 ${k.name}?用它的设备会立即无法访问。`, `Revoke ${k.name}? Devices using it lose access immediately.`))) return
453
+ try { await api('DELETE', `/keys/${encodeURIComponent(k.name)}`); await reload() } catch (e) { setErr(e.message) }
454
+ }
455
+ return h('div', { style: S.section },
456
+ h('div', { style: S.h }, 'API key'),
457
+ h('p', { style: S.note }, L('OpenAI 格式,点「复制」拿完整 key。dsh 用名为 dsh 的那把(自动配置);其他设备或软件各领一把,丢了就吊销。下面是最近 24 小时的状态。', 'OpenAI-style; click Copy to get the full key. dsh uses the "dsh" key (configured automatically); give each other device or tool its own key and revoke it if lost. Stats cover the last 24 hours.')),
458
+ err ? h('div', { style: { fontSize: 12, color: C.err, margin: '0 0 8px' } }, err) : null,
459
+ fresh ? h('div', { style: { ...S.card, padding: 12, marginBottom: 10 } },
460
+ h('div', { style: S.meta }, L(`新 key「${fresh.name}」只显示这一次:`, `New key "${fresh.name}" is shown only once:`)),
461
+ h('div', { style: { ...S.line, marginTop: 6 } }, h('code', { style: { ...S.mono, ...S.grow, wordBreak: 'break-all' } }, fresh.key), h(CopyButton, { get: fresh.key }), h('button', { type: 'button', style: S.btn, onClick: () => setFresh(null) }, L('我已保存', 'Saved')))) : null,
462
+ h('div', { style: S.card },
463
+ h('table', { style: S.table },
464
+ h('thead', null, h('tr', null, [L('名称', 'Name'), 'Key', L('请求', 'Requests'), L('成功率', 'Success'), L('平均延迟', 'Latency'), 'tokens/s', L('最后使用', 'Last used'), ''].map((t, i) => h('th', { key: i, style: S.th }, t)))),
465
+ h('tbody', null, keys.map((k) => {
466
+ const st = k.stats?.d1
467
+ return h('tr', { key: k.name },
468
+ h('td', { style: S.td }, k.name),
469
+ h('td', { style: { ...S.td, ...S.mono } }, k.key),
470
+ h('td', { style: S.td }, st ? st.requests : 0),
471
+ h('td', { style: { ...S.td, color: rateColor(st?.successRate) } }, pct(st?.successRate)),
472
+ h('td', { style: S.td }, ms(st?.avgLatencyMs)),
473
+ h('td', { style: S.td }, st?.tokensPerSec ?? '-'),
474
+ h('td', { style: S.td }, ago(st?.lastUsedAt)),
475
+ h('td', { style: { ...S.td, textAlign: 'right', whiteSpace: 'nowrap' } },
476
+ h(CopyButton, { get: async () => (await api('POST', `/keys/${encodeURIComponent(k.name)}/reveal`)).key, style: { marginRight: 6 } }),
477
+ h('button', { type: 'button', style: S.btn, onClick: () => rotate(k) }, L('轮换', 'Rotate')),
478
+ k.name !== 'dsh' ? h('button', { type: 'button', style: { ...S.btn, marginLeft: 6, color: C.err }, onClick: () => revoke(k) }, L('吊销', 'Revoke')) : null))
479
+ })))),
480
+ h('div', { style: { ...S.line, marginTop: 10 } },
481
+ h('input', { style: { ...S.input, width: 200 }, placeholder: L('新 key 名称,如 laptop', 'New key name, e.g. laptop'), value: name, onChange: (e) => setName(e.target.value) }),
482
+ h('button', { type: 'button', style: S.btnPrimary, disabled: !name.trim(), onClick: add }, L('新增 key', 'Add key'))))
483
+ }
484
+
485
+ function ModelsCard({ stats }) {
486
+ const rows = Object.entries(stats?.byModel || {}).sort((a, b) => b[1].d1.requests - a[1].d1.requests).slice(0, 30)
487
+ if (!rows.length) return null
488
+ return h('div', { style: S.section },
489
+ h('div', { style: S.h }, L('模型统计', 'Models')),
490
+ h('p', { style: S.note }, L('最近 24 小时经统一端点的请求,按模型。挑模型时参考成功率和速度。', 'Requests through the unified endpoint in the last 24 hours, by model.')),
491
+ h('div', { style: S.card }, h('table', { style: S.table },
492
+ h('thead', null, h('tr', null, [L('模型', 'Model'), L('来源', 'Source'), L('请求', 'Requests'), L('成功率', 'Success'), L('平均延迟', 'Latency'), 'tokens/s'].map((t, i) => h('th', { key: i, style: S.th }, t)))),
493
+ h('tbody', null, rows.map(([m, v]) => h('tr', { key: m },
494
+ h('td', { style: S.td }, m),
495
+ h('td', { style: { ...S.td, color: C.sub } }, v.source),
496
+ h('td', { style: S.td }, v.d1.requests),
497
+ h('td', { style: { ...S.td, color: rateColor(v.d1.successRate) } }, pct(v.d1.successRate)),
498
+ h('td', { style: S.td }, ms(v.d1.avgLatencyMs)),
499
+ h('td', { style: S.td }, v.d1.tokensPerSec ?? '-')))))))
500
+ }
501
+
502
+ // —— 整页 ——
503
+ function SettingsSection() {
504
+ const [data, setData] = useState(null)
505
+ const [err, setErr] = useState('')
506
+ const [login, setLogin] = useState(null)
507
+ const [keyDialog, setKeyDialog] = useState(false)
508
+ const alive = useRef(true)
509
+ const load = useCallback(async (refresh) => {
510
+ try {
511
+ 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)])
512
+ if (alive.current) { setData({ sources, keys, stats, endpoints }); setErr('') }
513
+ } catch (e) {
514
+ if (alive.current) setErr(e.hint ? `${e.message}(${e.hint})` : e.message)
515
+ }
516
+ }, [])
517
+ useEffect(() => {
518
+ alive.current = true
519
+ void load(true)
520
+ const t = setInterval(() => void load(false), 15000)
521
+ return () => { alive.current = false; clearInterval(t) }
522
+ }, [load])
523
+ if (!data) return h('div', { style: { fontSize: 13, color: err ? C.err : C.sub, padding: '8px 0' } }, err || L('加载中…', 'Loading…'))
524
+ return h('div', { style: { color: C.text, maxWidth: 820 } },
525
+ err ? h('div', { style: { fontSize: 12, color: C.err, marginBottom: 12 } }, err) : null,
526
+ h(EndpointCard, { endpoints: data.endpoints }),
527
+ h(SourcesCard, { sources: data.sources, reload: () => load(false), onLogin: (s, session) => setLogin({ source: s, session }), onKey: () => setKeyDialog(true) }),
528
+ h(KeysCard, { keys: data.keys, reload: () => load(false) }),
529
+ h(ModelsCard, { stats: data.stats }),
530
+ login ? h(LoginDialog, { source: login.source, session: login.session, onDone: () => { setLogin(null); void load(true) }, onClose: () => { setLogin(null); void load(false) } }) : null,
531
+ keyDialog ? h(KeyDialog, { onDone: () => { setKeyDialog(false); void load(true) }, onClose: () => setKeyDialog(false) }) : null)
532
+ }
533
+
534
+ const name = 'dsh-model'
535
+ // 用到的服务必须先声明,否则 ctx.slots 读不到(实测:cannot get property "slots" without inject)
536
+ const inject = ['slots']
537
+ function apply(ctx) {
538
+ try {
539
+ ctx.slots.inject('settings.section', () =>
540
+ ctx.slots.register({ name: 'settings.section', id: 'dsh-model', order: 25, label: () => 'dsh-model' }, SettingsSection))
541
+ } catch (error) {
542
+ console.warn('[dsh-model] 设置页注册失败', error)
543
+ }
544
+ }
545
+
546
+ module.exports = { name, inject, apply, __test: { until, pct, ms } }
547
+ return module.exports
548
+ },
549
+ })
@@ -0,0 +1,4 @@
1
+ # dsh-model 的 dsh 插件:在「设置」里加一个 dsh-model 分区(来源开关、订阅用量、key 状态)
2
+ - insert:
3
+ - id: dsh-model
4
+ name: dsh-model