dsh-dock 0.13.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.
@@ -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, subscribePanel, panelNav } from '../../src/shared.js'
8
7
 
9
8
  function rpc(method, payload) {
10
- return fetch('/dsh-dock/mobile-relay/' + method, {
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(() => ({}))
@@ -157,290 +156,12 @@ export function MobileRelayView() {
157
156
  }
158
157
 
159
158
  /**
160
- * 手机端会话抽屉(窄屏 ≤700px):底部 Tab 栏「会话」页签拉出侧边栏 + 遮罩点击收起。
161
- *
162
- * 直接操作 document.body 下的 DOM(渲染返回 null)——若作为 overlayLayer 的子元素,
163
- * 会落入 overlayLayer(z 90)的堆叠上下文,遮罩压不住侧栏抽屉(z 80)、Tab 栏也盖不住内容;
164
- * 挂到 body 下 z 序才是设计值(遮罩 75 < 侧栏 80 < overlayLayer 90)。
165
- * 左缘浮动「抽屉把手」已移除(与底部 Tab 栏「会话」页签功能重复,还悬浮在内容上)。
166
- *
167
- * 与宿主 tapIndex 注入版(features/mobile-relay/host.js,重启 dsh web 后才生效)互斥:
168
- * window.__dshDockMobileDrawer 先到先得——宿主脚本在 <head> 先跑并置 'host',本组件
169
- * 见旗标即整体让位(遮罩/底部 Tab/收起逻辑全归宿主);宿主未就位(旧版无注入、
170
- * 或刷新早于重启)时本组件自建全套。本组件的价值是 client bundle 按内容 hash 服务、
171
- * 刷新页面即生效,无需重启。
172
- * ⚠️ 两个版本绝不能各建一套:曾出现双底部 Tab 栏(两套按钮叠在一起)与客户端 CSS
173
- * 覆盖宿主 place() 定位的回归。
174
- * 与旧版宿主行为脚本并存是安全的:收起链路同一事件内第二次 collapse() 因 aria-label
175
- * 已翻转而自然空转。
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)。远程访问只负责「远程入口」本身。
176
164
  */
177
- export function MobileRelayOverlay() {
178
- // —— 预览版说明自动确认(独立 effect,与下方抽屉 chrome 的宿主互斥无关)——
179
- // 根因(dsh 0.2.0-rc.2 ui-settings-models/src/client/welcome-store.ts,文件头注释原文):
180
- // 回环浏览器的确认走宿主持久化,一次确认永久生效;**远程(非回环)浏览器的设置 scope
181
- // 是 memory 模式,确认只存页面进程内存,刷新即丢**——所以从局域网/组网 IP 打开时
182
- // 「预览版说明」每次刷新都重弹,profile patch 里钉 welcomeNoticeVersion 也救不了
183
- // (memory 分支根本不读持久值)。这里在远程访问场景下替用户点一次「继续」:
184
- // 每次页面加载最多点一次,回环地址不介入(让 dsh 原生的一次性持久确认自然工作)。
185
- useEffect(() => {
186
- if (typeof window === 'undefined' || typeof document === 'undefined') return undefined
187
- const host = (window.location && window.location.hostname) || ''
188
- const loopback = host === '127.0.0.1' || host === 'localhost' || host === '::1' || host.endsWith('.localhost')
189
- if (loopback) return undefined
190
- if (typeof MutationObserver === 'undefined') return undefined
191
- let done = false
192
- const tryDismiss = () => {
193
- if (done) return
194
- const dialogs = document.querySelectorAll('[role="dialog"]')
195
- for (const dialog of dialogs) {
196
- const text = (dialog.textContent || '')
197
- if (!/预览版说明|Preview Notice/.test(text)) continue
198
- const btn = [...dialog.querySelectorAll('button')].find((b) => {
199
- const t = (b.textContent || '').trim()
200
- return t === '继续' || t === 'Continue'
201
- })
202
- if (btn) {
203
- done = true
204
- btn.click()
205
- observer.disconnect()
206
- }
207
- break
208
- }
209
- }
210
- const observer = new MutationObserver(tryDismiss)
211
- observer.observe(document.body, { childList: true, subtree: true })
212
- tryDismiss()
213
- // 兜底停止:说明若 60 秒内没出现(未来版本改版/被官方移除),观察器自动退场
214
- const stopTimer = setTimeout(() => observer.disconnect(), 60000)
215
- return () => { clearTimeout(stopTimer); observer.disconnect() }
216
- }, [])
217
-
218
- useEffect(() => {
219
- if (typeof window === 'undefined' || typeof document === 'undefined') return undefined
220
- const mq = window.matchMedia ? window.matchMedia('(max-width:700px)') : null
221
- if (!mq) return undefined
222
- // 宿主版接管中:整套 chrome 由宿主负责,本组件零副作用退出。
223
- if (window.__dshDockMobileDrawer === 'host') return undefined
224
- window.__dshDockMobileDrawer = 'client'
225
-
226
- const narrow = () => mq.matches
227
- const frameEl = () => document.querySelector('[class$="frame"]')
228
- const sidebarCollapsed = () => { const f = frameEl(); return !!f && f.hasAttribute('data-sidebar-collapsed') }
229
- const dialogOpen = () => !!document.querySelector('[role="dialog"][class*="panel"]')
230
- // 右侧详情面板:dsh 0.1.7 起 detailsCol 改名 rightbarCol,窄屏原生全屏展开
231
- // (data-sidebar-right-panel / data-sidebar-right-open,折叠钮 data-sidebar-right-toggle)。
232
- const rightOpen = () => !!document.querySelector('[data-sidebar-right-panel][data-sidebar-right-open]')
233
- const collapse = () => { const b = document.querySelector('button[aria-label="收起侧边栏"],button[aria-label="Collapse sidebar"]'); if (b) b.click() }
234
- const expand = () => { const b = document.querySelector('button[aria-label="打开侧边栏"],button[aria-label="Open sidebar"]'); if (b) b.click() }
235
- const closeDetails = () => {
236
- const panel = document.querySelector('[data-sidebar-right-panel][data-sidebar-right-open]')
237
- if (!panel) return
238
- const toggle = panel.querySelector('[data-sidebar-right-toggle]')
239
- if (toggle) toggle.click()
240
- }
241
-
242
- // 抽屉遮罩:侧栏展开(浮层态)时盖住页面,点击经全局捕获 handler 收起。
243
- // 左缘的浮动「抽屉把手」已移除:底部 Tab 栏的「会话」页签就是会话抽屉的唯一入口,
244
- // 把手悬浮在内容上还压过输入区左下角(用户实测截图),纯属重复。
245
- const scrim = document.createElement('div')
246
- scrim.className = 'dsh-mobile-scrim'
247
- document.body.appendChild(scrim)
248
-
249
- // ---- 仿 ZCode 手机端底部 Tab 栏:会话 / 插件 / 设置 ----
250
- // 「任务」「功能坞」合并为一个「插件」页签(用户反馈两个入口冗余):插件面板里
251
- // 本来就有运行状态页,任务状态以角标显示在「插件」上——进行中蓝色数量、等待确认
252
- // 红色脉冲;有任务在跑时点「插件」直接落到运行状态页,否则进面板首页。
253
- // 挂 body(避开 overlayLayer 堆叠上下文),z 序 65:
254
- // 低于遮罩 75 / 侧栏抽屉 80 / 功能坞面板 200,这些打开时自然盖住 Tab 栏。
255
- // 走到这里说明宿主旗标不存在(宿主版未注入)——宿主版自建的 Tab 栏不会出现;
256
- // 兜底摘一次历史上可能残留的旧 Tab 栏,保证全程只有一份。
257
- document.querySelectorAll('.dsh-mobile-tabbar').forEach((el) => el.remove())
258
- const tabbar = document.createElement('nav')
259
- tabbar.className = 'dsh-mobile-tabbar'
260
- tabbar.setAttribute('aria-label', '底部导航')
261
- // 最近一次角标轮询看到的任务数(驱动「插件」页签的直达目标与角标显隐)。
262
- let tasksActive = 0
263
- let tasksWaiting = 0
264
- const tabDefs = [
265
- {
266
- id: 'sessions', label: '会话',
267
- 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>',
268
- act: () => expand(),
269
- },
270
- {
271
- id: 'dock', label: '插件',
272
- 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>',
273
- act: () => {
274
- // 始终打开、不再当开关用:若功能坞处于打开态但被原生全屏页/弹层(如从侧栏
275
- // 进的官方插件管理页)盖住,再点一下会把功能坞关掉、露出的反而是原生页——
276
- // 看起来就是「点插件出官方插件页」(用户实测截图)。关闭走面板自己的 ✕。
277
- // 配合手机端把功能坞面板 z 序抬到原生弹层之上(媒体查询内 2000)。
278
- openPanel((tasksActive > 0 || tasksWaiting > 0) ? 'runstate' : 'home')
279
- },
280
- },
281
- {
282
- id: 'settings', label: '设置',
283
- 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>',
284
- act: () => { const b = [...document.querySelectorAll('button')].find((x) => (x.getAttribute('aria-label') || '') === '设置' || (x.textContent || '').trim() === '设置'); if (b) b.click() },
285
- },
286
- ]
287
- let tasksBadge = null
288
- const tabButtons = tabDefs.map((def) => {
289
- const b = document.createElement('button')
290
- b.type = 'button'
291
- b.className = 'dsh-mobile-tab'
292
- b.dataset.tab = def.id
293
- b.innerHTML = def.icon + '<span>' + def.label + '</span>'
294
- b.addEventListener('click', () => def.act())
295
- if (def.id === 'dock') {
296
- tasksBadge = document.createElement('span')
297
- tasksBadge.className = 'dsh-mobile-tab-badge'
298
- tasksBadge.style.display = 'none'
299
- b.appendChild(tasksBadge)
300
- }
301
- tabbar.appendChild(b)
302
- return b
303
- })
304
- document.body.appendChild(tabbar)
305
- const syncTabs = () => {
306
- const expanded = !sidebarCollapsed()
307
- const settingsOpen = dialogOpen()
308
- for (const b of tabButtons) {
309
- const id = b.dataset.tab
310
- const on = (id === 'sessions' && expanded)
311
- || (id === 'dock' && panelNav.open)
312
- || (id === 'settings' && settingsOpen)
313
- b.classList.toggle('on', on)
314
- }
315
- }
316
- const unsubPanel = subscribePanel(syncTabs)
317
-
318
- // 「任务」角标:低频轮询 runstate 宿主路由(3s,仅窄屏 + 页面可见时发);
319
- // 进行中 = 蓝色数量,等待确认 = 红色数量 + 脉冲。路由不存在(runstate 未启用
320
- // 或宿主旧版)时静默停轮询并摘掉角标,不报错不打扰。
321
- let runstateGone = false
322
- const pollTasks = () => {
323
- if (runstateGone || !tasksBadge || !narrow() || document.visibilityState === 'hidden' || !tabbar.isConnected) return
324
- fetch('/dsh-dock/runstate/status', {
325
- method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
326
- }).then(async (res) => {
327
- if (res.status === 404 || res.status === 405) { runstateGone = true; tasksActive = 0; tasksWaiting = 0; tasksBadge.style.display = 'none'; return null }
328
- const body = await res.json().catch(() => null)
329
- if (!body) return null
330
- const active = (body.data && body.data.active) || []
331
- const waiting = active.filter((t) => t.approvals && t.approvals.length).length
332
- tasksActive = active.length
333
- tasksWaiting = waiting
334
- if (waiting > 0) {
335
- tasksBadge.textContent = String(waiting)
336
- tasksBadge.className = 'dsh-mobile-tab-badge wait'
337
- tasksBadge.style.display = ''
338
- } else if (active.length > 0) {
339
- tasksBadge.textContent = String(active.length)
340
- tasksBadge.className = 'dsh-mobile-tab-badge'
341
- tasksBadge.style.display = ''
342
- } else {
343
- tasksBadge.style.display = 'none'
344
- }
345
- }).catch(() => {})
346
- }
347
- const tasksTimer = setInterval(pollTasks, 3000)
348
-
349
- // 软键盘判定:visualViewport 与布局视口高度差超阈值视为键盘弹出(挂
350
- // html.dsh-dock-kbd,样式表随之隐藏 Tab 栏并收窄滚动体留白——打字时
351
- // 这两样只会挤占键盘上方的一线空间)。老内核没有 visualViewport 就跳过
352
- // (那些内核键盘行为是整页缩放,原生命中适配)。
353
- const vv = window.visualViewport || null
354
- const kbdSync = () => {
355
- if (!vv) return
356
- const kbd = vv.height > 0 && (window.innerHeight - vv.height) > 120
357
- document.documentElement.classList.toggle('dsh-dock-kbd', kbd)
358
- }
359
-
360
- const sync = () => {
361
- kbdSync()
362
- // 兜底摘除历史上可能残留的旧 Tab 栏(正常路径下走到这里说明宿主版未注入,
363
- // 不会有第二份;保险起见 sync 周期里也保持全局只有一份)。
364
- document.querySelectorAll('.dsh-mobile-tabbar').forEach((el) => { if (el !== tabbar) el.remove() })
365
- const on = narrow() && !!frameEl() && !dialogOpen()
366
- const collapsed = sidebarCollapsed()
367
- // 右侧详情面板(窄屏原生全屏,z 40)打开时:Tab 栏 65 比它高会压边,整体让位,
368
- // 面板收起(它自带折叠钮)后再回来。
369
- const right = rightOpen()
370
- if (scrim) scrim.style.display = on && !collapsed ? '' : 'none'
371
- // Tab 栏:窄屏且页面骨架就绪后常驻;功能坞面板/设置弹窗/右侧详情面板打开时隐藏(避免露边压内容)
372
- tabbar.style.display = narrow() && !!frameEl() && !panelNav.open && !right ? '' : 'none'
373
- syncTabs()
374
- }
375
- sync()
376
- pollTasks()
377
- // MutationObserver 跟属性翻转即时同步;1.5s 轮询覆盖路由重渲等一切边角
378
- const observer = typeof MutationObserver !== 'undefined' ? new MutationObserver(sync) : null
379
- if (observer) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-sidebar-collapsed'], subtree: true })
380
- const timer = setInterval(sync, 1500)
381
- const onMq = () => sync()
382
- if (mq.addEventListener) mq.addEventListener('change', onMq)
383
- else if (mq.addListener) mq.addListener(onMq)
384
- window.addEventListener('resize', kbdSync)
385
- if (vv && vv.addEventListener) vv.addEventListener('resize', kbdSync)
386
- const onVisible = () => { if (document.visibilityState === 'visible') pollTasks() }
387
- document.addEventListener('visibilitychange', onVisible)
388
-
389
- // 捕获阶段统一处理(仅自建模式;宿主就位时宿主已有同款全局捕获):
390
- // Tab 栏自身排除(否则页签动作会被"点外部收起"当场撤销);
391
- // 遮罩不挂自己的 click——它的点击也走这里 collapse(),单一路径防止同一事件两次 toggle。
392
- const onClick = (e) => {
393
- if (!narrow()) return
394
- const t = e.target
395
- if (!t || !t.closest) return
396
- if (t.closest('.dsh-mobile-tabbar')) return
397
- const expanded = !!document.querySelector('button[aria-label="收起侧边栏"],button[aria-label="Collapse sidebar"]')
398
- const col = document.querySelector('[class*="sidebarCol"]')
399
- const inSidebar = col && col.contains(t)
400
- if (!expanded && !inSidebar) return
401
- if (inSidebar) {
402
- // 只有点「会话」才收抽屉:会话行的 data-row-key 以 session: 开头(0.1.7 结构,
403
- // ui-workspace Rows.tsx);工作区文件夹行是 workspace: 开头——点它只是展开/收拢
404
- // 分组,不能收抽屉(旧的「无子 treeitem 即叶子」判定会误伤折叠状态的文件夹:
405
- // 折叠时其子节点不在 DOM 里)。搜索结果行无 data-row-key,回退按无子叶判定。
406
- const row = t.closest('[role="treeitem"]')
407
- const rowKey = (row && row.getAttribute('data-row-key')) || ''
408
- const sessionRow = (rowKey && rowKey.startsWith('session:'))
409
- || (row && !rowKey && !row.querySelector('[role="treeitem"]') && row.closest('[role="tree"]'))
410
- const fresh = t.closest('[class*="newSession"]')
411
- if (sessionRow || fresh) setTimeout(() => { collapse(); closeDetails() }, 300)
412
- else {
413
- // 侧栏入口(插件/自动化任务/设置/记忆/自动进化…)点击后会弹出面板,而面板
414
- // z 序常低于展开的抽屉——被盖住等于「点了没反应」(用户实测截图)。稍候
415
- // 探测:有面板弹出就收起抽屉让它露出来;目录折叠/搜索等不弹面板的点击不受影响。
416
- setTimeout(() => { if (document.querySelector('[role="dialog"]')) collapse() }, 260)
417
- }
418
- return
419
- }
420
- if (t.closest('[role="dialog"],[class*="dockm"],[class*="dgfab"],[class*="dgwin"],[class*="dgame"],[data-sidebar-right-panel]')) return
421
- collapse()
422
- }
423
- document.addEventListener('click', onClick, true)
424
-
425
- return () => {
426
- document.removeEventListener('click', onClick, true)
427
- if (observer) observer.disconnect()
428
- clearInterval(timer)
429
- clearInterval(tasksTimer)
430
- unsubPanel()
431
- if (mq.removeEventListener) mq.removeEventListener('change', onMq)
432
- else if (mq.removeListener) mq.removeListener(onMq)
433
- window.removeEventListener('resize', kbdSync)
434
- if (vv && vv.removeEventListener) vv.removeEventListener('resize', kbdSync)
435
- document.removeEventListener('visibilitychange', onVisible)
436
- document.documentElement.classList.remove('dsh-dock-kbd')
437
- if (scrim) scrim.remove()
438
- tabbar.remove()
439
- if (window.__dshDockMobileDrawer === 'client') delete window.__dshDockMobileDrawer
440
- }
441
- }, [])
442
- return null
443
- }
444
165
 
445
166
  export function MobileRelayHomeStat() {
446
167
  const [summary, setSummary] = useState('未开启,开启后可远程登录')
@@ -463,49 +184,7 @@ export const feature = {
463
184
  description: '账号密码登录的远程入口:所有设备访问同一个 DSH,任务进度实时一致', defaultEnabled: false,
464
185
  css: `
465
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)}
466
- /* 抽屉遮罩:侧栏浮层展开时盖住页面。左缘浮动「抽屉把手」已移除——会话入口
467
- * 统一走底部 Tab 栏「会话」页签,把手悬浮在内容上还压过输入区(用户实测)。 */
468
- @media (max-width:700px){.dsh-mobile-scrim{position:fixed;inset:0;z-index:75;background:rgba(8,10,14,.45);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);touch-action:none}
469
- /* ---- 仿 ZCode 手机端:底部 Tab 栏 + 输入区吸附底部 ---- */
470
- .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}
471
- .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}
472
- .dsh-mobile-tab svg{width:21px;height:21px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
473
- .dsh-mobile-tab.on{color:var(--dsw-alias-label-primary,#e6eaf2)}
474
- .dsh-mobile-tab.on svg{color:var(--dk-accent,#4d9fff)}
475
- .dsh-mobile-tab:active{transform:scale(.94)}
476
- /* 「任务」页签角标:进行中=蓝(数量),等待确认=红(数量+脉冲)——接力场景里
477
- * 等待确认是最需要人介入的状态,一眼可见;数据来自 runstate 宿主路由低频轮询。 */
478
- .dsh-mobile-tab{position:relative}
479
- .dsh-mobile-tab-badge{position:absolute;top:0;right:calc(50% - 26px);box-sizing:border-box;min-width:16px;height:16px;padding:0 4px;border-radius:9px;display:flex;align-items:center;justify-content:center;background:#4d9fff;color:#fff;font-size:10px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums;box-shadow:0 2px 8px rgba(0,0,0,.35);pointer-events:none}
480
- .dsh-mobile-tab-badge.wait{background:#ef4444;animation:dsh-badge-pulse 1.2s ease-in-out infinite}
481
- @keyframes dsh-badge-pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.18)}}
482
- @media (prefers-reduced-motion:reduce){.dsh-mobile-tab-badge.wait{animation:none}}
483
- /* 输入区吸附到 Tab 栏上方:宿主把欢迎内容 justify-content:center 垂直居中,
484
- * 手机视口下输入卡片悬在中部、下方留出大片空白。改用「输入区 margin-top:auto」
485
- * 贴底,并给滚动体留出 Tab 栏高度的内边距,避免内容被 Tab 栏遮挡。
486
- * ⚠️ 绝不能给滚动体加 justify-content:flex-end/center 贴底:内容会被挤到滚动体
487
- * 起点之外,Chrome 不把起点方向溢出算进 scrollHeight(手机视口实测
488
- * scrollHeight==clientHeight、maxScroll==0),会话记录彻底滚不动、首条消息永远
489
- * 看不到。宿主半部旧版注入里还留着那条 flex-end(要重启 dsh web 才消失),这里
490
- * 用更高优先级的选择器显式复位:刷新页面即修好,不必等重启。 */
491
- [class*="scrollBody"][data-conversation-scroll]{justify-content:flex-start !important;padding-bottom:calc(72px + env(safe-area-inset-bottom,0px)) !important}
492
- [class*="scrollBody"]{padding-bottom:calc(72px + env(safe-area-inset-bottom,0px)) !important}
493
- [class*="scrollBody"]>[class*="composerSeat"]{margin-top:auto;padding-bottom:6px}
494
- /* 输入区工具行上的 dsh-dock chip(用量/余额):窄屏下 28cqw 的宽度上限把它们挤成
495
- * 「…」「余..」(手机实测),既读不出数值又白占输入行空间。手机端整组收起,数值在
496
- * 底部「功能坞」里看(用量/余额页信息更全);桌面端不动。 */
497
- .dockchip-row{display:none}
498
- /* 收起的侧栏轨道(collapsed rail)在窄屏只剩 1px 占位,但轨道里的按钮
499
- * 仍会溢出贴在左边缘(功能坞/进化/设置三枚露出半边的圆钮)。隐藏整列,
500
- * 这些入口已由底部 Tab 栏接管;展开抽屉(z 80)不受影响。 */
501
- [class*="sidebarCol"]:has([class*="collapsed"]){visibility:hidden}
502
- /* 软键盘弹出(visualViewport 判定,挂 html.dsh-dock-kbd):Tab 栏整体让位(打字时
503
- * 它只会挤占键盘上方的一线空间),滚动体留白收窄。选择器特异性高于上面的 72px
504
- * 留白规则,能压过去。宿主接管期间由宿主脚本同步置/摘这个类。 */
505
- html.dsh-dock-kbd .dsh-mobile-tabbar{display:none !important}
506
- html.dsh-dock-kbd [class*="scrollBody"]{padding-bottom:12px !important}
507
- /* 功能坞面板在手机端抬到原生弹层之上:从侧栏进的官方插件管理页等原生弹层 z 序高于
508
- * 面板默认的 200,功能坞开在下面会被盖住——「插件」页签点了像没反应/露出原页面。 */
509
- [class*="dockm-backdrop"]{z-index:2000 !important}}`,
510
- View: MobileRelayView, HomeStat: MobileRelayHomeStat, Overlay: MobileRelayOverlay,
187
+ `,
188
+
189
+ View: MobileRelayView, HomeStat: MobileRelayHomeStat,
511
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 result = await writeModelConfig(ctx, body)
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
  }