dsh-remote-plugin 0.6.14 → 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
@@ -64,6 +64,9 @@ const state = {
64
64
  workspaceFilter: LS.get('workspaceFilterV1', ''),
65
65
  byId: new Map(),
66
66
  current: null, // 当前打开的 sessionId
67
+ sessionRecovery: { status: 'idle', error: '' },
68
+ pendingProjections: new Map(),
69
+ lastStreamResyncAt: 0,
67
70
  hostInfo: null,
68
71
  localVersion: '',
69
72
  updateInfo: null,
@@ -76,6 +79,8 @@ const state = {
76
79
  queueSteering: {}, // sessionId:itemId -> pending steer request
77
80
  sessionTurnTimes: {}, // sessionId -> 本轮开始/结束时间,避免中间事件推动排序
78
81
  jobs: {}, // sessionId -> jobs
82
+ sessionActivity: new Set(), // 已发送消息或已执行命令的会话
83
+ pendingPrompts: new Set(), // 正在提交消息的会话
79
84
  history: emptyHistory(),
80
85
  errCount: 0,
81
86
  streamInfo: {
@@ -88,6 +93,9 @@ const state = {
88
93
  fs: { path: null, initial: null, loaded: false, upload: null, workspaceId: LS.get('fsWorkspaceIdV1', ''), preview: null },
89
94
  composerImages: [], // 当前草稿中的图片附件:只在发送成功后释放
90
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: [] },
91
99
  wb: null,
92
100
  wbProjects: [],
93
101
  wbArchived: [],
@@ -1082,6 +1090,7 @@ function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
1082
1090
  renderPending()
1083
1091
  }
1084
1092
  if (refreshOnOpen) refreshAll()
1093
+ if (allStreamsOpen()) resyncAfterStreamOpen()
1085
1094
  }
1086
1095
  ws.onmessage = (msg) => {
1087
1096
  if (!streamIsCurrent(kind, ws, generation)) return
@@ -1168,9 +1177,9 @@ async function pollKind(kind) {
1168
1177
  const since = state.pollSeq[kind] || 0
1169
1178
  let res
1170
1179
  try {
1171
- const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(5000) : undefined
1180
+ const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(30000) : undefined
1172
1181
  const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }
1173
- res = signal ? await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}`), { signal, headers }) : await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}`), { headers })
1182
+ res = signal ? await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}&wait=25000`), { signal, headers }) : await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}&wait=25000`), { headers })
1174
1183
  } catch { return }
1175
1184
  if (res.status === 401) { authFailure(); return }
1176
1185
  if (!res.ok) return
@@ -1188,6 +1197,7 @@ async function pollKind(kind) {
1188
1197
  renderPending()
1189
1198
  }
1190
1199
  scheduleRefresh()
1200
+ if (state.current) void resyncCurrentSession()
1191
1201
  }
