dsh-remote-plugin 0.6.15 → 0.6.16

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: {
@@ -91,6 +93,9 @@ const state = {
91
93
  fs: { path: null, initial: null, loaded: false, upload: null, workspaceId: LS.get('fsWorkspaceIdV1', ''), 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: [],
@@ -1357,6 +1362,23 @@ async function refreshSessions() {
1357
1362
  refreshWorkbench()
1358
1363
  }
1359
1364
 
1365
+ function removeLocalSessionRecord(sessionId) {
1366
+ if (!sessionId) return
1367
+ state.sessions = state.sessions.filter(session => session?.sessionId !== sessionId)
1368
+ state.byId.delete(sessionId)
1369
+ state.pendingProjections.delete(sessionId)
1370
+ state.sessionActivity.delete(sessionId)
1371
+ state.pendingPrompts.delete(sessionId)
1372
+ delete state.queues[sessionId]
1373
+ delete state.jobs[sessionId]
1374
+ const historyCache = readHistoryCache()
1375
+ if (Object.prototype.hasOwnProperty.call(historyCache, sessionId)) {
1376
+ delete historyCache[sessionId]
1377
+ writeHistoryCache(historyCache)
1378
+ }
1379
+ cacheWrite(CACHE.sessions, state.sessions.slice(0, 80))
1380
+ }
1381
+
1360
1382
  function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
1361
1383
  function hydrateSessionProjections(sessionId, projections) {
1362
1384
  const s = state.byId.get(sessionId)
@@ -1849,17 +1871,49 @@ async function openSession(id) {
1849
1871
  refreshSessions()
1850
1872
  }
1851
1873
 
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')
1874
+ function sessionHasPendingActivity(sessionId, session) {
1875
+ return !!session?.running
1876
+ || state.sessionActivity.has(sessionId)
1877
+ || state.pendingPrompts.has(sessionId)
1878
+ || (state.queues[sessionId] || []).some(item => item?.placement !== 'context')
1879
+ }
1880
+
1881
+ async function shouldDiscardEmptySession(sessionId) {
1882
+ const session = state.byId.get(sessionId)
1883
+ if (!session || sessionHasPendingActivity(sessionId, session)) return false
1884
+ const deadline = Date.now() + 3500
1885
+ while (state.current === sessionId && state.history.loading && Date.now() < deadline) {
1886
+ await new Promise(resolve => setTimeout(resolve, 25))
1887
+ }
1888
+ if (state.current !== sessionId || sessionHasPendingActivity(sessionId, session)) return false
1889
+ return isEmptySessionHistory(state.history)
1890
+ }
1891
+
1892
+ async function closeSession() {
1893
+ if (closeSession.pending) return closeSession.pending
1894
+ const sessionId = state.current
1895
+ if (!sessionId) return
1896
+ const task = (async () => {
1897
+ const discard = await shouldDiscardEmptySession(sessionId)
1898
+ if (state.current !== sessionId) return
1899
+ state.current = null
1900
+ if (discard) removeLocalSessionRecord(sessionId)
1901
+ setComposerFullscreen(false)
1902
+ clearComposerImages()
1903
+ setSessionRecovery('idle')
1904
+ $('btn-rename-session').classList.add('hidden')
1905
+ $('btn-archive-session').classList.add('hidden')
1906
+ state.history = emptyHistory()
1907
+ document.body.classList.remove('in-session')
1908
+ hideComposerMenu()
1909
+ renderSessions()
1910
+ renderWorkbench()
1911
+ showView('view-home')
1912
+ })()
1913
+ closeSession.pending = task
1914
+ try { await task } finally {
1915
+ if (closeSession.pending === task) closeSession.pending = null
1916
+ }
1863
1917
  }
1864
1918
 
1865
1919
  /* Android 手势返回/实体返回: 注册后系统不再直接杀 App, 由这里接管导航 */
@@ -1871,7 +1925,7 @@ function bindNativeBack() {
1871
1925
  if (customSelectCurrent) { closeCustomSelect(); return }
1872
1926
  const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
1873
1927
  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 } // 会话页 → 回主页
1928
+ if (document.body.classList.contains('in-session')) { void closeSession(); return } // 会话页 → 回主页
1875
1929
  if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
1876
1930
  if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
1877
1931
  showView('view-home'); return
@@ -1922,11 +1976,19 @@ const HISTORY_MAX_VISIBLE = 5000 // 已加载的可显示事件上限(消息/
1922
1976
  function emptyHistory() {
1923
1977
  return {
1924
1978
  visible: [], seqs: new Set(), minSeq: Infinity,
1925
- hasMore: false, loading: false, renderStart: 0, renderEnd: 0,
1979
+ hasMore: false, loading: false, loaded: false, renderStart: 0, renderEnd: 0,
1926
1980
  partialReasoning: new Map()
1927
1981
  }
1928
1982
  }
1929
1983
 
1984
+ function sessionHistoryHasContent(history) {
1985
+ return !!history && ((history.visible?.length || 0) > 0 || (history.partialReasoning?.size || 0) > 0)
1986
+ }
1987
+
1988
+ function isEmptySessionHistory(history) {
1989
+ return history?.loaded === true && !sessionHistoryHasContent(history)
1990
+ }
1991
+
1930
1992
  function reasoningStreamKey(data, index) {
1931
1993
  return `${data?.turn ?? '?'}:${data?.step ?? '?'}:${index ?? '?'}`
1932
1994
  }
@@ -2042,6 +2104,7 @@ function restoreCachedHistory() {
2042
2104
  h.visible.push(e)
2043
2105
  }
2044
2106
  h.visible.sort((a, b) => a.seq - b.seq)
2107
+ h.loaded = true
2045
2108
  state.history = h
2046
2109
  $('history-hint').textContent = t('history.offlineCache', { n: h.visible.length })
2047
2110
  renderHistory(true)
@@ -2051,7 +2114,8 @@ function restoreCachedHistory() {
2051
2114
  async function loadHistory(reset) {
2052
2115
  const id = state.current
2053
2116
  if (!id || state.history.loading) return
2054
- state.history.loading = true
2117
+ const history = state.history
2118
+ history.loading = true
2055
2119
  if (reset) setSessionRecovery('loading')
2056
2120
  const moreBtn = $('history-more')
2057
2121
  if (moreBtn) moreBtn.classList.add('hidden')
@@ -2062,7 +2126,8 @@ async function loadHistory(reset) {
2062
2126
  try {
2063
2127
  v = await rpc('session.history', payload)
2064
2128
  } catch (e) {
2065
- state.history.loading = false
2129
+ if (state.current !== id || state.history !== history) return
2130
+ history.loading = false
2066
2131
  if (e.message === 'AUTH') { authFailure(); return }
2067
2132
  if (restoreCachedHistory()) {
2068
2133
  setSessionRecovery('cached', e.message)
@@ -2082,7 +2147,9 @@ async function loadHistory(reset) {
2082
2147
  return
2083
2148
  }
2084
2149
 
2150
+ if (state.current !== id || state.history !== history) return
2085
2151
  hydrateSessionProjections(id, v.projections)
2152
+ history.loaded = true
2086
2153
  const incoming = v.events || []
2087
2154
  let added = 0
2088
2155
  if (reset) state.history.partialReasoning.clear()
@@ -2103,7 +2170,7 @@ async function loadHistory(reset) {
2103
2170
  state.history.visible.sort((a, b) => a.seq - b.seq)
2104
2171
  trimVisible()
2105
2172
  state.history.hasMore = !!v.hasMore
2106
- state.history.loading = false
2173
+ history.loading = false
2107
2174
  setSessionRecovery('ready')
2108
2175
  renderSessionTitle(); renderSessionSub(); renderSessionCards()
2109
2176
  try {
@@ -2601,33 +2668,48 @@ async function sendSessionText(text) {
2601
2668
  async function sendSessionContent(text, images) {
2602
2669
  const clean = String(text || '').trim()
2603
2670
  if ((!clean && !images.length) || !state.current) return false
2604
- if (images.length === 0 && clean && await runSlashCommand(clean)) return true
2671
+ const sessionId = state.current
2672
+ if (images.length === 0 && clean && await runSlashCommand(clean)) {
2673
+ state.sessionActivity.add(sessionId)
2674
+ return true
2675
+ }
2605
2676
  const buttons = [$('btn-send'), $('btn-fs-send')].filter(Boolean)
2606
2677
  buttons.forEach(button => { button.disabled = true })
2678
+ state.pendingPrompts.add(sessionId)
2607
2679
  try {
2608
2680
  const content = [...await encodeComposerImagesFor(images)]
2609
2681
  if (clean) content.push({ type: 'text', text: clean })
2610
2682
  setSessionRecovery('resuming')
2611
2683
  const v = await safeRpc('session.prompt', {
2612
- sessionId: state.current,
2684
+ sessionId,
2613
2685
  mode: 'queue',
2614
2686
  content
2615
2687
  }, t('send.failed'))
2616
2688
  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')
2689
+ state.sessionActivity.add(sessionId)
2690
+ if (state.current === sessionId) {
2691
+ setSessionRecovery('ready')
2692
+ noteSessionTurnTime(sessionId, Date.now())
2693
+ renderSessions()
2694
+ toast(images.length ? t('send.imageSent') : (clean.startsWith('/') ? t('send.commandSent') : t('send.sent')), 'ok')
2695
+ }
2696
+ return true
2697
+ }
2698
+ if (v?.command?.text) {
2699
+ state.sessionActivity.add(sessionId)
2700
+ if (state.current === sessionId) toast(t('send.commandExecuted'), 'ok')
2621
2701
  return true
2622
2702
  }
2623
- if (v?.command?.text) { toast(t('send.commandExecuted'), 'ok'); return true }
2624
- setSessionRecovery('error')
2703
+ if (state.current === sessionId) setSessionRecovery('error')
2625
2704
  return false
2626
2705
  } catch (e) {
2627
- setSessionRecovery('error', e?.message)
2628
- toast(t('composer.imageReadFailed', { msg: e?.message || e }), 'err')
2706
+ if (state.current === sessionId) {
2707
+ setSessionRecovery('error', e?.message)
2708
+ toast(t('composer.imageReadFailed', { msg: e?.message || e }), 'err')
2709
+ }
2629
2710
  return false
2630
2711
  } finally {
2712
+ state.pendingPrompts.delete(sessionId)
2631
2713
  buttons.forEach(button => { button.disabled = false })
2632
2714
  }
2633
2715
  }
@@ -2890,7 +2972,7 @@ async function confirmArchiveSession() {
2890
2972
  closeArchiveConfirm()
2891
2973
  toast(t('session.archived'), 'ok')
2892
2974
  await refreshSessions()
2893
- if (state.current === sessionId) closeSession()
2975
+ if (state.current === sessionId) void closeSession()
2894
2976
  } finally {
2895
2977
  button.disabled = false
2896
2978
  }
@@ -4611,6 +4693,716 @@ async function restorePeakReminders() {
4611
4693
  if (peakRemindOn() && legacyCleaned) await schedulePeakReminders({ legacyCleaned: true })
4612
4694
  }
4613
4695
 
4696
+ /* ---------------- 功能测试 / Android ASR ---------------- */
4697
+ function asrTestBridge() { return window.NativeAsrTest }
4698
+
4699
+ function emptyAsrTest() {
4700
+ return { running: false, status: 'idle', meta: null, summary: null, events: [], lastError: '' }
4701
+ }
4702
+
4703
+ function asrTestEvent(event) {
4704
+ if (!event || typeof event !== 'object') return
4705
+ const current = state.asrTest
4706
+ const data = event.data && typeof event.data === 'object' ? event.data : {}
4707
+ if (event.type === 'meta') current.meta = data
4708
+ if (event.type === 'summary') {
4709
+ current.summary = data
4710
+ current.running = false
4711
+ }
4712
+ if (event.type === 'status') {
4713
+ current.status = String(data.status || 'unknown')
4714
+ if (current.status === 'listening' || current.status === 'starting' || current.status === 'restarting') current.running = true
4715
+ if (['stopped', 'unsupported', 'permission-denied'].includes(current.status)) current.running = false
4716
+ }
4717
+ if (event.type === 'error') current.lastError = String(data.name || data.message || 'error')
4718
+ current.events.push({ type: event.type, atMs: Number(event.atMs) || 0, data })
4719
+ if (current.events.length > 500) current.events.splice(0, current.events.length - 500)
4720
+ renderAsrTest()
4721
+ }
4722
+ window.__dshAsrEvent = asrTestEvent
4723
+
4724
+ function asrTestStatusText(status) {
4725
+ const labels = {
4726
+ idle: t('settings.asrTestNativeOnly'),
4727
+ starting: t('settings.asrTestStarted'),
4728
+ listening: t('settings.asrTestStarted'),
4729
+ restarting: t('settings.asrTestRestarting'),
4730
+ 'permission-requesting': t('settings.asrTestPermission'),
4731
+ 'permission-denied': t('settings.asrTestPermissionDenied'),
4732
+ 'permission-error': t('settings.asrTestPermissionError'),
4733
+ unsupported: t('settings.asrTestUnavailable'),
4734
+ busy: t('settings.asrTestBusy'),
4735
+ stopped: t('settings.asrTestStopped')
4736
+ }
4737
+ return labels[status] || t('settings.asrTestStatus', { status })
4738
+ }
4739
+
4740
+ function asrTestLogLines() {
4741
+ const current = state.asrTest
4742
+ const lines = []
4743
+ for (const event of current.events) {
4744
+ const data = event.data || {}
4745
+ const at = `${event.atMs}ms`
4746
+ if (event.type === 'meta') {
4747
+ lines.push(`[${at}] meta brand=${data.brand || '—'} manufacturer=${data.manufacturer || '—'} model=${data.model || '—'} Android=${data.androidVersion || '—'} API=${data.apiLevel || '—'}`)
4748
+ lines.push(`[${at}] recordAudioPermission=${data.recordAudioPermission ?? 'unknown'} recordAudioAppOp=${data.recordAudioAppOp || 'unknown'} microphoneMuted=${data.microphoneMuted ?? 'unknown'}`)
4749
+ lines.push(`[${at}] recognitionAvailable=${data.recognitionAvailable === true} onDeviceAvailable=${data.onDeviceAvailable === true} path=${data.networkPath || '—'}`)
4750
+ for (const service of data.recognitionServices || []) lines.push(`[${at}] service ${service.packageName || '—'} / ${service.serviceName || '—'} xiaomiLike=${service.xiaomiLike === true}`)
4751
+ } else if (event.type === 'status') {
4752
+ lines.push(`[${at}] status=${data.status || '—'} session=${data.session ?? '—'} reason=${data.reason || '—'} ${data.message || ''}`.trim())
4753
+ } else if (event.type === 'partial' || event.type === 'final') {
4754
+ lines.push(`[${at}] ${event.type}#${data.count ?? '—'} session=${data.session ?? '—'} +${data.elapsedMs ?? '—'}ms: ${data.text || '(empty)'}`)
4755
+ } else if (event.type === 'callback') {
4756
+ lines.push(`[${at}] callback=${data.name || '—'} session=${data.session ?? '—'} +${data.elapsedMs ?? '—'}ms${data.bytes >= 0 ? ` bytes=${data.bytes}` : ''}`)
4757
+ } else if (event.type === 'error') {
4758
+ lines.push(`[${at}] error=${data.name || '—'} code=${data.code ?? '—'} session=${data.session ?? '—'} ${data.message || ''}`.trim())
4759
+ } else if (event.type === 'summary') {
4760
+ 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 ?? '—'}`)
4761
+ }
4762
+ }
4763
+ return lines
4764
+ }
4765
+
4766
+ function asrTestReport() {
4767
+ const current = state.asrTest
4768
+ const meta = current.meta || {}
4769
+ const summary = current.summary || {}
4770
+ const lines = [
4771
+ 'DSH Remote Android ASR 测试报告',
4772
+ `生成时间: ${new Date().toISOString()}`,
4773
+ `设备: ${meta.brand || '—'} / ${meta.manufacturer || '—'} / ${meta.model || '—'}`,
4774
+ `Android: ${meta.androidVersion || '—'} (API ${meta.apiLevel || '—'})`,
4775
+ `识别可用: ${meta.recognitionAvailable === true ? 'yes' : meta.recognitionAvailable === false ? 'no' : 'unknown'}`,
4776
+ `端侧识别可用: ${meta.onDeviceAvailable === true ? 'yes' : meta.onDeviceAvailable === false ? 'no' : 'unknown'}`,
4777
+ `路径: ${meta.networkPath || 'system-default-recognition-service'}`,
4778
+ `测试结束原因: ${summary.reason || current.status || '—'}`,
4779
+ `总时长: ${summary.durationMs ?? '—'}ms`,
4780
+ `session: ${summary.sessionCount ?? '—'} / 重建: ${summary.restartCount ?? '—'} / partial: ${summary.partialCount ?? '—'} / final: ${summary.finalCount ?? '—'} / errors: ${summary.errorCount ?? '—'}`,
4781
+ '',
4782
+ '事件日志:',
4783
+ ...asrTestLogLines()
4784
+ ]
4785
+ return lines.join('\n')
4786
+ }
4787
+
4788
+ function renderAsrTest() {
4789
+ const start = $('btn-asr-test-start')
4790
+ const stop = $('btn-asr-test-stop')
4791
+ const copy = $('btn-asr-test-copy')
4792
+ const permission = $('btn-asr-test-permission')
4793
+ const engine = $('btn-asr-test-engine')
4794
+ const status = $('asr-test-status')
4795
+ const summary = $('asr-test-summary')
4796
+ const log = $('asr-test-log')
4797
+ if (!start || !stop || !copy || !permission || !engine || !status || !summary || !log) return
4798
+ const current = state.asrTest
4799
+ const native = !!(CAP?.isNativePlatform?.() && asrTestBridge()?.startAsrTest)
4800
+ start.disabled = current.running || !native
4801
+ stop.disabled = !current.running || !native
4802
+ copy.disabled = !current.events.length
4803
+ const permissionError = current.status === 'permission-error' || current.status === 'permission-denied' || current.summary?.reason === 'permission-error'
4804
+ status.className = 'feature-test-status ' + (permissionError || current.status === 'unsupported' ? 'error' : current.status === 'stopped' ? 'ok' : 'muted')
4805
+ status.textContent = native ? (permissionError ? t('settings.asrTestPermissionError') : asrTestStatusText(current.status)) : t('settings.asrTestWebUnsupported')
4806
+ permission.classList.toggle('hidden', !native || !permissionError)
4807
+ engine.classList.toggle('hidden', !native || !permissionError)
4808
+ const meta = current.meta || {}
4809
+ const s = current.summary
4810
+ summary.textContent = [
4811
+ meta.model ? `${t('settings.asrTestMeta')}: ${meta.brand || '—'} / ${meta.manufacturer || '—'} / ${meta.model}` : '',
4812
+ s ? `${t('settings.asrTestSummary')}: ${t('settings.asrTestStatus', { status: s.reason || 'done' })} · session ${s.sessionCount ?? '—'} · partial ${s.partialCount ?? '—'} · final ${s.finalCount ?? '—'} · error ${s.errorCount ?? '—'}` : ''
4813
+ ].filter(Boolean).join('\n')
4814
+ log.textContent = current.events.length ? asrTestLogLines().join('\n') : t('settings.asrTestLogEmpty')
4815
+ log.scrollTop = log.scrollHeight
4816
+ }
4817
+
4818
+ function clearAsrTest() {
4819
+ if (state.asrTest.running) return toast(t('settings.asrTestBusy'), 'err')
4820
+ state.asrTest = emptyAsrTest()
4821
+ renderAsrTest()
4822
+ }
4823
+
4824
+ async function startAsrTest() {
4825
+ const native = asrTestBridge()
4826
+ if (!CAP?.isNativePlatform?.() || !native?.startAsrTest) return toast(t('settings.asrTestWebUnsupported'), 'err')
4827
+ if (state.asrTest.running) return toast(t('settings.asrTestBusy'), 'err')
4828
+ if (!confirm(t('settings.asrTestConsent'))) return
4829
+ state.asrTest = { ...emptyAsrTest(), running: true, status: 'starting' }
4830
+ renderAsrTest()
4831
+ try {
4832
+ if (native.startAsrTest() === false) throw new Error(t('settings.asrTestUnavailable'))
4833
+ } catch (error) {
4834
+ state.asrTest.running = false
4835
+ state.asrTest.status = 'error'
4836
+ state.asrTest.lastError = error?.message || String(error)
4837
+ renderAsrTest()
4838
+ toast(state.asrTest.lastError, 'err')
4839
+ }
4840
+ }
4841
+
4842
+ function stopAsrTest() {
4843
+ try { asrTestBridge()?.stopAsrTest?.() } catch {}
4844
+ }
4845
+
4846
+ function openAsrPermissionSettings() {
4847
+ try {
4848
+ if (asrTestBridge()?.openAsrPermissionSettings?.() === false) throw new Error('permission settings unavailable')
4849
+ } catch (error) {
4850
+ toast(error?.message || String(error), 'err')
4851
+ }
4852
+ }
4853
+
4854
+ function openAsrEngineSettings() {
4855
+ try {
4856
+ if (asrTestBridge()?.openAsrEngineSettings?.() === false) throw new Error('voice engine settings unavailable')
4857
+ } catch (error) {
4858
+ toast(error?.message || String(error), 'err')
4859
+ }
4860
+ }
4861
+
4862
+ async function copyAsrTestLog() {
4863
+ const ok = await copyText(asrTestReport())
4864
+ toast(t(ok ? 'settings.asrTestCopyOk' : 'settings.asrTestCopyFailed'), ok ? 'ok' : 'err')
4865
+ }
4866
+
4867
+ /* ---------------- 模型设置 ---------------- */
4868
+ const MODEL_SETTINGS_FIELDS = ['baseURL', 'api', 'apiKeyEnv', 'displayName', 'models']
4869
+ const MODEL_REASONING_LIMIT = 12
4870
+ const MODEL_REASONING_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/
4871
+
4872
+ function modelValueAt(value, path = []) {
4873
+ let current = value
4874
+ for (const part of path) {
4875
+ if (current === null || typeof current !== 'object') return undefined
4876
+ current = current[part]
4877
+ }
4878
+ return current
4879
+ }
4880
+
4881
+ function cloneModelValue(value) {
4882
+ if (value === undefined) return undefined
4883
+ try { return structuredClone(value) } catch {}
4884
+ try { return JSON.parse(JSON.stringify(value)) } catch { return value }
4885
+ }
4886
+
4887
+ function modelObjectAt(value, path = []) {
4888
+ const result = modelValueAt(value, path)
4889
+ return result && typeof result === 'object' && !Array.isArray(result) ? cloneModelValue(result) : {}
4890
+ }
4891
+
4892
+ function modelKeyRefFor(provider, namespace, path) {
4893
+ const effective = modelObjectAt(namespace?.value, path)
4894
+ const user = modelObjectAt(namespace?.user, path)
4895
+ const named = typeof user.apiKeyEnv === 'string' && user.apiKeyEnv.trim()
4896
+ ? user.apiKeyEnv.trim()
4897
+ : typeof effective.apiKeyEnv === 'string' && effective.apiKeyEnv.trim()
4898
+ ? effective.apiKeyEnv.trim()
4899
+ : ''
4900
+ if (named) return named
4901
+ if (namespace?.ns === 'llm-deepseek') return 'DEEPSEEK_API_KEY'
4902
+ if (namespace?.ns === 'llm-pi-ai') return provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_') + '_API_KEY'
4903
+ return ''
4904
+ }
4905
+
4906
+ function modelProfileName(row) {
4907
+ const display = String(row.displayName || row.provider || '')
4908
+ return display === row.provider ? display : `${display} (${row.provider})`
4909
+ }
4910
+
4911
+ function modelSettingsNamespace(ns) {
4912
+ return state.modelSettings.namespaces.find(item => item.ns === ns) || null
4913
+ }
4914
+
4915
+ function modelSettingsRow(provider) {
4916
+ return state.modelSettings.providers.find(row => row.provider === provider) || null
4917
+ }
4918
+
4919
+ function modelSettingsPathChanged(before, after, key) {
4920
+ return JSON.stringify(before?.[key]) !== JSON.stringify(after?.[key])
4921
+ }
4922
+
4923
+ function modelCatalogRows(value) {
4924
+ if (!Array.isArray(value)) return []
4925
+ return value
4926
+ .filter(model => model && typeof model === 'object' && !Array.isArray(model))
4927
+ .map(model => cloneModelValue(model))
4928
+ }
4929
+
4930
+ function modelReasoningRows(model) {
4931
+ const reasoning = modelObjectAt(model, ['reasoning'])
4932
+ const raw = Array.isArray(reasoning.efforts) && reasoning.efforts.length
4933
+ ? reasoning.efforts
4934
+ : (Array.isArray(model?.reasoningEfforts) ? model.reasoningEfforts : [])
4935
+ return raw.map(item => {
4936
+ const value = typeof item === 'string' ? { id: item } : modelObjectAt(item)
4937
+ return {
4938
+ id: typeof value.id === 'string' ? value.id.trim() : '',
4939
+ name: typeof value.name === 'string' ? value.name.trim() : '',
4940
+ description: typeof value.description === 'string' ? value.description.trim() : ''
4941
+ }
4942
+ })
4943
+ }
4944
+
4945
+ function withModelReasoning(model, rows, defaultEffort = '') {
4946
+ const next = cloneModelValue(model) || {}
4947
+ delete next.reasoningEfforts
4948
+ const reasoning = modelObjectAt(next, ['reasoning'])
4949
+ const efforts = rows.map(row => {
4950
+ const value = { id: String(row.id || '').trim() }
4951
+ if (String(row.name || '').trim()) value.name = String(row.name).trim()
4952
+ if (String(row.description || '').trim()) value.description = String(row.description).trim()
4953
+ return value
4954
+ })
4955
+ if (efforts.length) {
4956
+ reasoning.efforts = efforts
4957
+ const selected = efforts.some(row => row.id === defaultEffort) ? defaultEffort : ''
4958
+ if (selected) reasoning.defaultEffort = selected
4959
+ else delete reasoning.defaultEffort
4960
+ next.reasoning = reasoning
4961
+ } else {
4962
+ delete reasoning.efforts
4963
+ delete reasoning.defaultEffort
4964
+ if (Object.keys(reasoning).length) next.reasoning = reasoning
4965
+ else delete next.reasoning
4966
+ }
4967
+ return next
4968
+ }
4969
+
4970
+ function modelReasoningError(models) {
4971
+ for (const model of models) {
4972
+ const rows = modelReasoningRows(model)
4973
+ if (rows.length > MODEL_REASONING_LIMIT) return t('settings.modelReasoningLimit', { count: MODEL_REASONING_LIMIT })
4974
+ const ids = new Set()
4975
+ for (const row of rows) {
4976
+ if (!row.id) return t('settings.modelReasoningIdRequired')
4977
+ if (!MODEL_REASONING_ID_RE.test(row.id)) return t('settings.modelReasoningInvalid')
4978
+ if (ids.has(row.id)) return t('settings.modelReasoningDuplicate')
4979
+ ids.add(row.id)
4980
+ }
4981
+ const defaultEffort = modelObjectAt(model, ['reasoning']).defaultEffort
4982
+ if (defaultEffort && !ids.has(defaultEffort)) return t('settings.modelReasoningDefaultInvalid')
4983
+ }
4984
+ return ''
4985
+ }
4986
+
4987
+ async function loadModelSettings(force = false) {
4988
+ if (!state.token) {
4989
+ state.modelSettings = { ...state.modelSettings, status: 'error', error: t('token.notSetHint') }
4990
+ renderModelSettings()
4991
+ return
4992
+ }
4993
+ if (state.modelSettings.status === 'loading') return
4994
+ if (!force && state.modelSettings.status === 'ready') return
4995
+ state.modelSettings = { ...state.modelSettings, status: 'loading', error: '' }
4996
+ renderModelSettings()
4997
+ try {
4998
+ const [providersValue, settingsValue] = await Promise.all([
4999
+ rpc('llm.providers', {}),
5000
+ rpc('settings.describe', {})
5001
+ ])
5002
+ const namespaces = Array.isArray(settingsValue?.namespaces) ? settingsValue.namespaces : []
5003
+ const providers = Array.isArray(providersValue?.providers) ? providersValue.providers : []
5004
+ const rows = providers.map(entry => {
5005
+ const settingsPath = Array.isArray(entry.settingsPath) ? entry.settingsPath : []
5006
+ const namespace = namespaces.find(item => item.ns === entry.settingsNs) || null
5007
+ const effective = modelObjectAt(namespace?.value, settingsPath)
5008
+ const keyRef = modelKeyRefFor(entry.provider, namespace, settingsPath)
5009
+ return {
5010
+ ...entry,
5011
+ settingsPath,
5012
+ keyRef,
5013
+ namespace,
5014
+ configured: namespace !== null && (settingsPath.length === 0 || modelValueAt(namespace.value, settingsPath) !== undefined),
5015
+ effective,
5016
+ credential: null
5017
+ }
5018
+ })
5019
+ const refs = [...new Set(rows.map(row => row.keyRef).filter(Boolean))]
5020
+ let credentials = {}
5021
+ if (refs.length > 0) {
5022
+ const value = await rpc('credentials.describe', { refs })
5023
+ credentials = value?.credentials && typeof value.credentials === 'object' ? value.credentials : {}
5024
+ }
5025
+ state.modelSettings = {
5026
+ status: 'ready',
5027
+ error: '',
5028
+ writable: settingsValue?.writable === true,
5029
+ hasDocument: settingsValue?.hasDocument === true,
5030
+ providers: rows.map(row => ({ ...row, credential: row.keyRef ? credentials[row.keyRef] || null : null })),
5031
+ namespaces,
5032
+ credentials
5033
+ }
5034
+ state.modelEditor = null
5035
+ } catch (error) {
5036
+ state.modelSettings = { ...state.modelSettings, status: 'error', error: error?.message || String(error) }
5037
+ }
5038
+ renderModelSettings()
5039
+ }
5040
+
5041
+ function renderModelSettings() {
5042
+ const status = $('model-settings-status')
5043
+ const list = $('model-settings-list')
5044
+ if (!status || !list) return
5045
+ const current = state.modelSettings
5046
+ if (current.status === 'loading') {
5047
+ status.className = 'model-settings-status muted'
5048
+ status.textContent = t('settings.modelLoading')
5049
+ list.innerHTML = ''
5050
+ return
5051
+ }
5052
+ if (current.status === 'error') {
5053
+ status.className = 'model-settings-status error'
5054
+ status.textContent = t('settings.modelUnavailable', { msg: current.error || t('err.dshError') })
5055
+ list.innerHTML = ''
5056
+ return
5057
+ }
5058
+ if (!current.providers.length) {
5059
+ status.className = 'model-settings-status muted'
5060
+ status.textContent = t('settings.modelEmpty')
5061
+ list.innerHTML = ''
5062
+ return
5063
+ }
5064
+ status.className = 'model-settings-status ' + (current.writable ? 'muted' : 'model-readonly')
5065
+ status.textContent = current.writable ? t('settings.modelIntro') : t('settings.modelReadOnly')
5066
+ list.innerHTML = current.providers.map(renderModelProviderCard).join('')
5067
+ }
5068
+
5069
+ function renderModelProviderCard(row) {
5070
+ const editor = state.modelEditor?.provider === row.provider ? renderModelEditor() : ''
5071
+ const credentialConfigured = row.credential?.configured === true
5072
+ const dot = row.keyRef ? (credentialConfigured ? 'configured' : '') : 'unknown'
5073
+ const stateLabel = row.keyRef
5074
+ ? (credentialConfigured ? t('settings.modelConfigured') : t('settings.modelMissing'))
5075
+ : t('settings.modelConfigured')
5076
+ return `<article class="model-provider-card" data-model-provider-card="${esc(row.provider)}">
5077
+ <div class="model-provider-head">
5078
+ <div class="model-provider-identity">
5079
+ <span class="model-provider-dot ${dot}" title="${esc(stateLabel)}" aria-label="${esc(stateLabel)}"></span>
5080
+ <span class="model-provider-name">${esc(row.displayName || row.provider)}</span>
5081
+ <code class="model-provider-route">${esc(row.provider)}</code>
5082
+ </div>
5083
+ <button class="mini-btn" type="button" data-model-action="edit" data-model-provider="${esc(row.provider)}">${esc(t('settings.modelEdit'))}</button>
5084
+ </div>
5085
+ ${editor}
5086
+ </article>`
5087
+ }
5088
+
5089
+ function renderModelReasoningEditor(model, index, readOnly) {
5090
+ const rows = modelReasoningRows(model)
5091
+ const reasoning = modelObjectAt(model, ['reasoning'])
5092
+ const defaultEffort = typeof reasoning.defaultEffort === 'string' ? reasoning.defaultEffort : ''
5093
+ const effortRows = rows.length
5094
+ ? rows.map((row, effortIndex) => `<div class="model-reasoning-entry">
5095
+ <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' : ''}>
5096
+ <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' : ''}>
5097
+ <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' : ''}>
5098
+ <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>
5099
+ </div>`).join('')
5100
+ : `<div class="model-empty">${esc(t('settings.modelReasoningEmpty'))}</div>`
5101
+ 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('')
5102
+ return `<div class="model-reasoning" data-model-reasoning-editor="${index}">
5103
+ <div class="model-reasoning-head">
5104
+ <div><div class="model-catalog-title">${esc(t('settings.modelReasoning'))}</div><div class="model-catalog-hint">${esc(t('settings.modelReasoningHint'))}</div></div>
5105
+ <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>
5106
+ </div>
5107
+ <div class="model-reasoning-list">${effortRows}</div>
5108
+ <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>
5109
+ </div>`
5110
+ }
5111
+
5112
+ function renderModelEditor() {
5113
+ const editor = state.modelEditor
5114
+ if (!editor) return ''
5115
+ const readOnly = !state.modelSettings.writable || editor.busy
5116
+ const keyPlaceholder = editor.keyConfigured && !editor.clearKey
5117
+ ? t('settings.modelApiKeyStored')
5118
+ : t('settings.modelApiKeyPlaceholder')
5119
+ const models = editor.models || []
5120
+ const modelList = models.length
5121
+ ? models.map((model, index) => `<div class="model-entry-card">
5122
+ <div class="model-entry">
5123
+ <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' : ''}>
5124
+ <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' : ''}>
5125
+ <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>
5126
+ </div>
5127
+ ${renderModelReasoningEditor(model, index, readOnly)}
5128
+ </div>`).join('')
5129
+ : `<div class="model-empty">${esc(t('settings.modelNoModels'))}</div>`
5130
+ const discovery = editor.discovered?.length
5131
+ ? `<div class="model-discovery">
5132
+ <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>
5133
+ <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>
5134
+ <button class="mini-btn" type="button" data-model-action="add-selected" ${readOnly ? 'disabled' : ''}>${esc(t('settings.modelAddSelected'))}</button>
5135
+ </div>`
5136
+ : ''
5137
+ const effectText = editor.applies === 'restart' ? t('settings.modelRestart') : t('settings.modelLive')
5138
+ return `<div class="model-editor">
5139
+ <div class="model-editor-title"><strong>${esc(editor.displayName || editor.provider)}</strong><code>${esc(editor.provider)}</code></div>
5140
+ <div class="model-field">
5141
+ <label for="model-api-key-${esc(editor.provider)}">${esc(t('settings.modelApiKey'))}</label>
5142
+ <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' : ''}>
5143
+ ${editor.keyConfigured && editor.keyWritable !== false ? `<button class="mini-btn" type="button" data-model-action="clear-key" ${readOnly ? 'disabled' : ''}>${esc(t('settings.modelClearKey'))}</button>` : ''}
5144
+ </div>
5145
+ <div class="model-inline">
5146
+ <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>
5147
+ ${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>` : ''}
5148
+ </div>
5149
+ <div class="model-catalog">
5150
+ <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>
5151
+ <div class="model-list">${modelList}</div>
5152
+ ${discovery}
5153
+ </div>
5154
+ ${editor.error ? `<p class="model-editor-error">${esc(editor.error)}</p>` : ''}
5155
+ <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>
5156
+ </div>`
5157
+ }
5158
+
5159
+ function openModelEditor(provider) {
5160
+ const row = modelSettingsRow(provider)
5161
+ const namespace = row?.namespace
5162
+ if (!row || !namespace) return
5163
+ const effective = modelObjectAt(namespace.value, row.settingsPath)
5164
+ const user = modelObjectAt(namespace.user, row.settingsPath)
5165
+ state.modelEditor = {
5166
+ provider: row.provider,
5167
+ displayName: row.displayName,
5168
+ settingsNs: row.settingsNs,
5169
+ settingsPath: row.settingsPath,
5170
+ namespace,
5171
+ userProfile: user,
5172
+ effectiveProfile: effective,
5173
+ keyRef: row.keyRef || '',
5174
+ keyConfigured: row.credential?.configured === true,
5175
+ keyWritable: row.credential?.writable !== false,
5176
+ baseURL: typeof (user.baseURL ?? effective.baseURL) === 'string' ? (user.baseURL ?? effective.baseURL) : '',
5177
+ initialBaseURL: typeof user.baseURL === 'string' ? user.baseURL : '',
5178
+ api: typeof (user.api ?? effective.api) === 'string' ? (user.api ?? effective.api) : '',
5179
+ initialApi: typeof user.api === 'string' ? user.api : '',
5180
+ models: modelCatalogRows(user.models ?? effective.models),
5181
+ modelsDirty: false,
5182
+ baseURLDirty: false,
5183
+ apiDirty: false,
5184
+ keyDraft: '',
5185
+ clearKey: false,
5186
+ discovered: [],
5187
+ discoverySelected: new Set(),
5188
+ applies: namespace.applies,
5189
+ busy: false,
5190
+ error: ''
5191
+ }
5192
+ renderModelSettings()
5193
+ }
5194
+
5195
+ function collectModelEditorForm() {
5196
+ const editor = state.modelEditor
5197
+ const root = $('model-settings-list')
5198
+ if (!editor || !root) return
5199
+ const base = root.querySelector('[data-model-field="baseURL"]')
5200
+ const api = root.querySelector('[data-model-field="api"]')
5201
+ const key = root.querySelector('[data-model-field="apiKey"]')
5202
+ if (base) editor.baseURL = base.value.trim()
5203
+ if (api) editor.api = api.value.trim()
5204
+ if (key) editor.keyDraft = key.value
5205
+ root.querySelectorAll('[data-model-field="model-id"]').forEach(input => {
5206
+ const index = Number(input.dataset.modelIndex)
5207
+ if (editor.models[index]) editor.models[index].id = input.value.trim()
5208
+ })
5209
+ root.querySelectorAll('[data-model-field="model-name"]').forEach(input => {
5210
+ const index = Number(input.dataset.modelIndex)
5211
+ if (editor.models[index]) {
5212
+ const value = input.value.trim()
5213
+ if (value) editor.models[index].name = value
5214
+ else delete editor.models[index].name
5215
+ }
5216
+ })
5217
+ editor.models.forEach((model, index) => {
5218
+ const section = root.querySelector(`[data-model-reasoning-editor="${index}"]`)
5219
+ if (!section) return
5220
+ const rows = [...section.querySelectorAll('[data-model-field="reasoning-id"]')].map((input, effortIndex) => ({
5221
+ id: input.value.trim(),
5222
+ name: section.querySelector(`[data-model-field="reasoning-name"][data-reasoning-index="${effortIndex}"]`)?.value.trim() || '',
5223
+ description: section.querySelector(`[data-model-field="reasoning-description"][data-reasoning-index="${effortIndex}"]`)?.value.trim() || ''
5224
+ }))
5225
+ const defaultEffort = section.querySelector('[data-model-field="reasoning-default"]')?.value || ''
5226
+ editor.models[index] = withModelReasoning(model, rows, defaultEffort)
5227
+ })
5228
+ }
5229
+
5230
+ function setModelEditorError(message) {
5231
+ if (!state.modelEditor) return
5232
+ state.modelEditor.error = message || ''
5233
+ renderModelSettings()
5234
+ }
5235
+
5236
+ async function discoverModelSettings() {
5237
+ const editor = state.modelEditor
5238
+ if (!editor || editor.busy) return
5239
+ collectModelEditorForm()
5240
+ editor.busy = true
5241
+ editor.error = ''
5242
+ renderModelSettings()
5243
+ try {
5244
+ const payload = { settingsNs: editor.settingsNs }
5245
+ if (editor.provider) payload.provider = editor.provider
5246
+ if (editor.baseURL) payload.baseURL = editor.baseURL
5247
+ if (editor.api) payload.api = editor.api
5248
+ if (editor.keyDraft.trim()) payload.apiKey = editor.keyDraft.trim()
5249
+ const value = await rpc('llm.discoverModels', payload)
5250
+ const found = Array.isArray(value?.models) ? value.models.filter(model => model && typeof model.id === 'string' && model.id.trim()) : []
5251
+ if (!found.length) throw new Error(t('settings.modelFetchEmpty'))
5252
+ const known = new Set(editor.models.map(model => model.id))
5253
+ editor.discovered = found
5254
+ editor.discoverySelected = new Set(found.filter(model => !known.has(model.id)).map(model => model.id))
5255
+ } catch (error) {
5256
+ editor.error = t('settings.modelFetchFailed', { msg: error?.message || String(error) })
5257
+ } finally {
5258
+ editor.busy = false
5259
+ }
5260
+ renderModelSettings()
5261
+ }
5262
+
5263
+ function addDiscoveredModels() {
5264
+ const editor = state.modelEditor
5265
+ if (!editor) return
5266
+ collectModelEditorForm()
5267
+ const known = new Set(editor.models.map(model => model.id))
5268
+ for (const candidate of editor.discovered || []) {
5269
+ if (!editor.discoverySelected.has(candidate.id) || known.has(candidate.id)) continue
5270
+ editor.models.push({ id: candidate.id, ...(candidate.name ? { name: candidate.name } : {}), ...(candidate.contextWindow ? { contextWindow: candidate.contextWindow } : {}), ...(candidate.maxTokens ? { maxTokens: candidate.maxTokens } : {}) })
5271
+ known.add(candidate.id)
5272
+ }
5273
+ editor.modelsDirty = true
5274
+ editor.discovered = []
5275
+ editor.discoverySelected = new Set()
5276
+ renderModelSettings()
5277
+ }
5278
+
5279
+ async function saveModelEditor() {
5280
+ const editor = state.modelEditor
5281
+ if (!editor || editor.busy) return
5282
+ collectModelEditorForm()
5283
+ if (!state.modelSettings.writable) return setModelEditorError(t('settings.modelReadOnly'))
5284
+ const models = editor.models || []
5285
+ if (editor.modelsDirty && models.some(model => !String(model.id || '').trim())) return setModelEditorError(t('settings.modelIdRequired'))
5286
+ const reasoningError = editor.modelsDirty ? modelReasoningError(models) : ''
5287
+ if (reasoningError) return setModelEditorError(reasoningError)
5288
+ if (editor.keyDraft.trim() && !editor.keyRef) return setModelEditorError(t('settings.modelSaveFailed', { msg: t('settings.modelApiKey') }))
5289
+ editor.busy = true
5290
+ editor.error = ''
5291
+ renderModelSettings()
5292
+ try {
5293
+ const before = editor.userProfile || {}
5294
+ const after = { ...before }
5295
+ if (editor.baseURLDirty) {
5296
+ if (editor.baseURL) after.baseURL = editor.baseURL
5297
+ else delete after.baseURL
5298
+ }
5299
+ if (editor.apiDirty) {
5300
+ if (editor.api) after.api = editor.api
5301
+ else delete after.api
5302
+ }
5303
+ if (editor.modelsDirty) after.models = models.map(model => cloneModelValue(model))
5304
+ if (editor.settingsNs === 'llm-pi-ai' && editor.keyDraft.trim() && !after.apiKeyEnv) after.apiKeyEnv = editor.keyRef
5305
+ const ops = MODEL_SETTINGS_FIELDS.flatMap(key => {
5306
+ if (!modelSettingsPathChanged(before, after, key)) return []
5307
+ const path = [...editor.settingsPath, key]
5308
+ return after[key] === undefined ? [{ op: 'unset', path }] : [{ op: 'set', path, value: after[key] }]
5309
+ })
5310
+ if (ops.length) {
5311
+ const value = await rpc('settings.mutate', { ns: editor.settingsNs, ops, expectedRevision: editor.namespace.revision })
5312
+ editor.namespace = value
5313
+ }
5314
+ if (editor.keyDraft.trim()) {
5315
+ await rpc('credentials.set', { ref: editor.keyRef, value: editor.keyDraft.trim() })
5316
+ } else if (editor.clearKey && editor.keyConfigured && editor.keyRef) {
5317
+ await rpc('credentials.unset', { ref: editor.keyRef })
5318
+ }
5319
+ state.modelEditor = null
5320
+ await loadModelSettings(true)
5321
+ toast(t('settings.modelSaved'), 'ok')
5322
+ } catch (error) {
5323
+ editor.error = t('settings.modelSaveFailed', { msg: error?.message || String(error) })
5324
+ } finally {
5325
+ if (state.modelEditor === editor) {
5326
+ editor.busy = false
5327
+ renderModelSettings()
5328
+ }
5329
+ }
5330
+ }
5331
+
5332
+ function handleModelSettingsClick(event) {
5333
+ const action = event.target.closest('[data-model-action]')
5334
+ if (!action) return
5335
+ const type = action.dataset.modelAction
5336
+ if (type === 'edit') return openModelEditor(action.dataset.modelProvider)
5337
+ if (type === 'cancel') { state.modelEditor = null; renderModelSettings(); return }
5338
+ if (type === 'save') return void saveModelEditor()
5339
+ const editor = state.modelEditor
5340
+ if (!editor) return
5341
+ if (type === 'add-model') {
5342
+ collectModelEditorForm(); editor.models.push({ id: '' }); editor.modelsDirty = true; renderModelSettings(); return
5343
+ }
5344
+ if (type === 'remove-model') {
5345
+ collectModelEditorForm(); editor.models.splice(Number(action.dataset.modelIndex), 1); editor.modelsDirty = true; renderModelSettings(); return
5346
+ }
5347
+ if (type === 'add-reasoning') {
5348
+ collectModelEditorForm()
5349
+ const index = Number(action.dataset.modelIndex)
5350
+ const model = editor.models[index]
5351
+ if (!model) return
5352
+ const rows = modelReasoningRows(model)
5353
+ if (rows.length >= MODEL_REASONING_LIMIT) return setModelEditorError(t('settings.modelReasoningLimit', { count: MODEL_REASONING_LIMIT }))
5354
+ rows.push({ id: '', name: '', description: '' })
5355
+ editor.models[index] = withModelReasoning(model, rows, modelObjectAt(model, ['reasoning']).defaultEffort || '')
5356
+ editor.modelsDirty = true
5357
+ renderModelSettings()
5358
+ return
5359
+ }
5360
+ if (type === 'remove-reasoning') {
5361
+ collectModelEditorForm()
5362
+ const index = Number(action.dataset.modelIndex)
5363
+ const effortIndex = Number(action.dataset.reasoningIndex)
5364
+ const model = editor.models[index]
5365
+ if (!model) return
5366
+ const rows = modelReasoningRows(model)
5367
+ rows.splice(effortIndex, 1)
5368
+ editor.models[index] = withModelReasoning(model, rows, modelObjectAt(model, ['reasoning']).defaultEffort || '')
5369
+ editor.modelsDirty = true
5370
+ renderModelSettings()
5371
+ return
5372
+ }
5373
+ if (type === 'clear-reasoning') {
5374
+ collectModelEditorForm()
5375
+ const index = Number(action.dataset.modelIndex)
5376
+ const model = editor.models[index]
5377
+ if (!model) return
5378
+ editor.models[index] = withModelReasoning(model, [], '')
5379
+ editor.modelsDirty = true
5380
+ renderModelSettings()
5381
+ return
5382
+ }
5383
+ if (type === 'clear-key') { collectModelEditorForm(); editor.keyDraft = ''; editor.clearKey = true; renderModelSettings(); return }
5384
+ if (type === 'discover') return void discoverModelSettings()
5385
+ if (type === 'add-selected') return addDiscoveredModels()
5386
+ if (type === 'select-all') {
5387
+ const ids = (editor.discovered || []).map(model => model.id)
5388
+ editor.discoverySelected = editor.discoverySelected.size === ids.length ? new Set() : new Set(ids)
5389
+ renderModelSettings()
5390
+ }
5391
+ }
5392
+
5393
+ function handleModelSettingsInput(event) {
5394
+ const editor = state.modelEditor
5395
+ if (!editor) return
5396
+ const field = event.target.dataset.modelField
5397
+ if (field === 'baseURL') editor.baseURLDirty = true
5398
+ if (field === 'api') editor.apiDirty = true
5399
+ if (field === 'model-id' || field === 'model-name' || field?.startsWith('reasoning-')) editor.modelsDirty = true
5400
+ }
5401
+
5402
+ async function openModelConfigDocument() {
5403
+ const value = await safeRpc('settings.openDocument', {}, t('settings.modelOpenConfigFailed'))
5404
+ if (value?.opened) toast(t('settings.modelOpenedConfig'), 'ok')
5405
+ }
4614
5406
  /* ---------------- 视图切换 ---------------- */
4615
5407
  function showView(id) {
4616
5408
  for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-stats', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
@@ -4627,7 +5419,7 @@ function showView(id) {
4627
5419
  if (id === 'view-settings') showSettingsHome()
4628
5420
  }
4629
5421
 
4630
- const SETTINGS_GROUPS = ['general', 'servers', 'notify', 'theme', 'about']
5422
+ const SETTINGS_GROUPS = ['general', 'model', 'tests', 'servers', 'notify', 'theme', 'about']
4631
5423
  function showSettingsHome() {
4632
5424
  const home = $('settings-home')
4633
5425
  if (!home) return
@@ -4641,6 +5433,7 @@ function showSettingsPage(name) {
4641
5433
  home.classList.add('hidden')
4642
5434
  for (const g of SETTINGS_GROUPS) $('settings-page-' + g)?.classList.toggle('hidden', g !== name)
4643
5435
  window.scrollTo(0, 0)
5436
+ if (name === 'model') void loadModelSettings()
4644
5437
  }
4645
5438
 
4646
5439
  function updateConn() {
@@ -4765,19 +5558,24 @@ function bindComposerFullscreenGesture() {
4765
5558
  }
4766
5559
 
4767
5560
  /* ---------------- 初始化 ---------------- */
4768
- /** 解析 dshremote://pair?token=..&server=.. 配对二维码 */
5561
+ /** 解析 dshremote://pair?token=..&server=.. 配对二维码;server 可重复以携带多个主机地址。 */
4769
5562
  function applyPairUrl(url) {
4770
5563
  try {
4771
5564
  const u = new URL(String(url).trim())
4772
5565
  if (u.protocol !== 'dshremote:' || u.hostname !== 'pair') return false
4773
5566
  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
5567
+ const servers = [...new Set(u.searchParams.getAll('server')
5568
+ .map(value => value.trim().replace(/\/+$/, ''))
5569
+ .filter(value => /^https?:\/\//i.test(value)))]
5570
+ if (!tok || !servers.length) return false
4776
5571
  state.token = tok
4777
5572
  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 })
5573
+ state.server = servers[0]
5574
+ for (let i = servers.length - 1; i >= 0; i--) {
5575
+ const server = servers[i]
5576
+ if (!state.servers.some(s => s.url === server)) {
5577
+ state.servers.unshift({ id: newServerId(), url: server, note: '', group: state.activeGroup })
5578
+ }
4781
5579
  }
4782
5580
  saveServers()
4783
5581
  renderServers()
@@ -5091,6 +5889,10 @@ function dshControlFailureText(value) {
5091
5889
  INVALID_SERVICE: 'settings.dshErrorInvalidService',
5092
5890
  SYSTEMCTL_NOT_FOUND: 'settings.dshErrorSystemctlNotFound',
5093
5891
  SYSTEMD_UNAVAILABLE: 'settings.dshErrorSystemdUnavailable',
5892
+ SERVICE_CONTROL_NOT_FOUND: 'settings.dshErrorServiceControlNotFound',
5893
+ SERVICE_DISABLED: 'settings.dshErrorServiceDisabled',
5894
+ SERVICE_STOP_TIMEOUT: 'settings.dshErrorServiceStopTimeout',
5895
+ STATUS_PARSE_FAILED: 'settings.dshErrorStatusParseFailed',
5094
5896
  PERMISSION_DENIED: 'settings.dshErrorPermissionDenied',
5095
5897
  COMMAND_TIMEOUT: 'settings.dshErrorCommandTimeout',
5096
5898
  COMMAND_FAILED: 'settings.dshErrorCommandFailed',
@@ -5325,6 +6127,8 @@ function bindUi() {
5325
6127
  renderAnnouncementBoard()
5326
6128
  renderPending(); renderQueue(); renderJobs()
5327
6129
  updateConn()
6130
+ if (state.modelSettings.status === 'ready' || state.modelSettings.status === 'error' || state.modelSettings.status === 'loading') renderModelSettings()
6131
+ renderAsrTest()
5328
6132
  if (state.current) { renderSessionTitle(); renderSessionSub(); renderSessionCards(); renderHistory(true) }
5329
6133
  else renderModelMenu()
5330
6134
  loadLocalVersion()
@@ -5418,7 +6222,7 @@ function bindUi() {
5418
6222
  const session = e.target.closest('[data-wb-session]')
5419
6223
  if (session) openSession(session.dataset.wbSession)
5420
6224
  })
5421
- $('btn-back').addEventListener('click', closeSession)
6225
+ $('btn-back').addEventListener('click', () => { void closeSession() })
5422
6226
  $('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
5423
6227
  $('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
5424
6228
  $('btn-refresh').addEventListener('click', () => { toast(t('common.refreshing')); openStreams(); refreshAll() })
@@ -5625,6 +6429,17 @@ function bindUi() {
5625
6429
  if (group) { showSettingsPage(group.dataset.settingsGroup); return }
5626
6430
  if (e.target.closest('[data-settings-back]')) { showSettingsHome(); return }
5627
6431
  })
6432
+ $('btn-model-settings-refresh')?.addEventListener('click', () => loadModelSettings(true))
6433
+ $('btn-model-settings-open')?.addEventListener('click', openModelConfigDocument)
6434
+ $('model-settings-list')?.addEventListener('click', handleModelSettingsClick)
6435
+ $('model-settings-list')?.addEventListener('input', handleModelSettingsInput)
6436
+ $('btn-asr-test-start')?.addEventListener('click', startAsrTest)
6437
+ $('btn-asr-test-stop')?.addEventListener('click', stopAsrTest)
6438
+ $('btn-asr-test-copy')?.addEventListener('click', copyAsrTestLog)
6439
+ $('btn-asr-test-clear')?.addEventListener('click', clearAsrTest)
6440
+ $('btn-asr-test-permission')?.addEventListener('click', openAsrPermissionSettings)
6441
+ $('btn-asr-test-engine')?.addEventListener('click', openAsrEngineSettings)
6442
+ renderAsrTest()
5628
6443
  $('btn-scan-camera').addEventListener('click', () => scanPair('CAMERA'))
5629
6444
  $('btn-scan-gallery').addEventListener('click', () => scanPair('PHOTOS'))
5630
6445
  $('scan-live-cancel')?.addEventListener('click', () => closeLiveScan(''))