dsh-remote-plugin 0.6.13 → 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 = {
@@ -71,6 +73,8 @@ const state = {
71
73
  approvals: [], // 待处理审批
72
74
  questions: [], // 待处理提问
73
75
  queues: {}, // sessionId -> queue items
76
+ queueSteering: {}, // sessionId:itemId -> pending steer request
77
+ sessionTurnTimes: {}, // sessionId -> 本轮开始/结束时间,避免中间事件推动排序
74
78
  jobs: {}, // sessionId -> jobs
75
79
  history: emptyHistory(),
76
80
  errCount: 0,
@@ -88,7 +92,8 @@ const state = {
88
92
  wbProjects: [],
89
93
  wbArchived: [],
90
94
  wbOpen: false,
91
- wbOpenProjects: {}
95
+ wbOpenProjects: {},
96
+ subagentExpandedSession: ''
92
97
  }
93
98
 
94
99
  const $ = (id) => document.getElementById(id)
@@ -363,6 +368,7 @@ async function getWsTicket() {
363
368
  headers: {
364
369
  authorization: 'Bearer ' + token,
365
370
  'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web',
371
+ ...clientIdHeaders(),
366
372
  }
367
373
  })
368
374
  if (!res.ok) throw new Error('ws ticket HTTP ' + res.status)
@@ -414,7 +420,7 @@ async function loadStats() {
414
420
  }
415
421
  try {
416
422
  const res = await fetch(apiUrl('/stats/summary?days=7'), {
417
- 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() }
418
424
  })
419
425
  if (res.status === 401) { authFailure(); return }
420
426
  if (!res.ok) throw new Error('HTTP ' + res.status)
@@ -480,7 +486,7 @@ function renderStats(days) {
480
486
  async function rpc(method, payload = {}, timeoutMs = 45000) {
481
487
  const opts = {
482
488
  method: 'POST',
483
- 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() },
484
490
  body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
485
491
  }
486
492
  if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
@@ -501,7 +507,7 @@ async function rpc(method, payload = {}, timeoutMs = 45000) {
501
507
  async function respond(rpcId, value) {
502
508
  const opts = {
503
509
  method: 'POST',
504
- 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() },
505
511
  body: JSON.stringify({ type: 'client-response', rpcId, result: { ok: true, value } })
506
512
  }
507
513
  if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
@@ -1163,7 +1169,7 @@ async function pollKind(kind) {
1163
1169
  let res
1164
1170
  try {
1165
1171
  const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(5000) : undefined
1166
- 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() }
1167
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 })
1168
1174
  } catch { return }
1169
1175
  if (res.status === 401) { authFailure(); return }
@@ -1295,7 +1301,10 @@ function onHostFrame(full) {
1295
1301
  function onSessionEvent(sessionId, event) {
1296
1302
  if (!event) return
1297
1303
  const s = state.byId.get(sessionId)
1298
- if (s) s.updatedAt = Date.now()
1304
+ if (event.type === 'turn/start' || event.type === 'turn/end') {
1305
+ noteSessionTurnTime(sessionId, event)
1306
+ renderSessions()
1307
+ }
1299
1308
  if (event.type === 'agent/status') {
1300
1309
  if (s) { s.running = !!event.data?.running; s.blank = false; if (s.running) s.error = false }
1301
1310
  if (state.current === sessionId) { updateCancelBtn(); renderSessionSub(); updateSessionStatus() }
@@ -1469,7 +1478,7 @@ async function refreshWorkbench() {
1469
1478
  if (!state.token) return
1470
1479
  try {
1471
1480
  const res = await fetch(apiUrl('/workbench'), {
1472
- 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() }
1473
1482
  })
1474
1483
  if (res.ok) {
1475
1484
  const value = await res.json().catch(() => null)
@@ -1546,7 +1555,7 @@ function renderWorkbench() {
1546
1555
  <div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
1547
1556
  <button class="wb-session" type="button" data-wb-session="${esc(s.sessionId)}">
1548
1557
  <span class="wb-session-title">${esc(titleOf(s))}</span>
1549
- <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>
1550
1559
  </button>
1551
1560
  <button type="button" class="sc-archive-btn" data-archive-session="${esc(s.sessionId)}">${esc(t('session.archive'))}</button>
1552
1561
  </div>`).join('') : `<div class="wb-empty">${esc(t('wb.noSessions'))}</div>`}</div>` : ''
@@ -1576,6 +1585,15 @@ function workspaceDisplayName(label) {
1576
1585
  const parts = clean.split(/[\\/]/).filter(Boolean)
1577
1586
  return parts[parts.length - 1] || value
1578
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
+ }
1579
1597
  function sortedSessions() {
1580
1598
  const items = topLevelSessions()
1581
1599
  if (state.sessionSort === 'workspace') {
@@ -1583,10 +1601,10 @@ function sortedSessions() {
1583
1601
  const aw = sessionCwd(a) || '\uffff'
1584
1602
  const bw = sessionCwd(b) || '\uffff'
1585
1603
  const byWorkspace = aw.localeCompare(bw, undefined, { numeric: true, sensitivity: 'base' })
1586
- return byWorkspace || ((b.updatedAt || 0) - (a.updatedAt || 0))
1604
+ return byWorkspace || (sessionSortTime(b) - sessionSortTime(a))
1587
1605
  })
1588
1606
  }
1589
- return items.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
1607
+ return items.sort((a, b) => sessionSortTime(b) - sessionSortTime(a))
1590
1608
  }
1591
1609
  function renderSessions() {
1592
1610
  const list = $('session-list')
@@ -1625,7 +1643,7 @@ function renderSessions() {
1625
1643
  <div class="sc-title">${esc(title)}</div>
1626
1644
  <div class="sc-meta">
1627
1645
  <span class="sc-dot ${dots.join(' ')}"></span>
1628
- <span>${fmtTime(s.updatedAt)}</span>
1646
+ <span>${fmtTime(sessionSortTime(s))}</span>
1629
1647
  ${s.running ? '<span>' + t('sessions.running') + '</span>' : ''}
1630
1648
  ${badge}${queueBadge}
1631
1649
  </div>
@@ -1662,6 +1680,7 @@ async function openSession(id) {
1662
1680
  $('session-cards').innerHTML = ''
1663
1681
  renderSessionTitle(); renderSessionSub(); updateCancelBtn(); updateSessionStatus()
1664
1682
  $('history').innerHTML = '<div class="empty">' + t('history.loading') + '</div>'
1683
+ renderQueue()
1665
1684
  restoreCachedHistory()
1666
1685
  await loadHistory(true)
1667
1686
  renderSessionCards()
@@ -1686,7 +1705,7 @@ function bindNativeBack() {
1686
1705
  if ($('composer-wrap')?.classList.contains('fs')) { setComposerFullscreen(false); return }
1687
1706
  if (customSelectCurrent) { closeCustomSelect(); return }
1688
1707
  const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
1689
- 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 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 } // 先关弹窗
1690
1709
  if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
1691
1710
  if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
1692
1711
  if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
@@ -1717,6 +1736,8 @@ function updateSessionStatus() {
1717
1736
  const s = state.byId.get(state.current)
1718
1737
  const head = $('session-head')
1719
1738
  if (!head) return
1739
+ const composerStatus = $('composer-status')
1740
+ if (composerStatus) composerStatus.classList.toggle('hidden', !s?.running)
1720
1741
  head.classList.remove('running', 'interrupted')
1721
1742
  const queued = (state.queues[state.current] || []).some(i => i.placement !== 'context')
1722
1743
  if (s?.running || queued) head.classList.add('running')
@@ -1896,9 +1917,10 @@ async function loadHistory(reset) {
1896
1917
  for (const entry of incoming) {
1897
1918
  const ev = entry?.event
1898
1919
  const seq = ev?.seq
1920
+ if (ev?.type === 'turn/start' || ev?.type === 'turn/end') noteSessionTurnTime(id, ev)
1899
1921
  applyReasoningStreamEvent(ev)
1900
1922
  if (seq == null || state.history.seqs.has(seq)) continue
1901
- if (!shouldShowEvent(ev.type)) continue // chunk 等内部事件不保留
1923
+ if (!shouldShowEvent(ev.type, ev)) continue // chunk 与非用户上下文不保留
1902
1924
  state.history.seqs.add(seq)
1903
1925
  state.history.visible.push({ seq, event: ev, view: entry.view })
1904
1926
  added++
@@ -1929,7 +1951,7 @@ function insertLiveEvent(event) {
1929
1951
  return
1930
1952
  }
1931
1953
  const seq = event?.seq
1932
- if (seq == null || h.seqs.has(seq) || !shouldShowEvent(event.type)) {
1954
+ if (seq == null || h.seqs.has(seq) || !shouldShowEvent(event.type, event)) {
1933
1955
  if (reasoningChanged) scheduleReasoningRender()
1934
1956
  return
1935
1957
  }
@@ -2069,9 +2091,24 @@ const INTERESTING_EVENTS = new Set([
2069
2091
  'approval/asked', 'approval/resolved',
2070
2092
  'session/title', 'title'
2071
2093
  ])
2072
- function shouldShowEvent(type) {
2073
- if (INTERESTING_EVENTS.has(type)) return true
2074
- 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
2075
2112
  }
2076
2113
  function systemReminderText(blocks) {
2077
2114
  if (!Array.isArray(blocks)) return ''
@@ -2085,7 +2122,7 @@ function eventHtml(entry, ctx = {}) {
2085
2122
  const ev = entry.event || {}
2086
2123
  const data = ev.data || {}
2087
2124
  const type = ev.type || 'event'
2088
- if (!shouldShowEvent(type)) return ''
2125
+ if (!shouldShowEvent(type, ev)) return ''
2089
2126
  let inner = ''
2090
2127
 
2091
2128
  if (type === 'user/message' || type === 'assistant/message') {
@@ -2095,6 +2132,8 @@ function eventHtml(entry, ctx = {}) {
2095
2132
  const sysText = type === 'user/message' ? systemReminderText(blocks) : ''
2096
2133
  if (sysText) {
2097
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 ''
2098
2137
  } else {
2099
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>`
2100
2139
  }
@@ -2202,13 +2241,19 @@ async function renderSessionCards() {
2202
2241
  const sub = await safeRpc('subagent.list', { parentSessionId: sessionId })
2203
2242
  if (renderGeneration !== sessionCardsRenderGeneration || state.current !== sessionId) return
2204
2243
  if (sub?.entries?.length) {
2244
+ const expanded = state.subagentExpandedSession === sessionId
2245
+ const toggleLabel = expanded ? t('subagent.collapse') : t('subagent.expand')
2205
2246
  const rows = sub.entries.map(e => {
2206
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>`
2207
2248
  const label = e.label || short(e.id)
2208
2249
  const running = e.activity === 'running'
2209
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>`
2210
2251
  }).join('')
2211
- 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
+ })
2212
2257
  box.querySelectorAll('[data-sub-interrupt]').forEach(btn =>
2213
2258
  btn.addEventListener('click', () => interruptSubagent(btn.dataset.subInterrupt)))
2214
2259
  }
@@ -2261,7 +2306,7 @@ async function runSlashCommand(text) {
2261
2306
  : undefined
2262
2307
  const res = await fetch(apiUrl('/remote/api/command'), {
2263
2308
  method: 'POST',
2264
- 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() },
2265
2310
  body: JSON.stringify({ sessionId: state.current, line: clean }),
2266
2311
  ...(signal ? { signal } : {})
2267
2312
  })
@@ -2388,7 +2433,12 @@ async function sendSessionContent(text, images) {
2388
2433
  mode: 'queue',
2389
2434
  content
2390
2435
  }, t('send.failed'))
2391
- 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
+ }
2392
2442
  if (v?.command?.text) { toast(t('send.commandExecuted'), 'ok'); return true }
2393
2443
  return false
2394
2444
  } catch (e) {
@@ -2735,7 +2785,7 @@ function renderOverview() {
2735
2785
 
2736
2786
  const topSessions = topLevelSessions()
2737
2787
  const running = topSessions.filter(s => s.running).length
2738
- const sessions = topSessions.sort((a, b) => Number(b.running) - Number(a.running) || (new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0))).slice(0, 4)
2788
+ const sessions = topSessions.sort((a, b) => Number(b.running) - Number(a.running) || (sessionSortTime(b) - sessionSortTime(a))).slice(0, 4)
2739
2789
  const primary = $('overview-primary-action')
2740
2790
  if (primary) {
2741
2791
  let action = 'new'
@@ -2766,7 +2816,7 @@ function renderOverview() {
2766
2816
  $('overview-connection-mode').textContent = state.token ? t(state.streamMode === 'poll' ? 'overview.poll' : 'overview.ws') : '—'
2767
2817
  $('overview-active-count').textContent = running ? t('overview.activeCount', { n: running }) : ''
2768
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)}">
2769
- <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>
2770
2820
  </button>`).join('') : `<div class="overview-empty">${t('overview.noSession')}</div>`
2771
2821
  $('overview-session-list').querySelectorAll('[data-overview-session]').forEach(btn => btn.addEventListener('click', () => openSession(btn.dataset.overviewSession)))
2772
2822
  }
@@ -2862,11 +2912,46 @@ async function submitQuestion() {
2862
2912
  }
2863
2913
 
2864
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
+ }
2865
2937
  function renderQueue() {
2866
2938
  const s = state.byId.get(state.current)
2867
2939
  if (!s) return
2868
- 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
+ }
2869
2953
  updateCancelBtn()
2954
+ updateSessionStatus()
2870
2955
  // 队列数量在会话列表已显示; 详情页不重复大 UI
2871
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 })
2872
2957
  renderSessions()
@@ -2889,7 +2974,8 @@ function renderJobs() {
2889
2974
  function fsHeaders() {
2890
2975
  return {
2891
2976
  authorization: 'Bearer ' + state.token,
2892
- 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web'
2977
+ 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web',
2978
+ ...clientIdHeaders()
2893
2979
  }
2894
2980
  }
2895
2981
 
@@ -3259,6 +3345,7 @@ async function runFsUpload(up) {
3259
3345
  xhr.open('POST', fsApiUrl('/upload', params))
3260
3346
  xhr.setRequestHeader('authorization', 'Bearer ' + state.token)
3261
3347
  xhr.setRequestHeader('x-dsh-remote-client', CAP?.isNativePlatform?.() ? 'app' : 'web')
3348
+ if (CLIENT_ID) xhr.setRequestHeader('x-dsh-remote-client-id', CLIENT_ID)
3262
3349
  xhr.upload.onprogress = (e) => {
3263
3350
  if (e.lengthComputable) {
3264
3351
  const loaded = up.offset + Math.min(e.loaded, e.total)
@@ -4179,7 +4266,7 @@ function saveBgConfig(enabled) {
4179
4266
  const base = bgBase()
4180
4267
  const intervalMin = parseFloat($('bg-interval')?.value || '1') || 1
4181
4268
  const notifyTaskDone = $('opt-task-done')?.checked !== false
4182
- 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 }))
4183
4270
  if (enabled) $('bg-auth-status')?.classList.add('hidden')
4184
4271
  return true
4185
4272
  }
@@ -4505,14 +4592,207 @@ async function decodeQrDataUrl(dataUrl) {
4505
4592
  return code?.data || ''
4506
4593
  }
4507
4594
 
4508
- /** App 内扫码: 官方 Camera 拍照/相册 + jsQR 本地解码(无 Google ML Kit/GMS 依赖, 国内可用)。
4509
- * 冗余路径 1: 系统相机扫 dshremote:// 二维码直接唤起 App(见 bindNativeLinks);
4510
- * 冗余路径 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。 */
4511
4773
  async function scanPair(source) {
4512
4774
  if (!CAP?.isNativePlatform?.()) {
4513
4775
  toast(t('scan.browserHint'), 'err')
4514
4776
  return
4515
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
+ }
4516
4796
  const camera = CAP.Plugins?.Camera
4517
4797
  if (!camera?.getPhoto) { toast(t('scan.unsupported'), 'err'); return }
4518
4798
  try {
@@ -5121,6 +5401,8 @@ function bindUi() {
5121
5401
  })
5122
5402
  $('btn-scan-camera').addEventListener('click', () => scanPair('CAMERA'))
5123
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('') })
5124
5406
  $('btn-change-token').addEventListener('click', () => {
5125
5407
  const input = prompt(t('token.prompt'), state.token)
5126
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() }
@@ -236,6 +236,18 @@ button.ds-overview-attention-item, button.ds-overview-session-item { cursor:poin
236
236
  /* 会话视图 */
237
237
  #view-chat { padding: 0; }
238
238
  .ds-history { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 18px 22px; display: flex; flex-direction: column; gap: 10px; }
239
+ .ds-queue-dock { flex: none; margin: 0 16px 8px; border: 1px solid var(--dsr-line); border-radius: 12px; background: var(--dsr-surface); overflow: hidden; }
240
+ .ds-queue-dock-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; color: var(--dsr-muted); font-size: 12px; font-weight: 700; border-bottom: 1px solid var(--dsr-line); }
241
+ .ds-queue-dock-list { max-height: 220px; overflow-y: auto; }
242
+ .ds-queue-dock-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px 8px 12px; border-bottom: 1px solid var(--dsr-line); }
243
+ .ds-queue-dock-item:last-child { border-bottom: 0; }
244
+ .ds-queue-dock-preview { flex: 1; min-width: 0; color: var(--dsr-text); font-size: 13px; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
245
+ .ds-queue-dock-action { flex: 0 0 auto; }
246
+ .md-table-wrap { max-width: 100%; overflow-x: auto; margin: 8px 0; }
247
+ .md-table-wrap table { width: max-content; min-width: 100%; border-collapse: collapse; font-size: .94em; }
248
+ .md-table-wrap th, .md-table-wrap td { padding: 6px 9px; border: 1px solid var(--dsr-line); text-align: left; white-space: nowrap; }
249
+ .md-table-wrap th { background: var(--dsr-bg-2); font-weight: 700; }
250
+ .md-table-wrap tbody tr:nth-child(even) { background: color-mix(in srgb, var(--dsr-bg-2) 45%, transparent); }
239
251
  .ds-msg { max-width: min(78%, 680px); width: fit-content; box-sizing: border-box; padding: 9px 12px; border-radius: 12px; font-size: 13.5px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
240
252
  .ds-msg.user { align-self: flex-end; background: var(--dsr-accent-soft); border: 1px solid var(--dsr-accent-line); color: var(--dsr-accent-strong); }
241
253
  .ds-msg.assistant { align-self: flex-start; background: var(--dsr-panel); border: 1px solid var(--dsr-line); color: var(--dsr-text); }
@@ -274,6 +286,11 @@ button.ds-overview-attention-item, button.ds-overview-session-item { cursor:poin
274
286
  .ds-card-row { display: flex; align-items: center; gap: 10px; font-size: 12.5px; padding: 3px 0; }
275
287
  .ds-card-k { flex: 1; min-width: 0; word-break: break-word; }
276
288
  .ds-card-v { color: var(--dsr-muted); font-size: 11.5px; white-space: nowrap; }
289
+ .ds-subagent-card { padding: 0; overflow: hidden; }
290
+ .ds-subagent-toggle { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; }
291
+ .ds-subagent-toggle .ds-card-title { margin: 0; }
292
+ .ds-subagent-toggle-icon { color: var(--dsr-muted); font-size: 16px; line-height: 1; }
293
+ .ds-subagent-list { padding: 0 12px 10px; border-top: 1px solid var(--dsr-line); }
277
294
  .ds-model-head { font-size: 11px; font-weight: 700; color: var(--dsr-muted); letter-spacing: .6px; padding: 4px 6px 2px; }
278
295
  .ds-model-group { padding: 2px 0 4px; }
279
296
  .ds-model-provider { font-size: 11px; color: var(--dsr-muted); padding: 2px 6px; }
@@ -289,6 +306,9 @@ button.ds-overview-attention-item, button.ds-overview-session-item { cursor:poin
289
306
  .ds-reasoning-live summary::after { content: ''; display: inline-block; width: 6px; height: 6px; margin-left: 7px; border-radius: 50%; background: var(--dsr-accent-strong); animation: ds-reasoning-pulse 1.2s ease-in-out infinite; }
290
307
  @keyframes ds-reasoning-pulse { 0%, 100% { opacity: .35; transform: scale(.8); } 50% { opacity: 1; transform: scale(1); } }
291
308
  .ds-composer { flex: none; display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--dsr-line); background: var(--dsr-panel); }
309
+ .ds-composer-status { display: flex; align-items: center; gap: 7px; padding: 0 3px; color: var(--dsr-accent-strong); font-size: 12px; font-weight: 700; }
310
+ .ds-composer-status-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 0 currentColor; animation: dsr-running-pulse 1.3s ease-out infinite; }
311
+ @keyframes dsr-running-pulse { 0% { box-shadow: 0 0 0 0 currentColor; opacity: 1; } 70% { box-shadow: 0 0 0 6px transparent; opacity: .65; } 100% { box-shadow: 0 0 0 0 transparent; opacity: 1; } }
292
312
  .ds-composer textarea { width: 100%; min-width: 0; resize: none; background: var(--dsr-bg-2); color: var(--dsr-text); border: 1px solid var(--dsr-line); border-radius: 12px; padding: 10px 14px; font: inherit; font-size: 13.5px; line-height: 1.5; outline: none; min-height: 44px; max-height: 120px; box-sizing: border-box; }
293
313
  .ds-composer-actions { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
294
314
  .ds-composer-left { display: flex; align-items: center; gap: 8px; min-width: 0; flex-wrap: wrap; }