1192
1202
  for (const item of data.events) {
1193
1203
  if (item.seq > (state.pollSeq[kind] || 0)) {
@@ -1346,24 +1356,90 @@ async function refreshSessions() {
1346
1356
  }
1347
1357
  state.sessions = v.items || []
1348
1358
  state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
1359
+ applyPendingProjections()
1349
1360
  cacheWrite(CACHE.sessions, state.sessions.slice(0, 80))
1350
1361
  renderSessions()
1351
1362
  refreshWorkbench()
1352
1363
  }
1353
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
+
1354
1382
  function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
1383
+ function hydrateSessionProjections(sessionId, projections) {
1384
+ const s = state.byId.get(sessionId)
1385
+ if (!s || !projections || typeof projections !== 'object') return
1386
+ const incomingSeq = Number(projections.asOfSeq) || 0
1387
+ const current = s.projections || { asOfSeq: 0, values: {} }
1388
+ const currentSeq = Number(current.asOfSeq) || 0
1389
+ if (incomingSeq < currentSeq) return
1390
+ s.projections = {
1391
+ asOfSeq: Math.max(currentSeq, incomingSeq),
1392
+ values: { ...(current.values || {}), ...(projections.values || {}) }
1393
+ }
1394
+ }
1395
+ function applyPendingProjections() {
1396
+ for (const [sessionId, projections] of state.pendingProjections) {
1397
+ if (!state.byId.has(sessionId)) continue
1398
+ hydrateSessionProjections(sessionId, projections)
1399
+ state.pendingProjections.delete(sessionId)
1400
+ }
1401
+ }
1355
1402
  function applyProjection(sessionId, key, value, seq) {
1356
1403
  const s = state.byId.get(sessionId)
1357
- if (s) {
1358
- s.projections = s.projections || { asOfSeq: 0, values: {} }
1359
- s.projections.values = s.projections.values || {}
1360
- s.projections.values[key] = value
1361
- s.projections.asOfSeq = Math.max(s.projections.asOfSeq || 0, seq || 0)
1404
+ if (!s) {
1405
+ const pending = state.pendingProjections.get(sessionId) || { asOfSeq: 0, values: {} }
1406
+ pending.values[key] = value
1407
+ pending.asOfSeq = Math.max(pending.asOfSeq || 0, seq || 0)
1408
+ state.pendingProjections.set(sessionId, pending)
1409
+ return
1362
1410
  }
1411
+ const currentSeq = Number(s.projections?.asOfSeq) || 0
1412
+ if (seq && seq < currentSeq) return
1413
+ s.projections = s.projections || { asOfSeq: 0, values: {} }
1414
+ s.projections.values = s.projections.values || {}
1415
+ s.projections.values[key] = value
1416
+ s.projections.asOfSeq = Math.max(currentSeq, seq || 0)
1363
1417
  if (state.current === sessionId) { renderSessionTitle(); renderSessionCards() }
1364
1418
  if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) scheduleRefresh()
1365
1419
  else renderSessions()
1366
1420
  }
1421
+ function setSessionRecovery(status, error = '') {
1422
+ state.sessionRecovery = { status, error: String(error || '') }
1423
+ if (state.current) { renderSessionSub(); updateSessionStatus() }
1424
+ }
1425
+ function recoveryLabel() {
1426
+ const status = state.sessionRecovery.status
1427
+ if (status === 'loading' || status === 'resuming') return t('session.recovering')
1428
+ if (status === 'cached') return t('session.recoveryCached')
1429
+ if (status === 'error') return t('session.recoveryFailed')
1430
+ return ''
1431
+ }
1432
+ function resyncCurrentSession() {
1433
+ if (!state.current) return Promise.resolve()
1434
+ return loadHistory(true).then(() => {
1435
+ if (state.current) { renderSessionCards(); renderSessionSub(); updateCancelBtn(); updateSessionStatus() }
1436
+ })
1437
+ }
1438
+ function resyncAfterStreamOpen() {
1439
+ if (!state.current || Date.now() - state.lastStreamResyncAt < 1200) return
1440
+ state.lastStreamResyncAt = Date.now()
1441
+ void refreshAll().then(() => resyncCurrentSession())
1442
+ }
1367
1443
  function titleOf(s) { return proj(s, 'title') || (s?.sessionId ? short(s.sessionId) : t('session.unknown')) }
1368
1444
  function short(id) { return '…' + String(id).slice(-8) }
1369
1445
  function isTopLevelSession(session) {
@@ -1415,6 +1491,63 @@ const WORKSPACE_UNGROUPED = '__ungrouped__'
1415
1491
  function workspaceItems() {
1416
1492
  return (state.wbProjects || []).filter(w => w && typeof w.workspaceId === 'string' && w.workspaceId && typeof w.path === 'string' && w.path)
1417
1493
  }
1494
+ const WORKBENCH_ORDER_CACHE_KEY = 'workbenchOrderV1'
1495
+ function workbenchOrderScope() { return String(state.server || location.origin || 'default') }
1496
+ function workbenchOrderStore() {
1497
+ const value = cacheRead(WORKBENCH_ORDER_CACHE_KEY, {})
1498
+ if (!value || typeof value !== 'object') return { scopes: {} }
1499
+ if (!value.scopes || typeof value.scopes !== 'object') value.scopes = {}
1500
+ return value
1501
+ }
1502
+ function workbenchOrderScopeValue() {
1503
+ const store = workbenchOrderStore()
1504
+ const key = workbenchOrderScope()
1505
+ if (!store.scopes[key] || typeof store.scopes[key] !== 'object') store.scopes[key] = {}
1506
+ return { store, value: store.scopes[key] }
1507
+ }
1508
+ function orderedItems(items, ids, getId) {
1509
+ const source = Array.isArray(items) ? items : []
1510
+ const byId = new Map(source.map(item => [String(getId(item)), item]))
1511
+ const result = []
1512
+ const used = new Set()
1513
+ for (const id of Array.isArray(ids) ? ids : []) {
1514
+ const key = String(id)
1515
+ const item = byId.get(key)
1516
+ if (item && !used.has(key)) { result.push(item); used.add(key) }
1517
+ }
1518
+ for (const item of source) {
1519
+ const key = String(getId(item))
1520
+ if (!used.has(key)) { result.push(item); used.add(key) }
1521
+ }
1522
+ return result
1523
+ }
1524
+ function orderedWorkspaceItems(items) {
1525
+ const { value } = workbenchOrderScopeValue()
1526
+ return orderedItems(items, value.workspaceIds, item => item.workspaceId)
1527
+ }
1528
+ function orderedWorkspaceSessions(workspaceId, items) {
1529
+ const { value } = workbenchOrderScopeValue()
1530
+ return orderedItems(items, value.sessionIds?.[String(workspaceId)], item => item.sessionId)
1531
+ }
1532
+ function saveWorkbenchOrder(mutator) {
1533
+ const { store, value } = workbenchOrderScopeValue()
1534
+ mutator(value)
1535
+ cacheWrite(WORKBENCH_ORDER_CACHE_KEY, store)
1536
+ }
1537
+ function commitWorkspaceOrder(order) {
1538
+ saveWorkbenchOrder(value => { value.workspaceIds = order.map(String) })
1539
+ renderWorkbench()
1540
+ toast(t('wb.orderSaved'), 'ok')
1541
+ }
1542
+ function commitWorkspaceSessionOrder(workspaceId, order) {
1543
+ if (!workspaceId) return
1544
+ saveWorkbenchOrder(value => {
1545
+ value.sessionIds ||= {}
1546
+ value.sessionIds[String(workspaceId)] = order.map(String)
1547
+ })
1548
+ renderWorkbench()
1549
+ toast(t('wb.orderSaved'), 'ok')
1550
+ }
1418
1551
  function workspaceById(workspaceId) {
1419
1552
  return workspaceItems().find(w => w.workspaceId === workspaceId) || null
1420
1553
  }
@@ -1541,32 +1674,48 @@ function renderWorkbench() {
1541
1674
  toggle.setAttribute('aria-expanded', state.wbOpen ? 'true' : 'false')
1542
1675
  panel.classList.toggle('hidden', !state.wbOpen)
1543
1676
  if (!state.wbOpen) { panel.innerHTML = ''; return }
1544
- const projects = state.wbProjects.filter(w => wbStrictInside(w.path, workbenchRoot()))
1677
+ const projects = orderedWorkspaceItems(state.wbProjects.filter(w => wbStrictInside(w.path, workbenchRoot())))
1545
1678
  if (!projects.length) {
1546
1679
  panel.innerHTML = `<div class="wb-empty">${esc(t('wb.noProjects'))}</div>`
1547
1680
  return
1548
1681
  }
1549
1682
  const archivedSet = new Set(state.wbArchived || [])
1550
- panel.innerHTML = projects.map(w => {
1683
+ const projectHtml = projects.map(w => {
1551
1684
  const id = String(w.workspaceId || '')
1552
1685
  const open = !!state.wbOpenProjects[id]
1553
- const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isTopLevelSession).filter(s => !archivedSet.has(s.sessionId))
1686
+ const sessions = orderedWorkspaceSessions(id, (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isTopLevelSession).filter(s => !archivedSet.has(s.sessionId)))
1554
1687
  const body = open ? `<div class="wb-sessions">${sessions.length ? sessions.map(s => `
1555
1688
  <div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
1556
- <button class="wb-session" type="button" data-wb-session="${esc(s.sessionId)}">
1689
+ <button class="wb-session" type="button" data-wb-session="${esc(s.sessionId)}" data-motion-key="${esc(s.sessionId)}">
1690
+ <span class="wb-session-drag-handle" data-reorder-handle aria-hidden="true">⠿</span>
1557
1691
  <span class="wb-session-title">${esc(titleOf(s))}</span>
1558
1692
  <span class="wb-session-meta">${s.running ? esc(t('sessions.running')) : esc(fmtTime(sessionSortTime(s)))}</span>
1559
1693
  </button>
1560
1694
  <button type="button" class="sc-archive-btn" data-archive-session="${esc(s.sessionId)}">${esc(t('session.archive'))}</button>
1561
1695
  </div>`).join('') : `<div class="wb-empty">${esc(t('wb.noSessions'))}</div>`}</div>` : ''
1562
- return `<div class="wb-project ${open ? 'open' : ''}" data-wb-project="${esc(id)}">
1696
+ return `<div class="wb-project ${open ? 'open' : ''}" data-wb-project="${esc(id)}" data-motion-key="${esc(id)}">
1563
1697
  <div class="wb-project-head">
1698
+ <span class="wb-drag-handle" data-reorder-handle aria-hidden="true">⠿</span>
1564
1699
  <span class="wb-chevron" aria-hidden="true">${open ? '▾' : '▸'}</span>
1565
1700
  <span class="wb-project-title">${esc(w.title || wbBaseName(w.path) || w.path)}</span>
1566
1701
  <button class="mini-btn wb-new" type="button" data-wb-new="${esc(id)}">${esc(t('wb.newSession'))}</button>
1567
1702
  </div>${body}
1568
1703
  </div>`
1569
1704
  }).join('')
1705
+ if (window.DshMotion?.relayout) {
1706
+ window.DshMotion.relayout(panel, '.wb-project', () => { panel.innerHTML = projectHtml })
1707
+ } else panel.innerHTML = projectHtml
1708
+ window.DshMotion?.list(panel, '.wb-session')
1709
+ window.DshMotion?.bindLongPressReorder(panel, '.wb-project', {
1710
+ handleSelector: '.wb-project-head',
1711
+ excludeSelector: '[data-wb-new]',
1712
+ onCommit: ({ order }) => commitWorkspaceOrder(order)
1713
+ })
1714
+ window.DshMotion?.bindLongPressReorder(panel, '.session-swipe', {
1715
+ groupSelector: '.wb-project',
1716
+ handleSelector: '.wb-session',
1717
+ onCommit: ({ item, order }) => commitWorkspaceSessionOrder(item.closest('[data-wb-project]')?.dataset.wbProject, order)
1718
+ })
1570
1719
  }
1571
1720
 
1572
1721
  function sessionCwd(s) { return typeof s?.cwd === 'string' ? s.cwd.trim() : '' }
@@ -1594,6 +1743,16 @@ function noteSessionTurnTime(sessionId, eventOrTime) {
1594
1743
  if (!sessionId || !Number.isFinite(time)) return
1595
1744
  state.sessionTurnTimes[sessionId] = Math.max(Number(state.sessionTurnTimes[sessionId]) || 0, time)
1596
1745
  }
1746
+ function sessionWorkspaceOrderKey(s) {
1747
+ const workspace = workspaceForSession(s)
1748
+ return String(workspace?.workspaceId || 'path:' + (sessionWorkspaceLabel(s) || WORKSPACE_UNGROUPED))
1749
+ }
1750
+ function commitWorkspaceGroupOrder(order) {
1751
+ saveWorkbenchOrder(value => { value.workspaceIds = order.map(String) })
1752
+ renderSessions()
1753
+ renderWorkbench()
1754
+ toast(t('wb.orderSaved'), 'ok')
1755
+ }
1597
1756
  function sortedSessions() {
1598
1757
  const items = topLevelSessions()
1599
1758
  if (state.sessionSort === 'workspace') {
@@ -1618,16 +1777,9 @@ function renderSessions() {
1618
1777
  const archived = visible.filter(s => archivedSet.has(s.sessionId))
1619
1778
  const main = visible.filter(s => !archivedSet.has(s.sessionId))
1620
1779
  const showArchived = LS.get('showArchivedV1', '0') === '1'
1621
- const renderItems = (items) => {
1622
- let lastWorkspace = null
1623
- const rows = []
1624
- for (const s of items) {
1780
+ const renderSession = s => {
1625
1781
  const workspace = sessionWorkspaceLabel(s)
1626
1782
  const workspaceTitle = sessionWorkspaceName(s)
1627
- if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
1628
- rows.push(`<div class="session-group-label" title="${esc(workspace)}"><span class="session-group-icon" aria-hidden="true">⌂</span><span class="session-group-name">${esc(workspaceTitle)}</span></div>`)
1629
- lastWorkspace = workspace
1630
- }
1631
1783
  const title = titleOf(s)
1632
1784
  const goal = goalOf(s)
1633
1785
  const pending = (state.approvals.some(a => a.sessionId === s.sessionId) || state.questions.some(q => q.sessionId === s.sessionId)) ? 'pending' : ''
@@ -1638,7 +1790,7 @@ function renderSessions() {
1638
1790
  const badge = goal ? `<span class="sc-badge ${goal.phase === 'active' ? 'goal-active' : ''}">${esc(t('sessions.goalBadge', { phase: goal.phase || '?' }))}</span>` : ''
1639
1791
  const queueBadge = queueN ? `<span class="sc-badge">${esc(t('sessions.queueBadge', { n: queueN }))}</span>` : ''
1640
1792
  const archiveButton = archivedSet.has(s.sessionId) ? '' : `<button type="button" class="sc-archive-btn" data-archive-session="${esc(s.sessionId)}">${esc(t('session.archive'))}</button>`
1641
- rows.push(`<div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
1793
+ return `<div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
1642
1794
  <div class="session-card ${state.current === s.sessionId ? 'current' : ''}">
1643
1795
  <div class="sc-title">${esc(title)}</div>
1644
1796
  <div class="sc-meta">
@@ -1651,13 +1803,42 @@ function renderSessions() {
1651
1803
  <span class="sc-arrow">›</span>
1652
1804
  </div>
1653
1805
  ${archiveButton}
1654
- </div>`)
1806
+ </div>`
1807
+ }
1808
+ const renderItems = (items) => {
1809
+ if (state.sessionSort !== 'workspace') return items.map(renderSession).join('')
1810
+ const groups = []
1811
+ const byKey = new Map()
1812
+ for (const session of items) {
1813
+ const key = sessionWorkspaceOrderKey(session)
1814
+ let group = byKey.get(key)
1815
+ if (!group) {
1816
+ group = { key, label: sessionWorkspaceName(session), path: sessionWorkspaceLabel(session), items: [] }
1817
+ byKey.set(key, group)
1818
+ groups.push(group)
1819
+ }
1820
+ group.items.push(session)
1655
1821
  }
1656
- return rows.join('')
1822
+ const { value } = workbenchOrderScopeValue()
1823
+ return orderedItems(groups, value.workspaceIds, group => group.key).map(group => `<div class="session-workspace-group" data-workspace-group="${esc(group.key)}" data-motion-key="${esc(group.key)}">
1824
+ <div class="session-group-label" data-reorder-handle title="${esc(group.path)}"><span class="session-group-drag-handle" aria-hidden="true">⠿</span><span class="session-group-icon" aria-hidden="true">⌂</span><span class="session-group-name">${esc(group.label)}</span></div>
1825
+ ${orderedWorkspaceSessions(group.key, group.items).map(renderSession).join('')}
1826
+ </div>`).join('')
1657
1827
  }
1658
1828
  const divider = archived.length ? `<button class="archived-toggle" type="button" data-archived-toggle>${esc(showArchived ? t('wb.archivedShown') : t('wb.archivedHidden'))}</button>` : ''
1659
1829
  const rows = renderItems(main) + divider + (showArchived ? renderItems(archived) : '')
1660
- list.innerHTML = rows || `<div class="empty">${esc(t('home.empty'))}</div>`
1830
+ const renderList = () => { list.innerHTML = rows || `<div class="empty">${esc(t('home.empty'))}</div>` }
1831
+ if (window.DshMotion?.relayout) window.DshMotion.relayout(list, '.session-swipe', renderList)
1832
+ else renderList()
1833
+ window.DshMotion?.bindLongPressReorder(list, '.session-workspace-group', {
1834
+ handleSelector: '.session-group-label',
1835
+ onCommit: ({ order }) => commitWorkspaceGroupOrder(order)
1836
+ })
1837
+ window.DshMotion?.bindLongPressReorder(list, '.session-swipe', {
1838
+ groupSelector: '.session-workspace-group',
1839
+ handleSelector: '.session-card',
1840
+ onCommit: ({ item, order }) => commitWorkspaceSessionOrder(item.closest('[data-workspace-group]')?.dataset.workspaceGroup, order)
1841
+ })
1661
1842
  list.classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1662
1843
  const sort = $('session-sort')
1663
1844
  if (sort) { sort.value = state.sessionSort; syncCustomSelect(sort) }
@@ -1674,9 +1855,12 @@ function renderSessions() {
1674
1855
  /* ---------------- 会话详情 ---------------- */
1675
1856
  async function openSession(id) {
1676
1857
  state.current = id
1858
+ setSessionRecovery('loading')
1677
1859
  state.history = emptyHistory()
1678
1860
  document.body.classList.add('in-session')
1679
1861
  showView('view-session')
1862
+ $('btn-rename-session').classList.remove('hidden')
1863
+ $('btn-archive-session').classList.toggle('hidden', (state.wbArchived || []).includes(id))
1680
1864
  $('session-cards').innerHTML = ''
1681
1865
  renderSessionTitle(); renderSessionSub(); updateCancelBtn(); updateSessionStatus()
1682
1866
  $('history').innerHTML = '<div class="empty">' + t('history.loading') + '</div>'
@@ -1687,14 +1871,49 @@ async function openSession(id) {
1687
1871
  refreshSessions()
1688
1872
  }
1689
1873
 
1690
- function closeSession() {
1691
- setComposerFullscreen(false)
1692
- clearComposerImages()
1693
- state.current = null
1694
- state.history = emptyHistory()
1695
- document.body.classList.remove('in-session')
1696
- hideComposerMenu()
1697
- 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
+ }
1698
1917
  }
1699
1918
 
1700
1919
  /* Android 手势返回/实体返回: 注册后系统不再直接杀 App, 由这里接管导航 */
@@ -1705,8 +1924,8 @@ function bindNativeBack() {
1705
1924
  if ($('composer-wrap')?.classList.contains('fs')) { setComposerFullscreen(false); return }
1706
1925
  if (customSelectCurrent) { closeCustomSelect(); return }
1707
1926
  const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
1708
- if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else if (openModal.id === 'modal-archive') closeArchiveConfirm(); else if (openModal.id === 'modal-app-version-warning') closeAppVersionWarning(false); else if (openModal.id === 'modal-scan-live') closeLiveScan(''); else openModal.classList.add('hidden'); return } // 先关弹窗
1709
- if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
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 } // 先关弹窗
1928
+ if (document.body.classList.contains('in-session')) { void closeSession(); return } // 会话页 → 回主页
1710
1929
  if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
1711
1930
  if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
1712
1931
  showView('view-home'); return
@@ -1728,6 +1947,8 @@ function renderSessionSub() {
1728
1947
  if (s.cwd) parts.push(s.cwd)
1729
1948
  if (s.running) parts.push(t('session.running'))
1730
1949
  else if (s.error) parts.push(t('session.interrupted'))
1950
+ const recovery = recoveryLabel()
1951
+ if (recovery) parts.push(recovery)
1731
1952
  $('session-sub').textContent = parts.join(' · ')
1732
1953
  }
1733
1954
 
@@ -1755,11 +1976,19 @@ const HISTORY_MAX_VISIBLE = 5000 // 已加载的可显示事件上限(消息/
1755
1976
  function emptyHistory() {
1756
1977
  return {
1757
1978
  visible: [], seqs: new Set(), minSeq: Infinity,
1758
- hasMore: false, loading: false, renderStart: 0, renderEnd: 0,
1979
+ hasMore: false, loading: false, loaded: false, renderStart: 0, renderEnd: 0,
1759
1980
  partialReasoning: new Map()
1760
1981
  }
1761
1982
  }
1762
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
+
1763
1992
  function reasoningStreamKey(data, index) {
1764
1993
  return `${data?.turn ?? '?'}:${data?.step ?? '?'}:${index ?? '?'}`
1765
1994
  }
@@ -1867,6 +2096,7 @@ function restoreCachedHistory() {
1867
2096
  if (!id) return false
1868
2097
  const cached = readHistoryCache()[id]
1869
2098
  if (!cached?.events?.length) return false
2099
+ if (cached.title) hydrateSessionProjections(id, { values: { title: cached.title }, asOfSeq: 0 })
1870
2100
  const h = emptyHistory()
1871
2101
  for (const e of cached.events) {
1872
2102
  if (e?.seq == null) continue
@@ -1874,6 +2104,7 @@ function restoreCachedHistory() {
1874
2104
  h.visible.push(e)
1875
2105
  }
1876
2106
  h.visible.sort((a, b) => a.seq - b.seq)
2107
+ h.loaded = true
1877
2108
  state.history = h
1878
2109
  $('history-hint').textContent = t('history.offlineCache', { n: h.visible.length })
1879
2110
  renderHistory(true)
@@ -1883,7 +2114,9 @@ function restoreCachedHistory() {
1883
2114
  async function loadHistory(reset) {
1884
2115
  const id = state.current
1885
2116
  if (!id || state.history.loading) return
1886
- state.history.loading = true
2117
+ const history = state.history
2118
+ history.loading = true
2119
+ if (reset) setSessionRecovery('loading')
1887
2120
  const moreBtn = $('history-more')
1888
2121
  if (moreBtn) moreBtn.classList.add('hidden')
1889
2122
  const payload = { sessionId: id, maxMessages: 60 }
@@ -1893,13 +2126,16 @@ async function loadHistory(reset) {
1893
2126
  try {
1894
2127
  v = await rpc('session.history', payload)
1895
2128
  } catch (e) {
1896
- state.history.loading = false
2129
+ if (state.current !== id || state.history !== history) return
2130
+ history.loading = false
1897
2131
  if (e.message === 'AUTH') { authFailure(); return }
1898
2132
  if (restoreCachedHistory()) {
2133
+ setSessionRecovery('cached', e.message)
1899
2134
  toast(t('history.cacheFallback'), 'ok')
1900
2135
  return
1901
2136
  }
1902
2137
  const msg = e.message || t('err.dshError')
2138
+ setSessionRecovery('error', msg)
1903
2139
  const box = $('history')
1904
2140
  if (box && (reset || !state.history.visible.length)) {
1905
2141
  box.innerHTML = `<div class="empty"><div>${esc(t('history.loadFailed', { msg }))}</div><button type="button" class="mini-btn" id="btn-history-retry" style="margin-top:10px">${esc(t('history.retry'))}</button></div>`
@@ -1911,6 +2147,9 @@ async function loadHistory(reset) {
1911
2147
  return
1912
2148
  }
1913
2149
 
2150
+ if (state.current !== id || state.history !== history) return
2151
+ hydrateSessionProjections(id, v.projections)
2152
+ history.loaded = true
1914
2153
  const incoming = v.events || []
1915
2154
  let added = 0
1916
2155
  if (reset) state.history.partialReasoning.clear()
@@ -1931,7 +2170,9 @@ async function loadHistory(reset) {
1931
2170
  state.history.visible.sort((a, b) => a.seq - b.seq)
1932
2171
  trimVisible()
1933
2172
  state.history.hasMore = !!v.hasMore
1934
- state.history.loading = false
2173
+ history.loading = false
2174
+ setSessionRecovery('ready')
2175
+ renderSessionTitle(); renderSessionSub(); renderSessionCards()
1935
2176
  try {
1936
2177
  if (reset) renderHistory(true)
1937
2178
  else if (added) renderHistory(false, 'keep')
@@ -2249,10 +2490,15 @@ async function renderSessionCards() {
2249
2490
  const running = e.activity === 'running'
2250
2491
  return `<div class="card-row"><span class="k">${running ? '▶ ' : ''}${esc(label)}</span><span class="v">${esc(e.mode)} ${running ? t('subagent.running') : ''}${e.mode === 'continuable' && running ? ` <button class="mini-btn" data-sub-interrupt="${esc(e.id)}">${t('subagent.interrupt')}</button>` : ''}</span></div>`
2251
2492
  }).join('')
2252
- box.insertAdjacentHTML('beforeend', `<div class="card subagent-card"><button type="button" class="subagent-toggle" data-subagent-toggle aria-expanded="${expanded}" aria-label="${esc(toggleLabel)}" title="${esc(toggleLabel)}"><span class="card-title">${esc(t('subagent.count', { n: sub.entries.length }))}</span><span class="subagent-toggle-icon" aria-hidden="true">${expanded ? '⌃' : '⌄'}</span></button><div class="subagent-list${expanded ? '' : ' hidden'}">${rows}</div></div>`)
2493
+ const subagentClosedIcon = 'M7 10l5 5 5-5'
2494
+ const subagentOpenIcon = 'M7 14l5-5 5 5'
2495
+ const subagentIcon = expanded ? subagentOpenIcon : subagentClosedIcon
2496
+ box.insertAdjacentHTML('beforeend', `<div class="card subagent-card"><button type="button" class="subagent-toggle" data-subagent-toggle aria-expanded="${expanded}" aria-label="${esc(toggleLabel)}" title="${esc(toggleLabel)}"><span class="card-title">${esc(t('subagent.count', { n: sub.entries.length }))}</span><span class="subagent-toggle-icon" aria-hidden="true"><morph-icon data-morph-state="${expanded ? 'open' : 'closed'}" data-morph-closed="${subagentClosedIcon}" data-morph-open="${subagentOpenIcon}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${subagentIcon}"/></svg></morph-icon></span></button><div class="subagent-list${expanded ? '' : ' hidden'}">${rows}</div></div>`)
2253
2497
  box.querySelector('[data-subagent-toggle]')?.addEventListener('click', () => {
2498
+ const icon = box.querySelector('[data-subagent-toggle] morph-icon')
2499
+ if (icon) icon.setAttribute('data-morph-state', expanded ? 'closed' : 'open')
2254
2500
  state.subagentExpandedSession = expanded ? '' : sessionId
2255
- renderSessionCards()
2501
+ setTimeout(() => renderSessionCards(), 240)
2256
2502
  })
2257
2503
  box.querySelectorAll('[data-sub-interrupt]').forEach(btn =>
2258
2504
  btn.addEventListener('click', () => interruptSubagent(btn.dataset.subInterrupt)))
@@ -2422,29 +2668,48 @@ async function sendSessionText(text) {
2422
2668
  async function sendSessionContent(text, images) {
2423
2669
  const clean = String(text || '').trim()
2424
2670
  if ((!clean && !images.length) || !state.current) return false
2425
- 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
+ }
2426
2676
  const buttons = [$('btn-send'), $('btn-fs-send')].filter(Boolean)
2427
2677
  buttons.forEach(button => { button.disabled = true })
2678
+ state.pendingPrompts.add(sessionId)
2428
2679
  try {
2429
2680
  const content = [...await encodeComposerImagesFor(images)]
2430
2681
  if (clean) content.push({ type: 'text', text: clean })
2682
+ setSessionRecovery('resuming')
2431
2683
  const v = await safeRpc('session.prompt', {
2432
- sessionId: state.current,
2684
+ sessionId,
2433
2685
  mode: 'queue',
2434
2686
  content
2435
2687
  }, t('send.failed'))
2436
2688
  if (v?.accepted) {
2437
- noteSessionTurnTime(state.current, Date.now())
2438
- renderSessions()
2439
- 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
+ }
2440
2696
  return true
2441
2697
  }
2442
- if (v?.command?.text) { toast(t('send.commandExecuted'), 'ok'); return true }
2698
+ if (v?.command?.text) {
2699
+ state.sessionActivity.add(sessionId)
2700
+ if (state.current === sessionId) toast(t('send.commandExecuted'), 'ok')
2701
+ return true
2702
+ }
2703
+ if (state.current === sessionId) setSessionRecovery('error')
2443
2704
  return false
2444
2705
  } catch (e) {
2445
- 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
+ }
2446
2710
  return false
2447
2711
  } finally {
2712
+ state.pendingPrompts.delete(sessionId)
2448
2713
  buttons.forEach(button => { button.disabled = false })
2449
2714
  }
2450
2715
  }
@@ -2603,7 +2868,41 @@ async function cancelSession() {
2603
2868
  if (!state.current) return
2604
2869
  if (!confirm(t('session.confirmStop'))) return
2605
2870
  const v = await safeRpc('session.cancel', { sessionId: state.current }, t('session.stopFailed'))
2606
- if (v?.accepted) toast(t('session.stopRequested'), 'ok')
2871
+ if (v?.accepted) { setSessionRecovery('ready'); toast(t('session.stopRequested'), 'ok') }
2872
+ }
2873
+
2874
+ let renamePendingSessionId = null
2875
+ function renameSession(sessionId = state.current) {
2876
+ const session = state.byId.get(sessionId)
2877
+ if (!session) return
2878
+ renamePendingSessionId = sessionId
2879
+ $('rename-session-input').value = titleOf(session)
2880
+ $('modal-rename').classList.remove('hidden')
2881
+ setTimeout(() => { $('rename-session-input').focus(); $('rename-session-input').select() }, 40)
2882
+ }
2883
+ function closeRenameSession() {
2884
+ renamePendingSessionId = null
2885
+ $('modal-rename').classList.add('hidden')
2886
+ }
2887
+ async function confirmRenameSession() {
2888
+ const sessionId = renamePendingSessionId
2889
+ if (!sessionId) return
2890
+ const title = $('rename-session-input').value.trim()
2891
+ if (!title) return toast(t('session.renameEmpty'), 'err')
2892
+ const button = $('rename-confirm')
2893
+ button.disabled = true
2894
+ setSessionRecovery('resuming')
2895
+ try {
2896
+ const value = await safeRpc('session.rename', { sessionId, title }, t('session.renameFailed'))
2897
+ if (value == null) { setSessionRecovery('error'); return }
2898
+ if (value.title) applyProjection(sessionId, 'title', value.title, value.seq)
2899
+ closeRenameSession()
2900
+ setSessionRecovery('ready')
2901
+ toast(t('session.renamed'), 'ok')
2902
+ await refreshSessions()
2903
+ } finally {
2904
+ button.disabled = false
2905
+ }
2607
2906
  }
2608
2907
 
2609
2908
  async function newSession() {
@@ -2673,6 +2972,7 @@ async function confirmArchiveSession() {
2673
2972
  closeArchiveConfirm()
2674
2973
  toast(t('session.archived'), 'ok')
2675
2974
  await refreshSessions()
2975
+ if (state.current === sessionId) void closeSession()
2676
2976
  } finally {
2677
2977
  button.disabled = false
2678
2978
  }
@@ -3346,6 +3646,8 @@ async function runFsUpload(up) {
3346
3646
  xhr.setRequestHeader('authorization', 'Bearer ' + state.token)
3347
3647
  xhr.setRequestHeader('x-dsh-remote-client', CAP?.isNativePlatform?.() ? 'app' : 'web')
3348
3648
  if (CLIENT_ID) xhr.setRequestHeader('x-dsh-remote-client-id', CLIENT_ID)
3649
+ if (params.offset != null) xhr.setRequestHeader('Upload-Offset', String(params.offset))
3650
+ if (params.size != null) xhr.setRequestHeader('Upload-Length', String(params.size))
3349
3651
  xhr.upload.onprogress = (e) => {
3350
3652
  if (e.lengthComputable) {
3351
3653
  const loaded = up.offset + Math.min(e.loaded, e.total)
@@ -3370,7 +3672,7 @@ async function runFsUpload(up) {
3370
3672
  })
3371
3673
 
3372
3674
  const probe = async () => {
3373
- const res = await fetch(fsApiUrl('/upload-probe', { path: up.path, name: up.name, session: up.session }), { headers: fsHeaders() })
3675
+ const res = await fetch(fsApiUrl('/upload-probe', { path: up.path, name: up.name, session: up.session, size: String(up.size) }), { headers: fsHeaders() })
3374
3676
  if (res.status === 401) { fsAuthError(401); return null }
3375
3677
  const json = await res.json().catch(() => ({}))
3376
3678
  if (json.ok) up.offset = json.partialSize || 0
@@ -3402,7 +3704,7 @@ async function runFsUpload(up) {
3402
3704
  }
3403
3705
  hasher.update(chunkBytes)
3404
3706
  const isLast = end >= up.size
3405
- const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset) }
3707
+ const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset), size: String(up.size) }
3406
3708
  if (isLast) { params.finish = '1'; params.sha256 = hasher.hex() }
3407
3709
  if (overwrite) params.overwrite = '1'
3408
3710
  const r = await uploadChunk(params, blob)
@@ -3444,7 +3746,7 @@ async function runFsUpload(up) {
3444
3746
  // 发一个空 finish 块完成收尾, 同时带上全量 SHA-256 校验
3445
3747
  if (up.offset >= up.size) {
3446
3748
  const expected = hasher.hex()
3447
- const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset), finish: '1', sha256: expected }
3749
+ const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset), size: String(up.size), finish: '1', sha256: expected }
3448
3750
  if (overwrite) params.overwrite = '1'
3449
3751
  const r = await uploadChunk(params, new Blob([]))
3450
3752
  if (r.status === 401) { fsAuthError(401); return }
@@ -4264,7 +4566,7 @@ function saveBgConfig(enabled) {
4264
4566
  const b = bgBridge()
4265
4567
  if (!b?.saveBackgroundConfig) return false
4266
4568
  const base = bgBase()
4267
- const intervalMin = parseFloat($('bg-interval')?.value || '1') || 1
4569
+ const intervalMin = parseFloat($('bg-interval')?.value || '0.5') || 0.5
4268
4570
  const notifyTaskDone = $('opt-task-done')?.checked !== false
4269
4571
  b.saveBackgroundConfig(JSON.stringify({ enabled, intervalMin, base, token: state.token || '', clientId: CLIENT_ID || '', notifyTaskDone }))
4270
4572
  if (enabled) $('bg-auth-status')?.classList.add('hidden')
@@ -4391,12 +4693,723 @@ async function restorePeakReminders() {
4391
4693
  if (peakRemindOn() && legacyCleaned) await schedulePeakReminders({ legacyCleaned: true })
4392
4694
  }
4393
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
+ }
4394
5406
  /* ---------------- 视图切换 ---------------- */
4395
5407
  function showView(id) {
4396
5408
  for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-stats', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
4397
5409
  // 离开会话页必须清掉 in-session, 否则其他页面顶栏被 body 样式隐藏
4398
5410
  document.body.classList.toggle('in-session', id === 'view-session')
4399
5411
  document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === id))
5412
+ window.DshMotion?.view($(id))
4400
5413
  window.scrollTo(0, 0)
4401
5414
  if (id === 'view-files' && !state.fs.loaded) {
4402
5415
  const workspace = workspaceById(state.fs.workspaceId)
@@ -4406,7 +5419,7 @@ function showView(id) {
4406
5419
  if (id === 'view-settings') showSettingsHome()
4407
5420
  }
4408
5421
 
4409
- const SETTINGS_GROUPS = ['general', 'servers', 'notify', 'theme', 'about']
5422
+ const SETTINGS_GROUPS = ['general', 'model', 'tests', 'servers', 'notify', 'theme', 'about']
4410
5423
  function showSettingsHome() {
4411
5424
  const home = $('settings-home')
4412
5425
  if (!home) return
@@ -4420,6 +5433,7 @@ function showSettingsPage(name) {
4420
5433
  home.classList.add('hidden')
4421
5434
  for (const g of SETTINGS_GROUPS) $('settings-page-' + g)?.classList.toggle('hidden', g !== name)
4422
5435
  window.scrollTo(0, 0)
5436
+ if (name === 'model') void loadModelSettings()
4423
5437
  }
4424
5438
 
4425
5439
  function updateConn() {
@@ -4485,8 +5499,7 @@ function updateComposerFullscreenButton() {
4485
5499
  const shouldShow = active || input.scrollHeight > 120
4486
5500
  button.classList.toggle('hidden', !shouldShow)
4487
5501
  $('composer-input-wrap')?.classList.toggle('has-fs-btn', shouldShow)
4488
- $('fs-ico-expand')?.classList.toggle('hidden', active)
4489
- $('fs-ico-collapse')?.classList.toggle('hidden', !active)
5502
+ $('fs-ico')?.setAttribute('data-morph-state', active ? 'open' : 'closed')
4490
5503
  button.title = t(active ? 'composer.exitFullscreen' : 'composer.fullscreen')
4491
5504
  button.setAttribute('aria-label', t(active ? 'composer.exitFullscreen' : 'composer.fullscreen'))
4492
5505
  }
@@ -4545,19 +5558,24 @@ function bindComposerFullscreenGesture() {
4545
5558
  }
4546
5559
 
4547
5560
  /* ---------------- 初始化 ---------------- */
4548
- /** 解析 dshremote://pair?token=..&server=.. 配对二维码 */
5561
+ /** 解析 dshremote://pair?token=..&server=.. 配对二维码;server 可重复以携带多个主机地址。 */
4549
5562
  function applyPairUrl(url) {
4550
5563
  try {
4551
5564
  const u = new URL(String(url).trim())
4552
5565
  if (u.protocol !== 'dshremote:' || u.hostname !== 'pair') return false
4553
5566
  const tok = (u.searchParams.get('token') || '').trim()
4554
- const server = (u.searchParams.get('server') || '').trim().replace(/\/+$/, '')
4555
- 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
4556
5571
  state.token = tok
4557
5572
  LS.set('token', tok)
4558
- state.server = server
4559
- if (!state.servers.some(s => s.url === server)) {
4560
- 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
+ }
4561
5579
  }
4562
5580
  saveServers()
4563
5581
  renderServers()
@@ -4871,6 +5889,10 @@ function dshControlFailureText(value) {
4871
5889
  INVALID_SERVICE: 'settings.dshErrorInvalidService',
4872
5890
  SYSTEMCTL_NOT_FOUND: 'settings.dshErrorSystemctlNotFound',
4873
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',
4874
5896
  PERMISSION_DENIED: 'settings.dshErrorPermissionDenied',
4875
5897
  COMMAND_TIMEOUT: 'settings.dshErrorCommandTimeout',
4876
5898
  COMMAND_FAILED: 'settings.dshErrorCommandFailed',
@@ -5105,6 +6127,8 @@ function bindUi() {
5105
6127
  renderAnnouncementBoard()
5106
6128
  renderPending(); renderQueue(); renderJobs()
5107
6129
  updateConn()
6130
+ if (state.modelSettings.status === 'ready' || state.modelSettings.status === 'error' || state.modelSettings.status === 'loading') renderModelSettings()
6131
+ renderAsrTest()
5108
6132
  if (state.current) { renderSessionTitle(); renderSessionSub(); renderSessionCards(); renderHistory(true) }
5109
6133
  else renderModelMenu()
5110
6134
  loadLocalVersion()
@@ -5198,7 +6222,7 @@ function bindUi() {
5198
6222
  const session = e.target.closest('[data-wb-session]')
5199
6223
  if (session) openSession(session.dataset.wbSession)
5200
6224
  })
5201
- $('btn-back').addEventListener('click', closeSession)
6225
+ $('btn-back').addEventListener('click', () => { void closeSession() })
5202
6226
  $('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
5203
6227
  $('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
5204
6228
  $('btn-refresh').addEventListener('click', () => { toast(t('common.refreshing')); openStreams(); refreshAll() })
@@ -5275,6 +6299,12 @@ function bindUi() {
5275
6299
  })
5276
6300
  $('modal-file-preview').addEventListener('click', (e) => { if (e.target === $('modal-file-preview')) closeFsPreview() })
5277
6301
  $('btn-cancel').addEventListener('click', cancelSession)
6302
+ $('btn-rename-session').addEventListener('click', () => renameSession())
6303
+ $('btn-archive-session').addEventListener('click', () => archiveSession(state.current))
6304
+ $('rename-cancel').addEventListener('click', closeRenameSession)
6305
+ $('rename-confirm').addEventListener('click', confirmRenameSession)
6306
+ $('rename-session-input').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.isComposing) confirmRenameSession() })
6307
+ $('modal-rename').addEventListener('click', e => { if (e.target === $('modal-rename')) closeRenameSession() })
5278
6308
  $('btn-send').addEventListener('click', sendMessage)
5279
6309
  $('btn-fs-send').addEventListener('click', sendMessage)
5280
6310
  $('btn-plus').addEventListener('click', toggleComposerMenu)
@@ -5399,6 +6429,17 @@ function bindUi() {
5399
6429
  if (group) { showSettingsPage(group.dataset.settingsGroup); return }
5400
6430
  if (e.target.closest('[data-settings-back]')) { showSettingsHome(); return }
5401
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()
5402
6443
  $('btn-scan-camera').addEventListener('click', () => scanPair('CAMERA'))
5403
6444
  $('btn-scan-gallery').addEventListener('click', () => scanPair('PHOTOS'))
5404
6445
  $('scan-live-cancel')?.addEventListener('click', () => closeLiveScan(''))