dsh-remote-plugin 0.6.12 → 0.6.14

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
@@ -13,14 +13,16 @@ const LS = {
13
13
  }
14
14
  const CLIENT_ID = (() => {
15
15
  try {
16
- let id = sessionStorage.getItem('dshRemoteClientId')
16
+ const key = 'dshRemoteClientIdV2'
17
+ let id = localStorage.getItem(key)
17
18
  if (!id) {
18
19
  id = (globalThis.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`)
19
- sessionStorage.setItem('dshRemoteClientId', id)
20
+ localStorage.setItem(key, id)
20
21
  }
21
22
  return id
22
23
  } catch { return '' }
23
24
  })()
25
+ function clientIdHeaders() { return CLIENT_ID ? { 'x-dsh-remote-client-id': CLIENT_ID } : {} }
24
26
 
25
27
  /* 离线缓存: 会话列表 + 每会话聊天记录。只在网络失败时兜底展示, 不会替代线上数据。 */
26
28
  const CACHE = {
@@ -55,6 +57,7 @@ const state = {
55
57
  autoSelect: { '默认': true }, // 组内自动测速选优 / 手动指定
56
58
  groupActive: { '默认': '' }, // 每组当前生效的 server id(手动模式)
57
59
  serverLatency: {}, // url -> 最近一次 /health 测速毫秒数
60
+ gatewayHealth: {}, // url -> /health 协议版本与能力声明
58
61
  selectingServer: false, // 防重入: 测速/切换中
59
62
  sessions: [],
60
63
  sessionSort: LS.get('sessionSort', 'time') === 'workspace' ? 'workspace' : 'time',
@@ -64,11 +67,14 @@ const state = {
64
67
  hostInfo: null,
65
68
  localVersion: '',
66
69
  updateInfo: null,
70
+ warnedGatewayVersions: new Set(),
67
71
  announcement: null,
68
72
  announcements: [],
69
73
  approvals: [], // 待处理审批
70
74
  questions: [], // 待处理提问
71
75
  queues: {}, // sessionId -> queue items
76
+ queueSteering: {}, // sessionId:itemId -> pending steer request
77
+ sessionTurnTimes: {}, // sessionId -> 本轮开始/结束时间,避免中间事件推动排序
72
78
  jobs: {}, // sessionId -> jobs
73
79
  history: emptyHistory(),
74
80
  errCount: 0,
@@ -86,7 +92,8 @@ const state = {
86
92
  wbProjects: [],
87
93
  wbArchived: [],
88
94
  wbOpen: false,
89
- wbOpenProjects: {}
95
+ wbOpenProjects: {},
96
+ subagentExpandedSession: ''
90
97
  }
91
98
 
92
99
  const $ = (id) => document.getElementById(id)
@@ -355,11 +362,13 @@ async function getWsTicket() {
355
362
  const token = state.token
356
363
  const server = state.server
357
364
  wsTicketPromise = (async () => {
365
+ if (activeGatewayCapability('wsTicket') === false) throw new Error('ws ticket unsupported')
358
366
  const res = await fetch(apiUrl('/api/ws-ticket'), {
359
367
  method: 'POST',
360
368
  headers: {
361
369
  authorization: 'Bearer ' + token,
362
370
  'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web',
371
+ ...clientIdHeaders(),
363
372
  }
364
373
  })
365
374
  if (!res.ok) throw new Error('ws ticket HTTP ' + res.status)
@@ -411,7 +420,7 @@ async function loadStats() {
411
420
  }
412
421
  try {
413
422
  const res = await fetch(apiUrl('/stats/summary?days=7'), {
414
- headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
423
+ headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }
415
424
  })
416
425
  if (res.status === 401) { authFailure(); return }
417
426
  if (!res.ok) throw new Error('HTTP ' + res.status)
@@ -477,7 +486,7 @@ function renderStats(days) {
477
486
  async function rpc(method, payload = {}, timeoutMs = 45000) {
478
487
  const opts = {
479
488
  method: 'POST',
480
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
489
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() },
481
490
  body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
482
491
  }
483
492
  if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
@@ -498,7 +507,7 @@ async function rpc(method, payload = {}, timeoutMs = 45000) {
498
507
  async function respond(rpcId, value) {
499
508
  const opts = {
500
509
  method: 'POST',
501
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
510
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() },
502
511
  body: JSON.stringify({ type: 'client-response', rpcId, result: { ok: true, value } })
503
512
  }
504
513
  if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
@@ -621,7 +630,10 @@ async function pingServer(base) {
621
630
  const timer = setTimeout(() => ctrl.abort(), 3500)
622
631
  try {
623
632
  const res = await fetch(u + '/health?t=' + Date.now(), { signal: ctrl.signal, cache: 'no-store' })
624
- return res.ok ? Math.round(performance.now() - t0) : Infinity
633
+ if (!res.ok) return Infinity
634
+ const health = await res.json().catch(() => null)
635
+ if (health && typeof health === 'object') state.gatewayHealth[u] = health
636
+ return Math.round(performance.now() - t0)
625
637
  } catch {
626
638
  return Infinity
627
639
  } finally {
@@ -629,6 +641,13 @@ async function pingServer(base) {
629
641
  }
630
642
  }
631
643
 
644
+ function activeGatewayCapability(name) {
645
+ const key = String(state.server || location.origin || '').replace(/\/+$/, '')
646
+ const capabilities = state.gatewayHealth[key]?.capabilities
647
+ if (!capabilities || !Object.prototype.hasOwnProperty.call(capabilities, name)) return null
648
+ return Number(capabilities[name]) > 0
649
+ }
650
+
632
651
  async function selectFastestServer({ silent = false, reconnect = true } = {}) {
633
652
  if (state.selectingServer) return null
634
653
  state.selectingServer = true
@@ -677,6 +696,7 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
677
696
  else if (chosen) toast(t('speed.manualUsing', { url: chosen, ms: Number.isFinite(ms) ? ms : '—' }), 'ok')
678
697
  else toast(t('speed.allDown'), 'err')
679
698
  }
699
+ await maybeWarnAppBehindGateway()
680
700
  return chosen
681
701
  } finally {
682
702
  state.selectingServer = false
@@ -1149,7 +1169,7 @@ async function pollKind(kind) {
1149
1169
  let res
1150
1170
  try {
1151
1171
  const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(5000) : undefined
1152
- const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
1172
+ const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }
1153
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 })
1154
1174
  } catch { return }
1155
1175
  if (res.status === 401) { authFailure(); return }
@@ -1281,7 +1301,10 @@ function onHostFrame(full) {
1281
1301
  function onSessionEvent(sessionId, event) {
1282
1302
  if (!event) return
1283
1303
  const s = state.byId.get(sessionId)
1284
- if (s) s.updatedAt = Date.now()
1304
+ if (event.type === 'turn/start' || event.type === 'turn/end') {
1305
+ noteSessionTurnTime(sessionId, event)
1306
+ renderSessions()
1307
+ }
1285
1308
  if (event.type === 'agent/status') {
1286
1309
  if (s) { s.running = !!event.data?.running; s.blank = false; if (s.running) s.error = false }
1287
1310
  if (state.current === sessionId) { updateCancelBtn(); renderSessionSub(); updateSessionStatus() }
@@ -1343,6 +1366,10 @@ function applyProjection(sessionId, key, value, seq) {
1343
1366
  }
1344
1367
  function titleOf(s) { return proj(s, 'title') || (s?.sessionId ? short(s.sessionId) : t('session.unknown')) }
1345
1368
  function short(id) { return '…' + String(id).slice(-8) }
1369
+ function isTopLevelSession(session) {
1370
+ return !!session && !session.parentSessionId && session.origin !== 'subagent'
1371
+ }
1372
+ function topLevelSessions() { return state.sessions.filter(isTopLevelSession) }
1346
1373
  const GOAL_TERMINAL_PHASES = new Set(['complete', 'cleared'])
1347
1374
  function isGoalTerminal(goal) {
1348
1375
  return !!goal && GOAL_TERMINAL_PHASES.has(goal.phase)
@@ -1451,7 +1478,7 @@ async function refreshWorkbench() {
1451
1478
  if (!state.token) return
1452
1479
  try {
1453
1480
  const res = await fetch(apiUrl('/workbench'), {
1454
- headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
1481
+ headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }
1455
1482
  })
1456
1483
  if (res.ok) {
1457
1484
  const value = await res.json().catch(() => null)
@@ -1523,12 +1550,12 @@ function renderWorkbench() {
1523
1550
  panel.innerHTML = projects.map(w => {
1524
1551
  const id = String(w.workspaceId || '')
1525
1552
  const open = !!state.wbOpenProjects[id]
1526
- const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(Boolean).filter(s => !archivedSet.has(s.sessionId))
1553
+ const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isTopLevelSession).filter(s => !archivedSet.has(s.sessionId))
1527
1554
  const body = open ? `<div class="wb-sessions">${sessions.length ? sessions.map(s => `
1528
1555
  <div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
1529
1556
  <button class="wb-session" type="button" data-wb-session="${esc(s.sessionId)}">
1530
1557
  <span class="wb-session-title">${esc(titleOf(s))}</span>
1531
- <span class="wb-session-meta">${s.running ? esc(t('sessions.running')) : esc(fmtTime(s.updatedAt))}</span>
1558
+ <span class="wb-session-meta">${s.running ? esc(t('sessions.running')) : esc(fmtTime(sessionSortTime(s)))}</span>
1532
1559
  </button>
1533
1560
  <button type="button" class="sc-archive-btn" data-archive-session="${esc(s.sessionId)}">${esc(t('session.archive'))}</button>
1534
1561
  </div>`).join('') : `<div class="wb-empty">${esc(t('wb.noSessions'))}</div>`}</div>` : ''
@@ -1558,17 +1585,26 @@ function workspaceDisplayName(label) {
1558
1585
  const parts = clean.split(/[\\/]/).filter(Boolean)
1559
1586
  return parts[parts.length - 1] || value
1560
1587
  }
1588
+ function sessionSortTime(s) {
1589
+ return Math.max(Number(state.sessionTurnTimes[s?.sessionId]) || 0, Number(s?.updatedAt) || 0, Number(s?.createdAt) || 0)
1590
+ }
1591
+ function noteSessionTurnTime(sessionId, eventOrTime) {
1592
+ const raw = typeof eventOrTime === 'object' ? eventOrTime?.time : eventOrTime
1593
+ const time = Number(raw) > 0 ? Number(raw) : Date.now()
1594
+ if (!sessionId || !Number.isFinite(time)) return
1595
+ state.sessionTurnTimes[sessionId] = Math.max(Number(state.sessionTurnTimes[sessionId]) || 0, time)
1596
+ }
1561
1597
  function sortedSessions() {
1562
- const items = [...state.sessions]
1598
+ const items = topLevelSessions()
1563
1599
  if (state.sessionSort === 'workspace') {
1564
1600
  return items.sort((a, b) => {
1565
1601
  const aw = sessionCwd(a) || '\uffff'
1566
1602
  const bw = sessionCwd(b) || '\uffff'
1567
1603
  const byWorkspace = aw.localeCompare(bw, undefined, { numeric: true, sensitivity: 'base' })
1568
- return byWorkspace || ((b.updatedAt || 0) - (a.updatedAt || 0))
1604
+ return byWorkspace || (sessionSortTime(b) - sessionSortTime(a))
1569
1605
  })
1570
1606
  }
1571
- return items.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
1607
+ return items.sort((a, b) => sessionSortTime(b) - sessionSortTime(a))
1572
1608
  }
1573
1609
  function renderSessions() {
1574
1610
  const list = $('session-list')
@@ -1607,7 +1643,7 @@ function renderSessions() {
1607
1643
  <div class="sc-title">${esc(title)}</div>
1608
1644
  <div class="sc-meta">
1609
1645
  <span class="sc-dot ${dots.join(' ')}"></span>
1610
- <span>${fmtTime(s.updatedAt)}</span>
1646
+ <span>${fmtTime(sessionSortTime(s))}</span>
1611
1647
  ${s.running ? '<span>' + t('sessions.running') + '</span>' : ''}
1612
1648
  ${badge}${queueBadge}
1613
1649
  </div>
@@ -1626,7 +1662,7 @@ function renderSessions() {
1626
1662
  const sort = $('session-sort')
1627
1663
  if (sort) { sort.value = state.sessionSort; syncCustomSelect(sort) }
1628
1664
  $('home-empty').classList.toggle('hidden', visible.length > 0)
1629
- const running = state.sessions.filter(s => s.running).length
1665
+ const running = topLevelSessions().filter(s => s.running).length
1630
1666
  const pending = state.approvals.length + state.questions.length
1631
1667
  $('stat-strip').innerHTML = `
1632
1668
  <div class="stat running"><div class="v">${running}</div><div class="k">${t('sessions.statRunning')}</div></div>
@@ -1644,6 +1680,7 @@ async function openSession(id) {
1644
1680
  $('session-cards').innerHTML = ''
1645
1681
  renderSessionTitle(); renderSessionSub(); updateCancelBtn(); updateSessionStatus()
1646
1682
  $('history').innerHTML = '<div class="empty">' + t('history.loading') + '</div>'
1683
+ renderQueue()
1647
1684
  restoreCachedHistory()
1648
1685
  await loadHistory(true)
1649
1686
  renderSessionCards()
@@ -1668,7 +1705,7 @@ function bindNativeBack() {
1668
1705
  if ($('composer-wrap')?.classList.contains('fs')) { setComposerFullscreen(false); return }
1669
1706
  if (customSelectCurrent) { closeCustomSelect(); return }
1670
1707
  const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
1671
- if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else if (openModal.id === 'modal-archive') closeArchiveConfirm(); else openModal.classList.add('hidden'); return } // 先关弹窗
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 } // 先关弹窗
1672
1709
  if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
1673
1710
  if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
1674
1711
  if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
@@ -1699,6 +1736,8 @@ function updateSessionStatus() {
1699
1736
  const s = state.byId.get(state.current)
1700
1737
  const head = $('session-head')
1701
1738
  if (!head) return
1739
+ const composerStatus = $('composer-status')
1740
+ if (composerStatus) composerStatus.classList.toggle('hidden', !s?.running)
1702
1741
  head.classList.remove('running', 'interrupted')
1703
1742
  const queued = (state.queues[state.current] || []).some(i => i.placement !== 'context')
1704
1743
  if (s?.running || queued) head.classList.add('running')
@@ -1716,8 +1755,76 @@ const HISTORY_MAX_VISIBLE = 5000 // 已加载的可显示事件上限(消息/
1716
1755
  function emptyHistory() {
1717
1756
  return {
1718
1757
  visible: [], seqs: new Set(), minSeq: Infinity,
1719
- hasMore: false, loading: false, renderStart: 0, renderEnd: 0
1758
+ hasMore: false, loading: false, renderStart: 0, renderEnd: 0,
1759
+ partialReasoning: new Map()
1760
+ }
1761
+ }
1762
+
1763
+ function reasoningStreamKey(data, index) {
1764
+ return `${data?.turn ?? '?'}:${data?.step ?? '?'}:${index ?? '?'}`
1765
+ }
1766
+
1767
+ /**
1768
+ * DSH 的实时思考以 assistant/chunk 下发,历史尾页可能把增量压成
1769
+ * reasoning-chunks。最终 assistant/message 到达后再由正式消息接管展示。
1770
+ */
1771
+ function applyReasoningStreamEvent(event) {
1772
+ const h = state.history
1773
+ const data = event?.data || {}
1774
+ let changed = false
1775
+ if (event?.type === 'assistant/chunk') {
1776
+ const chunk = data.chunk || {}
1777
+ const key = reasoningStreamKey(data, chunk.index)
1778
+ if (chunk.type === 'block-start' && chunk.blockType === 'reasoning') {
1779
+ h.partialReasoning.set(key, { turn: data.turn, step: data.step, index: chunk.index, text: '' })
1780
+ changed = true
1781
+ } else if (chunk.type === 'reasoning-delta') {
1782
+ const item = h.partialReasoning.get(key) || { turn: data.turn, step: data.step, index: chunk.index, text: '' }
1783
+ item.text += String(chunk.text || '')
1784
+ h.partialReasoning.set(key, item)
1785
+ changed = true
1786
+ } else if (chunk.type === 'block-end' && chunk.block?.type === 'reasoning') {
1787
+ h.partialReasoning.set(key, {
1788
+ turn: data.turn, step: data.step, index: chunk.index,
1789
+ text: String(chunk.block.text ?? chunk.block.content ?? '')
1790
+ })
1791
+ changed = true
1792
+ }
1793
+ } else if (event?.type === 'reasoning-chunks') {
1794
+ const key = reasoningStreamKey(data, data.index)
1795
+ const item = h.partialReasoning.get(key) || { turn: data.turn, step: data.step, index: data.index, text: '' }
1796
+ item.text += Array.isArray(data.texts) ? data.texts.join('') : String(data.text || '')
1797
+ h.partialReasoning.set(key, item)
1798
+ changed = true
1799
+ } else if (event?.type === 'assistant/message') {
1800
+ for (const [key, item] of h.partialReasoning) {
1801
+ if (item.turn === data.turn && item.step === data.step) {
1802
+ h.partialReasoning.delete(key)
1803
+ changed = true
1804
+ }
1805
+ }
1720
1806
  }
1807
+ return changed
1808
+ }
1809
+
1810
+ let reasoningRenderTimer = null
1811
+ function scheduleReasoningRender() {
1812
+ if (reasoningRenderTimer) return
1813
+ reasoningRenderTimer = setTimeout(() => {
1814
+ reasoningRenderTimer = null
1815
+ if (!state.current) return
1816
+ const box = $('history')
1817
+ const nearBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 240
1818
+ renderHistory(false, nearBottom ? 'bottom' : 'fixed')
1819
+ }, 80)
1820
+ }
1821
+
1822
+ function partialReasoningHtml() {
1823
+ return [...state.history.partialReasoning.values()]
1824
+ .filter(item => item.text)
1825
+ .sort((a, b) => (a.turn ?? 0) - (b.turn ?? 0) || (a.step ?? 0) - (b.step ?? 0) || (a.index ?? 0) - (b.index ?? 0))
1826
+ .map(item => `<div class="msg assistant reasoning-live"><div class="role">${esc(t('role.dsh'))}</div><details class="tool" open><summary>${esc(t('block.thinkingLive'))}</summary><div class="tool-text">${esc(truncate(item.text, 12000))}</div></details></div>`)
1827
+ .join('')
1721
1828
  }
1722
1829
 
1723
1830
  function trimVisible() {
@@ -1806,11 +1913,14 @@ async function loadHistory(reset) {
1806
1913
 
1807
1914
  const incoming = v.events || []
1808
1915
  let added = 0
1916
+ if (reset) state.history.partialReasoning.clear()
1809
1917
  for (const entry of incoming) {
1810
1918
  const ev = entry?.event
1811
1919
  const seq = ev?.seq
1920
+ if (ev?.type === 'turn/start' || ev?.type === 'turn/end') noteSessionTurnTime(id, ev)
1921
+ applyReasoningStreamEvent(ev)
1812
1922
  if (seq == null || state.history.seqs.has(seq)) continue
1813
- if (!shouldShowEvent(ev.type)) continue // chunk 等内部事件不保留
1923
+ if (!shouldShowEvent(ev.type, ev)) continue // chunk 与非用户上下文不保留
1814
1924
  state.history.seqs.add(seq)
1815
1925
  state.history.visible.push({ seq, event: ev, view: entry.view })
1816
1926
  added++
@@ -1835,8 +1945,16 @@ async function loadHistory(reset) {
1835
1945
 
1836
1946
  function insertLiveEvent(event) {
1837
1947
  const h = state.history
1948
+ const reasoningChanged = applyReasoningStreamEvent(event)
1949
+ if (event?.type === 'assistant/chunk' || event?.type === 'reasoning-chunks') {
1950
+ if (reasoningChanged) scheduleReasoningRender()
1951
+ return
1952
+ }
1838
1953
  const seq = event?.seq
1839
- if (seq == null || h.seqs.has(seq) || !shouldShowEvent(event.type)) return
1954
+ if (seq == null || h.seqs.has(seq) || !shouldShowEvent(event.type, event)) {
1955
+ if (reasoningChanged) scheduleReasoningRender()
1956
+ return
1957
+ }
1840
1958
  h.seqs.add(seq)
1841
1959
  h.visible.push({ seq, event })
1842
1960
  h.visible.sort((a, b) => a.seq - b.seq)
@@ -1870,7 +1988,8 @@ function renderHistory(reset, mode = 'bottom') {
1870
1988
  const h = state.history
1871
1989
  const filtered = filteredEntries()
1872
1990
  const len = filtered.length
1873
- if (!len) {
1991
+ const reasoningHtml = partialReasoningHtml()
1992
+ if (!len && !reasoningHtml) {
1874
1993
  box.innerHTML = '<div class="empty">' + t('history.empty') + '</div>'
1875
1994
  h.renderStart = 0; h.renderEnd = 0
1876
1995
  updateRail()
@@ -1891,7 +2010,7 @@ function renderHistory(reset, mode = 'bottom') {
1891
2010
  const d = e.event.data || {}
1892
2011
  if (d.callId && d.name) toolNames.set(d.callId, d.name)
1893
2012
  }
1894
- box.innerHTML = filtered.slice(start, end).map(e => eventHtml(e, { toolNames })).join('')
2013
+ box.innerHTML = filtered.slice(start, end).map(e => eventHtml(e, { toolNames })).join('') + reasoningHtml
1895
2014
  if (reset || mode === 'bottom') box.scrollTop = box.scrollHeight
1896
2015
  else if (mode === 'keep') box.scrollTop = Math.max(0, oldTop + (box.scrollHeight - oldH))
1897
2016
  else if (mode === 'fixed') box.scrollTop = oldTop
@@ -1972,9 +2091,24 @@ const INTERESTING_EVENTS = new Set([
1972
2091
  'approval/asked', 'approval/resolved',
1973
2092
  'session/title', 'title'
1974
2093
  ])
1975
- function shouldShowEvent(type) {
1976
- if (INTERESTING_EVENTS.has(type)) return true
1977
- return false
2094
+ function messageSource(data) {
2095
+ const source = data?.source ?? data?.message?.source
2096
+ return source && typeof source === 'object' ? source : null
2097
+ }
2098
+ function isHumanUserMessage(event) {
2099
+ if (event?.type !== 'user/message') return false
2100
+ const source = messageSource(event.data || {})
2101
+ // Older DSH events may not carry source metadata; keep those visible for compatibility.
2102
+ return !source || source.kind === 'user'
2103
+ }
2104
+ function shouldShowEvent(type, event) {
2105
+ if (!INTERESTING_EVENTS.has(type)) return false
2106
+ if (type === 'user/message' && !isHumanUserMessage(event)) {
2107
+ const data = event?.data || {}
2108
+ const blocks = data.message?.content || data.content || []
2109
+ return systemReminderText(blocks).length > 0
2110
+ }
2111
+ return true
1978
2112
  }
1979
2113
  function systemReminderText(blocks) {
1980
2114
  if (!Array.isArray(blocks)) return ''
@@ -1988,7 +2122,7 @@ function eventHtml(entry, ctx = {}) {
1988
2122
  const ev = entry.event || {}
1989
2123
  const data = ev.data || {}
1990
2124
  const type = ev.type || 'event'
1991
- if (!shouldShowEvent(type)) return ''
2125
+ if (!shouldShowEvent(type, ev)) return ''
1992
2126
  let inner = ''
1993
2127
 
1994
2128
  if (type === 'user/message' || type === 'assistant/message') {
@@ -1998,6 +2132,8 @@ function eventHtml(entry, ctx = {}) {
1998
2132
  const sysText = type === 'user/message' ? systemReminderText(blocks) : ''
1999
2133
  if (sysText) {
2000
2134
  inner = `<details class="event event-detail" data-seq="${seq}"><summary>${esc(t('event.systemReminder'))}</summary><pre>${esc(truncate(sysText, 4000))}</pre></details>`
2135
+ } else if (type === 'user/message' && !isHumanUserMessage(ev)) {
2136
+ return ''
2001
2137
  } else {
2002
2138
  inner = `<div class="msg ${esc(role)}" data-seq="${seq}"><div class="role">${esc(role === 'user' ? t('role.me') : t('role.dsh'))}</div>${blocks.map(blockHtml).join('')}</div>`
2003
2139
  }
@@ -2105,13 +2241,19 @@ async function renderSessionCards() {
2105
2241
  const sub = await safeRpc('subagent.list', { parentSessionId: sessionId })
2106
2242
  if (renderGeneration !== sessionCardsRenderGeneration || state.current !== sessionId) return
2107
2243
  if (sub?.entries?.length) {
2244
+ const expanded = state.subagentExpandedSession === sessionId
2245
+ const toggleLabel = expanded ? t('subagent.collapse') : t('subagent.expand')
2108
2246
  const rows = sub.entries.map(e => {
2109
2247
  if (e.kind === 'diagnostic') return `<div class="card-row"><span class="k">${t('subagent.diagnostic')}</span><span class="v">${esc(e.reason)}</span></div>`
2110
2248
  const label = e.label || short(e.id)
2111
2249
  const running = e.activity === 'running'
2112
2250
  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>`
2113
2251
  }).join('')
2114
- box.insertAdjacentHTML('beforeend', `<div class="card"><div class="card-title">${t('subagent.title')}</div>${rows}</div>`)
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>`)
2253
+ box.querySelector('[data-subagent-toggle]')?.addEventListener('click', () => {
2254
+ state.subagentExpandedSession = expanded ? '' : sessionId
2255
+ renderSessionCards()
2256
+ })
2115
2257
  box.querySelectorAll('[data-sub-interrupt]').forEach(btn =>
2116
2258
  btn.addEventListener('click', () => interruptSubagent(btn.dataset.subInterrupt)))
2117
2259
  }
@@ -2164,7 +2306,7 @@ async function runSlashCommand(text) {
2164
2306
  : undefined
2165
2307
  const res = await fetch(apiUrl('/remote/api/command'), {
2166
2308
  method: 'POST',
2167
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
2309
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() },
2168
2310
  body: JSON.stringify({ sessionId: state.current, line: clean }),
2169
2311
  ...(signal ? { signal } : {})
2170
2312
  })
@@ -2291,7 +2433,12 @@ async function sendSessionContent(text, images) {
2291
2433
  mode: 'queue',
2292
2434
  content
2293
2435
  }, t('send.failed'))
2294
- if (v?.accepted) { toast(images.length ? t('send.imageSent') : (clean.startsWith('/') ? t('send.commandSent') : t('send.sent')), 'ok'); return true }
2436
+ 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')
2440
+ return true
2441
+ }
2295
2442
  if (v?.command?.text) { toast(t('send.commandExecuted'), 'ok'); return true }
2296
2443
  return false
2297
2444
  } catch (e) {
@@ -2393,16 +2540,37 @@ function renderEffortMenu() {
2393
2540
  const cur = state.models.current
2394
2541
  const provider = (state.models.groups || []).find(g => g.id === cur?.provider)
2395
2542
  const model = (provider?.models || []).find(m => m.id === cur?.model)
2396
- const efforts = model?.reasoning?.efforts || []
2397
- group.classList.toggle('hidden', !efforts.length)
2543
+ const { efforts, defaultEffort, custom } = reasoningEffortOptions(model)
2544
+ group.classList.toggle('hidden', !cur || !efforts.length)
2398
2545
  box.innerHTML = efforts.map(e => {
2399
- const isCur = cur?.reasoningEffort === e.id || (!cur?.reasoningEffort && e.id === model.reasoning.defaultEffort)
2546
+ const isCur = cur?.reasoningEffort === e.id || (!cur?.reasoningEffort && e.id === defaultEffort)
2400
2547
  return `<button class="menu-chip ${isCur ? 'current' : ''}" data-effort="${esc(e.id)}" title="${esc(e.description || '')}">${esc(e.name || e.id)}</button>`
2401
- }).join('')
2548
+ }).join('') + (custom ? `<span class="effort-hint">${esc(t('models.effortCustomHint'))}</span>` : '')
2402
2549
  box.querySelectorAll('[data-effort]').forEach(btn =>
2403
2550
  btn.addEventListener('click', () => selectSessionEffort(btn.dataset.effort)))
2404
2551
  }
2405
2552
 
2553
+ function reasoningEffortOptions(model) {
2554
+ const raw = Array.isArray(model?.reasoning?.efforts) && model.reasoning.efforts.length
2555
+ ? model.reasoning.efforts
2556
+ : (Array.isArray(model?.reasoningEfforts) && model.reasoningEfforts.length ? model.reasoningEfforts : null)
2557
+ const names = {
2558
+ low: t('models.effortLow'), high: t('models.effortHigh'), max: t('models.effortMax'), off: t('models.effortOff')
2559
+ }
2560
+ if (raw) {
2561
+ return {
2562
+ efforts: raw.map(e => typeof e === 'string' ? { id: e, name: names[e] || e } : e),
2563
+ defaultEffort: model?.reasoning?.defaultEffort,
2564
+ custom: !model?.reasoning?.efforts
2565
+ }
2566
+ }
2567
+ return {
2568
+ efforts: ['low', 'high', 'max'].map(id => ({ id, name: names[id] })),
2569
+ defaultEffort: undefined,
2570
+ custom: true
2571
+ }
2572
+ }
2573
+
2406
2574
  async function selectSessionEffort(effortId) {
2407
2575
  const cur = state.models.current
2408
2576
  if (!state.current || !cur) return
@@ -2615,8 +2783,9 @@ function renderOverview() {
2615
2783
  btn.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === btn.dataset.overviewQuestion)))
2616
2784
  })
2617
2785
 
2618
- const running = state.sessions.filter(s => s.running).length
2619
- const sessions = [...state.sessions].sort((a, b) => Number(b.running) - Number(a.running) || (new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0))).slice(0, 4)
2786
+ const topSessions = topLevelSessions()
2787
+ const running = topSessions.filter(s => s.running).length
2788
+ const sessions = topSessions.sort((a, b) => Number(b.running) - Number(a.running) || (sessionSortTime(b) - sessionSortTime(a))).slice(0, 4)
2620
2789
  const primary = $('overview-primary-action')
2621
2790
  if (primary) {
2622
2791
  let action = 'new'
@@ -2647,7 +2816,7 @@ function renderOverview() {
2647
2816
  $('overview-connection-mode').textContent = state.token ? t(state.streamMode === 'poll' ? 'overview.poll' : 'overview.ws') : '—'
2648
2817
  $('overview-active-count').textContent = running ? t('overview.activeCount', { n: running }) : ''
2649
2818
  $('overview-session-list').innerHTML = sessions.length ? sessions.map(s => `<button type="button" class="overview-session-item ${s.running ? 'running' : ''}" data-overview-session="${esc(s.sessionId)}">
2650
- <span class="overview-item-mark">${s.running ? '●' : '○'}</span><span class="overview-item-copy"><span class="overview-item-title">${esc(titleOf(s))}</span><span class="overview-item-desc">${s.running ? esc(t('sessions.running')) + ' · ' : ''}${esc(fmtTime(s.updatedAt))}</span></span><span class="overview-item-arrow">›</span>
2819
+ <span class="overview-item-mark">${s.running ? '●' : '○'}</span><span class="overview-item-copy"><span class="overview-item-title">${esc(titleOf(s))}</span><span class="overview-item-desc">${s.running ? esc(t('sessions.running')) + ' · ' : ''}${esc(fmtTime(sessionSortTime(s)))}</span></span><span class="overview-item-arrow">›</span>
2651
2820
  </button>`).join('') : `<div class="overview-empty">${t('overview.noSession')}</div>`
2652
2821
  $('overview-session-list').querySelectorAll('[data-overview-session]').forEach(btn => btn.addEventListener('click', () => openSession(btn.dataset.overviewSession)))
2653
2822
  }
@@ -2743,11 +2912,46 @@ async function submitQuestion() {
2743
2912
  }
2744
2913
 
2745
2914
  /* ---------------- 后台任务 ---------------- */
2915
+ function queuePreview(item) {
2916
+ const blocks = item?.message?.content || item?.content || []
2917
+ const text = Array.isArray(blocks)
2918
+ ? blocks.filter(block => block?.type === 'text').map(block => String(block.text || '')).join(' ').trim()
2919
+ : ''
2920
+ return text || (Array.isArray(blocks) && blocks.some(block => block?.type === 'image') ? t('queue.image') : '…')
2921
+ }
2922
+ async function steerQueueItem(itemId) {
2923
+ const sessionId = state.current
2924
+ const key = `${sessionId}:${itemId}`
2925
+ const s = state.byId.get(sessionId)
2926
+ if (!sessionId || !s?.running || state.queueSteering[key]) return
2927
+ state.queueSteering[key] = true
2928
+ renderQueue()
2929
+ try {
2930
+ const v = await safeRpc('session.updateQueue', { sessionId, itemId, action: { kind: 'steer' } }, t('queue.steerFailed', { msg: '' }).replace(/:$/, '').replace(/: $/, ''))
2931
+ if (v?.accepted) toast(t('queue.steerSubmitted'), 'ok')
2932
+ } finally {
2933
+ delete state.queueSteering[key]
2934
+ renderQueue()
2935
+ }
2936
+ }
2746
2937
  function renderQueue() {
2747
2938
  const s = state.byId.get(state.current)
2748
2939
  if (!s) return
2749
- const items = state.queues[state.current] || []
2940
+ const items = (state.queues[state.current] || []).filter(item => item?.placement === 'queued')
2941
+ const box = $('queue-dock')
2942
+ if (box) {
2943
+ box.classList.toggle('hidden', !items.length)
2944
+ box.innerHTML = items.length ? `<div class="queue-dock-head"><span>⌁</span><span>${esc(t('queue.title'))} · ${items.length}</span></div><div class="queue-dock-list">${items.map(item => {
2945
+ const key = `${state.current}:${item.id}`
2946
+ const busy = !!state.queueSteering[key]
2947
+ return `<div class="queue-dock-item"><span class="queue-dock-preview" title="${esc(queuePreview(item))}">${esc(queuePreview(item))}</span><button type="button" class="mini-btn queue-dock-action" data-queue-steer="${esc(item.id)}" title="${esc(s.running ? t('queue.steer') : t('queue.steerUnavailable'))}" ${s.running && !busy ? '' : 'disabled'}>${busy ? '…' : esc(t('queue.steer'))}</button></div>`
2948
+ }).join('')}</div>` : ''
2949
+ box.querySelectorAll('[data-queue-steer]').forEach(button => {
2950
+ button.addEventListener('click', () => steerQueueItem(button.dataset.queueSteer))
2951
+ })
2952
+ }
2750
2953
  updateCancelBtn()
2954
+ updateSessionStatus()
2751
2955
  // 队列数量在会话列表已显示; 详情页不重复大 UI
2752
2956
  $('history-hint').textContent = items.length ? t('history.queueAndCount', { q: items.length, n: state.history.visible.length }) : t('history.countOnly', { n: state.history.visible.length })
2753
2957
  renderSessions()
@@ -2770,7 +2974,8 @@ function renderJobs() {
2770
2974
  function fsHeaders() {
2771
2975
  return {
2772
2976
  authorization: 'Bearer ' + state.token,
2773
- 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web'
2977
+ 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web',
2978
+ ...clientIdHeaders()
2774
2979
  }
2775
2980
  }
2776
2981
 
@@ -3140,6 +3345,7 @@ async function runFsUpload(up) {
3140
3345
  xhr.open('POST', fsApiUrl('/upload', params))
3141
3346
  xhr.setRequestHeader('authorization', 'Bearer ' + state.token)
3142
3347
  xhr.setRequestHeader('x-dsh-remote-client', CAP?.isNativePlatform?.() ? 'app' : 'web')
3348
+ if (CLIENT_ID) xhr.setRequestHeader('x-dsh-remote-client-id', CLIENT_ID)
3143
3349
  xhr.upload.onprogress = (e) => {
3144
3350
  if (e.lengthComputable) {
3145
3351
  const loaded = up.offset + Math.min(e.loaded, e.total)
@@ -3385,6 +3591,54 @@ function cmpVersion(a, b) {
3385
3591
  return 0
3386
3592
  }
3387
3593
 
3594
+ function isComparableVersion(value) {
3595
+ return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(String(value || '').trim())
3596
+ }
3597
+
3598
+ function isAppBehindGateway(appVersion, gatewayVersion) {
3599
+ return isComparableVersion(appVersion) && isComparableVersion(gatewayVersion) && cmpVersion(gatewayVersion, appVersion) > 0
3600
+ }
3601
+
3602
+ function isAppVersionWarningOpen() {
3603
+ const modal = $('modal-app-version-warning')
3604
+ return !!modal && !modal.classList.contains('hidden')
3605
+ }
3606
+
3607
+ async function maybeWarnAppBehindGateway({ probe = false } = {}) {
3608
+ if (!CAP?.isNativePlatform?.() || !state.localVersion) return false
3609
+ const base = String(state.server || '').replace(/\/+$/, '')
3610
+ if (!base) return false
3611
+ let health = state.gatewayHealth[base]
3612
+ if (!health && probe) {
3613
+ await pingServer(base)
3614
+ health = state.gatewayHealth[base]
3615
+ }
3616
+ const gatewayVersion = String(health?.version || '').trim()
3617
+ if (!isAppBehindGateway(state.localVersion, gatewayVersion)) return false
3618
+ const warningKey = `${state.localVersion}\u0000${gatewayVersion}`
3619
+ if (state.warnedGatewayVersions.has(warningKey)) return false
3620
+ const anotherModal = [...document.querySelectorAll('.modal')]
3621
+ .some(modal => modal.id !== 'modal-app-version-warning' && !modal.classList.contains('hidden'))
3622
+ if (anotherModal) return false
3623
+ state.warnedGatewayVersions.add(warningKey)
3624
+ $('app-version-current').textContent = 'v' + state.localVersion
3625
+ $('app-version-gateway').textContent = 'v' + gatewayVersion
3626
+ $('modal-app-version-warning').classList.remove('hidden')
3627
+ setTimeout(() => $('app-version-update')?.focus(), 50)
3628
+ return true
3629
+ }
3630
+
3631
+ function closeAppVersionWarning(checkNow = false) {
3632
+ $('modal-app-version-warning').classList.add('hidden')
3633
+ if (checkNow) {
3634
+ showView('view-settings')
3635
+ showSettingsPage('about')
3636
+ void checkUpdate(false)
3637
+ } else {
3638
+ scheduleStartupNotices(150)
3639
+ }
3640
+ }
3641
+
3388
3642
  function resetUpdateExpand() {
3389
3643
  const desc = $('update-desc')
3390
3644
  if (desc) desc.classList.remove('expanded')
@@ -3672,7 +3926,7 @@ function closeAnnouncement(markSeen) {
3672
3926
  }
3673
3927
  async function fetchAnnouncements() {
3674
3928
  const base = updateBase()
3675
- if (!base || !state.localVersion) return false
3929
+ if (!base || !state.localVersion || isAppVersionWarningOpen()) return false
3676
3930
  try {
3677
3931
  const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(8000) : undefined
3678
3932
  const url = base + '/announcements.json?t=' + Date.now()
@@ -3713,6 +3967,16 @@ function startAnnouncementPolling() {
3713
3967
  window.addEventListener('online', () => { void checkAnnouncements() })
3714
3968
  }
3715
3969
 
3970
+ let startupNoticeTimer = null
3971
+ function scheduleStartupNotices(delay = 4000) {
3972
+ clearTimeout(startupNoticeTimer)
3973
+ startupNoticeTimer = setTimeout(async () => {
3974
+ if (isAppVersionWarningOpen()) return
3975
+ const shown = await checkAnnouncements()
3976
+ if (!shown && state.token && !isAppVersionWarningOpen()) checkUpdate(true)
3977
+ }, delay)
3978
+ }
3979
+
3716
3980
  /* ---------------- 更新内容弹窗 ---------------- */
3717
3981
  const NOTES_KEY = 'seenNotesVersion'
3718
3982
  let notesVersion = ''
@@ -4002,7 +4266,7 @@ function saveBgConfig(enabled) {
4002
4266
  const base = bgBase()
4003
4267
  const intervalMin = parseFloat($('bg-interval')?.value || '1') || 1
4004
4268
  const notifyTaskDone = $('opt-task-done')?.checked !== false
4005
- b.saveBackgroundConfig(JSON.stringify({ enabled, intervalMin, base, token: state.token || '', notifyTaskDone }))
4269
+ b.saveBackgroundConfig(JSON.stringify({ enabled, intervalMin, base, token: state.token || '', clientId: CLIENT_ID || '', notifyTaskDone }))
4006
4270
  if (enabled) $('bg-auth-status')?.classList.add('hidden')
4007
4271
  return true
4008
4272
  }
@@ -4328,14 +4592,207 @@ async function decodeQrDataUrl(dataUrl) {
4328
4592
  return code?.data || ''
4329
4593
  }
4330
4594
 
4331
- /** App 内扫码: 官方 Camera 拍照/相册 + jsQR 本地解码(无 Google ML Kit/GMS 依赖, 国内可用)。
4332
- * 冗余路径 1: 系统相机扫 dshremote:// 二维码直接唤起 App(见 bindNativeLinks);
4333
- * 冗余路径 2: 设置页手动粘贴令牌。 */
4595
+ let liveScanStream = null
4596
+ let liveScanTimer = null
4597
+ let liveScanResolve = null
4598
+ let liveScanCanvas = null
4599
+ let liveScanContext = null
4600
+ let liveScanCancelled = false
4601
+ let liveScanDetector = null
4602
+ let liveScanBusy = false
4603
+ let liveScanWorker = null
4604
+ let liveScanWorkerUrl = ''
4605
+ let liveScanWorkerResolve = null
4606
+
4607
+ function stopLiveScanWorker() {
4608
+ liveScanWorkerResolve?.('')
4609
+ liveScanWorkerResolve = null
4610
+ liveScanWorker?.terminate()
4611
+ liveScanWorker = null
4612
+ if (liveScanWorkerUrl) URL.revokeObjectURL(liveScanWorkerUrl)
4613
+ liveScanWorkerUrl = ''
4614
+ }
4615
+
4616
+ function startLiveScanWorker() {
4617
+ if (!window.Worker || !window.Blob || !window.URL?.createObjectURL) return
4618
+ try {
4619
+ const sourceUrl = new URL('jsqr.min.js', document.baseURI).href
4620
+ const workerSource = `
4621
+ let loaded = false
4622
+ self.onmessage = event => {
4623
+ try {
4624
+ if (!loaded) {
4625
+ self.importScripts(${JSON.stringify(sourceUrl)})
4626
+ loaded = true
4627
+ }
4628
+ const { data, width, height } = event.data || {}
4629
+ const code = self.jsQR?.(data, width, height, { inversionAttempts: 'attemptBoth' })
4630
+ self.postMessage({ raw: code?.data || '' })
4631
+ } catch (error) {
4632
+ self.postMessage({ raw: '', error: String(error?.message || error) })
4633
+ }
4634
+ }
4635
+ `
4636
+ liveScanWorkerUrl = URL.createObjectURL(new Blob([workerSource], { type: 'application/javascript' }))
4637
+ liveScanWorker = new Worker(liveScanWorkerUrl)
4638
+ liveScanWorker.onmessage = event => {
4639
+ const resolve = liveScanWorkerResolve
4640
+ liveScanWorkerResolve = null
4641
+ resolve?.(event.data?.raw || '')
4642
+ }
4643
+ liveScanWorker.onerror = () => {
4644
+ const resolve = liveScanWorkerResolve
4645
+ liveScanWorkerResolve = null
4646
+ resolve?.('')
4647
+ stopLiveScanWorker()
4648
+ }
4649
+ } catch {
4650
+ stopLiveScanWorker()
4651
+ }
4652
+ }
4653
+
4654
+ function closeLiveScan(result = '') {
4655
+ liveScanCancelled = true
4656
+ if (liveScanTimer) clearTimeout(liveScanTimer)
4657
+ liveScanTimer = null
4658
+ if (liveScanStream) liveScanStream.getTracks().forEach(track => track.stop())
4659
+ liveScanStream = null
4660
+ liveScanDetector = null
4661
+ stopLiveScanWorker()
4662
+ const video = $('scan-live-video')
4663
+ if (video) video.srcObject = null
4664
+ $('modal-scan-live')?.classList.add('hidden')
4665
+ const resolve = liveScanResolve
4666
+ liveScanResolve = null
4667
+ resolve?.(result)
4668
+ }
4669
+
4670
+ async function scanLiveFrame() {
4671
+ if (!liveScanResolve) return
4672
+ const video = $('scan-live-video')
4673
+ if (!video || video.readyState < 2 || !video.videoWidth || !liveScanContext) {
4674
+ liveScanTimer = setTimeout(scanLiveFrame, 180)
4675
+ return
4676
+ }
4677
+ if (liveScanBusy) return
4678
+ liveScanBusy = true
4679
+ try {
4680
+ let raw = ''
4681
+ if (liveScanDetector) {
4682
+ const codes = await liveScanDetector.detect(video)
4683
+ raw = codes?.[0]?.rawValue || ''
4684
+ } else {
4685
+ // 只解码取景框中央区域,避免在低端手机上对整幅高分辨率画面反复二值化。
4686
+ const side = Math.floor(Math.min(video.videoWidth, video.videoHeight) * 0.64)
4687
+ const sx = Math.floor((video.videoWidth - side) / 2)
4688
+ const sy = Math.floor((video.videoHeight - side) / 2)
4689
+ const maxSide = 480
4690
+ const scale = Math.min(1, maxSide / Math.max(1, side))
4691
+ const w = Math.max(1, Math.round(side * scale))
4692
+ const h = w
4693
+ if (liveScanCanvas.width !== w || liveScanCanvas.height !== h) {
4694
+ liveScanCanvas.width = w
4695
+ liveScanCanvas.height = h
4696
+ }
4697
+ liveScanContext.drawImage(video, sx, sy, side, side, 0, 0, w, h)
4698
+ const imageData = liveScanContext.getImageData(0, 0, w, h)
4699
+ if (liveScanWorker) {
4700
+ raw = await new Promise(resolve => {
4701
+ liveScanWorkerResolve = resolve
4702
+ liveScanWorker.postMessage({ data: imageData.data, width: w, height: h }, [imageData.data.buffer])
4703
+ })
4704
+ } else {
4705
+ raw = window.jsQR?.(imageData.data, w, h, { inversionAttempts: 'attemptBoth' })?.data || ''
4706
+ }
4707
+ }
4708
+ if (raw && liveScanResolve) return closeLiveScan(raw)
4709
+ } catch {
4710
+ // 摄像头帧在切后台或权限切换时可能暂时不可读,下一帧继续即可。
4711
+ } finally {
4712
+ liveScanBusy = false
4713
+ }
4714
+ if (liveScanResolve) liveScanTimer = setTimeout(scanLiveFrame, 180)
4715
+ }
4716
+
4717
+ /** 打开持续取帧的本地摄像头扫码;返回 undefined 表示当前 WebView 不支持实时摄像头。 */
4718
+ async function scanPairLive() {
4719
+ if (!navigator.mediaDevices?.getUserMedia) return undefined
4720
+ const video = $('scan-live-video')
4721
+ const modal = $('modal-scan-live')
4722
+ if (!video || !modal || !window.jsQR) return undefined
4723
+ const camera = CAP.Plugins?.Camera
4724
+ const perm = await camera?.requestPermissions?.({ permissions: ['camera'] })
4725
+ if (perm && perm.camera !== 'granted') throw new Error(t('scan.permissionDenied'))
4726
+ liveScanCancelled = false
4727
+ modal.classList.remove('hidden')
4728
+ $('scan-live-status').textContent = t('scan.liveStarting')
4729
+ try {
4730
+ liveScanStream = await navigator.mediaDevices.getUserMedia({
4731
+ audio: false,
4732
+ video: {
4733
+ facingMode: { ideal: 'environment' },
4734
+ width: { ideal: 960, max: 1280 },
4735
+ height: { ideal: 720, max: 1280 },
4736
+ frameRate: { ideal: 24, max: 30 }
4737
+ }
4738
+ })
4739
+ if (liveScanCancelled) {
4740
+ liveScanStream.getTracks().forEach(track => track.stop())
4741
+ liveScanStream = null
4742
+ return ''
4743
+ }
4744
+ video.srcObject = liveScanStream
4745
+ await video.play()
4746
+ $('scan-live-status').textContent = t('scan.liveHint')
4747
+ try {
4748
+ if (window.BarcodeDetector) {
4749
+ const formats = await window.BarcodeDetector.getSupportedFormats?.()
4750
+ if (!formats || formats.includes('qr_code')) liveScanDetector = new window.BarcodeDetector({ formats: ['qr_code'] })
4751
+ }
4752
+ } catch { liveScanDetector = null }
4753
+ liveScanCanvas = document.createElement('canvas')
4754
+ liveScanContext = liveScanCanvas.getContext('2d', { willReadFrequently: true })
4755
+ if (!liveScanContext) throw new Error(t('scan.decodeUnsupported'))
4756
+ startLiveScanWorker()
4757
+ return await new Promise(resolve => {
4758
+ liveScanResolve = resolve
4759
+ scanLiveFrame()
4760
+ })
4761
+ } catch (e) {
4762
+ closeLiveScan('')
4763
+ throw e
4764
+ } finally {
4765
+ liveScanCanvas = null
4766
+ liveScanContext = null
4767
+ liveScanDetector = null
4768
+ liveScanBusy = false
4769
+ }
4770
+ }
4771
+
4772
+ /** App 内扫码优先使用实时摄像头取帧;不支持时回退到官方 Camera 拍照/相册 + jsQR。 */
4334
4773
  async function scanPair(source) {
4335
4774
  if (!CAP?.isNativePlatform?.()) {
4336
4775
  toast(t('scan.browserHint'), 'err')
4337
4776
  return
4338
4777
  }
4778
+ if (source === 'CAMERA') {
4779
+ try {
4780
+ const liveRaw = await scanPairLive()
4781
+ if (liveRaw !== undefined) {
4782
+ if (!liveRaw) return toast(t('scan.cancelled'), 'ok')
4783
+ if (applyPairUrl(liveRaw)) {
4784
+ toast(t('scan.paired'), 'ok')
4785
+ openStreams()
4786
+ refreshAll()
4787
+ } else toast(t('scan.notPair'), 'err')
4788
+ return
4789
+ }
4790
+ } catch (e) {
4791
+ const msg = String(e?.message || e || '')
4792
+ toast(/cancel/i.test(msg) ? t('scan.cancelled') : t('scan.failed', { msg }), 'err')
4793
+ return
4794
+ }
4795
+ }
4339
4796
  const camera = CAP.Plugins?.Camera
4340
4797
  if (!camera?.getPhoto) { toast(t('scan.unsupported'), 'err'); return }
4341
4798
  try {
@@ -4523,6 +4980,10 @@ function renderDshControlStatus(value) {
4523
4980
 
4524
4981
  async function loadDshControl() {
4525
4982
  if (!state.token || !$('dsh-control-desc')) return
4983
+ if (activeGatewayCapability('dshLifecycle') === false) {
4984
+ renderDshControlStatus({ supported: false, message: t('settings.dshUnsupported') })
4985
+ return
4986
+ }
4526
4987
  try {
4527
4988
  const res = await fetch(adminApiUrl('/admin/api/dsh'), { headers: { authorization: 'Bearer ' + state.token }, cache: 'no-store' })
4528
4989
  if (res.status === 401) return authFailure()
@@ -4775,6 +5236,7 @@ function bindUi() {
4775
5236
  document.addEventListener('keydown', (e) => {
4776
5237
  if (e.key === 'Escape' && !$('feedback-sheet').classList.contains('hidden')) { closeFeedbackSheet(); $('btn-feedback').focus() }
4777
5238
  else if (e.key === 'Escape' && !$('modal-feedback-success').classList.contains('hidden')) closeFeedbackSuccess()
5239
+ else if (e.key === 'Escape' && isAppVersionWarningOpen()) closeAppVersionWarning(false)
4778
5240
  })
4779
5241
  $('btn-new-session').addEventListener('click', newSession)
4780
5242
  $('session-workspace-filter').addEventListener('change', (e) => {
@@ -4939,6 +5401,8 @@ function bindUi() {
4939
5401
  })
4940
5402
  $('btn-scan-camera').addEventListener('click', () => scanPair('CAMERA'))
4941
5403
  $('btn-scan-gallery').addEventListener('click', () => scanPair('PHOTOS'))
5404
+ $('scan-live-cancel')?.addEventListener('click', () => closeLiveScan(''))
5405
+ $('modal-scan-live')?.addEventListener('click', e => { if (e.target === $('modal-scan-live')) closeLiveScan('') })
4942
5406
  $('btn-change-token').addEventListener('click', () => {
4943
5407
  const input = prompt(t('token.prompt'), state.token)
4944
5408
  if (input && input.trim()) { state.token = input.trim(); LS.set('token', input.trim()); $('token-desc').textContent = t('token.saved'); toast(t('token.savedReconnect'), 'ok'); openStreams(); refreshAll(); syncBgConfig() }
@@ -4965,6 +5429,11 @@ function bindUi() {
4965
5429
  $('btn-check-update').addEventListener('click', () => checkUpdate(false))
4966
5430
  $('btn-download-update').addEventListener('click', downloadUpdate)
4967
5431
  $('btn-update-expand').addEventListener('click', toggleUpdateExpand)
5432
+ $('app-version-later').addEventListener('click', () => closeAppVersionWarning(false))
5433
+ $('app-version-update').addEventListener('click', () => closeAppVersionWarning(true))
5434
+ $('modal-app-version-warning').addEventListener('click', (e) => {
5435
+ if (e.target === $('modal-app-version-warning')) closeAppVersionWarning(false)
5436
+ })
4968
5437
  $('btn-reset').addEventListener('click', () => {
4969
5438
  if (!confirm(t('settings.confirmReset'))) return
4970
5439
  LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction'); LS.del(ANNOUNCEMENTS_KEY); LS.del(ANNOUNCEMENT_HISTORY_KEY); LS.del(ANNOUNCEMENT_VOTES_KEY)
@@ -5119,6 +5588,7 @@ async function boot() {
5119
5588
  } else {
5120
5589
  // 多服务器: 启动时静默测速一次, 选最快的连接(同源页面也参与比较)
5121
5590
  await selectFastestServer({ silent: true, reconnect: false })
5591
+ await maybeWarnAppBehindGateway({ probe: true })
5122
5592
  openStreams()
5123
5593
  await refreshAll()
5124
5594
  const host = await safeRpc('host.describe', {}, '')
@@ -5128,10 +5598,7 @@ async function boot() {
5128
5598
  // 网关从中央 HTTPS 公告源读取并在不可达时回退内置文件。前台每 30 秒检查,
5129
5599
  // 回到前台或网络恢复时立即补查;公告优先,避免启动时两个弹窗重叠。
5130
5600
  startAnnouncementPolling()
5131
- setTimeout(async () => {
5132
- const shown = await checkAnnouncements()
5133
- if (!shown && state.token) checkUpdate(true)
5134
- }, 4000)
5601
+ scheduleStartupNotices()
5135
5602
  renderPending()
5136
5603
  }
5137
5604