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.
@@ -334,6 +334,7 @@ button.ds-overview-attention-item, button.ds-overview-session-item { cursor:poin
334
334
 
335
335
  /* 文件传输 */
336
336
  .ds-fs-bar { display: flex; align-items: center; gap: 8px; margin: 8px 0; }
337
+ .ds-fs-root { max-width: 260px; min-width: 120px; border: 1px solid var(--dsr-line); border-radius: 9px; background: var(--dsr-panel); color: var(--dsr-text); padding: 7px 9px; }
337
338
  .ds-fs-path { flex: 1; min-width: 0; font-size: 12.5px; color: var(--dsr-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
338
339
  .ds-fs-list { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; border: 1px solid var(--dsr-line); border-radius: 12px; background: var(--dsr-panel); }
339
340
  .ds-fs-row { display: flex; align-items: center; gap: 9px; padding: 9px 12px; border-bottom: 1px solid var(--dsr-divider); font-size: 13px; }
@@ -189,7 +189,8 @@
189
189
  <div class="ds-section-label" data-i18n="ds.files">文件传输</div>
190
190
  <div class="ds-fs-bar">
191
191
  <button id="fs-up" class="ds-btn" data-i18n="ds.fsUp">上级</button>
192
- <span id="fs-path" class="ds-fs-path">/</span>
192
+ <select id="fs-root" class="ds-fs-root hidden" data-i18n-aria="ds.fsRoot" aria-label="允许根目录"></select>
193
+ <span id="fs-path" class="ds-fs-path">~</span>
193
194
  <button id="fs-new-workspace" class="ds-btn" data-i18n="ds.fsNewWorkspace">新建工作区</button>
194
195
  <button id="fs-refresh" class="ds-btn" data-i18n="ds.fsRefresh">刷新</button>
195
196
  </div>
@@ -458,7 +459,7 @@
458
459
  'ds.cmdCompact': '/compact 压缩对话历史', 'ds.cmdExport': '/export 导出会话日志 ZIP',
459
460
  'ds.cmdFeedback': '/feedback 反馈当前会话', 'ds.cmdGoal': '/goal 设置/查看任务目标',
460
461
  'ds.cmdPermission': '/permission 切换权限预设', 'ds.cmdPlan': '/plan 进入/退出计划模式',
461
- 'ds.fsUp': '上级', 'ds.fsNewWorkspace': '新建工作区', 'ds.fsRefresh': '刷新', 'ds.fsEmpty': '目录为空',
462
+ 'ds.fsUp': '上级', 'ds.fsRoot': '允许根目录', 'ds.fsNewWorkspace': '新建工作区', 'ds.fsRefresh': '刷新', 'ds.fsEmpty': '目录为空',
462
463
  'ds.workspaceCreateTitle': '新建工作区', 'ds.workspaceCreateDesc': '将在当前文件目录下创建文件夹,并自动打开新会话。', 'ds.workspaceParent': '父目录', 'ds.workspaceNamePlaceholder': '例如:my-project', 'ds.workspaceCreate': '创建并打开', 'ds.cancel': '取消', 'ds.workspaceNameRequired': '请输入工作区名称', 'ds.workspaceExists': '该目录已存在', 'ds.workspaceInvalidName': '名称不能包含路径分隔符', 'ds.workspaceCreateFailed': '创建工作区失败', 'ds.workspaceCreated': '工作区已创建', 'ds.workspaceCreatedNoSession': '工作区已创建,但新会话未能打开',
463
464
  'ds.groupGeneral': '通用', 'ds.groupGeneralDesc': '工具调用、预设提示词',
464
465
  'ds.groupServers': '服务器', 'ds.groupServersDesc': '服务器地址',
@@ -560,7 +561,7 @@
560
561
  'ds.cmdCompact': '/compact Compress conversation history', 'ds.cmdExport': '/export Export session log ZIP',
561
562
  'ds.cmdFeedback': '/feedback Feedback current session', 'ds.cmdGoal': '/goal Set/view task goal',
562
563
  'ds.cmdPermission': '/permission Switch permission preset', 'ds.cmdPlan': '/plan Enter/exit plan mode',
563
- 'ds.fsUp': 'Up', 'ds.fsNewWorkspace': 'New workspace', 'ds.fsRefresh': 'Refresh', 'ds.fsEmpty': 'Empty directory',
564
+ 'ds.fsUp': 'Up', 'ds.fsRoot': 'Allowed root', 'ds.fsNewWorkspace': 'New workspace', 'ds.fsRefresh': 'Refresh', 'ds.fsEmpty': 'Empty directory',
564
565
  'ds.workspaceCreateTitle': 'New workspace', 'ds.workspaceCreateDesc': 'Create a folder in the current file directory and open a new session there.', 'ds.workspaceParent': 'Parent folder', 'ds.workspaceNamePlaceholder': 'For example: my-project', 'ds.workspaceCreate': 'Create & open', 'ds.cancel': 'Cancel', 'ds.workspaceNameRequired': 'Enter a workspace name', 'ds.workspaceExists': 'That folder already exists', 'ds.workspaceInvalidName': 'The name cannot contain path separators', 'ds.workspaceCreateFailed': 'Could not create workspace', 'ds.workspaceCreated': 'Workspace created', 'ds.workspaceCreatedNoSession': 'Workspace created, but the new session could not be opened',
565
566
  'ds.groupGeneral': 'General', 'ds.groupGeneralDesc': 'Tool calls, prompt presets',
566
567
  'ds.groupServers': 'Servers', 'ds.groupServersDesc': 'Server address',
@@ -88,7 +88,7 @@ const state = {
88
88
  },
89
89
  streamMode: 'ws', // 'ws' | 'poll'
90
90
  pollSeq: { mux: 0, host: 0 },
91
- fs: { path: null, initial: null, loaded: false },
91
+ fs: { path: null, initial: null, loaded: false, roots: [], rootIndex: 0 },
92
92
  models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
93
93
  wb: { bound: false, path: '', title: '', expanded: false, projects: null, open: null, apiMissing: false },
94
94
  archivedIds: [],
@@ -603,6 +603,49 @@ async function safeRpc(method, payload, errText) {
603
603
  return null
604
604
  }
605
605
  }
606
+ let hostDescribePromise = null
607
+ let hostDescribeRetryTimer = null
608
+ let hostDescribeFailures = 0
609
+
610
+ function scheduleHostDescribeRetryDesktop() {
611
+ if (!state.token || hostDescribeRetryTimer) return
612
+ const delays = [2000, 5000, 15000, 30000, 60000]
613
+ const delay = delays[Math.min(Math.max(0, hostDescribeFailures - 1), delays.length - 1)]
614
+ hostDescribeRetryTimer = setTimeout(() => {
615
+ hostDescribeRetryTimer = null
616
+ void refreshHostDescriptionDesktop()
617
+ }, delay)
618
+ }
619
+
620
+ async function refreshHostDescriptionDesktop({ notify = false } = {}) {
621
+ if (!state.token) return null
622
+ if (hostDescribePromise) return hostDescribePromise
623
+ const server = state.server
624
+ hostDescribePromise = (async () => {
625
+ try {
626
+ const host = await rpc('host.describe', {}, 5000)
627
+ if (server !== state.server) return null
628
+ state.hostInfo = host
629
+ const health = activeGatewayHealth()
630
+ if (health) health.upstreamReachable = true
631
+ hostDescribeFailures = 0
632
+ if (hostDescribeRetryTimer) clearTimeout(hostDescribeRetryTimer)
633
+ hostDescribeRetryTimer = null
634
+ renderOverviewDesktop()
635
+ return host
636
+ } catch (error) {
637
+ if (server !== state.server) return null
638
+ hostDescribeFailures++
639
+ scheduleHostDescribeRetryDesktop()
640
+ if (notify) toast(error.message === 'AUTH' ? t('ds.toastAuth') : error.message, 'err')
641
+ return null
642
+ } finally {
643
+ hostDescribePromise = null
644
+ if (server !== state.server) scheduleHostDescribeRetryDesktop()
645
+ }
646
+ })()
647
+ return hostDescribePromise
648
+ }
606
649
  function uuid() {
607
650
  try { return crypto.randomUUID() } catch { return 'id-' + Date.now() + '-' + Math.random().toString(36).slice(2) }
608
651
  }
@@ -676,16 +719,29 @@ async function pingServer(base) {
676
719
  const res = await fetch(u + '/health?t=' + Date.now(), { signal: ctrl.signal, cache: 'no-store' })
677
720
  if (!res.ok) return Infinity
678
721
  const health = await res.json().catch(() => null)
679
- if (health && typeof health === 'object') state.gatewayHealth[u] = health
722
+ if (health && typeof health === 'object') {
723
+ state.gatewayHealth[u] = health
724
+ renderOverviewDesktop()
725
+ }
680
726
  return Math.round(performance.now() - t0)
681
727
  } catch { return Infinity } finally { clearTimeout(timer) }
682
728
  }
683
729
  function activeGatewayCapability(name) {
684
- const key = String(state.server || location.origin || '').replace(/\/+$/, '')
685
- const capabilities = state.gatewayHealth[key]?.capabilities
730
+ const capabilities = activeGatewayHealth()?.capabilities
686
731
  if (!capabilities || !Object.prototype.hasOwnProperty.call(capabilities, name)) return null
687
732
  return Number(capabilities[name]) > 0
688
733
  }
734
+
735
+ function activeGatewayHealth() {
736
+ const key = String(state.server || location.origin || '').replace(/\/+$/, '')
737
+ return state.gatewayHealth[key] || null
738
+ }
739
+
740
+ function dshReachable() {
741
+ const health = activeGatewayHealth()
742
+ if (health && typeof health.upstreamReachable === 'boolean') return health.upstreamReachable
743
+ return !!state.hostInfo
744
+ }
689
745
  async function selectFastestServer({ silent = false, reconnect = true } = {}) {
690
746
  if (state.selectingServer) return null
691
747
  state.selectingServer = true
@@ -709,6 +765,11 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
709
765
  }
710
766
  renderServers()
711
767
  if (chosen !== state.server) {
768
+ state.hostInfo = null
769
+ hostDescribeFailures = 0
770
+ if (hostDescribeRetryTimer) clearTimeout(hostDescribeRetryTimer)
771
+ hostDescribeRetryTimer = null
772
+ resetFsForServerDesktop()
712
773
  state.server = chosen
713
774
  if (best) { const srv = state.servers.find(s => s.url === best); if (srv) state.groupActive[state.activeGroup] = srv.id }
714
775
  saveServers()
@@ -794,6 +855,7 @@ function renderServers() {
794
855
  if (state.autoSelect[s.group] !== false) { editServer(id); return }
795
856
  state.groupActive[s.group] = id
796
857
  state.activeGroup = s.group
858
+ if (state.server !== s.url) resetFsForServerDesktop()
797
859
  state.server = s.url
798
860
  saveServers()
799
861
  renderServers()
@@ -836,8 +898,12 @@ function editServer(id) {
836
898
  const group = prompt(t('ds.serversPromptEditGroup'), s.group || '默认')
837
899
  if (group === null) return
838
900
  const wasActive = state.server === s.url
901
+ const changedActiveUrl = wasActive && state.server !== raw
839
902
  s.url = raw; s.note = note.trim(); s.group = ensureGroup(group.trim() || '默认')
840
- if (wasActive) state.server = raw
903
+ if (wasActive) {
904
+ if (changedActiveUrl) resetFsForServerDesktop()
905
+ state.server = raw
906
+ }
841
907
  saveServers(); renderServers(); toast(t('ds.serversEdited'), 'ok')
842
908
  if (wasActive && state.token) selectFastestServer({ silent: true })
843
909
  }
@@ -1020,7 +1086,11 @@ function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
1020
1086
  updateConn()
1021
1087
  if (kind === 'mux') { state.approvals = []; state.questions = []; renderNotifStack() }
1022
1088
  if (refreshOnOpen) refreshSessions()
1023
- if (allStreamsOpen()) resyncAfterStreamOpen()
1089
+ if (allStreamsOpen()) {
1090
+ resyncAfterStreamOpen()
1091
+ void pingServer(state.server || location.origin)
1092
+ void refreshHostDescriptionDesktop()
1093
+ }
1024
1094
  }
1025
1095
  ws.onmessage = (msg) => {
1026
1096
  if (!streamIsCurrent(kind, ws, generation)) return
@@ -1966,10 +2036,70 @@ function fsHeaders() {
1966
2036
  return { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() }
1967
2037
  }
1968
2038
  function fsParent(p) {
1969
- if (!p) return null
1970
- const parts = String(p).split('/').filter(Boolean)
1971
- parts.pop()
1972
- return parts.length ? '/' + parts.join('/') : '/'
2039
+ const meta = fsPathMeta(p)
2040
+ if (!meta.value) return null
2041
+ if (meta.root && fsPathEqual(meta.value, meta.root)) return meta.root
2042
+ const idx = meta.value.lastIndexOf(meta.separator)
2043
+ if (idx < 0) return meta.root || null
2044
+ const parent = meta.value.slice(0, idx)
2045
+ return meta.root && parent.length < meta.root.length ? meta.root : (parent || meta.root)
2046
+ }
2047
+ function fsJoin(dir, name) {
2048
+ const meta = fsPathMeta(dir)
2049
+ if (!meta.value) return String(name || '')
2050
+ return meta.value.endsWith(meta.separator) ? meta.value + name : meta.value + meta.separator + name
2051
+ }
2052
+ function fsPathMeta(input) {
2053
+ const source = String(input || '').trim()
2054
+ const windows = /^[A-Za-z]:(?:[\\/]|$)/.test(source) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(source) || source.includes('\\')
2055
+ if (!windows) {
2056
+ let value = source.replace(/\/+/g, '/')
2057
+ const root = value.startsWith('/') ? '/' : ''
2058
+ if (root && value.length > root.length) value = value.replace(/\/+$/, '')
2059
+ return { value, root, separator: '/', windows: false }
2060
+ }
2061
+ let value = source.replace(/\//g, '\\')
2062
+ const unc = /^\\{2,}/.test(value)
2063
+ value = unc
2064
+ ? '\\\\' + value.replace(/^\\+/, '').replace(/\\+/g, '\\')
2065
+ : value.replace(/\\+/g, '\\')
2066
+ let root = ''
2067
+ if (unc) {
2068
+ const parts = value.slice(2).split('\\').filter(Boolean)
2069
+ root = parts.length >= 2 ? `\\\\${parts[0]}\\${parts[1]}` : value
2070
+ } else {
2071
+ const drive = /^([A-Za-z]:)/.exec(value)
2072
+ if (drive) {
2073
+ root = drive[1] + '\\'
2074
+ if (value === drive[1]) value = root
2075
+ }
2076
+ }
2077
+ if (root && value.length > root.length) value = value.replace(/\\+$/, '')
2078
+ return { value, root, separator: '\\', windows: true }
2079
+ }
2080
+ function fsPathKey(value) {
2081
+ const meta = fsPathMeta(value)
2082
+ return meta.windows ? meta.value.toLowerCase() : meta.value
2083
+ }
2084
+ function fsPathEqual(left, right) { return fsPathKey(left) === fsPathKey(right) }
2085
+ function fsPathInside(candidate, root) {
2086
+ const child = fsPathMeta(candidate)
2087
+ const boundary = fsPathMeta(root)
2088
+ if (!child.value || !boundary.value || child.windows !== boundary.windows) return false
2089
+ const childKey = child.windows ? child.value.toLowerCase() : child.value
2090
+ const rootKey = boundary.windows ? boundary.value.toLowerCase() : boundary.value
2091
+ if (childKey === rootKey) return true
2092
+ const prefix = rootKey.endsWith(boundary.separator) ? rootKey : rootKey + boundary.separator
2093
+ return childKey.startsWith(prefix)
2094
+ }
2095
+ function resetFsForServerDesktop() {
2096
+ state.fs.path = null
2097
+ state.fs.initial = null
2098
+ state.fs.loaded = false
2099
+ state.fs.roots = []
2100
+ state.fs.rootIndex = 0
2101
+ const select = $('fs-root')
2102
+ if (select) select.classList.add('hidden')
1973
2103
  }
1974
2104
  async function openWorkspaceModal() {
1975
2105
  if (!state.token) { toast(t('ds.toastAuth'), 'err'); showView('view-settings'); return }
@@ -2031,10 +2161,15 @@ async function loadFs(dir, silent) {
2031
2161
  if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error || ('HTTP ' + res.status))
2032
2162
  state.fs.path = data.path
2033
2163
  if (!state.fs.initial) state.fs.initial = data.path
2164
+ if (Array.isArray(data.roots) && data.roots.length) state.fs.roots = data.roots.map(value => String(value || '')).filter(Boolean)
2165
+ if (!state.fs.roots.length) state.fs.roots = [data.path]
2166
+ const rootIndex = state.fs.roots.findIndex(root => fsPathInside(data.path, root))
2167
+ if (rootIndex >= 0) state.fs.rootIndex = rootIndex
2034
2168
  state.fs.loaded = true
2169
+ renderFsRootsDesktop()
2035
2170
  $('fs-path').textContent = data.path
2036
2171
  $('fs-list').innerHTML = (data.entries || []).map(e => `
2037
- <div class="ds-fs-row" data-fs-path="${esc(e.path)}" data-fs-dir="${e.type === 'dir' ? '1' : '0'}">
2172
+ <div class="ds-fs-row" data-fs-path="${esc(e.path || fsJoin(data.path, e.name))}" data-fs-dir="${e.type === 'dir' ? '1' : '0'}">
2038
2173
  <span class="ds-fs-type">${desktopFsIconSvg(e.type === 'dir')}</span>
2039
2174
  <span class="ds-fs-name">${esc(e.name)}</span>
2040
2175
  <span class="ds-fs-size">${e.type === 'dir' ? '' : fmtSize(e.size)}</span>
@@ -2049,15 +2184,23 @@ async function loadFs(dir, silent) {
2049
2184
  }
2050
2185
  }
2051
2186
 
2187
+ function renderFsRootsDesktop() {
2188
+ const select = $('fs-root')
2189
+ if (!select) return
2190
+ select.classList.toggle('hidden', state.fs.roots.length <= 1)
2191
+ select.innerHTML = state.fs.roots.map((root, index) => `<option value="${index}"${index === state.fs.rootIndex ? ' selected' : ''}>${esc(root)}</option>`).join('')
2192
+ }
2193
+
2052
2194
  function desktopFsIconSvg(isDir) {
2053
2195
  return isDir
2054
2196
  ? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 6.5h6l2 2H20a1 1 0 0 1 1 1v8.5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.5a1 1 0 0 1 .5-1Z"/></svg>'
2055
2197
  : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3.5h8l4 4V20a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1Z"/><path d="M14 3.5v4h4M8 13h8M8 16h6"/></svg>'
2056
2198
  }
2057
2199
  function fsUp() {
2058
- if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) {
2059
- loadFs(fsParent(state.fs.path))
2060
- }
2200
+ if (!state.fs.path || !state.fs.initial) return
2201
+ const parent = fsParent(state.fs.path)
2202
+ if (fsPathEqual(state.fs.path, state.fs.initial) || !fsPathInside(parent, state.fs.initial)) return
2203
+ loadFs(parent)
2061
2204
  }
2062
2205
 
2063
2206
  /* ---------------- 工作台绑定 / 项目会话 ---------------- */
@@ -2431,7 +2574,7 @@ function renderOverviewDesktop() {
2431
2574
  const checks = {
2432
2575
  // 桌面独立页面默认使用当前 origin,state.server 为空不代表网关离线。
2433
2576
  gateway: !!state.token && (!!state.server || /^https?:$/.test(location.protocol)),
2434
- dsh: !!state.hostInfo,
2577
+ dsh: dshReachable(),
2435
2578
  mux: !!state.streamsOk?.mux,
2436
2579
  host: !!state.streamsOk?.host
2437
2580
  }
@@ -2626,8 +2769,7 @@ function bindUi() {
2626
2769
  toast(t('ds.loading'))
2627
2770
  if (state.token) {
2628
2771
  await refreshSessions()
2629
- const host = await safeRpc('host.describe', {}, '')
2630
- if (host) state.hostInfo = host
2772
+ await refreshHostDescriptionDesktop({ notify: true })
2631
2773
  }
2632
2774
  renderOverviewDesktop()
2633
2775
  })
@@ -2791,6 +2933,12 @@ function bindUi() {
2791
2933
  renderServers(); renderSessions(); renderNotifStack(); renderOverviewDesktop(); updateConn(); themeApply()
2792
2934
  })
2793
2935
  $('fs-up').addEventListener('click', fsUp)
2936
+ $('fs-root').addEventListener('change', (event) => {
2937
+ const index = Math.min(Number(event.target.value) || 0, Math.max(0, state.fs.roots.length - 1))
2938
+ state.fs.rootIndex = index
2939
+ state.fs.initial = null
2940
+ loadFs(state.fs.roots[index] || null, true)
2941
+ })
2794
2942
  $('fs-new-workspace').addEventListener('click', openWorkspaceModal)
2795
2943
  $('fs-refresh').addEventListener('click', () => loadFs(state.fs.path || null))
2796
2944
  $('btn-question-submit').addEventListener('click', submitQuestion)
@@ -2813,11 +2961,10 @@ async function start() {
2813
2961
  updateConn()
2814
2962
  checkNotesOnStart()
2815
2963
  if (state.token) {
2816
- if (state.servers.length) await selectFastestServer({ silent: true, reconnect: false })
2964
+ await selectFastestServer({ silent: true, reconnect: false })
2817
2965
  openStreams()
2818
2966
  await refreshSessions()
2819
- const host = await safeRpc('host.describe', {}, '')
2820
- if (host) state.hostInfo = host
2967
+ await refreshHostDescriptionDesktop()
2821
2968
  refreshWorkbench({ silent: true })
2822
2969
  }
2823
2970
  renderOverviewDesktop()
package/public/index.html CHANGED
@@ -244,6 +244,12 @@
244
244
  <div class="setting-row" data-settings-group="general" style="cursor:pointer">
245
245
  <div><div class="setting-name" data-i18n="settings.groupGeneral">通用</div><div class="setting-desc" data-i18n="settings.groupGeneralDesc">显示工具调用、预设提示词</div></div>
246
246
  </div>
247
+ <div class="setting-row" data-settings-group="model" style="cursor:pointer">
248
+ <div><div class="setting-name" data-i18n="settings.groupModel">模型</div><div class="setting-desc" data-i18n="settings.groupModelDesc">提供方、API 密钥和模型目录</div></div>
249
+ </div>
250
+ <div class="setting-row" data-settings-group="tests" style="cursor:pointer">
251
+ <div><div class="setting-name" data-i18n="settings.groupTests">功能测试</div><div class="setting-desc" data-i18n="settings.groupTestsDesc">验证 Android 和 DSH 原生能力</div></div>
252
+ </div>
247
253
  <div class="setting-row" data-settings-group="servers" style="cursor:pointer">
248
254
  <div><div class="setting-name" data-i18n="settings.groupServers">服务器</div><div class="setting-desc" data-i18n="settings.groupServersDesc">服务器地址、扫码连接</div></div>
249
255
  </div>
@@ -322,6 +328,58 @@
322
328
  </div>
323
329
  </div>
324
330
 
331
+ <div id="settings-page-model" class="settings-subpage hidden">
332
+ <div class="settings-group">
333
+ <div class="setting-row" data-settings-back style="cursor:pointer">
334
+ <button class="mini-btn" data-i18n="settings.back">‹ 返回</button>
335
+ <div class="setting-name" data-i18n="settings.groupModel">模型</div>
336
+ </div>
337
+ </div>
338
+ <div class="settings-group model-settings-shell">
339
+ <div class="model-settings-head">
340
+ <div>
341
+ <div class="setting-name" data-i18n="settings.modelTitle">模型</div>
342
+ <div class="setting-desc expanded" data-i18n="settings.modelIntro">填入各提供方的 API 密钥即可使用其模型。</div>
343
+ </div>
344
+ <div class="setting-actions">
345
+ <button id="btn-model-settings-open" class="mini-btn" type="button" data-i18n="settings.modelOpenConfig">打开配置文件</button>
346
+ <button id="btn-model-settings-refresh" class="mini-btn" type="button" data-i18n="settings.modelRefresh">刷新</button>
347
+ </div>
348
+ </div>
349
+ <div id="model-settings-status" class="model-settings-status muted" data-i18n="settings.modelLoading">加载模型配置…</div>
350
+ <div id="model-settings-list" class="model-settings-list"></div>
351
+ </div>
352
+ </div>
353
+
354
+ <div id="settings-page-tests" class="settings-subpage hidden">
355
+ <div class="settings-group">
356
+ <div class="setting-row" data-settings-back style="cursor:pointer">
357
+ <button class="mini-btn" data-i18n="settings.back">‹ 返回</button>
358
+ <div class="setting-name" data-i18n="settings.groupTests">功能测试</div>
359
+ </div>
360
+ </div>
361
+ <div class="settings-group feature-test-shell">
362
+ <div class="feature-test-head">
363
+ <div>
364
+ <div class="setting-name" data-i18n="settings.asrTestTitle">ASR 模型测试(小米手机)</div>
365
+ <div class="setting-desc expanded" data-i18n="settings.asrTestDesc">使用 Android 系统 SpeechRecognizer 验证小米/系统云端语音识别,不上传测试日志。</div>
366
+ </div>
367
+ </div>
368
+ <div class="feature-test-note" data-i18n="settings.asrTestInstruction">请保持页面在前台,朗读一段中文,包含停顿、数字和英文 API 名称。测试约 60 秒,会自动记录 partial、最终结果、重建 session 和错误。</div>
369
+ <div class="feature-test-actions">
370
+ <button id="btn-asr-test-start" class="mini-btn primary" type="button" data-i18n="settings.asrTestStart">开始测试</button>
371
+ <button id="btn-asr-test-stop" class="mini-btn" type="button" data-i18n="settings.asrTestStop" disabled>停止测试</button>
372
+ <button id="btn-asr-test-copy" class="mini-btn" type="button" data-i18n="settings.asrTestCopy" disabled>复制日志</button>
373
+ <button id="btn-asr-test-clear" class="mini-btn" type="button" data-i18n="settings.asrTestClear">清空日志</button>
374
+ <button id="btn-asr-test-permission" class="mini-btn hidden" type="button" data-i18n="settings.asrTestPermissionSettings">权限设置</button>
375
+ <button id="btn-asr-test-engine" class="mini-btn hidden" type="button" data-i18n="settings.asrTestEngineSettings">语音引擎设置</button>
376
+ </div>
377
+ <div id="asr-test-status" class="feature-test-status muted" role="status" data-i18n="settings.asrTestNativeOnly">仅 Android App 可用</div>
378
+ <div id="asr-test-summary" class="feature-test-summary"></div>
379
+ <pre id="asr-test-log" class="feature-test-log" data-i18n="settings.asrTestLogEmpty">暂无测试日志</pre>
380
+ </div>
381
+ </div>
382
+
325
383
  <div id="settings-page-servers" class="settings-subpage hidden">
326
384
  <div class="settings-group">
327
385
  <div class="setting-row" data-settings-back style="cursor:pointer">
@@ -434,7 +492,7 @@
434
492
  <button id="btn-host-describe" class="mini-btn" data-i18n="settings.probe">探测</button>
435
493
  </div>
436
494
  <div class="setting-row">
437
- <div><div class="setting-name" data-i18n="settings.dshControlTitle">远程启动 DSH</div><div class="setting-desc expanded" id="dsh-control-desc" data-i18n="settings.dshControlProbing">检查本机服务状态…</div><div id="dsh-control-steps" class="dsh-control-steps hidden" aria-live="polite"></div></div>
495
+ <div><div class="setting-name"><span data-i18n="settings.dshControlTitle">远程启动 DSH</span><span class="dsh-experimental-badge" data-i18n="settings.dshExperimental">Windows 实验性</span></div><div class="setting-desc expanded" id="dsh-control-desc" data-i18n="settings.dshControlProbing">检查本机服务状态…</div><div id="dsh-control-steps" class="dsh-control-steps hidden" aria-live="polite"></div></div>
438
496
  <div class="setting-actions">
439
497
  <button id="btn-dsh-start" class="mini-btn" data-i18n="settings.dshStart">启动</button>
440
498
  <button id="btn-dsh-restart" class="mini-btn" data-i18n="settings.dshRestart">重启</button>
@@ -944,13 +1002,28 @@
944
1002
  'scan.paired': '配对成功,正在连接', 'scan.pairedLink': '已通过二维码配对', 'scan.notPair': '这不是 DSH Remote 的配对二维码',
945
1003
  'scan.cancelled': '已取消扫码', 'scan.failed': '扫码失败:{msg}',
946
1004
  'settings.groupGeneral': '通用', 'settings.groupGeneralDesc': '显示工具调用、预设提示词',
1005
+ 'settings.groupModel': '模型', 'settings.groupModelDesc': '提供方、API 密钥和模型目录',
1006
+ 'settings.groupTests': '功能测试', 'settings.groupTestsDesc': '验证 Android 和 DSH 原生能力',
947
1007
  'settings.groupServers': '服务器', 'settings.groupServersDesc': '服务器地址、扫码连接',
948
1008
  'settings.groupNotify': '通知', 'settings.groupNotifyDesc': '通知、提醒、后台轮询',
949
1009
  'settings.groupTheme': '皮肤', 'settings.groupThemeDesc': '配色主题',
950
1010
  'settings.groupAbout': '关于', 'settings.groupAboutDesc': 'DSH 状态、检查更新',
951
1011
  'settings.donate': '赞赏支持', 'settings.donateDesc': '如果 dsh-remote 帮到了你,欢迎赞赏支持开发 ☕', 'settings.donateThanks': '感谢你的支持 ☕',
952
1012
  'settings.back': '‹ 返回',
1013
+ 'settings.modelTitle': '模型', 'settings.modelIntro': '填入各提供方的 API 密钥即可使用其模型。', 'settings.modelRefresh': '刷新', 'settings.modelOpenConfig': '打开配置文件',
1014
+ 'settings.modelLoading': '加载模型配置…', 'settings.modelUnavailable': '模型配置暂不可用:{msg}', 'settings.modelEmpty': '当前 DSH 未声明可配置的提供方', 'settings.modelReadOnly': '当前配置只读',
1015
+ 'settings.modelConfigured': '已配置', 'settings.modelMissing': '未配置', 'settings.modelEdit': '编辑', 'settings.modelSave': '保存', 'settings.modelSaving': '保存中…', 'settings.modelCancel': '取消',
1016
+ 'settings.modelApiKey': 'API 密钥', 'settings.modelApiKeyPlaceholder': '输入 API 密钥(留空保持不变)', 'settings.modelApiKeyStored': '已保存的密钥,留空保持不变', 'settings.modelClearKey': '清除密钥',
1017
+ 'settings.modelBaseUrl': 'API 地址', 'settings.modelBaseUrlPlaceholder': '提供方默认', 'settings.modelProtocol': '协议', 'settings.modelDisplayName': '显示名称',
1018
+ 'settings.modelCatalog': '模型目录', 'settings.modelCatalogHint': '模型选择器使用以下模型;不修改则使用提供方默认目录', 'settings.modelFetch': '获取可用模型', 'settings.modelFetching': '获取中…',
1019
+ 'settings.modelReasoning': '思考深度档位', 'settings.modelReasoningHint': '按模型配置可选档位;实际请求参数由 DSH 提供方适配器解释', 'settings.modelReasoningAdd': '添加档位', 'settings.modelReasoningClear': '清除档位', 'settings.modelReasoningRemove': '删除档位', 'settings.modelReasoningId': '档位值', 'settings.modelReasoningName': '显示名称', 'settings.modelReasoningDescription': '说明(可选)', 'settings.modelReasoningEmpty': '未配置档位,使用提供方默认行为', 'settings.modelReasoningDefault': '默认档位', 'settings.modelReasoningProviderDefault': '使用提供方默认', 'settings.modelReasoningIdRequired': '思考档位值不能为空', 'settings.modelReasoningInvalid': '思考档位值仅支持字母、数字、点、下划线、冒号和连字符', 'settings.modelReasoningDuplicate': '同一模型的思考档位值不能重复', 'settings.modelReasoningDefaultInvalid': '默认思考档位必须是已配置的档位', 'settings.modelReasoningLimit': '每个模型最多配置 {count} 个思考档位',
1020
+ 'settings.modelFetchFailed': '获取模型失败:{msg}', 'settings.modelFetchEmpty': '提供方没有返回可用模型', 'settings.modelAdd': '添加模型', 'settings.modelId': '模型 ID', 'settings.modelIdRequired': '模型 ID 不能为空', 'settings.modelName': '名称(可选)', 'settings.modelRemove': '删除模型',
1021
+ 'settings.modelNoModels': '暂无自定义模型,当前使用提供方默认目录', 'settings.modelCandidates': '发现的模型', 'settings.modelSelectAll': '全选', 'settings.modelSelectNone': '取消全选', 'settings.modelAddSelected': '加入选中模型',
1022
+ 'settings.modelAdded': '已加入模型目录', 'settings.modelSaved': '模型配置已保存', 'settings.modelSaveFailed': '保存模型配置失败:{msg}', 'settings.modelOpenConfigFailed': '打开配置文件失败:{msg}', 'settings.modelOpenedConfig': '已请求打开 DSH 配置文件',
1023
+ 'settings.modelLive': '保存后立即生效', 'settings.modelRestart': '保存后需重启 DSH 才生效',
1024
+ 'settings.asrTestTitle': 'ASR 模型测试(小米手机)', 'settings.asrTestDesc': '使用 Android 系统 SpeechRecognizer 验证小米/系统云端语音识别,不上传测试日志。', 'settings.asrTestInstruction': '请保持页面在前台,朗读一段中文,包含停顿、数字和英文 API 名称。测试约 60 秒,会自动记录 partial、最终结果、重建 session 和错误。', 'settings.asrTestStart': '开始测试', 'settings.asrTestStop': '停止测试', 'settings.asrTestCopy': '复制日志', 'settings.asrTestClear': '清空日志', 'settings.asrTestPermissionSettings': '权限设置', 'settings.asrTestNativeOnly': '仅 Android App 可用', 'settings.asrTestWebUnsupported': '浏览器不支持原生 ASR 诊断,请在 Android App 中打开。', 'settings.asrTestConsent': '测试会使用麦克风,并可能将音频发送给 Android 系统默认语音识别服务。是否继续?', 'settings.asrTestStarted': 'ASR 测试已开始,请朗读测试内容', 'settings.asrTestStopped': 'ASR 测试已停止', 'settings.asrTestRestarting': '识别 session 重建中…', 'settings.asrTestPermission': '正在请求麦克风权限…', 'settings.asrTestPermissionDenied': '未授予麦克风权限,无法测试 ASR', 'settings.asrTestPermissionError': '系统语音识别服务未获麦克风授权,请打开权限设置后重试', 'settings.asrTestUnavailable': '当前设备没有可用的系统语音识别服务', 'settings.asrTestBusy': '已有 ASR 测试正在运行', 'settings.asrTestCopyOk': '测试日志已复制', 'settings.asrTestCopyFailed': '复制测试日志失败', 'settings.asrTestLogEmpty': '暂无测试日志', 'settings.asrTestMeta': '设备信息', 'settings.asrTestSummary': '测试摘要', 'settings.asrTestEvent': '事件日志', 'settings.asrTestCloud': '系统默认识别服务可能使用云端 ASR;本测试不上传日志', 'settings.asrTestStatus': '状态:{status}',
953
1025
  'settings.servers': '服务器地址(可多个)', 'settings.speedTest': '测速', 'settings.add': '添加',
1026
+ 'settings.asrTestEngineSettings': '语音引擎设置',
954
1027
  'settings.scanTitle': '扫码连接', 'settings.scanDesc': '实时相机识别二维码,也可拍照/相册本地解码;无需谷歌服务', 'settings.scan': '扫码',
955
1028
  'settings.notifyTitle': '通知', 'settings.notifyDesc': '收到审批/提问时推送', 'settings.announcementHistoryTitle': '历史公告', 'settings.announcementHistoryDesc': '查看以前收到的公告', 'settings.announcementHistory': '查看记录',
956
1029
  'settings.testNotifyTitle': '测试通知', 'settings.testNotifyDesc': '立即排一条 3 秒后的本地通知,用于排查系统通知限制', 'settings.testNotify': '发送测试通知',
@@ -982,11 +1055,11 @@
982
1055
  'theme.panelTitle': '选择配色', 'theme.close': '关闭',
983
1056
  'settings.tokenTitle': '访问令牌', 'settings.change': '更换',
984
1057
  'settings.hostTitle': 'DSH 状态', 'settings.hostProbing': '探测中…', 'settings.probe': '探测', 'settings.probeFailed': '探测失败',
985
- 'settings.dshControlTitle': '远程启动 DSH', 'settings.dshControlProbing': '正在向网关查询本机 systemd 服务…', 'settings.dshRunning': '运行中', 'settings.dshStopped': '已停止', 'settings.dshUnsupported': '当前系统未配置可控的 DSH 服务', 'settings.dshStart': '启动', 'settings.dshRestart': '重启', 'settings.dshStarting': '正在{action} DSH…', 'settings.dshStarted': 'DSH 已{action}', 'settings.dshFailed': 'DSH 操作失败:{msg}',
1058
+ 'settings.dshControlTitle': '远程启动 DSH', 'settings.dshExperimental': 'Windows 实验性', 'settings.dshControlProbing': '正在向网关查询本机 DSH 服务…', 'settings.dshRunning': '运行中', 'settings.dshStopped': '已停止', 'settings.dshUnsupported': '当前系统未配置可控的 DSH 服务', 'settings.dshStart': '启动', 'settings.dshRestart': '重启', 'settings.dshStarting': '正在{action} DSH…', 'settings.dshStarted': 'DSH 已{action}', 'settings.dshFailed': 'DSH 操作失败:{msg}',
986
1059
  'settings.dshRunningDetail': '运行中 · {service} · PID {pid} · {state}', 'settings.dshStoppedDetail': '已停止 · {service} · {state}', 'settings.dshStatusFailed': '服务状态查询失败:{msg}',
987
- 'settings.dshStageQueued': '请求已到达网关,等待检查服务', 'settings.dshStageChecking': '正在检查 systemd 用户服务 {service}', 'settings.dshStageCommand': '服务已确认,正在提交{action}命令', 'settings.dshStageWaitingService': '命令已接受,等待服务进程运行({seconds} 秒)', 'settings.dshStageWaitingUpstream': '服务进程已运行,等待 DSH HTTP 接口恢复({seconds} 秒)', 'settings.dshStageWaitingEvents': 'DSH HTTP 已恢复,等待实时消息通道连接({seconds} 秒)',
1060
+ 'settings.dshStageQueued': '请求已到达网关,等待检查服务', 'settings.dshStageChecking': '正在检查 DSH 服务 {service}', 'settings.dshStageCommand': '服务已确认,正在提交{action}命令', 'settings.dshStageWaitingService': '命令已接受,等待服务进程运行({seconds} 秒)', 'settings.dshStageWaitingUpstream': '服务进程已运行,等待 DSH HTTP 接口恢复({seconds} 秒)', 'settings.dshStageWaitingEvents': 'DSH HTTP 已恢复,等待实时消息通道连接({seconds} 秒)',
988
1061
  'settings.dshSuccessDetail': 'DSH {action}成功 · PID {pid} · HTTP {status} · 用时 {seconds} 秒', 'settings.dshAlreadyRunning': 'DSH 已在运行 · PID {pid}', 'settings.dshResultUnknown': '与网关的连接在操作中断开,最终结果未知;请重新进入此页查看服务状态',
989
- 'settings.dshErrorServiceNotFound': '未找到 systemd 用户服务 {service},请先安装或配置 DSH_REMOTE_DSH_SERVICE', 'settings.dshErrorInvalidService': 'DSH 服务名配置不合法', 'settings.dshErrorSystemctlNotFound': '系统中找不到 systemctl', 'settings.dshErrorSystemdUnavailable': '无法连接当前用户的 systemd 会话', 'settings.dshErrorPermissionDenied': '当前用户无权控制 DSH 服务', 'settings.dshErrorCommandTimeout': 'systemctl 命令执行超时', 'settings.dshErrorCommandFailed': 'systemctl 拒绝或未能执行 DSH 控制命令', 'settings.dshErrorServiceFailed': 'DSH 服务启动后进入 failed 状态', 'settings.dshErrorServiceTimeout': 'DSH 服务未在限时内进入运行状态', 'settings.dshErrorUpstreamTimeout': 'DSH 进程已运行,但 HTTP 接口未在限时内恢复', 'settings.dshErrorEventsTimeout': 'DSH 服务和 HTTP 已恢复,但实时消息通道未在限时内连接', 'settings.dshErrorOperationNotFound': '网关找不到该操作,可能在期间重启过', 'settings.dshErrorOperationProgress': '已有一个 DSH 启停操作正在执行', 'settings.dshErrorGeneric': '{msg}', 'settings.dshErrorDetail': '详情:{detail}',
1062
+ 'settings.dshErrorServiceNotFound': '未找到 DSH 服务 {service},请先安装或配置 DSH_REMOTE_DSH_SERVICE', 'settings.dshErrorInvalidService': 'DSH 服务名配置不合法', 'settings.dshErrorSystemctlNotFound': '系统中找不到 systemctl', 'settings.dshErrorSystemdUnavailable': '无法连接当前用户的 systemd 会话', 'settings.dshErrorServiceControlNotFound': '系统中找不到 sc.exe', 'settings.dshErrorServiceDisabled': 'Windows DSH 服务已被禁用', 'settings.dshErrorServiceStopTimeout': 'Windows DSH 服务停止超时', 'settings.dshErrorStatusParseFailed': '无法解析 Windows DSH 服务状态', 'settings.dshErrorPermissionDenied': '当前用户无权控制 DSH 服务', 'settings.dshErrorCommandTimeout': '服务控制命令超时', 'settings.dshErrorCommandFailed': '服务控制命令失败', 'settings.dshErrorServiceFailed': 'DSH 服务启动后进入 failed 状态', 'settings.dshErrorServiceTimeout': 'DSH 服务未在限时内进入运行状态', 'settings.dshErrorUpstreamTimeout': 'DSH 进程已运行,但 HTTP 接口未在限时内恢复', 'settings.dshErrorEventsTimeout': 'DSH 服务和 HTTP 已恢复,但实时消息通道未在限时内连接', 'settings.dshErrorOperationNotFound': '网关找不到该操作,可能在期间重启过', 'settings.dshErrorOperationProgress': '已有一个 DSH 启停操作正在执行', 'settings.dshErrorGeneric': '{msg}', 'settings.dshErrorDetail': '详情:{detail}',
990
1063
  'settings.hostDesc': 'DSH {version} · {cwd} · 附加会话 {n}',
991
1064
  'settings.updateTitle': '检查更新', 'settings.updateLoading': '加载中…', 'settings.downloadUpdate': '下载并安装更新', 'settings.check': '检查',
992
1065
  'settings.feedbackTitle': '反馈渠道', 'settings.feedbackDesc': 'GitHub / Gitee / B站:反馈 bug、提建议、唠嗑',
@@ -1156,13 +1229,28 @@
1156
1229
  'scan.paired': 'Paired, connecting', 'scan.pairedLink': 'Paired via QR code', 'scan.notPair': 'Not a DSH Remote pairing QR code',
1157
1230
  'scan.cancelled': 'Scan cancelled', 'scan.failed': 'Scan failed: {msg}',
1158
1231
  'settings.groupGeneral': 'General', 'settings.groupGeneralDesc': 'Tool calls, prompt presets',
1232
+ 'settings.groupModel': 'Models', 'settings.groupModelDesc': 'Providers, API keys, and model catalogs',
1233
+ 'settings.groupTests': 'Feature tests', 'settings.groupTestsDesc': 'Check Android and DSH native capabilities',
1159
1234
  'settings.groupServers': 'Servers', 'settings.groupServersDesc': 'Server address, scan to pair',
1160
1235
  'settings.groupNotify': 'Notifications', 'settings.groupNotifyDesc': 'Notifications, reminders, background polling',
1161
1236
  'settings.groupTheme': 'Theme', 'settings.groupThemeDesc': 'Color themes',
1162
1237
  'settings.groupAbout': 'About', 'settings.groupAboutDesc': 'DSH status, updates',
1163
1238
  'settings.donate': 'Support', 'settings.donateDesc': 'If dsh-remote has been helpful to you, feel free to support development with a coffee ☕', 'settings.donateThanks': 'Thanks for your support ☕',
1164
1239
  'settings.back': '‹ Back',
1240
+ 'settings.modelTitle': 'Models', 'settings.modelIntro': 'Add a provider API key to use its models.', 'settings.modelRefresh': 'Refresh', 'settings.modelOpenConfig': 'Open config file',
1241
+ 'settings.modelLoading': 'Loading model configuration…', 'settings.modelUnavailable': 'Model configuration unavailable: {msg}', 'settings.modelEmpty': 'DSH did not declare any configurable providers', 'settings.modelReadOnly': 'Configuration is read-only',
1242
+ 'settings.modelConfigured': 'Configured', 'settings.modelMissing': 'Not configured', 'settings.modelEdit': 'Edit', 'settings.modelSave': 'Save', 'settings.modelSaving': 'Saving…', 'settings.modelCancel': 'Cancel',
1243
+ 'settings.modelApiKey': 'API key', 'settings.modelApiKeyPlaceholder': 'Enter an API key (blank keeps the current key)', 'settings.modelApiKeyStored': 'A key is stored; blank keeps it', 'settings.modelClearKey': 'Clear key',
1244
+ 'settings.modelBaseUrl': 'API URL', 'settings.modelBaseUrlPlaceholder': 'Provider default', 'settings.modelProtocol': 'Protocol', 'settings.modelDisplayName': 'Display name',
1245
+ 'settings.modelCatalog': 'Model catalog', 'settings.modelCatalogHint': 'The model picker uses this list; leave it unchanged to use the provider catalog', 'settings.modelFetch': 'Fetch available models', 'settings.modelFetching': 'Fetching…',
1246
+ 'settings.modelReasoning': 'Reasoning effort', 'settings.modelReasoningHint': 'Define selectable efforts per model; the DSH provider adapter interprets the request value', 'settings.modelReasoningAdd': 'Add effort', 'settings.modelReasoningClear': 'Clear efforts', 'settings.modelReasoningRemove': 'Remove effort', 'settings.modelReasoningId': 'Effort value', 'settings.modelReasoningName': 'Display name', 'settings.modelReasoningDescription': 'Description (optional)', 'settings.modelReasoningEmpty': 'No efforts configured; use the provider default behavior', 'settings.modelReasoningDefault': 'Default effort', 'settings.modelReasoningProviderDefault': 'Provider default', 'settings.modelReasoningIdRequired': 'Effort value cannot be empty', 'settings.modelReasoningInvalid': 'Effort values may contain only letters, numbers, dots, underscores, colons, and hyphens', 'settings.modelReasoningDuplicate': 'Effort values must be unique per model', 'settings.modelReasoningDefaultInvalid': 'The default effort must be one of the configured efforts', 'settings.modelReasoningLimit': 'Each model can have at most {count} reasoning efforts',
1247
+ 'settings.modelFetchFailed': 'Could not fetch models: {msg}', 'settings.modelFetchEmpty': 'The provider returned no models', 'settings.modelAdd': 'Add model', 'settings.modelId': 'Model ID', 'settings.modelIdRequired': 'Model ID cannot be empty', 'settings.modelName': 'Name (optional)', 'settings.modelRemove': 'Remove model',
1248
+ 'settings.modelNoModels': 'No custom models; using the provider default catalog', 'settings.modelCandidates': 'Discovered models', 'settings.modelSelectAll': 'Select all', 'settings.modelSelectNone': 'Clear selection', 'settings.modelAddSelected': 'Add selected models',
1249
+ 'settings.modelAdded': 'Models added to the catalog', 'settings.modelSaved': 'Model configuration saved', 'settings.modelSaveFailed': 'Could not save model configuration: {msg}', 'settings.modelOpenConfigFailed': 'Could not open config file: {msg}', 'settings.modelOpenedConfig': 'Requested DSH config file',
1250
+ 'settings.modelLive': 'Takes effect immediately', 'settings.modelRestart': 'Restart DSH to apply',
1251
+ 'settings.asrTestTitle': 'ASR model test (Xiaomi)', 'settings.asrTestDesc': 'Use Android SpeechRecognizer to test Xiaomi/system cloud speech recognition. Test logs are not uploaded.', 'settings.asrTestInstruction': 'Keep this page in the foreground and read Chinese text containing pauses, numbers, and an English API name. The test runs for about 60 seconds and records partial/final results, session rebuilds, and errors.', 'settings.asrTestStart': 'Start test', 'settings.asrTestStop': 'Stop test', 'settings.asrTestRestarting': 'Rebuilding recognition session…', 'settings.asrTestCopy': 'Copy log', 'settings.asrTestClear': 'Clear log', 'settings.asrTestPermissionSettings': 'Permission settings', 'settings.asrTestNativeOnly': 'Available only in the Android app', 'settings.asrTestWebUnsupported': 'Native ASR diagnostics are unavailable in a browser. Open this page in the Android app.', 'settings.asrTestConsent': 'This test uses the microphone and may send audio to the Android system default speech recognition service. Continue?', 'settings.asrTestStarted': 'ASR test started; read the test content aloud', 'settings.asrTestStopped': 'ASR test stopped', 'settings.asrTestPermission': 'Requesting microphone permission…', 'settings.asrTestPermissionDenied': 'Microphone permission was not granted; ASR cannot be tested', 'settings.asrTestPermissionError': 'The system speech service lacks microphone authorization. Open permission settings and try again.', 'settings.asrTestUnavailable': 'No system speech recognition service is available on this device', 'settings.asrTestBusy': 'An ASR test is already running', 'settings.asrTestCopyOk': 'Test log copied', 'settings.asrTestCopyFailed': 'Could not copy the test log', 'settings.asrTestLogEmpty': 'No test log yet', 'settings.asrTestMeta': 'Device information', 'settings.asrTestSummary': 'Test summary', 'settings.asrTestEvent': 'Event log', 'settings.asrTestCloud': 'The system default recognition service may use cloud ASR; this test does not upload logs', 'settings.asrTestStatus': 'Status: {status}',
1165
1252
  'settings.servers': 'Servers (one or more)', 'settings.speedTest': 'Test', 'settings.add': 'Add',
1253
+ 'settings.asrTestEngineSettings': 'Speech engine settings',
1166
1254
  'settings.scanTitle': 'Scan to pair', 'settings.scanDesc': 'Use the live camera or photo/gallery QR decoding; no Google services required', 'settings.scan': 'Scan',
1167
1255
  'settings.notifyTitle': 'Notifications', 'settings.notifyDesc': 'Push on approvals / questions', 'settings.announcementHistoryTitle': 'Announcement history', 'settings.announcementHistoryDesc': 'Review announcements you received before', 'settings.announcementHistory': 'View history',
1168
1256
  'settings.testNotifyTitle': 'Test notification', 'settings.testNotifyDesc': 'Schedule a local notification in 3 seconds to verify the notification pipeline', 'settings.testNotify': 'Send test notification',
@@ -1194,11 +1282,11 @@
1194
1282
  'theme.panelTitle': 'Choose theme', 'theme.close': 'Close',
1195
1283
  'settings.tokenTitle': 'Access token', 'settings.change': 'Change',
1196
1284
  'settings.hostTitle': 'DSH status', 'settings.hostProbing': 'Probing…', 'settings.probe': 'Probe', 'settings.probeFailed': 'Probe failed',
1197
- 'settings.dshControlTitle': 'Remote DSH control', 'settings.dshControlProbing': 'Querying the gateway for the local systemd service…', 'settings.dshRunning': 'Running', 'settings.dshStopped': 'Stopped', 'settings.dshUnsupported': 'No controllable DSH service is configured', 'settings.dshStart': 'Start', 'settings.dshRestart': 'Restart', 'settings.dshStarting': '{action}ing DSH…', 'settings.dshStarted': 'DSH {action}ed', 'settings.dshFailed': 'DSH operation failed: {msg}',
1285
+ 'settings.dshControlTitle': 'Remote DSH control', 'settings.dshExperimental': 'Windows experimental', 'settings.dshControlProbing': 'Querying the gateway for the local DSH service…', 'settings.dshRunning': 'Running', 'settings.dshStopped': 'Stopped', 'settings.dshUnsupported': 'No controllable DSH service is configured', 'settings.dshStart': 'Start', 'settings.dshRestart': 'Restart', 'settings.dshStarting': '{action}ing DSH…', 'settings.dshStarted': 'DSH {action}ed', 'settings.dshFailed': 'DSH operation failed: {msg}',
1198
1286
  'settings.dshRunningDetail': 'Running · {service} · PID {pid} · {state}', 'settings.dshStoppedDetail': 'Stopped · {service} · {state}', 'settings.dshStatusFailed': 'Service status query failed: {msg}',
1199
- 'settings.dshStageQueued': 'Request reached the gateway; waiting to inspect the service', 'settings.dshStageChecking': 'Checking systemd user service {service}', 'settings.dshStageCommand': 'Service confirmed; submitting the {action} command', 'settings.dshStageWaitingService': 'Command accepted; waiting for the service process ({seconds}s)', 'settings.dshStageWaitingUpstream': 'Service process is running; waiting for the DSH HTTP endpoint ({seconds}s)', 'settings.dshStageWaitingEvents': 'DSH HTTP is ready; waiting for the realtime message channels ({seconds}s)',
1287
+ 'settings.dshStageQueued': 'Request reached the gateway; waiting to inspect the service', 'settings.dshStageChecking': 'Checking DSH service {service}', 'settings.dshStageCommand': 'Service confirmed; submitting the {action} command', 'settings.dshStageWaitingService': 'Command accepted; waiting for the service process ({seconds}s)', 'settings.dshStageWaitingUpstream': 'Service process is running; waiting for the DSH HTTP endpoint ({seconds}s)', 'settings.dshStageWaitingEvents': 'DSH HTTP is ready; waiting for the realtime message channels ({seconds}s)',
1200
1288
  'settings.dshSuccessDetail': 'DSH {action} succeeded · PID {pid} · HTTP {status} · {seconds}s', 'settings.dshAlreadyRunning': 'DSH is already running · PID {pid}', 'settings.dshResultUnknown': 'The gateway connection was interrupted during the operation, so the final result is unknown. Reopen this page to check the service state.',
1201
- 'settings.dshErrorServiceNotFound': 'systemd user service {service} was not found. Install it or configure DSH_REMOTE_DSH_SERVICE first.', 'settings.dshErrorInvalidService': 'The configured DSH service name is invalid', 'settings.dshErrorSystemctlNotFound': 'systemctl is not available on this host', 'settings.dshErrorSystemdUnavailable': 'Could not connect to the current user systemd session', 'settings.dshErrorPermissionDenied': 'The current user cannot control the DSH service', 'settings.dshErrorCommandTimeout': 'The systemctl command timed out', 'settings.dshErrorCommandFailed': 'systemctl rejected or could not run the DSH control command', 'settings.dshErrorServiceFailed': 'The DSH service entered the failed state after startup', 'settings.dshErrorServiceTimeout': 'The DSH service did not enter the running state before timeout', 'settings.dshErrorUpstreamTimeout': 'The DSH process is running, but its HTTP endpoint did not recover before timeout', 'settings.dshErrorEventsTimeout': 'The DSH service and HTTP endpoint recovered, but the realtime message channels did not reconnect before timeout', 'settings.dshErrorOperationNotFound': 'The gateway no longer knows this operation; it may have restarted', 'settings.dshErrorOperationProgress': 'Another DSH start/restart operation is already running', 'settings.dshErrorGeneric': '{msg}', 'settings.dshErrorDetail': 'Detail: {detail}',
1289
+ 'settings.dshErrorServiceNotFound': 'DSH service {service} was not found. Install it or configure DSH_REMOTE_DSH_SERVICE first.', 'settings.dshErrorInvalidService': 'The configured DSH service name is invalid', 'settings.dshErrorSystemctlNotFound': 'systemctl is not available on this host', 'settings.dshErrorSystemdUnavailable': 'Could not connect to the current user systemd session', 'settings.dshErrorServiceControlNotFound': 'sc.exe is not available on this host', 'settings.dshErrorServiceDisabled': 'The Windows DSH service is disabled', 'settings.dshErrorServiceStopTimeout': 'The Windows DSH service did not stop before timeout', 'settings.dshErrorStatusParseFailed': 'Could not parse the Windows DSH service status', 'settings.dshErrorPermissionDenied': 'The current user cannot control the DSH service', 'settings.dshErrorCommandTimeout': 'The service control command timed out', 'settings.dshErrorCommandFailed': 'The service control command failed', 'settings.dshErrorServiceFailed': 'The DSH service entered the failed state after startup', 'settings.dshErrorServiceTimeout': 'The DSH service did not enter the running state before timeout', 'settings.dshErrorUpstreamTimeout': 'The DSH process is running, but its HTTP endpoint did not recover before timeout', 'settings.dshErrorEventsTimeout': 'The DSH service and HTTP endpoint recovered, but the realtime message channels did not reconnect before timeout', 'settings.dshErrorOperationNotFound': 'The gateway no longer knows this operation; it may have restarted', 'settings.dshErrorOperationProgress': 'Another DSH start/restart operation is already running', 'settings.dshErrorGeneric': '{msg}', 'settings.dshErrorDetail': 'Detail: {detail}',
1202
1290
  'settings.hostDesc': 'DSH {version} · {cwd} · {n} attached sessions',
1203
1291
  'settings.updateTitle': 'Check for updates', 'settings.updateLoading': 'Loading…', 'settings.downloadUpdate': 'Download & install update', 'settings.check': 'Check',
1204
1292
  'settings.feedbackTitle': 'Feedback', 'settings.feedbackDesc': 'GitHub / Gitee / Bilibili: report bugs, suggest features, or just chat',