dsh-dock 0.12.0 → 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.
- package/README.md +72 -8
- package/client.js +1523 -486
- package/features/animation/view.jsx +2 -2
- package/features/balance/view.js +1 -1
- package/features/games/view.jsx +1 -1
- package/features/mobile/host.js +320 -0
- package/features/mobile/view.js +527 -0
- package/features/mobile-relay/gateway.js +187 -11
- package/features/mobile-relay/host.js +58 -173
- package/features/mobile-relay/view.jsx +19 -209
- package/features/modelconfig/host.js +195 -1
- package/features/modelconfig/view.js +762 -92
- package/features/notify/view.jsx +2 -2
- package/features/runstate/view.jsx +2 -2
- package/features/tokenlog/view.jsx +2 -2
- package/index.js +20 -2
- package/package.json +9 -3
- package/screenshots/dock-modelconfig-provider-create.png +0 -0
- package/screenshots/dock-modelconfig-provider-edit.png +0 -0
- package/screenshots/dock-modelconfig-providers.png +0 -0
- package/screenshots/dock-modelconfig.png +0 -0
- package/src/client.jsx +3 -2
- package/src/host-core.js +129 -0
- package/src/shared.js +6 -3
- package/src/welcome-ack.js +69 -0
|
@@ -4,10 +4,9 @@
|
|
|
4
4
|
// 访问地址二维码;远程设备上经 window.__DSH_REMOTE__ 显示退出登录。
|
|
5
5
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
6
6
|
import QRCode from 'qrcode'
|
|
7
|
-
import { openPanel, setPanelOpen, subscribePanel, panelNav } from '../../src/shared.js'
|
|
8
7
|
|
|
9
8
|
function rpc(method, payload) {
|
|
10
|
-
return fetch('
|
|
9
|
+
return fetch('dsh-dock/mobile-relay/' + method, {
|
|
11
10
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload || {}),
|
|
12
11
|
}).then(async (res) => {
|
|
13
12
|
const body = await res.json().catch(() => ({}))
|
|
@@ -145,7 +144,7 @@ function RemoteCard() {
|
|
|
145
144
|
<label className="dmr-field"><span>监听端口</span><input value={port} onChange={(e) => setPort(e.target.value)} inputMode="numeric" type="number" min="1024" max="65535" placeholder={'默认 ' + suggestedPort} aria-describedby="dmr-lan-port-help"/><small id="dmr-lan-port-help">网关和主服务是同一台机器上的两个端口,不能相同:主实例 {lan && lan.mainPort ? lan.mainPort : '…'} 只留给本机,其他设备走网关端口登录。留空即用 {suggestedPort}(主端口+1)。</small></label>
|
|
146
145
|
<div className="dmr-security"><strong>安全说明</strong><p>主实例保持仅监听 127.0.0.1:远程设备只能经这个登录网关进入,不存在免登录的直连路径。账号密码只发给自己;改密后所有设备需重新登录。</p></div>
|
|
147
146
|
</div>
|
|
148
|
-
{active ? <div className="dmr-share"><div className="dmr-share-layout"><div className="dmr-qr-card">{qr ? <img src={qr} alt="远程访问地址二维码" width="220" height="220"/> : <div className="dmr-qr-loading">正在生成二维码…</div>}<strong>扫码或输入地址</strong><small>打开后输入账号密码登录</small></div><div className="dmr-share-detail"><div><span className="dmr-eyebrow"><i/> 登录网关已运行</span><h4>{lanLink}</h4></div><div className="dmr-link" title={lanLink}>{lanLink}</div><div className="dmr-share-actions"><button type="button" className="dmr-primary" onClick={copyLink} disabled={!lanLink}><RelayIcon name="copy"/>复制地址</button><button type="button" className="dmr-secondary dmr-danger" onClick={disable} disabled={busy === 'stop'}><RelayIcon name="close"/>{busy === 'stop' ? '正在关闭…' : '关闭远程访问'}</button></div><small>主实例保持仅本机(结构上不存在免登录直连);账号密码只发给自己。若开启前已有旧服务器模式(0.0.0.0)配置,会一并移除并提示重启。</small></div></div></div>
|
|
147
|
+
{active ? <div className="dmr-share"><div className="dmr-share-layout"><div className="dmr-qr-card">{qr ? <img src={qr} alt="远程访问地址二维码" width="220" height="220"/> : <div className="dmr-qr-loading">正在生成二维码…</div>}<strong>扫码或输入地址</strong><small>打开后输入账号密码登录</small></div><div className="dmr-share-detail"><div><span className="dmr-eyebrow"><i/> 登录网关已运行</span><h4>{lanLink}</h4></div><div className="dmr-link" title={lanLink}>{lanLink}</div><div className="dmr-share-actions"><button type="button" className="dmr-primary" onClick={copyLink} disabled={!lanLink}><RelayIcon name="copy"/>复制地址</button><button type="button" className="dmr-secondary dmr-danger" onClick={disable} disabled={busy === 'stop'}><RelayIcon name="close"/>{busy === 'stop' ? '正在关闭…' : '关闭远程访问'}</button></div><small>已登录设备:{typeof lan.devices === 'number' ? lan.devices : 0} 台(改密或关闭入口即全部下线)</small><small>主实例保持仅本机(结构上不存在免登录直连);账号密码只发给自己。若开启前已有旧服务器模式(0.0.0.0)配置,会一并移除并提示重启。</small></div></div></div>
|
|
149
148
|
: <button type="button" className="dmr-primary" onClick={enable} disabled={busy === 'start'}>{busy === 'start' ? '正在开启…' : <><RelayIcon name="link"/>开启远程访问</>}</button>}
|
|
150
149
|
{active ? <div className="dmr-section"><div className="dmr-section-head"><h4>修改账号密码</h4><button type="button" className="dmr-text-button" onClick={() => setChanging((v) => !v)}>{changing ? '收起' : '修改'}</button></div>{changing ? <div className="dmr-note"><div className="dmr-network-grid"><label className="dmr-field"><span>账号</span><input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username"/></label><label className="dmr-field"><span>新密码</span><input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} autoComplete="new-password" placeholder="至少 6 位"/></label></div><button type="button" className="dmr-secondary" onClick={saveAuth} disabled={busy === 'auth' || !username.trim() || newPassword.length < 6}>{busy === 'auth' ? '保存中…' : '保存(所有设备重新登录)'}</button></div> : <p className="dmr-note">当前账号:{lan.username || '—'}。修改后所有已登录设备将被强制退出。</p>}</div> : null}
|
|
151
150
|
{message ? <div className={'dmr-message ' + (messageKind === 'success' ? 'success' : 'error')} role="alert">{message}</div> : null}
|
|
@@ -157,195 +156,25 @@ export function MobileRelayView() {
|
|
|
157
156
|
}
|
|
158
157
|
|
|
159
158
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* 与宿主 tapIndex 注入版(features/mobile-relay/host.js,重启 dsh web 后才生效)互斥:
|
|
167
|
-
* window.__dshDockMobileDrawer 先到先得——宿主脚本在 <head> 先跑,重启后总是宿主版接管,
|
|
168
|
-
* 本组件自动让位;本组件的价值是 client bundle 按内容 hash 服务、刷新页面即生效,无需重启。
|
|
169
|
-
* 与旧版宿主行为脚本(无抽屉按钮、只有自动收起)并存是安全的:收起链路同一事件内
|
|
170
|
-
* 第二次 collapse() 因 aria-label 已翻转而自然空转。
|
|
159
|
+
* 【远程访问】不再挂 Overlay:「预览版说明」自动确认已迁到插件级
|
|
160
|
+
* (src/welcome-ack.js 经 index.js 注册 tapIndex,不随任何功能开关启停)——
|
|
161
|
+
* 关掉远程访问、主实例直接绑 0.0.0.0 的直连形态同样每次重弹,归属不能在本模块。
|
|
162
|
+
* 手机端 chrome(底部 Tab 栏 / 抽屉遮罩 / 面板互斥)归【手机适配】
|
|
163
|
+
* (features/mobile/view.js + features/mobile/host.js)。远程访问只负责「远程入口」本身。
|
|
171
164
|
*/
|
|
172
|
-
export function MobileRelayOverlay() {
|
|
173
|
-
useEffect(() => {
|
|
174
|
-
if (typeof window === 'undefined' || typeof document === 'undefined') return undefined
|
|
175
|
-
// 宿主版(<head> 注入,重启后接管)就位时,抽屉把手/遮罩/收起逻辑由宿主负责,
|
|
176
|
-
// 本组件只补宿主旧版没有的底部 Tab 栏;宿主未就位(旧版无抽屉 DOM)时全套自建。
|
|
177
|
-
const hostPresent = !!(window.__dshDockMobileDrawer && document.querySelector('.dsh-mobile-drawer-btn'))
|
|
178
|
-
const mq = window.matchMedia ? window.matchMedia('(max-width:700px)') : null
|
|
179
|
-
if (!mq) return undefined
|
|
180
|
-
if (!hostPresent) window.__dshDockMobileDrawer = 'client'
|
|
181
|
-
|
|
182
|
-
const narrow = () => mq.matches
|
|
183
|
-
const frameEl = () => document.querySelector('[class$="frame"]')
|
|
184
|
-
const sidebarCollapsed = () => { const f = frameEl(); return !!f && f.hasAttribute('data-sidebar-collapsed') }
|
|
185
|
-
const dialogOpen = () => !!document.querySelector('[role="dialog"][class*="panel"]')
|
|
186
|
-
const collapse = () => { const b = document.querySelector('button[aria-label="收起侧边栏"],button[aria-label="Collapse sidebar"]'); if (b) b.click() }
|
|
187
|
-
const expand = () => { const b = document.querySelector('button[aria-label="打开侧边栏"],button[aria-label="Open sidebar"]'); if (b) b.click() }
|
|
188
|
-
const closeDetails = () => {
|
|
189
|
-
const d = document.querySelector('[class*="detailsCol"]')
|
|
190
|
-
if (!d || !d.getBoundingClientRect().width) return
|
|
191
|
-
const c = d.querySelector('button[aria-label="关闭详情"]')
|
|
192
|
-
if (c) c.click()
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// 抽屉按钮:窄屏 + 侧栏收起 + 无设置弹窗时才显示(见 sync)。
|
|
196
|
-
// 视觉是贴左边缘的「抽屉把手」(与趣味游戏浮标 dgfab 同款边缘吸附语言),
|
|
197
|
-
// 位置动态放在趣味游戏浮标正上方(place()),游戏浮标不在时兜底左侧中部。
|
|
198
|
-
// 宿主就位时把手/遮罩已由宿主创建,这里置 null 走纯 Tab 栏模式。
|
|
199
|
-
let fab = null
|
|
200
|
-
let scrim = null
|
|
201
|
-
if (!hostPresent) {
|
|
202
|
-
fab = document.createElement('button')
|
|
203
|
-
fab.type = 'button'
|
|
204
|
-
fab.className = 'dsh-mobile-drawer-btn'
|
|
205
|
-
fab.setAttribute('aria-label', '打开会话列表')
|
|
206
|
-
fab.innerHTML = '<svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h10"/></svg>'
|
|
207
|
-
fab.addEventListener('click', expand)
|
|
208
|
-
scrim = document.createElement('div')
|
|
209
|
-
scrim.className = 'dsh-mobile-scrim'
|
|
210
|
-
document.body.appendChild(fab)
|
|
211
|
-
document.body.appendChild(scrim)
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// ---- 仿 ZCode 手机端底部 Tab 栏:会话 / 功能坞 / 设置 ----
|
|
215
|
-
// 挂 body(与抽屉把手同理,避开 overlayLayer 堆叠上下文),z 序 65:
|
|
216
|
-
// 低于遮罩 75 / 侧栏抽屉 80 / 功能坞面板 200,这些打开时自然盖住 Tab 栏。
|
|
217
|
-
const tabbar = document.createElement('nav')
|
|
218
|
-
tabbar.className = 'dsh-mobile-tabbar'
|
|
219
|
-
tabbar.setAttribute('aria-label', '底部导航')
|
|
220
|
-
const tabDefs = [
|
|
221
|
-
{
|
|
222
|
-
id: 'sessions', label: '会话',
|
|
223
|
-
icon: '<svg viewBox="0 0 24 24"><path d="M21 12a8 8 0 0 1-8 8H5l-2 2V12a8 8 0 0 1 8-8h2a8 8 0 0 1 8 8z"/></svg>',
|
|
224
|
-
act: () => expand(),
|
|
225
|
-
},
|
|
226
|
-
{
|
|
227
|
-
id: 'dock', label: '功能坞',
|
|
228
|
-
icon: '<svg viewBox="0 0 24 24"><rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/></svg>',
|
|
229
|
-
act: () => { if (panelNav.open) setPanelOpen(false); else openPanel('home') },
|
|
230
|
-
},
|
|
231
|
-
{
|
|
232
|
-
id: 'settings', label: '设置',
|
|
233
|
-
icon: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1 1.51 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.23.6.86 1 1.51 1H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1z"/></svg>',
|
|
234
|
-
act: () => { const b = [...document.querySelectorAll('button')].find((x) => (x.getAttribute('aria-label') || '') === '设置' || (x.textContent || '').trim() === '设置'); if (b) b.click() },
|
|
235
|
-
},
|
|
236
|
-
]
|
|
237
|
-
const tabButtons = tabDefs.map((def) => {
|
|
238
|
-
const b = document.createElement('button')
|
|
239
|
-
b.type = 'button'
|
|
240
|
-
b.className = 'dsh-mobile-tab'
|
|
241
|
-
b.dataset.tab = def.id
|
|
242
|
-
b.innerHTML = def.icon + '<span>' + def.label + '</span>'
|
|
243
|
-
b.addEventListener('click', () => def.act())
|
|
244
|
-
tabbar.appendChild(b)
|
|
245
|
-
return b
|
|
246
|
-
})
|
|
247
|
-
document.body.appendChild(tabbar)
|
|
248
|
-
const syncTabs = () => {
|
|
249
|
-
const expanded = !sidebarCollapsed()
|
|
250
|
-
const settingsOpen = dialogOpen()
|
|
251
|
-
for (const b of tabButtons) {
|
|
252
|
-
const id = b.dataset.tab
|
|
253
|
-
const on = (id === 'sessions' && expanded) || (id === 'dock' && panelNav.open) || (id === 'settings' && settingsOpen)
|
|
254
|
-
b.classList.toggle('on', on)
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
const unsubPanel = subscribePanel(syncTabs)
|
|
258
|
-
|
|
259
|
-
// 把手定位:贴着趣味游戏浮标(.dgfab,可拖拽、位置内联在 style 上)正上方;
|
|
260
|
-
// 浮标被拖到顶/隐藏/不存在时兜底左侧中部(38% 视高)。sync 每 1.5s 复测,
|
|
261
|
-
// 拖拽浮标后把手最迟一个周期跟上;resize 立即复测。
|
|
262
|
-
const place = () => {
|
|
263
|
-
if (!fab) return
|
|
264
|
-
const vh = window.innerHeight
|
|
265
|
-
let top = null
|
|
266
|
-
const g = document.querySelector('.dgfab')
|
|
267
|
-
if (g && !g.classList.contains('dgfab-hide') && g.getBoundingClientRect) {
|
|
268
|
-
const r = g.getBoundingClientRect()
|
|
269
|
-
if (r && r.height > 0 && r.top > 0) {
|
|
270
|
-
top = r.top - fab.offsetHeight - 8
|
|
271
|
-
if (top < 64) top = r.bottom + 8 // 浮标太靠上时改放它下面,避开顶栏标题
|
|
272
|
-
if (top + fab.offsetHeight > vh - 16) top = null
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
if (top == null) top = Math.round(vh * 0.38)
|
|
276
|
-
fab.style.top = top + 'px'
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const sync = () => {
|
|
280
|
-
const on = narrow() && !!frameEl() && !dialogOpen()
|
|
281
|
-
const collapsed = sidebarCollapsed()
|
|
282
|
-
if (fab) {
|
|
283
|
-
const showFab = on && collapsed && !panelNav.open
|
|
284
|
-
fab.style.display = showFab ? '' : 'none'
|
|
285
|
-
if (showFab) place()
|
|
286
|
-
}
|
|
287
|
-
if (scrim) scrim.style.display = on && !collapsed ? '' : 'none'
|
|
288
|
-
// Tab 栏:窄屏且页面骨架就绪后常驻;功能坞面板/宿主设置弹窗打开时隐藏(它们的遮罩更高,避免露边)
|
|
289
|
-
tabbar.style.display = narrow() && !!frameEl() && !panelNav.open ? '' : 'none'
|
|
290
|
-
syncTabs()
|
|
291
|
-
}
|
|
292
|
-
sync()
|
|
293
|
-
// MutationObserver 跟属性翻转即时同步;1.5s 轮询覆盖路由重渲等一切边角
|
|
294
|
-
const observer = typeof MutationObserver !== 'undefined' ? new MutationObserver(sync) : null
|
|
295
|
-
if (observer) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-sidebar-collapsed'], subtree: true })
|
|
296
|
-
const timer = setInterval(sync, 1500)
|
|
297
|
-
const onMq = () => sync()
|
|
298
|
-
if (mq.addEventListener) mq.addEventListener('change', onMq)
|
|
299
|
-
else if (mq.addListener) mq.addListener(onMq)
|
|
300
|
-
window.addEventListener('resize', place)
|
|
301
|
-
|
|
302
|
-
// 捕获阶段统一处理(仅自建模式;宿主就位时宿主已有同款全局捕获):
|
|
303
|
-
// 抽屉按钮自身排除(否则展开动作会被"点外部收起"当场撤销);
|
|
304
|
-
// 遮罩不挂自己的 click——它的点击也走这里 collapse(),单一路径防止同一事件两次 toggle。
|
|
305
|
-
const onClick = (e) => {
|
|
306
|
-
if (!narrow()) return
|
|
307
|
-
const t = e.target
|
|
308
|
-
if (!t || !t.closest) return
|
|
309
|
-
if (t.closest('.dsh-mobile-drawer-btn') || t.closest('.dsh-mobile-tabbar')) return
|
|
310
|
-
const expanded = !!document.querySelector('button[aria-label="收起侧边栏"],button[aria-label="Collapse sidebar"]')
|
|
311
|
-
const col = document.querySelector('[class*="sidebarCol"]')
|
|
312
|
-
const inSidebar = col && col.contains(t)
|
|
313
|
-
if (!expanded && !inSidebar) return
|
|
314
|
-
if (inSidebar) {
|
|
315
|
-
const row = t.closest('[role="treeitem"]')
|
|
316
|
-
const leaf = row && !row.querySelector('[role="treeitem"]') && row.closest('[role="tree"]')
|
|
317
|
-
const fresh = t.closest('[class*="newSession"]')
|
|
318
|
-
if (leaf || fresh) setTimeout(() => { collapse(); closeDetails() }, 300)
|
|
319
|
-
return
|
|
320
|
-
}
|
|
321
|
-
if (t.closest('[role="dialog"],[class*="dockm"],[class*="dgfab"],[class*="dgwin"],[class*="dgame"],[class*="detailsCol"]')) return
|
|
322
|
-
collapse()
|
|
323
|
-
}
|
|
324
|
-
if (!hostPresent) document.addEventListener('click', onClick, true)
|
|
325
|
-
|
|
326
|
-
return () => {
|
|
327
|
-
if (!hostPresent) document.removeEventListener('click', onClick, true)
|
|
328
|
-
if (observer) observer.disconnect()
|
|
329
|
-
clearInterval(timer)
|
|
330
|
-
unsubPanel()
|
|
331
|
-
if (mq.removeEventListener) mq.removeEventListener('change', onMq)
|
|
332
|
-
else if (mq.removeListener) mq.removeListener(onMq)
|
|
333
|
-
window.removeEventListener('resize', place)
|
|
334
|
-
if (fab) fab.remove()
|
|
335
|
-
if (scrim) scrim.remove()
|
|
336
|
-
tabbar.remove()
|
|
337
|
-
if (window.__dshDockMobileDrawer === 'client') delete window.__dshDockMobileDrawer
|
|
338
|
-
}
|
|
339
|
-
}, [])
|
|
340
|
-
return null
|
|
341
|
-
}
|
|
342
165
|
|
|
343
166
|
export function MobileRelayHomeStat() {
|
|
344
167
|
const [summary, setSummary] = useState('未开启,开启后可远程登录')
|
|
345
168
|
useEffect(() => {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
169
|
+
const refresh = () => {
|
|
170
|
+
rpc('lan').then((data) => {
|
|
171
|
+
const devices = typeof data.devices === 'number' && data.devices > 0 ? ' · ' + data.devices + ' 台设备在线' : ''
|
|
172
|
+
setSummary(data.gatewayActive ? '入口运行中 · 端口 ' + data.gatewayPort + devices : data.accountSet ? '账号已设置,入口未开启' : '未开启,开启后可远程登录')
|
|
173
|
+
}).catch(() => {})
|
|
174
|
+
}
|
|
175
|
+
refresh()
|
|
176
|
+
const timer = setInterval(refresh, 10000)
|
|
177
|
+
return () => clearInterval(timer)
|
|
349
178
|
}, [])
|
|
350
179
|
return <span>{summary}</span>
|
|
351
180
|
}
|
|
@@ -355,26 +184,7 @@ export const feature = {
|
|
|
355
184
|
description: '账号密码登录的远程入口:所有设备访问同一个 DSH,任务进度实时一致', defaultEnabled: false,
|
|
356
185
|
css: `
|
|
357
186
|
.dmr{--dmr-accent:var(--dk-accent,#2f6fed);--dmr-accent-soft:color-mix(in srgb,var(--dmr-accent) 13%,transparent);display:flex;flex-direction:column;gap:16px;max-width:720px;color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5}.dmr h3,.dmr h4,.dmr p{margin:0}.dmr h3{font-size:17px;line-height:1.25;letter-spacing:-.01em}.dmr h4{font-size:13px}.dmr-hero,.dmr-status-head{display:flex;align-items:flex-start;gap:12px}.dmr-hero>div,.dmr-status-head>div{min-width:0;display:flex;flex-direction:column;gap:4px}.dmr-hero p,.dmr-status-head p,.dmr-field small,.dmr-share small,.dmr-overview-card small,.dmr-task small,.dmr-note p{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.55}.dmr-hero-icon{width:42px;height:42px;display:inline-flex;align-items:center;justify-content:center;flex:none;border-radius:14px;background:var(--dmr-accent-soft);color:var(--dmr-accent);border:1px solid color-mix(in srgb,var(--dmr-accent) 35%,var(--dsw-alias-border-l1))}.dmr-steps{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:0;padding:0;list-style:none}.dmr-steps li{display:flex;gap:9px;padding:11px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);border-radius:12px}.dmr-steps li>span{width:21px;height:21px;display:inline-flex;align-items:center;justify-content:center;flex:none;border-radius:50%;font-size:11px;font-weight:700;color:var(--dmr-accent);background:var(--dmr-accent-soft)}.dmr-steps div{display:flex;flex-direction:column;gap:3px;min-width:0}.dmr-steps strong{font-size:12px}.dmr-steps small{font-size:11px;color:var(--dsw-alias-label-secondary);line-height:1.45}.dmr-field{display:flex;flex-direction:column;gap:6px;font-weight:600}.dmr-field input,.dmr-field select,.dmr-note textarea{box-sizing:border-box;width:100%;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:10px;padding:10px 12px;min-height:44px;font:inherit;outline:none;transition:border-color .18s ease,box-shadow .18s ease}.dmr-field input:focus,.dmr-field select:focus,.dmr-note textarea:focus{border-color:var(--dmr-accent);box-shadow:0 0 0 3px var(--dmr-accent-soft)}.dmr-field small{font-weight:400}.dmr-network-grid{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(150px,.6fr);gap:12px}.dmr-security{padding:12px 13px;border:1px solid color-mix(in srgb,var(--dk-warn) 48%,var(--dsw-alias-border-l1));border-radius:12px;background:color-mix(in srgb,var(--dk-warn) 9%,transparent)}.dmr-security strong{display:block;margin-bottom:4px;color:var(--dk-warn);font-size:12px}.dmr-security p{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.55}.dmr-primary,.dmr-secondary,.dmr-text-button,.dmr-icon-button{font:inherit;touch-action:manipulation;cursor:pointer;transition:transform .15s ease,background .18s ease,border-color .18s ease,opacity .18s ease}.dmr-primary,.dmr-secondary{min-height:44px;display:inline-flex;align-items:center;justify-content:center;gap:8px;border-radius:10px;padding:9px 13px;font-weight:600}.dmr-primary{align-self:flex-start;border:1px solid var(--dmr-accent);background:var(--dmr-accent);color:#fff}.dmr-primary:hover{filter:brightness(1.05)}.dmr-secondary{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}.dmr-secondary:hover{border-color:var(--dmr-accent);background:var(--dmr-accent-soft)}.dmr-primary:active,.dmr-secondary:active,.dmr-icon-button:active{transform:scale(.98)}.dmr-primary:disabled,.dmr-secondary:disabled,.dmr-text-button:disabled,.dmr-icon-button:disabled{cursor:not-allowed;opacity:.5}.dmr-message{display:flex;align-items:flex-start;gap:7px;padding:10px 12px;border-radius:10px;font-size:12px}.dmr-message.success{color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent)}.dmr-message.error{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}.dmr-status-head{justify-content:space-between}.dmr-eyebrow{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:600;color:var(--dmr-accent)}.dmr-eyebrow i{width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 0 4px var(--dmr-accent-soft)}.dmr-icon-button{display:inline-flex;align-items:center;justify-content:center;flex:none;width:44px;height:44px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:transparent;color:var(--dsw-alias-label-secondary)}.dmr-icon-button:hover{color:var(--dsw-alias-state-error-primary);border-color:currentColor}.dmr-share{display:flex;flex-direction:column;gap:9px;padding:13px;border:1px solid color-mix(in srgb,var(--dmr-accent) 32%,var(--dsw-alias-border-l1));border-radius:13px;background:var(--dmr-accent-soft)}.dmr-share-layout{display:grid;grid-template-columns:196px minmax(0,1fr);gap:16px;align-items:center}.dmr-qr-card{box-sizing:border-box;display:flex;flex-direction:column;align-items:center;gap:5px;padding:10px;border-radius:12px;background:#fff;color:#111827;text-align:center}.dmr-qr-card img{display:block;width:176px;height:176px;max-width:100%;object-fit:contain}.dmr-qr-card strong{font-size:12px}.dmr-qr-card small{color:#4b5563;font-size:10px}.dmr-qr-loading{display:grid;place-items:center;width:176px;height:176px;color:#64748b;font-size:12px}.dmr-share-detail{min-width:0;display:flex;flex-direction:column;gap:10px}.dmr-share-detail>div:first-child{display:flex;flex-direction:column;gap:4px}.dmr-link{padding:10px 11px;border-radius:8px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all;color:var(--dsw-alias-label-secondary)}.dmr-share-actions{display:flex;gap:8px;flex-wrap:wrap}.dmr-share-actions .dmr-primary{align-self:auto}.dmr-overview{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.dmr-overview-card{display:flex;flex-direction:column;gap:3px;padding:12px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1)}.dmr-overview-card>span{font-size:11px;color:var(--dsw-alias-label-tertiary)}.dmr-overview-card strong{font-size:16px;letter-spacing:-.01em}.dmr-section{display:flex;flex-direction:column;gap:9px}.dmr-section-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.dmr-section-head small{font-size:11px;color:var(--dsw-alias-label-tertiary)}.dmr-text-button{border:0;background:transparent;color:var(--dmr-accent);padding:8px;min-height:36px;font-weight:600}.dmr-task-list{display:flex;flex-direction:column;gap:8px}.dmr-task{display:flex;align-items:flex-start;gap:9px;padding:10px 11px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1)}.dmr-task>div{display:flex;min-width:0;flex:1;flex-direction:column;gap:2px}.dmr-task strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dmr-phase{flex:none;border-radius:999px;padding:3px 8px;font-size:11px;background:var(--dmr-accent-soft);color:var(--dmr-accent)}.dmr-phase.write{color:var(--dsw-alias-state-success-primary)}.dmr-phase.code{color:var(--dk-warn)}.dmr-phase.search{color:#0d9488}.dmr-empty{padding:18px 12px;text-align:center;border:1px dashed var(--dsw-alias-border-l2);border-radius:10px;color:var(--dsw-alias-label-secondary);font-size:12px}.dmr-note{display:flex;flex-direction:column;gap:9px;padding-top:2px}.dmr-note blockquote{margin:0;padding:10px 12px;border-left:3px solid var(--dmr-accent);border-radius:0 9px 9px 0;background:var(--dmr-accent-soft);white-space:pre-wrap;font-size:12px}.dmr-note textarea{min-height:88px;resize:vertical;line-height:1.5}.dmr-note .dmr-secondary{align-self:flex-start}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media (max-width:680px){.dmr{gap:14px;font-size:14px}.dmr h3{font-size:18px}.dmr-steps{grid-template-columns:1fr;gap:8px}.dmr-steps li{padding:10px}.dmr-steps small,.dmr-hero p,.dmr-status-head p,.dmr-field small,.dmr-share small,.dmr-overview-card small,.dmr-task small,.dmr-note p{font-size:12px}.dmr-network-grid{grid-template-columns:1fr;gap:10px}.dmr-field input,.dmr-field select{font-size:16px}.dmr-primary,.dmr-secondary{width:100%;font-size:14px}.dmr-share-layout{grid-template-columns:1fr;gap:12px}.dmr-qr-card{width:min(240px,100%);margin:0 auto}.dmr-qr-card img,.dmr-qr-loading{width:210px;height:210px}.dmr-share-actions{flex-direction:column}.dmr-share-actions .dmr-primary{width:100%}.dmr-overview{gap:8px}.dmr-overview-card{padding:11px}.dmr-status-head{gap:8px}.dmr-link{font-size:11px}.dmr-note textarea{font-size:16px;min-height:104px}.dmr-note .dmr-secondary{align-self:stretch}.dmr-compact .dmr-hero-icon{width:40px;height:40px;border-radius:13px}}@media (prefers-reduced-motion:reduce){.dmr-primary,.dmr-secondary,.dmr-text-button,.dmr-icon-button,.dmr-field input,.dmr-field select,.dmr-note textarea{transition:none}}.dmr-lan{flex:none;margin-top:6px;padding-top:16px;border-top:1px solid var(--dsw-alias-border-l1)}.dmr-lan-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.dmr-lan-title{min-width:0;display:flex;flex-direction:column;gap:4px}.dmr-lan-title p{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.55}.dmr-lan-badge{flex:none;display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:5px 10px;font-size:11px;font-weight:700;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary)}.dmr-lan-badge.on{border-color:color-mix(in srgb,var(--dmr-accent) 45%,transparent);background:var(--dmr-accent-soft);color:var(--dmr-accent)}.dmr-lan-active,.dmr-lan-idle{display:flex;flex-direction:column;gap:12px}.dmr-lan-active .dmr-share-detail small strong{color:var(--dsw-alias-state-error-primary)}.dmr-danger{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 45%,var(--dsw-alias-border-l2));color:var(--dsw-alias-state-error-primary)}.dmr-danger:hover{border-color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
/* ---- 仿 ZCode 手机端:底部 Tab 栏 + 输入区吸附底部 ---- */
|
|
362
|
-
.dsh-mobile-tabbar{position:fixed;left:0;right:0;bottom:0;z-index:65;display:flex;align-items:stretch;justify-content:space-around;padding:6px 8px calc(8px + env(safe-area-inset-bottom,0px));background:color-mix(in srgb,var(--dsw-alias-bg-layer-2,#1c2230) 92%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-top:1px solid var(--dsw-alias-border-l1,rgba(127,139,161,.25));touch-action:manipulation}
|
|
363
|
-
.dsh-mobile-tab{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;min-height:48px;padding:4px 0;border:0;background:transparent;color:var(--dsw-alias-label-tertiary,#8b95ab);font:600 11px/1.2 inherit;cursor:pointer;-webkit-tap-highlight-color:transparent;border-radius:10px}
|
|
364
|
-
.dsh-mobile-tab svg{width:21px;height:21px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
|
|
365
|
-
.dsh-mobile-tab.on{color:var(--dsw-alias-label-primary,#e6eaf2)}
|
|
366
|
-
.dsh-mobile-tab.on svg{color:var(--dk-accent,#4d9fff)}
|
|
367
|
-
.dsh-mobile-tab:active{transform:scale(.94)}
|
|
368
|
-
/* 输入区吸附到 Tab 栏上方:宿主把欢迎内容 justify-content:center 垂直居中,
|
|
369
|
-
* 手机视口下输入卡片悬在中部、下方留出大片空白。改为 flex-end 贴底,并给
|
|
370
|
-
* 滚动体留出 Tab 栏高度的内边距,避免内容被 Tab 栏遮挡。 */
|
|
371
|
-
[class*="scrollBody"]{justify-content:flex-end !important;padding-bottom:calc(72px + env(safe-area-inset-bottom,0px)) !important}
|
|
372
|
-
[class*="scrollBody"]>[class*="composerSeat"]{padding-bottom:6px}
|
|
373
|
-
/* 收起的侧栏轨道(collapsed rail)在窄屏只剩 1px 占位,但轨道里的按钮
|
|
374
|
-
* 仍会溢出贴在左边缘(功能坞/进化/设置三枚露出半边的圆钮)。隐藏整列,
|
|
375
|
-
* 这些入口已由底部 Tab 栏接管;展开抽屉(z 80)不受影响。 */
|
|
376
|
-
[class*="sidebarCol"]:has([class*="collapsed"]){visibility:hidden}
|
|
377
|
-
/* 抽屉把手从屏幕中部挪到 Tab 栏上方一点,免得孤零零悬在半空 */
|
|
378
|
-
.dsh-mobile-drawer-btn{top:auto !important;bottom:calc(96px + env(safe-area-inset-bottom,0px))}}`,
|
|
379
|
-
View: MobileRelayView, HomeStat: MobileRelayHomeStat, Overlay: MobileRelayOverlay,
|
|
187
|
+
`,
|
|
188
|
+
|
|
189
|
+
View: MobileRelayView, HomeStat: MobileRelayHomeStat,
|
|
380
190
|
}
|
|
@@ -10,9 +10,17 @@
|
|
|
10
10
|
// - schemastery 保留未知字段(实测),dockTags 对官方校验透明
|
|
11
11
|
// - POST 也收图片理解代理配置(visionProxy 分支,写 dsh-dock 自有命名空间)
|
|
12
12
|
import { DOCK_NS, sendJson, readBody, walkPath, readDockRoot, mutateDockSection } from '../../src/host-core.js'
|
|
13
|
+
// 凭据引用(apiKeyEnv → 本地凭据库条目)。注意:只从本模块取 credentialRef;
|
|
14
|
+
// 不同 dsh 版本导出的辅助函数不一致(isCredentialRefName 在运行期并不存在),
|
|
15
|
+
// 非法环境变量名一律用 try/catch + credentialRef 兜住。
|
|
16
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
13
17
|
|
|
14
18
|
/** 官方 schema 接受的输入模态(写回配置的 input 只允许这两个)。 */
|
|
15
19
|
const HOST_MODALITIES = ['text', 'image']
|
|
20
|
+
/** pi-ai 路由支持的 API 协议。 */
|
|
21
|
+
const PI_AI_APIS = ['openai-completions', 'openai-responses', 'anthropic-messages']
|
|
22
|
+
/** Provider 路由 key(= settings 里 providers.<route> 的 key),须能安全用作对象键与凭据寻址。 */
|
|
23
|
+
const ROUTE_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
16
24
|
/** pi-ai 支持的思考档位(升序)。 */
|
|
17
25
|
const PI_AI_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
|
|
18
26
|
/** deepseek 官方支持的思考档位。 */
|
|
@@ -147,6 +155,9 @@ export function readModelDirectory(ctx) {
|
|
|
147
155
|
kind,
|
|
148
156
|
settingsNs: e.settingsNs,
|
|
149
157
|
settingsPath: [...e.settingsPath],
|
|
158
|
+
// 用户层原始配置:编辑表单按它回填,未知字段(自配 compat/description 等)原样保留。
|
|
159
|
+
rawProfile: profileUser && typeof profileUser === 'object' && !Array.isArray(profileUser) ? profileUser : {},
|
|
160
|
+
apis: PI_AI_APIS.slice(),
|
|
150
161
|
defaultInput: strList(resolvedProfile.defaultInput),
|
|
151
162
|
models: models.map((m) => piAiModelView(m && typeof m === 'object' ? m : { id: String(m) }, resolvedProfile.defaultInput)),
|
|
152
163
|
})
|
|
@@ -184,6 +195,24 @@ async function enrichRuntimeInput(ctx, dir) {
|
|
|
184
195
|
})))
|
|
185
196
|
}
|
|
186
197
|
|
|
198
|
+
/**
|
|
199
|
+
* 并行补 Provider 的 apiKeyConfigured:apiKeyEnv 指向的条目是否已在本地凭据库配置。
|
|
200
|
+
* 凭据库不可用 / 环境变量名非法时一律降级为 false(不抛错,避免整表 500)。
|
|
201
|
+
*/
|
|
202
|
+
async function enrichApiKeyStatus(ctx, dir) {
|
|
203
|
+
const credentials = ctx.get('credentials')
|
|
204
|
+
if (!credentials || typeof credentials.describe !== 'function') return
|
|
205
|
+
await Promise.all((dir.providers || []).filter((p) => p.kind === 'pi-ai').map(async (p) => {
|
|
206
|
+
if (typeof p.apiKeyEnv !== 'string' || !p.apiKeyEnv) { p.apiKeyConfigured = false; return }
|
|
207
|
+
try {
|
|
208
|
+
const d = await credentials.describe(credentialRef(p.apiKeyEnv))
|
|
209
|
+
p.apiKeyConfigured = !!(d && d.configured)
|
|
210
|
+
} catch {
|
|
211
|
+
p.apiKeyConfigured = false
|
|
212
|
+
}
|
|
213
|
+
}))
|
|
214
|
+
}
|
|
215
|
+
|
|
187
216
|
/** 校验面板数字草稿并落到条目上(空 = 删除字段,继承默认)。 */
|
|
188
217
|
function setPosInt(entry, field, value) {
|
|
189
218
|
if (value === undefined || value === null || value === '') {
|
|
@@ -354,6 +383,164 @@ async function writeModelConfig(ctx, body) {
|
|
|
354
383
|
return { ok: true, savedAt: Date.now() }
|
|
355
384
|
}
|
|
356
385
|
|
|
386
|
+
/**
|
|
387
|
+
* Provider 级增 / 改 / 删(走 llm-pi-ai 的 providers 字典,不改内核)。
|
|
388
|
+
* body.action = 'create' | 'update' | 'delete'
|
|
389
|
+
* body.route = providers 字典的 key(= 路由 id)
|
|
390
|
+
* body.profile = { displayName, api, baseURL, apiKeyEnv, defaultInput, models: [草稿...] }
|
|
391
|
+
* body.apiKey = 可选明文 Key(写本地凭据库,不落到配置文件)
|
|
392
|
+
* body.revisions['llm-pi-ai'] = GET 拿到的 revision(乐观锁,撞车 409)
|
|
393
|
+
* 未在 profile 里出现的未知字段?——不会:base 以原 profile 为基础合并,用户自配的
|
|
394
|
+
* compat / description 等原样保留,只覆盖面板负责的那几个键。
|
|
395
|
+
*/
|
|
396
|
+
async function writeProviderConfig(ctx, body) {
|
|
397
|
+
const settings = ctx.get('settings')
|
|
398
|
+
if (!settings || typeof settings.mutate !== 'function') throw new Error('settings 服务不可用或不可写')
|
|
399
|
+
const action = body.action
|
|
400
|
+
const route = typeof body.route === 'string' ? body.route.trim() : ''
|
|
401
|
+
const revision = body.revisions && typeof body.revisions['llm-pi-ai'] === 'number'
|
|
402
|
+
? body.revisions['llm-pi-ai'] : undefined
|
|
403
|
+
const bad = (msg) => { const e = new Error(msg); e.statusCode = 400; return e }
|
|
404
|
+
|
|
405
|
+
if (action === 'delete') {
|
|
406
|
+
if (!route) throw bad('请求缺少 route')
|
|
407
|
+
const dir = readModelDirectory(ctx)
|
|
408
|
+
const p = (dir.providers || []).find((x) => (x.route || x.id) === route)
|
|
409
|
+
if (!p) throw bad(`未找到 Provider:${route}`)
|
|
410
|
+
if (p.kind !== 'pi-ai') throw bad('内置 Provider(如 DeepSeek 官方)不能删除,只能编辑其模型')
|
|
411
|
+
try {
|
|
412
|
+
await settings.mutate('llm-pi-ai', [{ op: 'unset', path: ['providers', p.route || route] }], revision)
|
|
413
|
+
} catch (e) {
|
|
414
|
+
const msg = e && e.message ? e.message : String(e)
|
|
415
|
+
const err = new Error(`删除被拒绝:${msg}`)
|
|
416
|
+
err.statusCode = /conflict|revision/i.test(msg) ? 409 : 400
|
|
417
|
+
throw err
|
|
418
|
+
}
|
|
419
|
+
return { ok: true, deleted: p.route || route, savedAt: Date.now() }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (action !== 'create' && action !== 'update') throw bad(`不支持的 action:${action}`)
|
|
423
|
+
if (!route || !ROUTE_KEY_RE.test(route)) throw bad('路由 key 非法:须以字母数字开头,只能含字母、数字与 . _ -')
|
|
424
|
+
const src = body.profile && typeof body.profile === 'object' && !Array.isArray(body.profile) ? body.profile : {}
|
|
425
|
+
const api = typeof src.api === 'string' ? src.api : ''
|
|
426
|
+
if (PI_AI_APIS.indexOf(api) < 0) throw bad(`api 非法:只支持 ${PI_AI_APIS.join(' / ')}`)
|
|
427
|
+
const baseURL = typeof src.baseURL === 'string' ? src.baseURL.trim() : ''
|
|
428
|
+
if (!/^https?:\/\//i.test(baseURL)) throw bad('baseURL 非法:须以 http:// 或 https:// 开头')
|
|
429
|
+
let apiKeyEnv = typeof src.apiKeyEnv === 'string' ? src.apiKeyEnv.trim() : ''
|
|
430
|
+
if (apiKeyEnv) {
|
|
431
|
+
try { credentialRef(apiKeyEnv) } catch { throw bad('apiKeyEnv 非法:须是合法的环境变量名(如 MODELSCOPE_API_KEY)') }
|
|
432
|
+
}
|
|
433
|
+
const models = Array.isArray(src.models) ? src.models : []
|
|
434
|
+
if (models.length === 0) throw bad('至少需要一个模型')
|
|
435
|
+
const seen = new Set()
|
|
436
|
+
for (const m of models) {
|
|
437
|
+
const id = m && typeof m.id === 'string' ? m.id.trim() : ''
|
|
438
|
+
if (!id) throw bad('模型 id 不能为空')
|
|
439
|
+
if (seen.has(id)) throw bad(`模型 id 重复:${id}`)
|
|
440
|
+
seen.add(id)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const base = Object.assign({}, src)
|
|
444
|
+
delete base.models
|
|
445
|
+
const displayName = typeof src.displayName === 'string' ? src.displayName.trim() : ''
|
|
446
|
+
if (displayName) base.displayName = displayName; else delete base.displayName
|
|
447
|
+
base.api = api
|
|
448
|
+
base.baseURL = baseURL
|
|
449
|
+
if (apiKeyEnv) base.apiKeyEnv = apiKeyEnv; else delete base.apiKeyEnv
|
|
450
|
+
base.models = models.map((m) => piAiModelWrite(
|
|
451
|
+
Object.assign({}, m, { id: String(m.id).trim() }),
|
|
452
|
+
{ api, route, baseURL }
|
|
453
|
+
))
|
|
454
|
+
|
|
455
|
+
const apiKey = typeof body.apiKey === 'string' ? body.apiKey.trim() : ''
|
|
456
|
+
if (apiKey) {
|
|
457
|
+
if (!apiKeyEnv) {
|
|
458
|
+
// Key 填了但 apiKeyEnv 留空:按路由 key 自动生成凭据库条目名(与客户端 dkmEnvFromRoute 一致),
|
|
459
|
+
// 不再要求用户现编一个环境变量名
|
|
460
|
+
const s = String(route || '').toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '')
|
|
461
|
+
apiKeyEnv = (s ? (/^[0-9]/.test(s) ? 'KEY_' + s : s) : 'PROVIDER') + '_API_KEY'
|
|
462
|
+
base.apiKeyEnv = apiKeyEnv
|
|
463
|
+
}
|
|
464
|
+
const credentials = ctx.get('credentials')
|
|
465
|
+
if (!credentials || typeof credentials.set !== 'function') throw bad('凭据库不可用,无法保存 API Key')
|
|
466
|
+
try {
|
|
467
|
+
await credentials.set(credentialRef(apiKeyEnv), apiKey)
|
|
468
|
+
} catch (e) {
|
|
469
|
+
const msg = e && e.message ? e.message : String(e)
|
|
470
|
+
const err = new Error(`写入本地凭据库失败:${msg}`)
|
|
471
|
+
err.statusCode = 400
|
|
472
|
+
throw err
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
try {
|
|
477
|
+
await settings.mutate('llm-pi-ai', [{ op: 'set', path: ['providers', route], value: base }], revision)
|
|
478
|
+
} catch (e) {
|
|
479
|
+
const msg = e && e.message ? e.message : String(e)
|
|
480
|
+
const err = new Error(`写回官方配置被拒绝:${msg}`)
|
|
481
|
+
err.statusCode = /conflict|revision/i.test(msg) ? 409 : 400
|
|
482
|
+
throw err
|
|
483
|
+
}
|
|
484
|
+
return { ok: true, route, created: action === 'create', savedAt: Date.now() }
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* 拉取 Provider 的可用模型列表:OpenAI 兼容端点 GET {baseURL}/models。
|
|
489
|
+
* Key 优先用请求里刚填的明文(还没保存就能先拉列表),否则回退到已配置的凭据库条目。
|
|
490
|
+
* 只做「列目录」,不代表该模型真能调用(额度/权限以上游为准)。
|
|
491
|
+
*/
|
|
492
|
+
async function fetchProviderModels(ctx, body) {
|
|
493
|
+
const bad = (msg) => { const e = new Error(msg); e.statusCode = 400; return e }
|
|
494
|
+
const baseURL = typeof body.baseURL === 'string' ? body.baseURL.trim().replace(/\/+$/, '') : ''
|
|
495
|
+
if (!/^https?:\/\//i.test(baseURL)) throw bad('请先填写合法的 baseURL(http:// 或 https://)')
|
|
496
|
+
let key = typeof body.apiKey === 'string' ? body.apiKey.trim() : ''
|
|
497
|
+
if (!key && typeof body.apiKeyEnv === 'string' && body.apiKeyEnv.trim()) {
|
|
498
|
+
const credentials = ctx.get('credentials')
|
|
499
|
+
if (credentials && typeof credentials.resolve === 'function') {
|
|
500
|
+
try {
|
|
501
|
+
const got = await credentials.resolve(credentialRef(body.apiKeyEnv.trim()))
|
|
502
|
+
key = typeof got === 'string' ? got : (got && typeof got.value === 'string' ? got.value : '')
|
|
503
|
+
} catch { key = '' }
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const headers = { accept: 'application/json' }
|
|
507
|
+
if (key) {
|
|
508
|
+
if (body.api === 'anthropic-messages') {
|
|
509
|
+
headers['x-api-key'] = key
|
|
510
|
+
headers['anthropic-version'] = '2023-06-01'
|
|
511
|
+
} else {
|
|
512
|
+
headers.authorization = 'Bearer ' + key
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
let res
|
|
516
|
+
try {
|
|
517
|
+
res = await fetch(baseURL + '/models', { headers, signal: AbortSignal.timeout(20000) })
|
|
518
|
+
} catch (e) {
|
|
519
|
+
const err = new Error(`请求 ${baseURL}/models 失败:${(e && e.message) || String(e)}`)
|
|
520
|
+
err.statusCode = 400
|
|
521
|
+
throw err
|
|
522
|
+
}
|
|
523
|
+
let text = ''
|
|
524
|
+
try { text = await res.text() } catch { text = '' }
|
|
525
|
+
if (!res.ok) {
|
|
526
|
+
const err = new Error(`上游返回 HTTP ${res.status}${text ? ':' + text.slice(0, 240) : ''}`)
|
|
527
|
+
err.statusCode = 400
|
|
528
|
+
throw err
|
|
529
|
+
}
|
|
530
|
+
let json = null
|
|
531
|
+
try { json = JSON.parse(text) } catch { /* 非 JSON:下面按空列表处理 */ }
|
|
532
|
+
let list = []
|
|
533
|
+
if (Array.isArray(json)) list = json
|
|
534
|
+
else if (json && Array.isArray(json.data)) list = json.data
|
|
535
|
+
else if (json && Array.isArray(json.models)) list = json.models
|
|
536
|
+
const models = list.map((x) => {
|
|
537
|
+
if (typeof x === 'string') return { id: x }
|
|
538
|
+
const o = x && typeof x === 'object' ? x : {}
|
|
539
|
+
return { id: String(o.id || o.model || o.name || ''), name: typeof o.name === 'string' ? o.name : undefined }
|
|
540
|
+
}).filter((m) => m.id)
|
|
541
|
+
return { ok: true, count: models.length, models, fetchedAt: Date.now() }
|
|
542
|
+
}
|
|
543
|
+
|
|
357
544
|
/** 功能注册表条目:index.js 组装 hostSetups 时使用。 */
|
|
358
545
|
export const feature = {
|
|
359
546
|
// id 必须与客户端视图描述符(view.js 的 feature.id)一致:面板开关 / 补推同步都按它
|
|
@@ -371,6 +558,7 @@ export const feature = {
|
|
|
371
558
|
if (req.method === 'GET') {
|
|
372
559
|
const payload = readModelDirectory(ctx)
|
|
373
560
|
await enrichRuntimeInput(ctx, payload)
|
|
561
|
+
await enrichApiKeyStatus(ctx, payload)
|
|
374
562
|
// 附带图片理解代理配置与自有命名空间 revision(面板保存用)
|
|
375
563
|
try {
|
|
376
564
|
const v = readDockRoot(ctx)
|
|
@@ -391,7 +579,13 @@ export const feature = {
|
|
|
391
579
|
}
|
|
392
580
|
if (req.method === 'POST') {
|
|
393
581
|
const body = await readBody(req)
|
|
394
|
-
const
|
|
582
|
+
const action = body && typeof body === 'object' ? body.action : undefined
|
|
583
|
+
// action 显式区分三条链路:拉模型列表 / Provider 增删改 / 旧版模型目录写回
|
|
584
|
+
const result = action === 'listModels'
|
|
585
|
+
? await fetchProviderModels(ctx, body)
|
|
586
|
+
: (action === 'create' || action === 'update' || action === 'delete')
|
|
587
|
+
? await writeProviderConfig(ctx, body)
|
|
588
|
+
: await writeModelConfig(ctx, body)
|
|
395
589
|
sendJson(res, 200, result)
|
|
396
590
|
return
|
|
397
591
|
}
|