dsh-model 0.0.1 → 0.15.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 +127 -0
- package/README.md +96 -43
- package/bin/dsh-model.js +8 -0
- package/dist/bridge/aggregate.js +91 -0
- package/dist/bridge/login.js +127 -0
- package/dist/bridge/runtime.js +136 -0
- package/dist/bridge/server.js +183 -0
- package/dist/bridge/workbuddy/_compat.js +52 -0
- package/dist/bridge/workbuddy/app-version.js +141 -0
- package/dist/bridge/workbuddy/auth.js +548 -0
- package/dist/bridge/workbuddy/catalog.js +154 -0
- package/dist/bridge/workbuddy/client-identity.js +234 -0
- package/dist/bridge/workbuddy/desktop-credential-protection.js +992 -0
- package/dist/bridge/workbuddy/loopback.js +52 -0
- package/dist/bridge/workbuddy/probe.js +153 -0
- package/dist/bridge/workbuddy/status-paths.js +49 -0
- package/dist/bridge/workbuddy/upstream.js +1180 -0
- package/dist/bridge/workbuddy/variants.js +101 -0
- package/dist/cli.js +215 -0
- package/dist/commands/bridge.js +68 -0
- package/dist/commands/connect.js +67 -0
- package/dist/commands/engine.js +122 -0
- package/dist/commands/key.js +75 -0
- package/dist/commands/login.js +113 -0
- package/dist/commands/logs.js +19 -0
- package/dist/commands/opencode.js +83 -0
- package/dist/commands/remote.js +129 -0
- package/dist/commands/self.js +116 -0
- package/dist/commands/service.js +72 -0
- package/dist/commands/setup.js +219 -0
- package/dist/commands/sources.js +196 -0
- package/dist/commands/status.js +216 -0
- package/dist/commands/uninstall.js +97 -0
- package/dist/commands/workbuddy.js +117 -0
- package/dist/context.js +86 -0
- package/dist/daemon/client.js +27 -0
- package/dist/daemon/core.js +850 -0
- package/dist/daemon/service.js +33 -0
- package/dist/daemon/stats.js +126 -0
- package/dist/daemon/usage.js +213 -0
- package/dist/dsh/cli.js +90 -0
- package/dist/dsh/connect.js +257 -0
- package/dist/dsh/credentials.js +55 -0
- package/dist/dsh/locate.js +129 -0
- package/dist/dsh/patch.js +106 -0
- package/dist/dsh/pick.js +59 -0
- package/dist/engine/auth.js +40 -0
- package/dist/engine/client.js +104 -0
- package/dist/engine/compat.js +94 -0
- package/dist/engine/config.js +47 -0
- package/dist/engine/install.js +118 -0
- package/dist/engine/mgmt.js +99 -0
- package/dist/engine/sourcemap.js +48 -0
- package/dist/errors.js +14 -0
- package/dist/i18n.js +39 -0
- package/dist/integrations/dshplugin.js +89 -0
- package/dist/integrations/opencode.js +101 -0
- package/dist/integrations/workbuddy.js +114 -0
- package/dist/keys.js +54 -0
- package/dist/ops.js +178 -0
- package/dist/remote/public.js +109 -0
- package/dist/secrets.js +13 -0
- package/dist/service/index.js +48 -0
- package/dist/service/launchd.js +89 -0
- package/dist/service/systemd.js +83 -0
- package/dist/sources.js +36 -0
- package/dist/state.js +90 -0
- package/dist/upstreams.js +23 -0
- package/dist/util/diff.js +30 -0
- package/dist/util/exec.js +42 -0
- package/dist/util/fs.js +107 -0
- package/dist/util/output.js +50 -0
- package/dist/util/pkg.js +13 -0
- package/dist/util/port.js +16 -0
- package/dist/util/prompt.js +40 -0
- package/dist/util/proxy.js +73 -0
- package/dist/util/redact.js +7 -0
- package/engine-manifest.json +28 -0
- package/package.json +77 -7
- package/plugin/client.js +688 -0
- package/plugin/cordis.patch.yml +4 -0
- package/plugin/index.js +231 -0
- package/docs/DESIGN.md +0 -610
package/plugin/client.js
ADDED
|
@@ -0,0 +1,688 @@
|
|
|
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, lang: isZh() ? 'zh' : 'en', ...(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 (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
|
+
if (!data) return h('div', { style: { ...S.meta, paddingLeft: 46, marginTop: 8 } }, msg || L('加载中…', 'Loading…'))
|
|
333
|
+
const flip = (id) => setSel((x) => { const n = new Set(x); n.has(id) ? n.delete(id) : n.add(id); return n })
|
|
334
|
+
const save = async (models) => {
|
|
335
|
+
setSaving(true)
|
|
336
|
+
setMsg('')
|
|
337
|
+
try {
|
|
338
|
+
await api('POST', `/sources/${source.id}/models`, { models })
|
|
339
|
+
onClose(true)
|
|
340
|
+
} catch (e) {
|
|
341
|
+
setMsg(e.message)
|
|
342
|
+
} finally {
|
|
343
|
+
setSaving(false)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const shown = data.models.filter((m) => !q || `${m.name} ${m.id}`.toLowerCase().includes(q.toLowerCase()))
|
|
347
|
+
return h('div', { style: { marginTop: 10, marginLeft: 46, padding: 10, border: `1px solid ${C.border}`, borderRadius: 8 } },
|
|
348
|
+
h('div', { style: { ...S.line, marginBottom: 6, flexWrap: 'wrap' } },
|
|
349
|
+
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.`)),
|
|
350
|
+
data.models.length > 10 ? h('input', { style: { ...S.input, width: 140 }, placeholder: L('搜索', 'Search'), value: q, onChange: (e) => setQ(e.target.value) }) : null),
|
|
351
|
+
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
|
+
h('input', { type: 'checkbox', checked: sel.has(m.id), onChange: () => flip(m.id) }),
|
|
353
|
+
h('span', { style: S.grow }, m.name),
|
|
354
|
+
m.recommended ? h('span', { style: { fontSize: 11, color: C.accent } }, L('推荐', 'Suggested')) : null))),
|
|
355
|
+
msg ? h('div', { style: { fontSize: 12, color: C.err, marginTop: 6 } }, msg) : null,
|
|
356
|
+
h('div', { style: { ...S.line, marginTop: 8, flexWrap: 'wrap' } },
|
|
357
|
+
h('button', { type: 'button', style: S.btnPrimary, disabled: saving, onClick: () => save([...sel]) }, saving ? L('保存中…', 'Saving…') : L('保存', 'Save')),
|
|
358
|
+
h('button', { type: 'button', style: S.btn, disabled: saving, onClick: () => save(null) }, L('恢复推荐', 'Use suggested')),
|
|
359
|
+
h('button', { type: 'button', style: S.btn, onClick: () => setSel(new Set(data.models.map((m) => m.id))) }, L('全选', 'All')),
|
|
360
|
+
h('button', { type: 'button', style: S.btn, onClick: () => setSel(new Set()) }, L('全不选', 'None')),
|
|
361
|
+
h('span', { style: S.grow }),
|
|
362
|
+
h('button', { type: 'button', style: S.btn, onClick: () => onClose(false) }, L('取消', 'Cancel'))))
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function SourcesCard({ sources, reload, onLogin, onKey }) {
|
|
366
|
+
const [picking, setPicking] = useState('')
|
|
367
|
+
const [busy, setBusy] = useState({})
|
|
368
|
+
const [err, setErr] = useState('')
|
|
369
|
+
const [showRisky, setShowRisky] = useState(readRisky)
|
|
370
|
+
const toggleRisky = (v) => {
|
|
371
|
+
setShowRisky(v)
|
|
372
|
+
try { window.localStorage.setItem(RISKY_KEY, v ? '1' : '0') } catch { /* 无痕模式等:只在本次生效 */ }
|
|
373
|
+
}
|
|
374
|
+
// 高风险来源默认隐藏;已登录的照常显示,方便退出登录
|
|
375
|
+
const visible = sources.filter((s) => showRisky || !s.risky || s.loggedIn)
|
|
376
|
+
const hidden = sources.length - visible.length
|
|
377
|
+
const toggle = async (s, on) => {
|
|
378
|
+
setErr('')
|
|
379
|
+
setBusy((b) => ({ ...b, [s.id]: true }))
|
|
380
|
+
try {
|
|
381
|
+
if (!on) await api('POST', `/sources/${s.id}/disable`)
|
|
382
|
+
else if (s.kind === 'opencode' && !s.loggedIn) onKey()
|
|
383
|
+
else {
|
|
384
|
+
let r = await api('POST', `/sources/${s.id}/enable`, {})
|
|
385
|
+
if (r.riskNotice) {
|
|
386
|
+
if (!window.confirm(r.riskNotice)) return
|
|
387
|
+
r = await api('POST', `/sources/${s.id}/enable`, { acceptRisk: true })
|
|
388
|
+
}
|
|
389
|
+
if (r.login) onLogin(s, r.login)
|
|
390
|
+
}
|
|
391
|
+
await reload()
|
|
392
|
+
} catch (e) {
|
|
393
|
+
setErr(`${s.label}:${e.message}`)
|
|
394
|
+
} finally {
|
|
395
|
+
setBusy((b) => ({ ...b, [s.id]: false }))
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const logout = async (s) => {
|
|
399
|
+
if (!window.confirm(L(`退出 ${s.label} 的登录?(它的模型会从 dsh 里消失)`, `Sign out of ${s.label}? (its models disappear from dsh)`))) return
|
|
400
|
+
try { await api('POST', `/sources/${s.id}/logout`); await reload() } catch (e) { setErr(e.message) }
|
|
401
|
+
}
|
|
402
|
+
return h('div', { style: S.section },
|
|
403
|
+
h('div', { style: S.line },
|
|
404
|
+
h('div', { style: { ...S.h, ...S.grow, margin: 0 } }, L('来源', 'Sources')),
|
|
405
|
+
h('label', { style: { ...S.meta, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' } },
|
|
406
|
+
h('input', { type: 'checkbox', checked: showRisky, onChange: (e) => toggleRisky(e.target.checked) }),
|
|
407
|
+
L(`显示高风险来源${!showRisky && hidden ? `(${hidden})` : ''}`, `Show high-risk sources${!showRisky && hidden ? ` (${hidden})` : ''}`))),
|
|
408
|
+
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.')),
|
|
409
|
+
err ? h('div', { style: { fontSize: 12, color: C.err, margin: '0 0 8px' } }, err) : null,
|
|
410
|
+
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,
|
|
411
|
+
h('div', { style: S.card }, visible.map((s, i) => h('div', { key: s.id, style: i ? S.row : S.rowFirst },
|
|
412
|
+
h('div', { style: S.line },
|
|
413
|
+
h(Switch, { on: s.enabled, busy: busy[s.id], label: s.label, onChange: (on) => toggle(s, on) }),
|
|
414
|
+
h('div', { style: S.grow },
|
|
415
|
+
h('span', { style: S.label }, s.label),
|
|
416
|
+
h(PriceTag, { pricing: s.pricing }),
|
|
417
|
+
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
|
+
h('span', { style: { ...S.meta, marginLeft: 8 } },
|
|
419
|
+
s.loggedIn ? (s.account || '') : L('未登录', 'Signed out'),
|
|
420
|
+
s.models ? ` · ${L(`${s.models} 个模型`, `${s.models} models`)}` : '')),
|
|
421
|
+
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' ? 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),
|
|
424
|
+
picking === s.id ? h(ModelPicker, { source: s, onClose: (saved) => { setPicking(''); if (saved) void reload() } }) : null,
|
|
425
|
+
s.enabled ? h(Usage, { usage: s.usage, subscribeUrl: s.subscribeUrl }) : null,
|
|
426
|
+
!s.loggedIn && s.pricing ? h('div', { style: { ...S.meta, paddingLeft: 46, marginTop: 4 } },
|
|
427
|
+
L(s.pricing.zh, s.pricing.en),
|
|
428
|
+
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,
|
|
429
|
+
!s.loggedIn && s.detail ? h('div', { style: { ...S.meta, paddingLeft: 46, marginTop: 4 } }, s.detail) : null))))
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** 费用标签:免费 / 免费·额度少 / 需付费 */
|
|
433
|
+
function PriceTag({ pricing }) {
|
|
434
|
+
if (!pricing) return null
|
|
435
|
+
const color = pricing.tier === 'free' ? C.ok : pricing.tier === 'limited' ? C.warn : C.err
|
|
436
|
+
const text = pricing.tier === 'free' ? L('免费', 'Free') : pricing.tier === 'limited' ? L('免费·额度少', 'Free · limited') : L('需付费', 'Paid')
|
|
437
|
+
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)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// —— key ——
|
|
441
|
+
function KeysCard({ keys, reload }) {
|
|
442
|
+
const [name, setName] = useState('')
|
|
443
|
+
const [fresh, setFresh] = useState(null)
|
|
444
|
+
const [err, setErr] = useState('')
|
|
445
|
+
const add = async () => {
|
|
446
|
+
setErr('')
|
|
447
|
+
try { const r = await api('POST', '/keys', { name: name.trim() }); setFresh(r); setName(''); await reload() } catch (e) { setErr(e.message) }
|
|
448
|
+
}
|
|
449
|
+
const rotate = async (k) => {
|
|
450
|
+
if (!window.confirm(L(`轮换 ${k.name}?旧 key 立即失效。`, `Rotate ${k.name}? The old key stops working immediately.`))) return
|
|
451
|
+
try { const r = await api('POST', `/keys/${encodeURIComponent(k.name)}/rotate`); if (r.key) setFresh(r); await reload() } catch (e) { setErr(e.message) }
|
|
452
|
+
}
|
|
453
|
+
const revoke = async (k) => {
|
|
454
|
+
if (!window.confirm(L(`吊销 ${k.name}?用它的设备会立即无法访问。`, `Revoke ${k.name}? Devices using it lose access immediately.`))) return
|
|
455
|
+
try { await api('DELETE', `/keys/${encodeURIComponent(k.name)}`); await reload() } catch (e) { setErr(e.message) }
|
|
456
|
+
}
|
|
457
|
+
return h('div', { style: S.section },
|
|
458
|
+
h('div', { style: S.h }, 'API key'),
|
|
459
|
+
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.')),
|
|
460
|
+
err ? h('div', { style: { fontSize: 12, color: C.err, margin: '0 0 8px' } }, err) : null,
|
|
461
|
+
fresh ? h('div', { style: { ...S.card, padding: 12, marginBottom: 10 } },
|
|
462
|
+
h('div', { style: S.meta }, L(`新 key「${fresh.name}」只显示这一次:`, `New key "${fresh.name}" is shown only once:`)),
|
|
463
|
+
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,
|
|
464
|
+
h('div', { style: S.card },
|
|
465
|
+
h('table', { style: S.table },
|
|
466
|
+
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)))),
|
|
467
|
+
h('tbody', null, keys.map((k) => {
|
|
468
|
+
const st = k.stats?.d1
|
|
469
|
+
return h('tr', { key: k.name },
|
|
470
|
+
h('td', { style: S.td }, k.name),
|
|
471
|
+
h('td', { style: { ...S.td, ...S.mono } }, k.key),
|
|
472
|
+
h('td', { style: S.td }, st ? st.requests : 0),
|
|
473
|
+
h('td', { style: { ...S.td, color: rateColor(st?.successRate) } }, pct(st?.successRate)),
|
|
474
|
+
h('td', { style: S.td }, ms(st?.avgLatencyMs)),
|
|
475
|
+
h('td', { style: S.td }, st?.tokensPerSec ?? '-'),
|
|
476
|
+
h('td', { style: S.td }, ago(st?.lastUsedAt)),
|
|
477
|
+
h('td', { style: { ...S.td, textAlign: 'right', whiteSpace: 'nowrap' } },
|
|
478
|
+
h(CopyButton, { get: async () => (await api('POST', `/keys/${encodeURIComponent(k.name)}/reveal`)).key, style: { marginRight: 6 } }),
|
|
479
|
+
h('button', { type: 'button', style: S.btn, onClick: () => rotate(k) }, L('轮换', 'Rotate')),
|
|
480
|
+
k.name !== 'dsh' ? h('button', { type: 'button', style: { ...S.btn, marginLeft: 6, color: C.err }, onClick: () => revoke(k) }, L('吊销', 'Revoke')) : null))
|
|
481
|
+
})))),
|
|
482
|
+
h('div', { style: { ...S.line, marginTop: 10 } },
|
|
483
|
+
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) }),
|
|
484
|
+
h('button', { type: 'button', style: S.btnPrimary, disabled: !name.trim(), onClick: add }, L('新增 key', 'Add key'))))
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function ModelsCard({ stats }) {
|
|
488
|
+
const rows = Object.entries(stats?.byModel || {}).sort((a, b) => b[1].d1.requests - a[1].d1.requests).slice(0, 30)
|
|
489
|
+
if (!rows.length) return null
|
|
490
|
+
return h('div', { style: S.section },
|
|
491
|
+
h('div', { style: S.h }, L('模型统计', 'Models')),
|
|
492
|
+
h('p', { style: S.note }, L('最近 24 小时经统一端点的请求,按模型。挑模型时参考成功率和速度。', 'Requests through the unified endpoint in the last 24 hours, by model.')),
|
|
493
|
+
h('div', { style: S.card }, h('table', { style: S.table },
|
|
494
|
+
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)))),
|
|
495
|
+
h('tbody', null, rows.map(([m, v]) => h('tr', { key: m },
|
|
496
|
+
h('td', { style: S.td }, m),
|
|
497
|
+
h('td', { style: { ...S.td, color: C.sub } }, v.source),
|
|
498
|
+
h('td', { style: S.td }, v.d1.requests),
|
|
499
|
+
h('td', { style: { ...S.td, color: rateColor(v.d1.successRate) } }, pct(v.d1.successRate)),
|
|
500
|
+
h('td', { style: S.td }, ms(v.d1.avgLatencyMs)),
|
|
501
|
+
h('td', { style: S.td }, v.d1.tokensPerSec ?? '-')))))))
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** 守护进程还没装 / 没在运行:说明 + 一键安装(本机)或命令(VPS 上要 root) */
|
|
505
|
+
function Onboarding({ code, onDone }) {
|
|
506
|
+
const [busy, setBusy] = useState(false)
|
|
507
|
+
const [res, setRes] = useState(null)
|
|
508
|
+
const notInstalled = code === 'daemon_not_configured'
|
|
509
|
+
const action = notInstalled ? 'setup' : 'repair'
|
|
510
|
+
const run = async () => {
|
|
511
|
+
setBusy(true)
|
|
512
|
+
setRes(null)
|
|
513
|
+
try {
|
|
514
|
+
const r = await fetch('/api-dsh-model/setup', {
|
|
515
|
+
method: 'POST',
|
|
516
|
+
credentials: 'same-origin',
|
|
517
|
+
headers: { 'content-type': 'application/json', 'x-dsh-model-token': token },
|
|
518
|
+
body: JSON.stringify({ action, lang: isZh() ? 'zh' : 'en' }),
|
|
519
|
+
})
|
|
520
|
+
const j = await r.json().catch(() => ({}))
|
|
521
|
+
if (r.status === 403 && j?.error?.code === 'refused') { await refreshToken(); setBusy(false); return run() }
|
|
522
|
+
setRes(j)
|
|
523
|
+
if (j.code === 0) setTimeout(onDone, 1500)
|
|
524
|
+
} catch (e) {
|
|
525
|
+
setRes({ code: -1, output: e.message })
|
|
526
|
+
} finally {
|
|
527
|
+
setBusy(false)
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const cmds = notInstalled ? ['npm install -g dsh-model', 'dsh-model setup'] : ['dsh-model repair']
|
|
531
|
+
const vpsCmds = notInstalled ? ['sudo npm install -g dsh-model', 'sudo dsh-model setup'] : ['sudo dsh-model repair']
|
|
532
|
+
const cmdBlock = (list) => h('div', { style: { ...S.card, padding: '8px 12px', marginTop: 8 } }, list.map((c) => h('div', { key: c, style: { ...S.line, minHeight: 30 } }, h('code', { style: { ...S.mono, ...S.grow } }, c), h(CopyButton, { get: c }))))
|
|
533
|
+
return h('div', { style: { color: C.text, maxWidth: 820 } },
|
|
534
|
+
h('div', { style: S.section },
|
|
535
|
+
h('div', { style: S.h }, notInstalled ? L('还差一步:在这台机器上装好 dsh-model', 'One more step: set up dsh-model on this machine') : L('dsh-model 的服务没在运行', 'The dsh-model service is not running')),
|
|
536
|
+
h('p', { style: S.note }, notInstalled
|
|
537
|
+
? L('这个页面只是管理界面;模型由 dsh-model 在本机运行的服务提供(统一端点、WorkBuddy bridge、登录与用量)。装好后,来源、key 和模型都在这里管理。', 'This page is only the control panel; models are served by dsh-model\'s local service (the unified endpoint, WorkBuddy bridge, sign-ins and usage). Once it is set up, manage sources, keys and models here.')
|
|
538
|
+
: L('可能是电脑重启后服务没起来,或者刚升级。修复会按记录重新注册并启动服务。', 'The service may not have started after a reboot, or was just upgraded. Repair re-registers and starts it from the saved records.')),
|
|
539
|
+
res?.vps ? h('div', null,
|
|
540
|
+
h('p', { style: { ...S.note, color: C.text } }, L('这是 dsh-vps 服务器:安装要 root 权限,请在服务器终端里执行:', 'This is a dsh-vps server: setup needs root, so run this in a server terminal:')),
|
|
541
|
+
cmdBlock(vpsCmds),
|
|
542
|
+
h('p', { style: { ...S.note, marginTop: 8 } }, L('装好后刷新本页。', 'Reload this page when done.'))) : h('div', null,
|
|
543
|
+
h('div', { style: S.line },
|
|
544
|
+
h('button', { type: 'button', style: S.btnPrimary, disabled: busy, onClick: run }, busy ? (notInstalled ? L('安装中…(约 1 分钟)', 'Setting up… (about a minute)') : L('修复中…', 'Repairing…')) : (notInstalled ? L('一键安装', 'Set up now') : L('修复', 'Repair'))),
|
|
545
|
+
h('button', { type: 'button', style: S.btn, onClick: onDone }, L('重新检测', 'Check again'))),
|
|
546
|
+
h('p', { style: { ...S.note, marginTop: 10 } }, L('也可以在终端里执行:', 'Or run in a terminal:')),
|
|
547
|
+
cmdBlock(cmds)),
|
|
548
|
+
res && !res.vps ? h('div', { style: { marginTop: 12 } },
|
|
549
|
+
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:`)),
|
|
550
|
+
h('pre', { style: { ...S.mono, ...S.card, padding: 10, maxHeight: 260, overflow: 'auto', whiteSpace: 'pre-wrap', margin: 0 } }, res.output || '')) : null))
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const newer = (a, b) => {
|
|
554
|
+
const pa = String(a || '').split(/[.-]/).map((x) => parseInt(x, 10) || 0)
|
|
555
|
+
const pb = String(b || '').split(/[.-]/).map((x) => parseInt(x, 10) || 0)
|
|
556
|
+
for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) > (pb[i] || 0)
|
|
557
|
+
return false
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** 版本、更新、卸载 */
|
|
561
|
+
function AboutCard({ onJob }) {
|
|
562
|
+
const [info, setInfo] = useState(null)
|
|
563
|
+
const [confirming, setConfirming] = useState(false)
|
|
564
|
+
const [keepAuth, setKeepAuth] = useState(false)
|
|
565
|
+
const [err, setErr] = useState('')
|
|
566
|
+
useEffect(() => {
|
|
567
|
+
let alive = true
|
|
568
|
+
api('GET', '/self').then((r) => alive && setInfo(r), () => {})
|
|
569
|
+
return () => { alive = false }
|
|
570
|
+
}, [])
|
|
571
|
+
if (!info) return null
|
|
572
|
+
const hasUpdate = info.latest && newer(info.latest, info.version)
|
|
573
|
+
const start = async (action) => {
|
|
574
|
+
setErr('')
|
|
575
|
+
try {
|
|
576
|
+
await api('POST', `/self/${action}`, action === 'uninstall' ? { keepAuth } : {})
|
|
577
|
+
onJob({ action, from: info.version, target: info.latest })
|
|
578
|
+
} catch (e) {
|
|
579
|
+
setErr(e.message)
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return h('div', { style: S.section },
|
|
583
|
+
h('div', { style: S.h }, L('关于', 'About')),
|
|
584
|
+
h('div', { style: { ...S.card, padding: '12px 14px' } },
|
|
585
|
+
h('div', { style: S.line },
|
|
586
|
+
h('span', { style: { ...S.label, ...S.grow } }, `dsh-model ${info.version}`,
|
|
587
|
+
h('span', { style: { ...S.meta, marginLeft: 8 } }, info.latest ? (hasUpdate ? L(`有新版本 ${info.latest}`, `${info.latest} available`) : L('已是最新', 'Up to date')) : L('查不到最新版本', 'Latest version unknown'))),
|
|
588
|
+
info.canManage && hasUpdate ? h('button', { type: 'button', style: S.btnPrimary, onClick: () => void start('update') }, L(`更新到 ${info.latest}`, `Update to ${info.latest}`)) : null,
|
|
589
|
+
info.canManage ? h('button', { type: 'button', style: { ...S.btn, color: C.err }, onClick: () => setConfirming(true) }, L('卸载', 'Uninstall')) : null),
|
|
590
|
+
!info.canManage ? h('div', { style: { marginTop: 8 } },
|
|
591
|
+
h('div', { style: S.meta }, L('服务器上更新和卸载要 root 权限,请在服务器终端里执行:', 'On the server, update and uninstall need root; run in a server terminal:')),
|
|
592
|
+
[[L('更新', 'Update'), info.commands.update], [L('卸载', 'Uninstall'), info.commands.uninstall]].map(([t, c]) => h('div', { key: c, style: { ...S.line, minHeight: 30 } },
|
|
593
|
+
h('span', { style: { ...S.meta, width: 40 } }, t), h('code', { style: { ...S.mono, ...S.grow } }, c), h(CopyButton, { get: c })))) : null,
|
|
594
|
+
confirming ? h('div', { style: { marginTop: 10, padding: 10, border: `1px solid ${C.err}`, borderRadius: 8 } },
|
|
595
|
+
h('div', { style: { fontSize: 13, color: C.text, lineHeight: 1.7 } }, L('卸载会:关闭对外访问 → 移除这个管理页 → 把 dsh 里的模型配置还原 → 停止并删除引擎与守护进程 → 删除 ~/.dsh-model(含各家登录、OpenCode key、WorkBuddy 令牌副本)。', 'Uninstall will: turn off remote access → remove this page → restore dsh\'s model config → stop and delete the engine and daemon → delete ~/.dsh-model (including sign-ins, the OpenCode key and WorkBuddy token copies).')),
|
|
596
|
+
h('label', { style: { ...S.line, fontSize: 13, marginTop: 6, cursor: 'pointer' } },
|
|
597
|
+
h('input', { type: 'checkbox', checked: keepAuth, onChange: (e) => setKeepAuth(e.target.checked) }),
|
|
598
|
+
L('先把各家登录凭据备份到主目录(以后重装可以拷回来)', 'Back up provider sign-ins to the home folder first (to reuse after reinstalling)')),
|
|
599
|
+
h('div', { style: { ...S.line, marginTop: 8 } },
|
|
600
|
+
h('button', { type: 'button', style: { ...S.btnPrimary, background: C.err }, onClick: () => void start('uninstall') }, L('确认卸载', 'Uninstall')),
|
|
601
|
+
h('button', { type: 'button', style: S.btn, onClick: () => setConfirming(false) }, L('取消', 'Cancel')))) : null,
|
|
602
|
+
err ? h('div', { style: { fontSize: 12, color: C.err, marginTop: 6 } }, err) : null))
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** 更新 / 卸载进行中:守护进程会重启或消失,期间不显示别的 */
|
|
606
|
+
function SelfProgress({ job, onDone }) {
|
|
607
|
+
const [state, setState] = useState({ phase: 'running', log: '' })
|
|
608
|
+
useEffect(() => {
|
|
609
|
+
let stop = false
|
|
610
|
+
const tick = async () => {
|
|
611
|
+
if (stop) return
|
|
612
|
+
try {
|
|
613
|
+
const r = await api('GET', '/self')
|
|
614
|
+
if (r.job?.log) setState((s) => ({ ...s, log: r.job.log }))
|
|
615
|
+
if (job.action === 'update' && r.version !== job.from) { setState({ phase: 'done', log: r.job?.log || '' }); return }
|
|
616
|
+
} catch (e) {
|
|
617
|
+
if (job.action === 'uninstall' && e.code === 'daemon_not_configured') { setState((s) => ({ ...s, phase: 'done' })); return }
|
|
618
|
+
}
|
|
619
|
+
setTimeout(tick, 2000)
|
|
620
|
+
}
|
|
621
|
+
const t = setTimeout(tick, 2000)
|
|
622
|
+
return () => { stop = true; clearTimeout(t) }
|
|
623
|
+
}, [])
|
|
624
|
+
const upd = job.action === 'update'
|
|
625
|
+
const title = state.phase === 'done'
|
|
626
|
+
? (upd ? L('更新完成', 'Update complete') : L('已卸载', 'Uninstalled'))
|
|
627
|
+
: (upd ? L(`正在更新到 ${job.target}…(服务会重启一下)`, `Updating to ${job.target}… (the service restarts briefly)`) : L('正在卸载…', 'Uninstalling…'))
|
|
628
|
+
return h('div', { style: { color: C.text, maxWidth: 820 } },
|
|
629
|
+
h('div', { style: S.section },
|
|
630
|
+
h('div', { style: S.h }, title),
|
|
631
|
+
state.phase === 'done' ? h('p', { style: { ...S.note, color: C.text } }, upd
|
|
632
|
+
? L('完全退出 dsh 再打开一次,管理页的新代码才会生效。', 'Quit dsh completely and open it again so the management page loads the new code.')
|
|
633
|
+
: L('dsh 里的 dsh-model 模型已移除。完全退出 dsh 再打开,这一页就会消失。日志在主目录的 dsh-model-uninstall.log。', 'dsh-model\'s models are gone from dsh. Quit dsh completely and reopen it and this page disappears. The log is ~/dsh-model-uninstall.log.')) : null,
|
|
634
|
+
state.log ? h('pre', { style: { ...S.mono, ...S.card, padding: 10, maxHeight: 300, overflow: 'auto', whiteSpace: 'pre-wrap', margin: 0 } }, state.log) : null,
|
|
635
|
+
state.phase === 'done' && upd ? h('button', { type: 'button', style: { ...S.btn, marginTop: 10 }, onClick: onDone }, L('返回', 'Back')) : null))
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// —— 整页 ——
|
|
639
|
+
function SettingsSection() {
|
|
640
|
+
const [data, setData] = useState(null)
|
|
641
|
+
const [err, setErr] = useState('')
|
|
642
|
+
const [errCode, setErrCode] = useState('')
|
|
643
|
+
const [selfJob, setSelfJob] = useState(null)
|
|
644
|
+
const [login, setLogin] = useState(null)
|
|
645
|
+
const [keyDialog, setKeyDialog] = useState(false)
|
|
646
|
+
const alive = useRef(true)
|
|
647
|
+
const load = useCallback(async (refresh) => {
|
|
648
|
+
try {
|
|
649
|
+
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)])
|
|
650
|
+
if (alive.current) { setData({ sources, keys, stats, endpoints }); setErr(''); setErrCode('') }
|
|
651
|
+
} catch (e) {
|
|
652
|
+
if (alive.current) { setErr(e.hint ? `${e.message}(${e.hint})` : e.message); setErrCode(e.code || '') }
|
|
653
|
+
}
|
|
654
|
+
}, [])
|
|
655
|
+
useEffect(() => {
|
|
656
|
+
alive.current = true
|
|
657
|
+
void load(true)
|
|
658
|
+
const t = setInterval(() => void load(false), 15000)
|
|
659
|
+
return () => { alive.current = false; clearInterval(t) }
|
|
660
|
+
}, [load])
|
|
661
|
+
if (!data) return h('div', { style: { fontSize: 13, color: err ? C.err : C.sub, padding: '8px 0' } }, err || L('加载中…', 'Loading…'))
|
|
662
|
+
return h('div', { style: { color: C.text, maxWidth: 820 } },
|
|
663
|
+
err ? h('div', { style: { fontSize: 12, color: C.err, marginBottom: 12 } }, err) : null,
|
|
664
|
+
h(EndpointCard, { endpoints: data.endpoints }),
|
|
665
|
+
h(SourcesCard, { sources: data.sources, reload: () => load(false), onLogin: (s, session) => setLogin({ source: s, session }), onKey: () => setKeyDialog(true) }),
|
|
666
|
+
h(KeysCard, { keys: data.keys, reload: () => load(false) }),
|
|
667
|
+
h(ModelsCard, { stats: data.stats }),
|
|
668
|
+
h(AboutCard, { onJob: setSelfJob }),
|
|
669
|
+
login ? h(LoginDialog, { source: login.source, session: login.session, onDone: () => { setLogin(null); void load(true) }, onClose: () => { setLogin(null); void load(false) } }) : null,
|
|
670
|
+
keyDialog ? h(KeyDialog, { onDone: () => { setKeyDialog(false); void load(true) }, onClose: () => setKeyDialog(false) }) : null)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const name = 'dsh-model'
|
|
674
|
+
// 用到的服务必须先声明,否则 ctx.slots 读不到(实测:cannot get property "slots" without inject)
|
|
675
|
+
const inject = ['slots']
|
|
676
|
+
function apply(ctx) {
|
|
677
|
+
try {
|
|
678
|
+
ctx.slots.inject('settings.section', () =>
|
|
679
|
+
ctx.slots.register({ name: 'settings.section', id: 'dsh-model', order: 25, label: () => 'dsh-model' }, SettingsSection))
|
|
680
|
+
} catch (error) {
|
|
681
|
+
console.warn('[dsh-model] 设置页注册失败', error)
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
module.exports = { name, inject, apply, __test: { until, pct, ms } }
|
|
686
|
+
return module.exports
|
|
687
|
+
},
|
|
688
|
+
})
|