dsh-remote-plugin 0.6.15 → 0.6.17

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/public/app.js CHANGED
@@ -79,6 +79,8 @@ const state = {
79
79
  queueSteering: {}, // sessionId:itemId -> pending steer request
80
80
  sessionTurnTimes: {}, // sessionId -> 本轮开始/结束时间,避免中间事件推动排序
81
81
  jobs: {}, // sessionId -> jobs
82
+ sessionActivity: new Set(), // 已发送消息或已执行命令的会话
83
+ pendingPrompts: new Set(), // 正在提交消息的会话
82
84
  history: emptyHistory(),
83
85
  errCount: 0,
84
86
  streamInfo: {
@@ -88,9 +90,12 @@ const state = {
88
90
  streamMode: 'ws', // 'ws' | 'poll'
89
91
  pollSeq: { mux: 0, host: 0 },
90
92
  refreshTimer: null,
91
- fs: { path: null, initial: null, loaded: false, upload: null, workspaceId: LS.get('fsWorkspaceIdV1', ''), preview: null },
93
+ fs: { path: null, initial: null, loaded: false, upload: null, workspaceId: LS.get('fsWorkspaceIdV1', ''), roots: [], rootIndex: 0, preview: null },
92
94
  composerImages: [], // 当前草稿中的图片附件:只在发送成功后释放
93
95
  models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
96
+ modelSettings: { status: 'idle', error: '', writable: false, hasDocument: false, providers: [], namespaces: [], credentials: {} },
97
+ modelEditor: null,
98
+ asrTest: { running: false, status: 'idle', meta: null, summary: null, events: [] },
94
99
  wb: null,
95
100
  wbProjects: [],
96
101
  wbArchived: [],
@@ -531,6 +536,55 @@ async function safeRpc(method, payload, errText) {
531
536
  }
532
537
  }
533
538
 
539
+ let hostDescribePromise = null
540
+ let hostDescribeRetryTimer = null
541
+ let hostDescribeFailures = 0
542
+
543
+ function scheduleHostDescribeRetry() {
544
+ if (!state.token || hostDescribeRetryTimer) return
545
+ const delays = [2000, 5000, 15000, 30000, 60000]
546
+ const delay = delays[Math.min(Math.max(0, hostDescribeFailures - 1), delays.length - 1)]
547
+ hostDescribeRetryTimer = setTimeout(() => {
548
+ hostDescribeRetryTimer = null
549
+ void refreshHostDescription()
550
+ }, delay)
551
+ }
552
+
553
+ async function refreshHostDescription({ notify = false } = {}) {
554
+ if (!state.token) return null
555
+ if (hostDescribePromise) return hostDescribePromise
556
+ const server = state.server
557
+ hostDescribePromise = (async () => {
558
+ try {
559
+ const host = await rpc('host.describe', {}, 5000)
560
+ if (server !== state.server) return null
561
+ state.hostInfo = host
562
+ const health = activeGatewayHealth()
563
+ if (health) health.upstreamReachable = true
564
+ hostDescribeFailures = 0
565
+ if (hostDescribeRetryTimer) clearTimeout(hostDescribeRetryTimer)
566
+ hostDescribeRetryTimer = null
567
+ const desc = $('host-desc')
568
+ if (desc && host) desc.textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions })
569
+ renderOverview()
570
+ return host
571
+ } catch (error) {
572
+ if (server !== state.server) return null
573
+ if (error.message === 'AUTH') authFailure()
574
+ else {
575
+ hostDescribeFailures++
576
+ scheduleHostDescribeRetry()
577
+ if (notify) toast(`${t('settings.probeFailed')}:${error.message}`, 'err')
578
+ }
579
+ return null
580
+ } finally {
581
+ hostDescribePromise = null
582
+ if (server !== state.server) scheduleHostDescribeRetry()
583
+ }
584
+ })()
585
+ return hostDescribePromise
586
+ }
587
+
534
588
  function authFailure() {
535
589
  toast(t('err.accessDenied'), 'err')
536
590
  showView('view-settings')
@@ -635,7 +689,10 @@ async function pingServer(base) {
635
689
  const res = await fetch(u + '/health?t=' + Date.now(), { signal: ctrl.signal, cache: 'no-store' })
636
690
  if (!res.ok) return Infinity
637
691
  const health = await res.json().catch(() => null)
638
- if (health && typeof health === 'object') state.gatewayHealth[u] = health
692
+ if (health && typeof health === 'object') {
693
+ state.gatewayHealth[u] = health
694
+ renderOverview()
695
+ }
639
696
  return Math.round(performance.now() - t0)
640
697
  } catch {
641
698
  return Infinity
@@ -645,12 +702,22 @@ async function pingServer(base) {
645
702
  }
646
703
 
647
704
  function activeGatewayCapability(name) {
648
- const key = String(state.server || location.origin || '').replace(/\/+$/, '')
649
- const capabilities = state.gatewayHealth[key]?.capabilities
705
+ const capabilities = activeGatewayHealth()?.capabilities
650
706
  if (!capabilities || !Object.prototype.hasOwnProperty.call(capabilities, name)) return null
651
707
  return Number(capabilities[name]) > 0
652
708
  }
653
709
 
710
+ function activeGatewayHealth() {
711
+ const key = String(state.server || location.origin || '').replace(/\/+$/, '')
712
+ return state.gatewayHealth[key] || null
713
+ }
714
+
715
+ function dshReachable() {
716
+ const health = activeGatewayHealth()
717
+ if (health && typeof health.upstreamReachable === 'boolean') return health.upstreamReachable
718
+ return !!state.hostInfo
719
+ }
720
+
654
721
  async function selectFastestServer({ silent = false, reconnect = true } = {}) {
655
722
  if (state.selectingServer) return null
656
723
  state.selectingServer = true
@@ -682,6 +749,11 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
682
749
 
683
750
  renderServers()
684
751
  if (chosen !== state.server) {
752
+ state.hostInfo = null
753
+ hostDescribeFailures = 0
754
+ if (hostDescribeRetryTimer) clearTimeout(hostDescribeRetryTimer)
755
+ hostDescribeRetryTimer = null
756
+ resetFsForServer()
685
757
  state.server = chosen
686
758
  if (state.autoSelect[state.activeGroup] !== false && best) {
687
759
  const srv = state.servers.find(s => s.url === best)
@@ -804,6 +876,7 @@ function renderServers() {
804
876
  // 手动模式: 点击条目 = 选中该服务器连接
805
877
  state.groupActive[group] = id
806
878
  state.activeGroup = group
879
+ if (state.server !== s.url) resetFsForServer()
807
880
  state.server = s.url
808
881
  saveServers()
809
882
  renderServers()
@@ -858,10 +931,14 @@ function editServer(id) {
858
931
  const group = prompt(t('servers.promptEditGroup'), s.group || '默认')
859
932
  if (group === null) return
860
933
  const wasActive = state.server === s.url
934
+ const changedActiveUrl = wasActive && state.server !== raw
861
935
  s.url = raw
862
936
  s.note = note.trim()
863
937
  s.group = ensureGroup(group.trim() || '默认')
864
- if (wasActive) state.server = raw
938
+ if (wasActive) {
939
+ if (changedActiveUrl) resetFsForServer()
940
+ state.server = raw
941
+ }
865
942
  saveServers()
866
943
  renderServers()
867
944
  toast(t('servers.edited'), 'ok')
@@ -1085,7 +1162,11 @@ function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
1085
1162
  renderPending()
1086
1163
  }
1087
1164
  if (refreshOnOpen) refreshAll()
1088
- if (allStreamsOpen()) resyncAfterStreamOpen()
1165
+ if (allStreamsOpen()) {
1166
+ resyncAfterStreamOpen()
1167
+ void pingServer(state.server || location.origin)
1168
+ void refreshHostDescription()
1169
+ }
1089
1170
  }
1090
1171
  ws.onmessage = (msg) => {
1091
1172
  if (!streamIsCurrent(kind, ws, generation)) return
@@ -1357,6 +1438,23 @@ async function refreshSessions() {
1357
1438
  refreshWorkbench()
1358
1439
  }
1359
1440
 
1441
+ function removeLocalSessionRecord(sessionId) {
1442
+ if (!sessionId) return
1443
+ state.sessions = state.sessions.filter(session => session?.sessionId !== sessionId)
1444
+ state.byId.delete(sessionId)
1445
+ state.pendingProjections.delete(sessionId)
1446
+ state.sessionActivity.delete(sessionId)
1447
+ state.pendingPrompts.delete(sessionId)
1448
+ delete state.queues[sessionId]
1449
+ delete state.jobs[sessionId]
1450
+ const historyCache = readHistoryCache()
1451
+ if (Object.prototype.hasOwnProperty.call(historyCache, sessionId)) {
1452
+ delete historyCache[sessionId]
1453
+ writeHistoryCache(historyCache)
1454
+ }
1455
+ cacheWrite(CACHE.sessions, state.sessions.slice(0, 80))
1456
+ }
1457
+
1360
1458
  function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
1361
1459
  function hydrateSessionProjections(sessionId, projections) {
1362
1460
  const s = state.byId.get(sessionId)
@@ -1551,7 +1649,13 @@ function workspaceOptionLabel(workspace) {
1551
1649
  function workspaceOptionsHtml({ all = false, ungrouped = false, root = false, selected = '' } = {}) {
1552
1650
  const rows = []
1553
1651
  if (all) rows.push({ id: '', label: t('workspace.all') })
1554
- if (root) rows.push({ id: '', label: t('fs.root') })
1652
+ if (root) {
1653
+ const roots = state.fs.roots.length ? state.fs.roots : ['']
1654
+ roots.forEach((rootPath, index) => rows.push({
1655
+ id: index === 0 ? '' : `__fs_root__:${index}`,
1656
+ label: rootPath && roots.length > 1 ? `${t('fs.root')} — ${rootPath}` : t('fs.root'),
1657
+ }))
1658
+ }
1555
1659
  for (const workspace of workspaceItems()) rows.push({ id: workspace.workspaceId, label: workspaceOptionLabel(workspace) })
1556
1660
  if (ungrouped) rows.push({ id: WORKSPACE_UNGROUPED, label: t('workspace.ungrouped') })
1557
1661
  return rows.map(row => `<option value="${esc(row.id)}"${row.id === selected ? ' selected' : ''}>${esc(row.label)}</option>`).join('')
@@ -1579,7 +1683,8 @@ function renderWorkspaceNavigation() {
1579
1683
  }
1580
1684
  const fsSelect = $('fs-workspace')
1581
1685
  if (fsSelect) {
1582
- fsSelect.innerHTML = workspaceOptionsHtml({ root: true, selected: state.fs.workspaceId })
1686
+ const selected = state.fs.workspaceId || (state.fs.rootIndex > 0 ? `__fs_root__:${state.fs.rootIndex}` : '')
1687
+ fsSelect.innerHTML = workspaceOptionsHtml({ root: true, selected })
1583
1688
  syncCustomSelect(fsSelect)
1584
1689
  }
1585
1690
  if ($('modal-new-session') && !$('modal-new-session').classList.contains('hidden')) renderNewSessionWorkspace()
@@ -1849,17 +1954,49 @@ async function openSession(id) {
1849
1954
  refreshSessions()
1850
1955
  }
1851
1956
 
1852
- function closeSession() {
1853
- setComposerFullscreen(false)
1854
- clearComposerImages()
1855
- state.current = null
1856
- setSessionRecovery('idle')
1857
- $('btn-rename-session').classList.add('hidden')
1858
- $('btn-archive-session').classList.add('hidden')
1859
- state.history = emptyHistory()
1860
- document.body.classList.remove('in-session')
1861
- hideComposerMenu()
1862
- showView('view-home')
1957
+ function sessionHasPendingActivity(sessionId, session) {
1958
+ return !!session?.running
1959
+ || state.sessionActivity.has(sessionId)
1960
+ || state.pendingPrompts.has(sessionId)
1961
+ || (state.queues[sessionId] || []).some(item => item?.placement !== 'context')
1962
+ }
1963
+
1964
+ async function shouldDiscardEmptySession(sessionId) {
1965
+ const session = state.byId.get(sessionId)
1966
+ if (!session || sessionHasPendingActivity(sessionId, session)) return false
1967
+ const deadline = Date.now() + 3500
1968
+ while (state.current === sessionId && state.history.loading && Date.now() < deadline) {
1969
+ await new Promise(resolve => setTimeout(resolve, 25))
1970
+ }
1971
+ if (state.current !== sessionId || sessionHasPendingActivity(sessionId, session)) return false
1972
+ return isEmptySessionHistory(state.history)
1973
+ }
1974
+
1975
+ async function closeSession() {
1976
+ if (closeSession.pending) return closeSession.pending
1977
+ const sessionId = state.current
1978
+ if (!sessionId) return
1979
+ const task = (async () => {
1980
+ const discard = await shouldDiscardEmptySession(sessionId)
1981
+ if (state.current !== sessionId) return
1982
+ state.current = null
1983
+ if (discard) removeLocalSessionRecord(sessionId)
1984
+ setComposerFullscreen(false)
1985
+ clearComposerImages()
1986
+ setSessionRecovery('idle')
1987
+ $('btn-rename-session').classList.add('hidden')
1988
+ $('btn-archive-session').classList.add('hidden')
1989
+ state.history = emptyHistory()
1990
+ document.body.classList.remove('in-session')
1991
+ hideComposerMenu()
1992
+ renderSessions()
1993
+ renderWorkbench()
1994
+ showView('view-home')
1995
+ })()
1996
+ closeSession.pending = task
1997
+ try { await task } finally {
1998
+ if (closeSession.pending === task) closeSession.pending = null
1999
+ }
1863
2000
  }
1864
2001
 
1865
2002
  /* Android 手势返回/实体返回: 注册后系统不再直接杀 App, 由这里接管导航 */
@@ -1871,7 +2008,7 @@ function bindNativeBack() {
1871
2008
  if (customSelectCurrent) { closeCustomSelect(); return }
1872
2009
  const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
1873
2010
  if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else if (openModal.id === 'modal-archive') closeArchiveConfirm(); else if (openModal.id === 'modal-rename') closeRenameSession(); else if (openModal.id === 'modal-app-version-warning') closeAppVersionWarning(false); else if (openModal.id === 'modal-scan-live') closeLiveScan(''); else openModal.classList.add('hidden'); return } // 先关弹窗
1874
- if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
2011
+ if (document.body.classList.contains('in-session')) { void closeSession(); return } // 会话页 → 回主页
1875
2012
  if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
1876
2013
  if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
1877
2014
  showView('view-home'); return
@@ -1922,11 +2059,19 @@ const HISTORY_MAX_VISIBLE = 5000 // 已加载的可显示事件上限(消息/
1922
2059
  function emptyHistory() {
1923
2060
  return {
1924
2061
  visible: [], seqs: new Set(), minSeq: Infinity,
1925
- hasMore: false, loading: false, renderStart: 0, renderEnd: 0,
2062
+ hasMore: false, loading: false, loaded: false, renderStart: 0, renderEnd: 0,
1926
2063
  partialReasoning: new Map()
1927
2064
  }
1928
2065
  }
1929
2066
 
2067
+ function sessionHistoryHasContent(history) {
2068
+ return !!history && ((history.visible?.length || 0) > 0 || (history.partialReasoning?.size || 0) > 0)
2069
+ }
2070
+
2071
+ function isEmptySessionHistory(history) {
2072
+ return history?.loaded === true && !sessionHistoryHasContent(history)
2073
+ }
2074
+
1930
2075
  function reasoningStreamKey(data, index) {
1931
2076
  return `${data?.turn ?? '?'}:${data?.step ?? '?'}:${index ?? '?'}`
1932
2077
  }
@@ -2042,6 +2187,7 @@ function restoreCachedHistory() {
2042
2187
  h.visible.push(e)
2043
2188
  }
2044
2189
  h.visible.sort((a, b) => a.seq - b.seq)
2190
+ h.loaded = true
2045
2191
  state.history = h
2046
2192
  $('history-hint').textContent = t('history.offlineCache', { n: h.visible.length })
2047
2193
  renderHistory(true)
@@ -2051,7 +2197,8 @@ function restoreCachedHistory() {
2051
2197
  async function loadHistory(reset) {
2052
2198
  const id = state.current
2053
2199
  if (!id || state.history.loading) return
2054
- state.history.loading = true
2200
+ const history = state.history
2201
+ history.loading = true
2055
2202
  if (reset) setSessionRecovery('loading')
2056
2203
  const moreBtn = $('history-more')
2057
2204
  if (moreBtn) moreBtn.classList.add('hidden')
@@ -2062,7 +2209,8 @@ async function loadHistory(reset) {
2062
2209
  try {
2063
2210
  v = await rpc('session.history', payload)
2064
2211
  } catch (e) {
2065
- state.history.loading = false
2212
+ if (state.current !== id || state.history !== history) return
2213
+ history.loading = false
2066
2214
  if (e.message === 'AUTH') { authFailure(); return }
2067
2215
  if (restoreCachedHistory()) {
2068
2216
  setSessionRecovery('cached', e.message)
@@ -2082,7 +2230,9 @@ async function loadHistory(reset) {
2082
2230
  return
2083
2231
  }
2084
2232
 
2233
+ if (state.current !== id || state.history !== history) return
2085
2234
  hydrateSessionProjections(id, v.projections)
2235
+ history.loaded = true
2086
2236
  const incoming = v.events || []
2087
2237
  let added = 0
2088
2238
  if (reset) state.history.partialReasoning.clear()
@@ -2103,7 +2253,7 @@ async function loadHistory(reset) {
2103
2253
  state.history.visible.sort((a, b) => a.seq - b.seq)
2104
2254
  trimVisible()
2105
2255
  state.history.hasMore = !!v.hasMore
2106
- state.history.loading = false
2256
+ history.loading = false
2107
2257
  setSessionRecovery('ready')
2108
2258
  renderSessionTitle(); renderSessionSub(); renderSessionCards()
2109
2259
  try {
@@ -2601,33 +2751,48 @@ async function sendSessionText(text) {
2601
2751
  async function sendSessionContent(text, images) {
2602
2752
  const clean = String(text || '').trim()
2603
2753
  if ((!clean && !images.length) || !state.current) return false
2604
- if (images.length === 0 && clean && await runSlashCommand(clean)) return true
2754
+ const sessionId = state.current
2755
+ if (images.length === 0 && clean && await runSlashCommand(clean)) {
2756
+ state.sessionActivity.add(sessionId)
2757
+ return true
2758
+ }
2605
2759
  const buttons = [$('btn-send'), $('btn-fs-send')].filter(Boolean)
2606
2760
  buttons.forEach(button => { button.disabled = true })
2761
+ state.pendingPrompts.add(sessionId)
2607
2762
  try {
2608
2763
  const content = [...await encodeComposerImagesFor(images)]
2609
2764
  if (clean) content.push({ type: 'text', text: clean })
2610
2765
  setSessionRecovery('resuming')
2611
2766
  const v = await safeRpc('session.prompt', {
2612
- sessionId: state.current,
2767
+ sessionId,
2613
2768
  mode: 'queue',
2614
2769
  content
2615
2770
  }, t('send.failed'))
2616
2771
  if (v?.accepted) {
2617
- setSessionRecovery('ready')
2618
- noteSessionTurnTime(state.current, Date.now())
2619
- renderSessions()
2620
- toast(images.length ? t('send.imageSent') : (clean.startsWith('/') ? t('send.commandSent') : t('send.sent')), 'ok')
2772
+ state.sessionActivity.add(sessionId)
2773
+ if (state.current === sessionId) {
2774
+ setSessionRecovery('ready')
2775
+ noteSessionTurnTime(sessionId, Date.now())
2776
+ renderSessions()
2777
+ toast(images.length ? t('send.imageSent') : (clean.startsWith('/') ? t('send.commandSent') : t('send.sent')), 'ok')
2778
+ }
2779
+ return true
2780
+ }
2781
+ if (v?.command?.text) {
2782
+ state.sessionActivity.add(sessionId)
2783
+ if (state.current === sessionId) toast(t('send.commandExecuted'), 'ok')
2621
2784
  return true
2622
2785
  }
2623
- if (v?.command?.text) { toast(t('send.commandExecuted'), 'ok'); return true }
2624
- setSessionRecovery('error')
2786
+ if (state.current === sessionId) setSessionRecovery('error')
2625
2787
  return false
2626
2788
  } catch (e) {
2627
- setSessionRecovery('error', e?.message)
2628
- toast(t('composer.imageReadFailed', { msg: e?.message || e }), 'err')
2789
+ if (state.current === sessionId) {
2790
+ setSessionRecovery('error', e?.message)
2791
+ toast(t('composer.imageReadFailed', { msg: e?.message || e }), 'err')
2792
+ }
2629
2793
  return false
2630
2794
  } finally {
2795
+ state.pendingPrompts.delete(sessionId)
2631
2796
  buttons.forEach(button => { button.disabled = false })
2632
2797
  }
2633
2798
  }
@@ -2890,7 +3055,7 @@ async function confirmArchiveSession() {
2890
3055
  closeArchiveConfirm()
2891
3056
  toast(t('session.archived'), 'ok')
2892
3057
  await refreshSessions()
2893
- if (state.current === sessionId) closeSession()
3058
+ if (state.current === sessionId) void closeSession()
2894
3059
  } finally {
2895
3060
  button.disabled = false
2896
3061
  }
@@ -2954,7 +3119,7 @@ function renderOverview() {
2954
3119
  // 独立网关页面默认走同源,此时 state.server 合法地为空;不能因此把
2955
3120
  // 已连接网关误报为离线。Capacitor 等非 HTTP 页面仍要求显式服务器。
2956
3121
  gateway: !!state.token && (!!state.server || /^https?:$/.test(location.protocol)),
2957
- dsh: !!state.hostInfo,
3122
+ dsh: dshReachable(),
2958
3123
  mux: !!state.streamsOk?.mux,
2959
3124
  host: !!state.streamsOk?.host
2960
3125
  }
@@ -3198,14 +3363,80 @@ function fsHeaders() {
3198
3363
  }
3199
3364
 
3200
3365
  function fsJoin(dir, name) {
3201
- return dir.replace(/\/+$/, '') + '/' + name
3366
+ const meta = fsPathMeta(dir)
3367
+ if (!meta.value) return String(name || '')
3368
+ return meta.value.endsWith(meta.separator) ? meta.value + name : meta.value + meta.separator + name
3202
3369
  }
3203
3370
 
3204
3371
  function fsParent(p) {
3205
- const clean = String(p || '').replace(/\/+$/, '')
3206
- const idx = clean.lastIndexOf('/')
3207
- if (idx <= 0) return clean === '' ? '' : '/'
3208
- return clean.slice(0, idx)
3372
+ const meta = fsPathMeta(p)
3373
+ if (!meta.value) return ''
3374
+ if (meta.root && fsPathEqual(meta.value, meta.root)) return meta.root
3375
+ const idx = meta.value.lastIndexOf(meta.separator)
3376
+ if (idx < 0) return meta.root || ''
3377
+ const parent = meta.value.slice(0, idx)
3378
+ return meta.root && parent.length < meta.root.length ? meta.root : (parent || meta.root)
3379
+ }
3380
+
3381
+ function fsPathMeta(input) {
3382
+ const source = String(input || '').trim()
3383
+ const windows = /^[A-Za-z]:(?:[\\/]|$)/.test(source) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(source) || source.includes('\\')
3384
+ if (!windows) {
3385
+ let value = source.replace(/\/+/g, '/')
3386
+ const root = value.startsWith('/') ? '/' : ''
3387
+ if (root && value.length > root.length) value = value.replace(/\/+$/, '')
3388
+ return { value, root, separator: '/', windows: false }
3389
+ }
3390
+ let value = source.replace(/\//g, '\\')
3391
+ const unc = /^\\{2,}/.test(value)
3392
+ value = unc
3393
+ ? '\\\\' + value.replace(/^\\+/, '').replace(/\\+/g, '\\')
3394
+ : value.replace(/\\+/g, '\\')
3395
+ let root = ''
3396
+ if (unc) {
3397
+ const parts = value.slice(2).split('\\').filter(Boolean)
3398
+ root = parts.length >= 2 ? `\\\\${parts[0]}\\${parts[1]}` : value
3399
+ } else {
3400
+ const drive = /^([A-Za-z]:)/.exec(value)
3401
+ if (drive) {
3402
+ root = drive[1] + '\\'
3403
+ if (value === drive[1]) value = root
3404
+ }
3405
+ }
3406
+ if (root && value.length > root.length) value = value.replace(/\\+$/, '')
3407
+ return { value, root, separator: '\\', windows: true }
3408
+ }
3409
+
3410
+ function fsPathKey(value) {
3411
+ const meta = fsPathMeta(value)
3412
+ return meta.windows ? meta.value.toLowerCase() : meta.value
3413
+ }
3414
+
3415
+ function fsPathEqual(left, right) {
3416
+ return fsPathKey(left) === fsPathKey(right)
3417
+ }
3418
+
3419
+ function fsPathInside(candidate, root) {
3420
+ const child = fsPathMeta(candidate)
3421
+ const boundary = fsPathMeta(root)
3422
+ if (!child.value || !boundary.value || child.windows !== boundary.windows) return false
3423
+ const childKey = child.windows ? child.value.toLowerCase() : child.value
3424
+ const rootKey = boundary.windows ? boundary.value.toLowerCase() : boundary.value
3425
+ if (childKey === rootKey) return true
3426
+ const prefix = rootKey.endsWith(boundary.separator) ? rootKey : rootKey + boundary.separator
3427
+ return childKey.startsWith(prefix)
3428
+ }
3429
+
3430
+ function resetFsForServer() {
3431
+ state.fs.path = null
3432
+ state.fs.initial = null
3433
+ state.fs.loaded = false
3434
+ state.fs.upload = null
3435
+ state.fs.preview = null
3436
+ state.fs.roots = []
3437
+ state.fs.rootIndex = 0
3438
+ state.fs.workspaceId = ''
3439
+ LS.del('fsWorkspaceIdV1')
3209
3440
  }
3210
3441
 
3211
3442
  const FS_PREVIEW_EXTENSIONS = new Set([
@@ -3321,7 +3552,14 @@ async function loadFs(dir, { silent = false, resetRoot = false } = {}) {
3321
3552
  if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error === 'not-found' ? t('fs.notFound') : data.error === 'forbidden' ? t('fs.forbidden') : data.error || ('HTTP ' + res.status))
3322
3553
  state.fs.path = data.path
3323
3554
  if (!state.fs.initial) state.fs.initial = data.path
3555
+ if (Array.isArray(data.roots) && data.roots.length) state.fs.roots = data.roots.map(value => String(value || '')).filter(Boolean)
3556
+ if (!state.fs.roots.length) state.fs.roots = [data.path]
3557
+ if (!state.fs.workspaceId) {
3558
+ const rootIndex = state.fs.roots.findIndex(root => fsPathInside(data.path, root))
3559
+ if (rootIndex >= 0) state.fs.rootIndex = rootIndex
3560
+ }
3324
3561
  state.fs.loaded = true
3562
+ renderWorkspaceNavigation()
3325
3563
  renderFs(data)
3326
3564
  } catch (e) {
3327
3565
  if (e.message === 'AUTH') return
@@ -3725,11 +3963,12 @@ async function runFsUpload(up) {
3725
3963
 
3726
3964
  function fsUp() {
3727
3965
  if (!state.fs.path || !state.fs.initial) return
3728
- if (state.fs.path === state.fs.initial) {
3966
+ const parent = fsParent(state.fs.path)
3967
+ if (fsPathEqual(state.fs.path, state.fs.initial) || !fsPathInside(parent, state.fs.initial)) {
3729
3968
  toast(t('fs.alreadyRoot'))
3730
3969
  return
3731
3970
  }
3732
- loadFs(fsParent(state.fs.path))
3971
+ loadFs(parent)
3733
3972
  }
3734
3973
 
3735
3974
  function bindFsPullRefresh() {
@@ -4611,6 +4850,716 @@ async function restorePeakReminders() {
4611
4850
  if (peakRemindOn() && legacyCleaned) await schedulePeakReminders({ legacyCleaned: true })
4612
4851
  }
4613
4852
 
4853
+ /* ---------------- 功能测试 / Android ASR ---------------- */
4854
+ function asrTestBridge() { return window.NativeAsrTest }
4855
+
4856
+ function emptyAsrTest() {
4857
+ return { running: false, status: 'idle', meta: null, summary: null, events: [], lastError: '' }
4858
+ }
4859
+
4860
+ function asrTestEvent(event) {
4861
+ if (!event || typeof event !== 'object') return
4862
+ const current = state.asrTest
4863
+ const data = event.data && typeof event.data === 'object' ? event.data : {}
4864
+ if (event.type === 'meta') current.meta = data
4865
+ if (event.type === 'summary') {
4866
+ current.summary = data
4867
+ current.running = false
4868
+ }
4869
+ if (event.type === 'status') {
4870
+ current.status = String(data.status || 'unknown')
4871
+ if (current.status === 'listening' || current.status === 'starting' || current.status === 'restarting') current.running = true
4872
+ if (['stopped', 'unsupported', 'permission-denied'].includes(current.status)) current.running = false
4873
+ }
4874
+ if (event.type === 'error') current.lastError = String(data.name || data.message || 'error')
4875
+ current.events.push({ type: event.type, atMs: Number(event.atMs) || 0, data })
4876
+ if (current.events.length > 500) current.events.splice(0, current.events.length - 500)
4877
+ renderAsrTest()
4878
+ }
4879
+ window.__dshAsrEvent = asrTestEvent
4880
+
4881
+ function asrTestStatusText(status) {
4882
+ const labels = {
4883
+ idle: t('settings.asrTestNativeOnly'),
4884
+ starting: t('settings.asrTestStarted'),
4885
+ listening: t('settings.asrTestStarted'),
4886
+ restarting: t('settings.asrTestRestarting'),
4887
+ 'permission-requesting': t('settings.asrTestPermission'),
4888
+ 'permission-denied': t('settings.asrTestPermissionDenied'),
4889
+ 'permission-error': t('settings.asrTestPermissionError'),
4890
+ unsupported: t('settings.asrTestUnavailable'),
4891
+ busy: t('settings.asrTestBusy'),
4892
+ stopped: t('settings.asrTestStopped')
4893
+ }
4894
+ return labels[status] || t('settings.asrTestStatus', { status })
4895
+ }
4896
+
4897
+ function asrTestLogLines() {
4898
+ const current = state.asrTest
4899
+ const lines = []
4900
+ for (const event of current.events) {
4901
+ const data = event.data || {}
4902
+ const at = `${event.atMs}ms`
4903
+ if (event.type === 'meta') {
4904
+ lines.push(`[${at}] meta brand=${data.brand || '—'} manufacturer=${data.manufacturer || '—'} model=${data.model || '—'} Android=${data.androidVersion || '—'} API=${data.apiLevel || '—'}`)
4905
+ lines.push(`[${at}] recordAudioPermission=${data.recordAudioPermission ?? 'unknown'} recordAudioAppOp=${data.recordAudioAppOp || 'unknown'} microphoneMuted=${data.microphoneMuted ?? 'unknown'}`)
4906
+ lines.push(`[${at}] recognitionAvailable=${data.recognitionAvailable === true} onDeviceAvailable=${data.onDeviceAvailable === true} path=${data.networkPath || '—'}`)
4907
+ for (const service of data.recognitionServices || []) lines.push(`[${at}] service ${service.packageName || '—'} / ${service.serviceName || '—'} xiaomiLike=${service.xiaomiLike === true}`)
4908
+ } else if (event.type === 'status') {
4909
+ lines.push(`[${at}] status=${data.status || '—'} session=${data.session ?? '—'} reason=${data.reason || '—'} ${data.message || ''}`.trim())
4910
+ } else if (event.type === 'partial' || event.type === 'final') {
4911
+ lines.push(`[${at}] ${event.type}#${data.count ?? '—'} session=${data.session ?? '—'} +${data.elapsedMs ?? '—'}ms: ${data.text || '(empty)'}`)
4912
+ } else if (event.type === 'callback') {
4913
+ lines.push(`[${at}] callback=${data.name || '—'} session=${data.session ?? '—'} +${data.elapsedMs ?? '—'}ms${data.bytes >= 0 ? ` bytes=${data.bytes}` : ''}`)
4914
+ } else if (event.type === 'error') {
4915
+ lines.push(`[${at}] error=${data.name || '—'} code=${data.code ?? '—'} session=${data.session ?? '—'} ${data.message || ''}`.trim())
4916
+ } else if (event.type === 'summary') {
4917
+ lines.push(`[${at}] summary reason=${data.reason || '—'} duration=${data.durationMs ?? '—'}ms sessions=${data.sessionCount ?? '—'} restarts=${data.restartCount ?? '—'} partial=${data.partialCount ?? '—'} final=${data.finalCount ?? '—'} errors=${data.errorCount ?? '—'}`)
4918
+ }
4919
+ }
4920
+ return lines
4921
+ }
4922
+
4923
+ function asrTestReport() {
4924
+ const current = state.asrTest
4925
+ const meta = current.meta || {}
4926
+ const summary = current.summary || {}
4927
+ const lines = [
4928
+ 'DSH Remote Android ASR 测试报告',
4929
+ `生成时间: ${new Date().toISOString()}`,
4930
+ `设备: ${meta.brand || '—'} / ${meta.manufacturer || '—'} / ${meta.model || '—'}`,
4931
+ `Android: ${meta.androidVersion || '—'} (API ${meta.apiLevel || '—'})`,
4932
+ `识别可用: ${meta.recognitionAvailable === true ? 'yes' : meta.recognitionAvailable === false ? 'no' : 'unknown'}`,
4933
+ `端侧识别可用: ${meta.onDeviceAvailable === true ? 'yes' : meta.onDeviceAvailable === false ? 'no' : 'unknown'}`,
4934
+ `路径: ${meta.networkPath || 'system-default-recognition-service'}`,
4935
+ `测试结束原因: ${summary.reason || current.status || '—'}`,
4936
+ `总时长: ${summary.durationMs ?? '—'}ms`,
4937
+ `session: ${summary.sessionCount ?? '—'} / 重建: ${summary.restartCount ?? '—'} / partial: ${summary.partialCount ?? '—'} / final: ${summary.finalCount ?? '—'} / errors: ${summary.errorCount ?? '—'}`,
4938
+ '',
4939
+ '事件日志:',
4940
+ ...asrTestLogLines()
4941
+ ]
4942
+ return lines.join('\n')
4943
+ }
4944
+
4945
+ function renderAsrTest() {
4946
+ const start = $('btn-asr-test-start')
4947
+ const stop = $('btn-asr-test-stop')
4948
+ const copy = $('btn-asr-test-copy')
4949
+ const permission = $('btn-asr-test-permission')
4950
+ const engine = $('btn-asr-test-engine')
4951
+ const status = $('asr-test-status')
4952
+ const summary = $('asr-test-summary')
4953
+ const log = $('asr-test-log')
4954
+ if (!start || !stop || !copy || !permission || !engine || !status || !summary || !log) return
4955
+ const current = state.asrTest
4956
+ const native = !!(CAP?.isNativePlatform?.() && asrTestBridge()?.startAsrTest)
4957
+ start.disabled = current.running || !native
4958
+ stop.disabled = !current.running || !native
4959
+ copy.disabled = !current.events.length
4960
+ const permissionError = current.status === 'permission-error' || current.status === 'permission-denied' || current.summary?.reason === 'permission-error'
4961
+ status.className = 'feature-test-status ' + (permissionError || current.status === 'unsupported' ? 'error' : current.status === 'stopped' ? 'ok' : 'muted')
4962
+ status.textContent = native ? (permissionError ? t('settings.asrTestPermissionError') : asrTestStatusText(current.status)) : t('settings.asrTestWebUnsupported')
4963
+ permission.classList.toggle('hidden', !native || !permissionError)
4964
+ engine.classList.toggle('hidden', !native || !permissionError)
4965
+ const meta = current.meta || {}
4966
+ const s = current.summary
4967
+ summary.textContent = [
4968
+ meta.model ? `${t('settings.asrTestMeta')}: ${meta.brand || '—'} / ${meta.manufacturer || '—'} / ${meta.model}` : '',
4969
+ s ? `${t('settings.asrTestSummary')}: ${t('settings.asrTestStatus', { status: s.reason || 'done' })} · session ${s.sessionCount ?? '—'} · partial ${s.partialCount ?? '—'} · final ${s.finalCount ?? '—'} · error ${s.errorCount ?? '—'}` : ''
4970
+ ].filter(Boolean).join('\n')
4971
+ log.textContent = current.events.length ? asrTestLogLines().join('\n') : t('settings.asrTestLogEmpty')
4972
+ log.scrollTop = log.scrollHeight
4973
+ }
4974
+
4975
+ function clearAsrTest() {
4976
+ if (state.asrTest.running) return toast(t('settings.asrTestBusy'), 'err')
4977
+ state.asrTest = emptyAsrTest()
4978
+ renderAsrTest()
4979
+ }
4980
+
4981
+ async function startAsrTest() {
4982
+ const native = asrTestBridge()
4983
+ if (!CAP?.isNativePlatform?.() || !native?.startAsrTest) return toast(t('settings.asrTestWebUnsupported'), 'err')
4984
+ if (state.asrTest.running) return toast(t('settings.asrTestBusy'), 'err')
4985
+ if (!confirm(t('settings.asrTestConsent'))) return
4986
+ state.asrTest = { ...emptyAsrTest(), running: true, status: 'starting' }
4987
+ renderAsrTest()
4988
+ try {
4989
+ if (native.startAsrTest() === false) throw new Error(t('settings.asrTestUnavailable'))
4990
+ } catch (error) {
4991
+ state.asrTest.running = false
4992
+ state.asrTest.status = 'error'
4993
+ state.asrTest.lastError = error?.message || String(error)
4994
+ renderAsrTest()
4995
+ toast(state.asrTest.lastError, 'err')
4996
+ }
4997
+ }
4998
+
4999
+ function stopAsrTest() {
5000
+ try { asrTestBridge()?.stopAsrTest?.() } catch {}
5001
+ }
5002
+
5003
+ function openAsrPermissionSettings() {
5004
+ try {
5005
+ if (asrTestBridge()?.openAsrPermissionSettings?.() === false) throw new Error('permission settings unavailable')
5006
+ } catch (error) {
5007
+ toast(error?.message || String(error), 'err')
5008
+ }
5009
+ }
5010
+
5011
+ function openAsrEngineSettings() {
5012
+ try {
5013
+ if (asrTestBridge()?.openAsrEngineSettings?.() === false) throw new Error('voice engine settings unavailable')
5014
+ } catch (error) {
5015
+ toast(error?.message || String(error), 'err')
5016
+ }
5017
+ }
5018
+
5019
+ async function copyAsrTestLog() {
5020
+ const ok = await copyText(asrTestReport())
5021
+ toast(t(ok ? 'settings.asrTestCopyOk' : 'settings.asrTestCopyFailed'), ok ? 'ok' : 'err')
5022
+ }
5023
+
5024
+ /* ---------------- 模型设置 ---------------- */
5025
+ const MODEL_SETTINGS_FIELDS = ['baseURL', 'api', 'apiKeyEnv', 'displayName', 'models']
5026
+ const MODEL_REASONING_LIMIT = 12
5027
+ const MODEL_REASONING_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/
5028
+
5029
+ function modelValueAt(value, path = []) {
5030
+ let current = value
5031
+ for (const part of path) {
5032
+ if (current === null || typeof current !== 'object') return undefined
5033
+ current = current[part]
5034
+ }
5035
+ return current
5036
+ }
5037
+
5038
+ function cloneModelValue(value) {
5039
+ if (value === undefined) return undefined
5040
+ try { return structuredClone(value) } catch {}
5041
+ try { return JSON.parse(JSON.stringify(value)) } catch { return value }
5042
+ }
5043
+
5044
+ function modelObjectAt(value, path = []) {
5045
+ const result = modelValueAt(value, path)
5046
+ return result && typeof result === 'object' && !Array.isArray(result) ? cloneModelValue(result) : {}
5047
+ }
5048
+
5049
+ function modelKeyRefFor(provider, namespace, path) {
5050
+ const effective = modelObjectAt(namespace?.value, path)
5051
+ const user = modelObjectAt(namespace?.user, path)
5052
+ const named = typeof user.apiKeyEnv === 'string' && user.apiKeyEnv.trim()
5053
+ ? user.apiKeyEnv.trim()
5054
+ : typeof effective.apiKeyEnv === 'string' && effective.apiKeyEnv.trim()
5055
+ ? effective.apiKeyEnv.trim()
5056
+ : ''
5057
+ if (named) return named
5058
+ if (namespace?.ns === 'llm-deepseek') return 'DEEPSEEK_API_KEY'
5059
+ if (namespace?.ns === 'llm-pi-ai') return provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_') + '_API_KEY'
5060
+ return ''
5061
+ }
5062
+
5063
+ function modelProfileName(row) {
5064
+ const display = String(row.displayName || row.provider || '')
5065
+ return display === row.provider ? display : `${display} (${row.provider})`
5066
+ }
5067
+
5068
+ function modelSettingsNamespace(ns) {
5069
+ return state.modelSettings.namespaces.find(item => item.ns === ns) || null
5070
+ }
5071
+
5072
+ function modelSettingsRow(provider) {
5073
+ return state.modelSettings.providers.find(row => row.provider === provider) || null
5074
+ }
5075
+
5076
+ function modelSettingsPathChanged(before, after, key) {
5077
+ return JSON.stringify(before?.[key]) !== JSON.stringify(after?.[key])
5078
+ }
5079
+
5080
+ function modelCatalogRows(value) {
5081
+ if (!Array.isArray(value)) return []
5082
+ return value
5083
+ .filter(model => model && typeof model === 'object' && !Array.isArray(model))
5084
+ .map(model => cloneModelValue(model))
5085
+ }
5086
+
5087
+ function modelReasoningRows(model) {
5088
+ const reasoning = modelObjectAt(model, ['reasoning'])
5089
+ const raw = Array.isArray(reasoning.efforts) && reasoning.efforts.length
5090
+ ? reasoning.efforts
5091
+ : (Array.isArray(model?.reasoningEfforts) ? model.reasoningEfforts : [])
5092
+ return raw.map(item => {
5093
+ const value = typeof item === 'string' ? { id: item } : modelObjectAt(item)
5094
+ return {
5095
+ id: typeof value.id === 'string' ? value.id.trim() : '',
5096
+ name: typeof value.name === 'string' ? value.name.trim() : '',
5097
+ description: typeof value.description === 'string' ? value.description.trim() : ''
5098
+ }
5099
+ })
5100
+ }
5101
+
5102
+ function withModelReasoning(model, rows, defaultEffort = '') {
5103
+ const next = cloneModelValue(model) || {}
5104
+ delete next.reasoningEfforts
5105
+ const reasoning = modelObjectAt(next, ['reasoning'])
5106
+ const efforts = rows.map(row => {
5107
+ const value = { id: String(row.id || '').trim() }
5108
+ if (String(row.name || '').trim()) value.name = String(row.name).trim()
5109
+ if (String(row.description || '').trim()) value.description = String(row.description).trim()
5110
+ return value
5111
+ })
5112
+ if (efforts.length) {
5113
+ reasoning.efforts = efforts
5114
+ const selected = efforts.some(row => row.id === defaultEffort) ? defaultEffort : ''
5115
+ if (selected) reasoning.defaultEffort = selected
5116
+ else delete reasoning.defaultEffort
5117
+ next.reasoning = reasoning
5118
+ } else {
5119
+ delete reasoning.efforts
5120
+ delete reasoning.defaultEffort
5121
+ if (Object.keys(reasoning).length) next.reasoning = reasoning
5122
+ else delete next.reasoning
5123
+ }
5124
+ return next
5125
+ }
5126
+
5127
+ function modelReasoningError(models) {
5128
+ for (const model of models) {
5129
+ const rows = modelReasoningRows(model)
5130
+ if (rows.length > MODEL_REASONING_LIMIT) return t('settings.modelReasoningLimit', { count: MODEL_REASONING_LIMIT })
5131
+ const ids = new Set()
5132
+ for (const row of rows) {
5133
+ if (!row.id) return t('settings.modelReasoningIdRequired')
5134
+ if (!MODEL_REASONING_ID_RE.test(row.id)) return t('settings.modelReasoningInvalid')
5135
+ if (ids.has(row.id)) return t('settings.modelReasoningDuplicate')
5136
+ ids.add(row.id)
5137
+ }
5138
+ const defaultEffort = modelObjectAt(model, ['reasoning']).defaultEffort
5139
+ if (defaultEffort && !ids.has(defaultEffort)) return t('settings.modelReasoningDefaultInvalid')
5140
+ }
5141
+ return ''
5142
+ }
5143
+
5144
+ async function loadModelSettings(force = false) {
5145
+ if (!state.token) {
5146
+ state.modelSettings = { ...state.modelSettings, status: 'error', error: t('token.notSetHint') }
5147
+ renderModelSettings()
5148
+ return
5149
+ }
5150
+ if (state.modelSettings.status === 'loading') return
5151
+ if (!force && state.modelSettings.status === 'ready') return
5152
+ state.modelSettings = { ...state.modelSettings, status: 'loading', error: '' }
5153
+ renderModelSettings()
5154
+ try {
5155
+ const [providersValue, settingsValue] = await Promise.all([
5156
+ rpc('llm.providers', {}),
5157
+ rpc('settings.describe', {})
5158
+ ])
5159
+ const namespaces = Array.isArray(settingsValue?.namespaces) ? settingsValue.namespaces : []
5160
+ const providers = Array.isArray(providersValue?.providers) ? providersValue.providers : []
5161
+ const rows = providers.map(entry => {
5162
+ const settingsPath = Array.isArray(entry.settingsPath) ? entry.settingsPath : []
5163
+ const namespace = namespaces.find(item => item.ns === entry.settingsNs) || null
5164
+ const effective = modelObjectAt(namespace?.value, settingsPath)
5165
+ const keyRef = modelKeyRefFor(entry.provider, namespace, settingsPath)
5166
+ return {
5167
+ ...entry,
5168
+ settingsPath,
5169
+ keyRef,
5170
+ namespace,
5171
+ configured: namespace !== null && (settingsPath.length === 0 || modelValueAt(namespace.value, settingsPath) !== undefined),
5172
+ effective,
5173
+ credential: null
5174
+ }
5175
+ })
5176
+ const refs = [...new Set(rows.map(row => row.keyRef).filter(Boolean))]
5177
+ let credentials = {}
5178
+ if (refs.length > 0) {
5179
+ const value = await rpc('credentials.describe', { refs })
5180
+ credentials = value?.credentials && typeof value.credentials === 'object' ? value.credentials : {}
5181
+ }
5182
+ state.modelSettings = {
5183
+ status: 'ready',
5184
+ error: '',
5185
+ writable: settingsValue?.writable === true,
5186
+ hasDocument: settingsValue?.hasDocument === true,
5187
+ providers: rows.map(row => ({ ...row, credential: row.keyRef ? credentials[row.keyRef] || null : null })),
5188
+ namespaces,
5189
+ credentials
5190
+ }
5191
+ state.modelEditor = null
5192
+ } catch (error) {
5193
+ state.modelSettings = { ...state.modelSettings, status: 'error', error: error?.message || String(error) }
5194
+ }
5195
+ renderModelSettings()
5196
+ }
5197
+
5198
+ function renderModelSettings() {
5199
+ const status = $('model-settings-status')
5200
+ const list = $('model-settings-list')
5201
+ if (!status || !list) return
5202
+ const current = state.modelSettings
5203
+ if (current.status === 'loading') {
5204
+ status.className = 'model-settings-status muted'
5205
+ status.textContent = t('settings.modelLoading')
5206
+ list.innerHTML = ''
5207
+ return
5208
+ }
5209
+ if (current.status === 'error') {
5210
+ status.className = 'model-settings-status error'
5211
+ status.textContent = t('settings.modelUnavailable', { msg: current.error || t('err.dshError') })
5212
+ list.innerHTML = ''
5213
+ return
5214
+ }
5215
+ if (!current.providers.length) {
5216
+ status.className = 'model-settings-status muted'
5217
+ status.textContent = t('settings.modelEmpty')
5218
+ list.innerHTML = ''
5219
+ return
5220
+ }
5221
+ status.className = 'model-settings-status ' + (current.writable ? 'muted' : 'model-readonly')
5222
+ status.textContent = current.writable ? t('settings.modelIntro') : t('settings.modelReadOnly')
5223
+ list.innerHTML = current.providers.map(renderModelProviderCard).join('')
5224
+ }
5225
+
5226
+ function renderModelProviderCard(row) {
5227
+ const editor = state.modelEditor?.provider === row.provider ? renderModelEditor() : ''
5228
+ const credentialConfigured = row.credential?.configured === true
5229
+ const dot = row.keyRef ? (credentialConfigured ? 'configured' : '') : 'unknown'
5230
+ const stateLabel = row.keyRef
5231
+ ? (credentialConfigured ? t('settings.modelConfigured') : t('settings.modelMissing'))
5232
+ : t('settings.modelConfigured')
5233
+ return `<article class="model-provider-card" data-model-provider-card="${esc(row.provider)}">
5234
+ <div class="model-provider-head">
5235
+ <div class="model-provider-identity">
5236
+ <span class="model-provider-dot ${dot}" title="${esc(stateLabel)}" aria-label="${esc(stateLabel)}"></span>
5237
+ <span class="model-provider-name">${esc(row.displayName || row.provider)}</span>
5238
+ <code class="model-provider-route">${esc(row.provider)}</code>
5239
+ </div>
5240
+ <button class="mini-btn" type="button" data-model-action="edit" data-model-provider="${esc(row.provider)}">${esc(t('settings.modelEdit'))}</button>
5241
+ </div>
5242
+ ${editor}
5243
+ </article>`
5244
+ }
5245
+
5246
+ function renderModelReasoningEditor(model, index, readOnly) {
5247
+ const rows = modelReasoningRows(model)
5248
+ const reasoning = modelObjectAt(model, ['reasoning'])
5249
+ const defaultEffort = typeof reasoning.defaultEffort === 'string' ? reasoning.defaultEffort : ''
5250
+ const effortRows = rows.length
5251
+ ? rows.map((row, effortIndex) => `<div class="model-reasoning-entry">
5252
+ <input class="model-input" data-model-field="reasoning-id" data-model-index="${index}" data-reasoning-index="${effortIndex}" value="${esc(row.id)}" placeholder="${esc(t('settings.modelReasoningId'))}" aria-label="${esc(t('settings.modelReasoningId'))} ${effortIndex + 1}" ${readOnly ? 'disabled' : ''}>
5253
+ <input class="model-input" data-model-field="reasoning-name" data-model-index="${index}" data-reasoning-index="${effortIndex}" value="${esc(row.name)}" placeholder="${esc(t('settings.modelReasoningName'))}" aria-label="${esc(t('settings.modelReasoningName'))} ${effortIndex + 1}" ${readOnly ? 'disabled' : ''}>
5254
+ <input class="model-input" data-model-field="reasoning-description" data-model-index="${index}" data-reasoning-index="${effortIndex}" value="${esc(row.description)}" placeholder="${esc(t('settings.modelReasoningDescription'))}" aria-label="${esc(t('settings.modelReasoningDescription'))} ${effortIndex + 1}" ${readOnly ? 'disabled' : ''}>
5255
+ <button class="model-entry-remove" type="button" data-model-action="remove-reasoning" data-model-index="${index}" data-reasoning-index="${effortIndex}" aria-label="${esc(t('settings.modelReasoningRemove'))}" title="${esc(t('settings.modelReasoningRemove'))}" ${readOnly ? 'disabled' : ''}>×</button>
5256
+ </div>`).join('')
5257
+ : `<div class="model-empty">${esc(t('settings.modelReasoningEmpty'))}</div>`
5258
+ const defaultOptions = rows.filter(row => row.id).map(row => `<option value="${esc(row.id)}" ${defaultEffort === row.id ? 'selected' : ''}>${esc(row.name || row.id)}</option>`).join('')
5259
+ return `<div class="model-reasoning" data-model-reasoning-editor="${index}">
5260
+ <div class="model-reasoning-head">
5261
+ <div><div class="model-catalog-title">${esc(t('settings.modelReasoning'))}</div><div class="model-catalog-hint">${esc(t('settings.modelReasoningHint'))}</div></div>
5262
+ <div class="model-catalog-actions"><button class="mini-btn" type="button" data-model-action="add-reasoning" data-model-index="${index}" ${readOnly || rows.length >= MODEL_REASONING_LIMIT ? 'disabled' : ''}>${esc(t('settings.modelReasoningAdd'))}</button><button class="mini-btn" type="button" data-model-action="clear-reasoning" data-model-index="${index}" ${readOnly || !rows.length ? 'disabled' : ''}>${esc(t('settings.modelReasoningClear'))}</button></div>
5263
+ </div>
5264
+ <div class="model-reasoning-list">${effortRows}</div>
5265
+ <label class="model-reasoning-default"><span>${esc(t('settings.modelReasoningDefault'))}</span><select class="model-input" data-model-field="reasoning-default" data-model-index="${index}" ${readOnly || !rows.length ? 'disabled' : ''}><option value="" ${defaultEffort ? '' : 'selected'}>${esc(t('settings.modelReasoningProviderDefault'))}</option>${defaultOptions}</select></label>
5266
+ </div>`
5267
+ }
5268
+
5269
+ function renderModelEditor() {
5270
+ const editor = state.modelEditor
5271
+ if (!editor) return ''
5272
+ const readOnly = !state.modelSettings.writable || editor.busy
5273
+ const keyPlaceholder = editor.keyConfigured && !editor.clearKey
5274
+ ? t('settings.modelApiKeyStored')
5275
+ : t('settings.modelApiKeyPlaceholder')
5276
+ const models = editor.models || []
5277
+ const modelList = models.length
5278
+ ? models.map((model, index) => `<div class="model-entry-card">
5279
+ <div class="model-entry">
5280
+ <input class="model-input" data-model-field="model-id" data-model-index="${index}" value="${esc(model.id || '')}" placeholder="${esc(t('settings.modelId'))}" aria-label="${esc(t('settings.modelId'))} ${index + 1}" ${readOnly ? 'disabled' : ''}>
5281
+ <input class="model-input model-name-input" data-model-field="model-name" data-model-index="${index}" value="${esc(model.name || '')}" placeholder="${esc(t('settings.modelName'))}" aria-label="${esc(t('settings.modelName'))} ${index + 1}" ${readOnly ? 'disabled' : ''}>
5282
+ <button class="model-entry-remove" type="button" data-model-action="remove-model" data-model-index="${index}" aria-label="${esc(t('settings.modelRemove'))}" title="${esc(t('settings.modelRemove'))}" ${readOnly ? 'disabled' : ''}>×</button>
5283
+ </div>
5284
+ ${renderModelReasoningEditor(model, index, readOnly)}
5285
+ </div>`).join('')
5286
+ : `<div class="model-empty">${esc(t('settings.modelNoModels'))}</div>`
5287
+ const discovery = editor.discovered?.length
5288
+ ? `<div class="model-discovery">
5289
+ <div class="model-discovery-head"><span>${esc(t('settings.modelCandidates'))}</span><button class="mini-btn" type="button" data-model-action="select-all">${esc(editor.discoverySelected.size === editor.discovered.length ? t('settings.modelSelectNone') : t('settings.modelSelectAll'))}</button></div>
5290
+ <div class="model-discovery-list">${editor.discovered.map((model, index) => `<label class="model-discovery-row"><input type="checkbox" data-model-candidate="${esc(model.id)}" ${editor.discoverySelected.has(model.id) ? 'checked' : ''}><code>${esc(model.id)}${model.name && model.name !== model.id ? ` · ${esc(model.name)}` : ''}</code></label>`).join('')}</div>
5291
+ <button class="mini-btn" type="button" data-model-action="add-selected" ${readOnly ? 'disabled' : ''}>${esc(t('settings.modelAddSelected'))}</button>
5292
+ </div>`
5293
+ : ''
5294
+ const effectText = editor.applies === 'restart' ? t('settings.modelRestart') : t('settings.modelLive')
5295
+ return `<div class="model-editor">
5296
+ <div class="model-editor-title"><strong>${esc(editor.displayName || editor.provider)}</strong><code>${esc(editor.provider)}</code></div>
5297
+ <div class="model-field">
5298
+ <label for="model-api-key-${esc(editor.provider)}">${esc(t('settings.modelApiKey'))}</label>
5299
+ <input id="model-api-key-${esc(editor.provider)}" class="model-input" type="password" autocomplete="off" data-model-field="apiKey" value="${esc(editor.keyDraft || '')}" placeholder="${esc(keyPlaceholder)}" ${readOnly || editor.keyWritable === false ? 'disabled' : ''}>
5300
+ ${editor.keyConfigured && editor.keyWritable !== false ? `<button class="mini-btn" type="button" data-model-action="clear-key" ${readOnly ? 'disabled' : ''}>${esc(t('settings.modelClearKey'))}</button>` : ''}
5301
+ </div>
5302
+ <div class="model-inline">
5303
+ <div class="model-field"><label for="model-base-url-${esc(editor.provider)}">${esc(t('settings.modelBaseUrl'))}</label><input id="model-base-url-${esc(editor.provider)}" class="model-input" type="url" data-model-field="baseURL" value="${esc(editor.baseURL || '')}" placeholder="${esc(t('settings.modelBaseUrlPlaceholder'))}" ${readOnly ? 'disabled' : ''}></div>
5304
+ ${editor.api ? `<div class="model-field"><span class="model-field-label">${esc(t('settings.modelProtocol'))}</span><input class="model-input" data-model-field="api" value="${esc(editor.api)}" ${readOnly ? 'disabled' : ''}></div>` : ''}
5305
+ </div>
5306
+ <div class="model-catalog">
5307
+ <div class="model-catalog-head"><div><div class="model-catalog-title">${esc(t('settings.modelCatalog'))}</div><div class="model-catalog-hint">${esc(t('settings.modelCatalogHint'))}</div></div><div class="model-catalog-actions"><button class="mini-btn" type="button" data-model-action="discover" ${readOnly || editor.busy ? 'disabled' : ''}>${esc(editor.busy ? t('settings.modelFetching') : t('settings.modelFetch'))}</button><button class="mini-btn" type="button" data-model-action="add-model" ${readOnly ? 'disabled' : ''}>${esc(t('settings.modelAdd'))}</button></div></div>
5308
+ <div class="model-list">${modelList}</div>
5309
+ ${discovery}
5310
+ </div>
5311
+ ${editor.error ? `<p class="model-editor-error">${esc(editor.error)}</p>` : ''}
5312
+ <div class="model-editor-actions"><span class="model-catalog-hint">${esc(effectText)}</span><button class="mini-btn" type="button" data-model-action="cancel">${esc(t('settings.modelCancel'))}</button><button class="mini-btn primary" type="button" data-model-action="save" ${readOnly ? 'disabled' : ''}>${esc(editor.busy ? t('settings.modelSaving') : t('settings.modelSave'))}</button></div>
5313
+ </div>`
5314
+ }
5315
+
5316
+ function openModelEditor(provider) {
5317
+ const row = modelSettingsRow(provider)
5318
+ const namespace = row?.namespace
5319
+ if (!row || !namespace) return
5320
+ const effective = modelObjectAt(namespace.value, row.settingsPath)
5321
+ const user = modelObjectAt(namespace.user, row.settingsPath)
5322
+ state.modelEditor = {
5323
+ provider: row.provider,
5324
+ displayName: row.displayName,
5325
+ settingsNs: row.settingsNs,
5326
+ settingsPath: row.settingsPath,
5327
+ namespace,
5328
+ userProfile: user,
5329
+ effectiveProfile: effective,
5330
+ keyRef: row.keyRef || '',
5331
+ keyConfigured: row.credential?.configured === true,
5332
+ keyWritable: row.credential?.writable !== false,
5333
+ baseURL: typeof (user.baseURL ?? effective.baseURL) === 'string' ? (user.baseURL ?? effective.baseURL) : '',
5334
+ initialBaseURL: typeof user.baseURL === 'string' ? user.baseURL : '',
5335
+ api: typeof (user.api ?? effective.api) === 'string' ? (user.api ?? effective.api) : '',
5336
+ initialApi: typeof user.api === 'string' ? user.api : '',
5337
+ models: modelCatalogRows(user.models ?? effective.models),
5338
+ modelsDirty: false,
5339
+ baseURLDirty: false,
5340
+ apiDirty: false,
5341
+ keyDraft: '',
5342
+ clearKey: false,
5343
+ discovered: [],
5344
+ discoverySelected: new Set(),
5345
+ applies: namespace.applies,
5346
+ busy: false,
5347
+ error: ''
5348
+ }
5349
+ renderModelSettings()
5350
+ }
5351
+
5352
+ function collectModelEditorForm() {
5353
+ const editor = state.modelEditor
5354
+ const root = $('model-settings-list')
5355
+ if (!editor || !root) return
5356
+ const base = root.querySelector('[data-model-field="baseURL"]')
5357
+ const api = root.querySelector('[data-model-field="api"]')
5358
+ const key = root.querySelector('[data-model-field="apiKey"]')
5359
+ if (base) editor.baseURL = base.value.trim()
5360
+ if (api) editor.api = api.value.trim()
5361
+ if (key) editor.keyDraft = key.value
5362
+ root.querySelectorAll('[data-model-field="model-id"]').forEach(input => {
5363
+ const index = Number(input.dataset.modelIndex)
5364
+ if (editor.models[index]) editor.models[index].id = input.value.trim()
5365
+ })
5366
+ root.querySelectorAll('[data-model-field="model-name"]').forEach(input => {
5367
+ const index = Number(input.dataset.modelIndex)
5368
+ if (editor.models[index]) {
5369
+ const value = input.value.trim()
5370
+ if (value) editor.models[index].name = value
5371
+ else delete editor.models[index].name
5372
+ }
5373
+ })
5374
+ editor.models.forEach((model, index) => {
5375
+ const section = root.querySelector(`[data-model-reasoning-editor="${index}"]`)
5376
+ if (!section) return
5377
+ const rows = [...section.querySelectorAll('[data-model-field="reasoning-id"]')].map((input, effortIndex) => ({
5378
+ id: input.value.trim(),
5379
+ name: section.querySelector(`[data-model-field="reasoning-name"][data-reasoning-index="${effortIndex}"]`)?.value.trim() || '',
5380
+ description: section.querySelector(`[data-model-field="reasoning-description"][data-reasoning-index="${effortIndex}"]`)?.value.trim() || ''
5381
+ }))
5382
+ const defaultEffort = section.querySelector('[data-model-field="reasoning-default"]')?.value || ''
5383
+ editor.models[index] = withModelReasoning(model, rows, defaultEffort)
5384
+ })
5385
+ }
5386
+
5387
+ function setModelEditorError(message) {
5388
+ if (!state.modelEditor) return
5389
+ state.modelEditor.error = message || ''
5390
+ renderModelSettings()
5391
+ }
5392
+
5393
+ async function discoverModelSettings() {
5394
+ const editor = state.modelEditor
5395
+ if (!editor || editor.busy) return
5396
+ collectModelEditorForm()
5397
+ editor.busy = true
5398
+ editor.error = ''
5399
+ renderModelSettings()
5400
+ try {
5401
+ const payload = { settingsNs: editor.settingsNs }
5402
+ if (editor.provider) payload.provider = editor.provider
5403
+ if (editor.baseURL) payload.baseURL = editor.baseURL
5404
+ if (editor.api) payload.api = editor.api
5405
+ if (editor.keyDraft.trim()) payload.apiKey = editor.keyDraft.trim()
5406
+ const value = await rpc('llm.discoverModels', payload)
5407
+ const found = Array.isArray(value?.models) ? value.models.filter(model => model && typeof model.id === 'string' && model.id.trim()) : []
5408
+ if (!found.length) throw new Error(t('settings.modelFetchEmpty'))
5409
+ const known = new Set(editor.models.map(model => model.id))
5410
+ editor.discovered = found
5411
+ editor.discoverySelected = new Set(found.filter(model => !known.has(model.id)).map(model => model.id))
5412
+ } catch (error) {
5413
+ editor.error = t('settings.modelFetchFailed', { msg: error?.message || String(error) })
5414
+ } finally {
5415
+ editor.busy = false
5416
+ }
5417
+ renderModelSettings()
5418
+ }
5419
+
5420
+ function addDiscoveredModels() {
5421
+ const editor = state.modelEditor
5422
+ if (!editor) return
5423
+ collectModelEditorForm()
5424
+ const known = new Set(editor.models.map(model => model.id))
5425
+ for (const candidate of editor.discovered || []) {
5426
+ if (!editor.discoverySelected.has(candidate.id) || known.has(candidate.id)) continue
5427
+ editor.models.push({ id: candidate.id, ...(candidate.name ? { name: candidate.name } : {}), ...(candidate.contextWindow ? { contextWindow: candidate.contextWindow } : {}), ...(candidate.maxTokens ? { maxTokens: candidate.maxTokens } : {}) })
5428
+ known.add(candidate.id)
5429
+ }
5430
+ editor.modelsDirty = true
5431
+ editor.discovered = []
5432
+ editor.discoverySelected = new Set()
5433
+ renderModelSettings()
5434
+ }
5435
+
5436
+ async function saveModelEditor() {
5437
+ const editor = state.modelEditor
5438
+ if (!editor || editor.busy) return
5439
+ collectModelEditorForm()
5440
+ if (!state.modelSettings.writable) return setModelEditorError(t('settings.modelReadOnly'))
5441
+ const models = editor.models || []
5442
+ if (editor.modelsDirty && models.some(model => !String(model.id || '').trim())) return setModelEditorError(t('settings.modelIdRequired'))
5443
+ const reasoningError = editor.modelsDirty ? modelReasoningError(models) : ''
5444
+ if (reasoningError) return setModelEditorError(reasoningError)
5445
+ if (editor.keyDraft.trim() && !editor.keyRef) return setModelEditorError(t('settings.modelSaveFailed', { msg: t('settings.modelApiKey') }))
5446
+ editor.busy = true
5447
+ editor.error = ''
5448
+ renderModelSettings()
5449
+ try {
5450
+ const before = editor.userProfile || {}
5451
+ const after = { ...before }
5452
+ if (editor.baseURLDirty) {
5453
+ if (editor.baseURL) after.baseURL = editor.baseURL
5454
+ else delete after.baseURL
5455
+ }
5456
+ if (editor.apiDirty) {
5457
+ if (editor.api) after.api = editor.api
5458
+ else delete after.api
5459
+ }
5460
+ if (editor.modelsDirty) after.models = models.map(model => cloneModelValue(model))
5461
+ if (editor.settingsNs === 'llm-pi-ai' && editor.keyDraft.trim() && !after.apiKeyEnv) after.apiKeyEnv = editor.keyRef
5462
+ const ops = MODEL_SETTINGS_FIELDS.flatMap(key => {
5463
+ if (!modelSettingsPathChanged(before, after, key)) return []
5464
+ const path = [...editor.settingsPath, key]
5465
+ return after[key] === undefined ? [{ op: 'unset', path }] : [{ op: 'set', path, value: after[key] }]
5466
+ })
5467
+ if (ops.length) {
5468
+ const value = await rpc('settings.mutate', { ns: editor.settingsNs, ops, expectedRevision: editor.namespace.revision })
5469
+ editor.namespace = value
5470
+ }
5471
+ if (editor.keyDraft.trim()) {
5472
+ await rpc('credentials.set', { ref: editor.keyRef, value: editor.keyDraft.trim() })
5473
+ } else if (editor.clearKey && editor.keyConfigured && editor.keyRef) {
5474
+ await rpc('credentials.unset', { ref: editor.keyRef })
5475
+ }
5476
+ state.modelEditor = null
5477
+ await loadModelSettings(true)
5478
+ toast(t('settings.modelSaved'), 'ok')
5479
+ } catch (error) {
5480
+ editor.error = t('settings.modelSaveFailed', { msg: error?.message || String(error) })
5481
+ } finally {
5482
+ if (state.modelEditor === editor) {
5483
+ editor.busy = false
5484
+ renderModelSettings()
5485
+ }
5486
+ }
5487
+ }
5488
+
5489
+ function handleModelSettingsClick(event) {
5490
+ const action = event.target.closest('[data-model-action]')
5491
+ if (!action) return
5492
+ const type = action.dataset.modelAction
5493
+ if (type === 'edit') return openModelEditor(action.dataset.modelProvider)
5494
+ if (type === 'cancel') { state.modelEditor = null; renderModelSettings(); return }
5495
+ if (type === 'save') return void saveModelEditor()
5496
+ const editor = state.modelEditor
5497
+ if (!editor) return
5498
+ if (type === 'add-model') {
5499
+ collectModelEditorForm(); editor.models.push({ id: '' }); editor.modelsDirty = true; renderModelSettings(); return
5500
+ }
5501
+ if (type === 'remove-model') {
5502
+ collectModelEditorForm(); editor.models.splice(Number(action.dataset.modelIndex), 1); editor.modelsDirty = true; renderModelSettings(); return
5503
+ }
5504
+ if (type === 'add-reasoning') {
5505
+ collectModelEditorForm()
5506
+ const index = Number(action.dataset.modelIndex)
5507
+ const model = editor.models[index]
5508
+ if (!model) return
5509
+ const rows = modelReasoningRows(model)
5510
+ if (rows.length >= MODEL_REASONING_LIMIT) return setModelEditorError(t('settings.modelReasoningLimit', { count: MODEL_REASONING_LIMIT }))
5511
+ rows.push({ id: '', name: '', description: '' })
5512
+ editor.models[index] = withModelReasoning(model, rows, modelObjectAt(model, ['reasoning']).defaultEffort || '')
5513
+ editor.modelsDirty = true
5514
+ renderModelSettings()
5515
+ return
5516
+ }
5517
+ if (type === 'remove-reasoning') {
5518
+ collectModelEditorForm()
5519
+ const index = Number(action.dataset.modelIndex)
5520
+ const effortIndex = Number(action.dataset.reasoningIndex)
5521
+ const model = editor.models[index]
5522
+ if (!model) return
5523
+ const rows = modelReasoningRows(model)
5524
+ rows.splice(effortIndex, 1)
5525
+ editor.models[index] = withModelReasoning(model, rows, modelObjectAt(model, ['reasoning']).defaultEffort || '')
5526
+ editor.modelsDirty = true
5527
+ renderModelSettings()
5528
+ return
5529
+ }
5530
+ if (type === 'clear-reasoning') {
5531
+ collectModelEditorForm()
5532
+ const index = Number(action.dataset.modelIndex)
5533
+ const model = editor.models[index]
5534
+ if (!model) return
5535
+ editor.models[index] = withModelReasoning(model, [], '')
5536
+ editor.modelsDirty = true
5537
+ renderModelSettings()
5538
+ return
5539
+ }
5540
+ if (type === 'clear-key') { collectModelEditorForm(); editor.keyDraft = ''; editor.clearKey = true; renderModelSettings(); return }
5541
+ if (type === 'discover') return void discoverModelSettings()
5542
+ if (type === 'add-selected') return addDiscoveredModels()
5543
+ if (type === 'select-all') {
5544
+ const ids = (editor.discovered || []).map(model => model.id)
5545
+ editor.discoverySelected = editor.discoverySelected.size === ids.length ? new Set() : new Set(ids)
5546
+ renderModelSettings()
5547
+ }
5548
+ }
5549
+
5550
+ function handleModelSettingsInput(event) {
5551
+ const editor = state.modelEditor
5552
+ if (!editor) return
5553
+ const field = event.target.dataset.modelField
5554
+ if (field === 'baseURL') editor.baseURLDirty = true
5555
+ if (field === 'api') editor.apiDirty = true
5556
+ if (field === 'model-id' || field === 'model-name' || field?.startsWith('reasoning-')) editor.modelsDirty = true
5557
+ }
5558
+
5559
+ async function openModelConfigDocument() {
5560
+ const value = await safeRpc('settings.openDocument', {}, t('settings.modelOpenConfigFailed'))
5561
+ if (value?.opened) toast(t('settings.modelOpenedConfig'), 'ok')
5562
+ }
4614
5563
  /* ---------------- 视图切换 ---------------- */
4615
5564
  function showView(id) {
4616
5565
  for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-stats', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
@@ -4627,7 +5576,7 @@ function showView(id) {
4627
5576
  if (id === 'view-settings') showSettingsHome()
4628
5577
  }
4629
5578
 
4630
- const SETTINGS_GROUPS = ['general', 'servers', 'notify', 'theme', 'about']
5579
+ const SETTINGS_GROUPS = ['general', 'model', 'tests', 'servers', 'notify', 'theme', 'about']
4631
5580
  function showSettingsHome() {
4632
5581
  const home = $('settings-home')
4633
5582
  if (!home) return
@@ -4641,6 +5590,7 @@ function showSettingsPage(name) {
4641
5590
  home.classList.add('hidden')
4642
5591
  for (const g of SETTINGS_GROUPS) $('settings-page-' + g)?.classList.toggle('hidden', g !== name)
4643
5592
  window.scrollTo(0, 0)
5593
+ if (name === 'model') void loadModelSettings()
4644
5594
  }
4645
5595
 
4646
5596
  function updateConn() {
@@ -4765,19 +5715,25 @@ function bindComposerFullscreenGesture() {
4765
5715
  }
4766
5716
 
4767
5717
  /* ---------------- 初始化 ---------------- */
4768
- /** 解析 dshremote://pair?token=..&server=.. 配对二维码 */
5718
+ /** 解析 dshremote://pair?token=..&server=.. 配对二维码;server 可重复以携带多个主机地址。 */
4769
5719
  function applyPairUrl(url) {
4770
5720
  try {
4771
5721
  const u = new URL(String(url).trim())
4772
5722
  if (u.protocol !== 'dshremote:' || u.hostname !== 'pair') return false
4773
5723
  const tok = (u.searchParams.get('token') || '').trim()
4774
- const server = (u.searchParams.get('server') || '').trim().replace(/\/+$/, '')
4775
- if (!tok || !/^https?:\/\//i.test(server)) return false
5724
+ const servers = [...new Set(u.searchParams.getAll('server')
5725
+ .map(value => value.trim().replace(/\/+$/, ''))
5726
+ .filter(value => /^https?:\/\//i.test(value)))]
5727
+ if (!tok || !servers.length) return false
4776
5728
  state.token = tok
4777
5729
  LS.set('token', tok)
4778
- state.server = server
4779
- if (!state.servers.some(s => s.url === server)) {
4780
- state.servers.unshift({ id: newServerId(), url: server, note: '', group: state.activeGroup })
5730
+ if (state.server !== servers[0]) resetFsForServer()
5731
+ state.server = servers[0]
5732
+ for (let i = servers.length - 1; i >= 0; i--) {
5733
+ const server = servers[i]
5734
+ if (!state.servers.some(s => s.url === server)) {
5735
+ state.servers.unshift({ id: newServerId(), url: server, note: '', group: state.activeGroup })
5736
+ }
4781
5737
  }
4782
5738
  saveServers()
4783
5739
  renderServers()
@@ -5091,6 +6047,10 @@ function dshControlFailureText(value) {
5091
6047
  INVALID_SERVICE: 'settings.dshErrorInvalidService',
5092
6048
  SYSTEMCTL_NOT_FOUND: 'settings.dshErrorSystemctlNotFound',
5093
6049
  SYSTEMD_UNAVAILABLE: 'settings.dshErrorSystemdUnavailable',
6050
+ SERVICE_CONTROL_NOT_FOUND: 'settings.dshErrorServiceControlNotFound',
6051
+ SERVICE_DISABLED: 'settings.dshErrorServiceDisabled',
6052
+ SERVICE_STOP_TIMEOUT: 'settings.dshErrorServiceStopTimeout',
6053
+ STATUS_PARSE_FAILED: 'settings.dshErrorStatusParseFailed',
5094
6054
  PERMISSION_DENIED: 'settings.dshErrorPermissionDenied',
5095
6055
  COMMAND_TIMEOUT: 'settings.dshErrorCommandTimeout',
5096
6056
  COMMAND_FAILED: 'settings.dshErrorCommandFailed',
@@ -5201,7 +6161,10 @@ function renderDshControlStatus(value) {
5201
6161
  async function loadDshControl() {
5202
6162
  if (!state.token || !$('dsh-control-desc')) return
5203
6163
  if (activeGatewayCapability('dshLifecycle') === false) {
5204
- renderDshControlStatus({ supported: false, message: t('settings.dshUnsupported') })
6164
+ renderDshControlStatus({
6165
+ supported: false,
6166
+ message: activeGatewayHealth()?.dshControl?.message || t('settings.dshUnsupported')
6167
+ })
5205
6168
  return
5206
6169
  }
5207
6170
  try {
@@ -5325,6 +6288,8 @@ function bindUi() {
5325
6288
  renderAnnouncementBoard()
5326
6289
  renderPending(); renderQueue(); renderJobs()
5327
6290
  updateConn()
6291
+ if (state.modelSettings.status === 'ready' || state.modelSettings.status === 'error' || state.modelSettings.status === 'loading') renderModelSettings()
6292
+ renderAsrTest()
5328
6293
  if (state.current) { renderSessionTitle(); renderSessionSub(); renderSessionCards(); renderHistory(true) }
5329
6294
  else renderModelMenu()
5330
6295
  loadLocalVersion()
@@ -5418,7 +6383,7 @@ function bindUi() {
5418
6383
  const session = e.target.closest('[data-wb-session]')
5419
6384
  if (session) openSession(session.dataset.wbSession)
5420
6385
  })
5421
- $('btn-back').addEventListener('click', closeSession)
6386
+ $('btn-back').addEventListener('click', () => { void closeSession() })
5422
6387
  $('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
5423
6388
  $('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
5424
6389
  $('btn-refresh').addEventListener('click', () => { toast(t('common.refreshing')); openStreams(); refreshAll() })
@@ -5478,12 +6443,20 @@ function bindUi() {
5478
6443
  renderSessions()
5479
6444
  })
5480
6445
  $('fs-workspace').addEventListener('change', (e) => {
5481
- state.fs.workspaceId = e.target.value
6446
+ const rootMatch = /^__fs_root__:(\d+)$/.exec(e.target.value)
6447
+ if (rootMatch) {
6448
+ state.fs.rootIndex = Math.min(Number(rootMatch[1]), Math.max(0, state.fs.roots.length - 1))
6449
+ state.fs.workspaceId = ''
6450
+ } else {
6451
+ state.fs.rootIndex = 0
6452
+ state.fs.workspaceId = e.target.value
6453
+ }
5482
6454
  if (state.fs.workspaceId) LS.set('fsWorkspaceIdV1', state.fs.workspaceId)
5483
6455
  else LS.del('fsWorkspaceIdV1')
5484
6456
  state.fs.loaded = false
5485
6457
  const workspace = workspaceById(state.fs.workspaceId)
5486
- loadFs(workspace?.path || null, { resetRoot: true })
6458
+ const rootPath = !workspace && rootMatch ? state.fs.roots[state.fs.rootIndex] : null
6459
+ loadFs(workspace?.path || rootPath || null, { resetRoot: true })
5487
6460
  })
5488
6461
  $('file-preview-close').addEventListener('click', closeFsPreview)
5489
6462
  $('file-preview-done').addEventListener('click', closeFsPreview)
@@ -5625,6 +6598,17 @@ function bindUi() {
5625
6598
  if (group) { showSettingsPage(group.dataset.settingsGroup); return }
5626
6599
  if (e.target.closest('[data-settings-back]')) { showSettingsHome(); return }
5627
6600
  })
6601
+ $('btn-model-settings-refresh')?.addEventListener('click', () => loadModelSettings(true))
6602
+ $('btn-model-settings-open')?.addEventListener('click', openModelConfigDocument)
6603
+ $('model-settings-list')?.addEventListener('click', handleModelSettingsClick)
6604
+ $('model-settings-list')?.addEventListener('input', handleModelSettingsInput)
6605
+ $('btn-asr-test-start')?.addEventListener('click', startAsrTest)
6606
+ $('btn-asr-test-stop')?.addEventListener('click', stopAsrTest)
6607
+ $('btn-asr-test-copy')?.addEventListener('click', copyAsrTestLog)
6608
+ $('btn-asr-test-clear')?.addEventListener('click', clearAsrTest)
6609
+ $('btn-asr-test-permission')?.addEventListener('click', openAsrPermissionSettings)
6610
+ $('btn-asr-test-engine')?.addEventListener('click', openAsrEngineSettings)
6611
+ renderAsrTest()
5628
6612
  $('btn-scan-camera').addEventListener('click', () => scanPair('CAMERA'))
5629
6613
  $('btn-scan-gallery').addEventListener('click', () => scanPair('PHOTOS'))
5630
6614
  $('scan-live-cancel')?.addEventListener('click', () => closeLiveScan(''))
@@ -5644,11 +6628,7 @@ function bindUi() {
5644
6628
  if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); addServer() }
5645
6629
  })
5646
6630
  $('btn-host-describe').addEventListener('click', async () => {
5647
- const v = await safeRpc('host.describe', {}, t('settings.probeFailed'))
5648
- if (v) {
5649
- state.hostInfo = v
5650
- $('host-desc').textContent = t('settings.hostDesc', { version: v.version, cwd: v.cwd, n: v.attachedSessions })
5651
- }
6631
+ await refreshHostDescription({ notify: true })
5652
6632
  })
5653
6633
  $('btn-dsh-start')?.addEventListener('click', () => controlDsh('start'))
5654
6634
  $('btn-dsh-restart')?.addEventListener('click', () => controlDsh('restart'))
@@ -5817,8 +6797,7 @@ async function boot() {
5817
6797
  await maybeWarnAppBehindGateway({ probe: true })
5818
6798
  openStreams()
5819
6799
  await refreshAll()
5820
- const host = await safeRpc('host.describe', {}, '')
5821
- if (host) { state.hostInfo = host; $('host-desc').textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions }) }
6800
+ await refreshHostDescription()
5822
6801
  loadDshControl()
5823
6802
  }
5824
6803
  // 网关从中央 HTTPS 公告源读取并在不可达时回退内置文件。前台每 30 秒检查,