dsh-remote-plugin 0.6.21 → 0.6.23

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
@@ -75,11 +75,17 @@ const state = {
75
75
  updateInfo: null,
76
76
  warnedGatewayVersions: new Set(),
77
77
  announcement: null,
78
+ announcementItems: [],
79
+ announcementIndex: 0,
78
80
  announcements: [],
79
81
  approvals: [], // 待处理审批
80
82
  questions: [], // 待处理提问
81
83
  queues: {}, // sessionId -> queue items
82
84
  queueSteering: {}, // sessionId:itemId -> pending steer request
85
+ compactions: {}, // sessionId -> {active, phase, startedAt, message, source}
86
+ pendingCommands: {}, // sessionId -> command name; waits briefly before showing generic progress
87
+ compactionPollTimer: null,
88
+ compactionClockTimer: null,
83
89
  sessionTurnTimes: {}, // sessionId -> 本轮开始/结束时间,避免中间事件推动排序
84
90
  jobs: {}, // sessionId -> jobs
85
91
  sessionActivity: new Set(), // 已发送消息或已执行命令的会话
@@ -287,6 +293,7 @@ function openFeedbackModal() {
287
293
  document.querySelectorAll('#fb-chips .fb-chip').forEach(b => b.classList.toggle('current', b.dataset.fbType === 'bug'))
288
294
  $('fb-msg').value = ''
289
295
  $('fb-contact').value = ''
296
+ $('fb-include-diagnostics').checked = false
290
297
  $('modal-feedback').classList.remove('hidden')
291
298
  setTimeout(() => $('fb-msg').focus(), 50)
292
299
  }
@@ -303,6 +310,7 @@ async function submitFeedback() {
303
310
  const type = state.feedbackType || 'bug'
304
311
  const message = $('fb-msg').value.trim()
305
312
  const contact = $('fb-contact').value.trim()
313
+ const includeDiagnostics = $('fb-include-diagnostics').checked
306
314
  if (!message) { toast(t('feedback.empty'), 'err'); return }
307
315
  if (message.length > 2000) { toast(t('feedback.tooLong'), 'err'); return }
308
316
  const btn = $('fb-submit')
@@ -313,7 +321,7 @@ async function submitFeedback() {
313
321
  const res = await fetch(base + '/feedback', {
314
322
  method: 'POST',
315
323
  headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token },
316
- body: JSON.stringify({ type, message, contact, appVersion: state.localVersion })
324
+ body: JSON.stringify({ type, message, contact, appVersion: state.localVersion, includeDiagnostics })
317
325
  })
318
326
  let json = {}
319
327
  try { json = await res.json() } catch {}
@@ -1583,6 +1591,11 @@ function onHostFrame(full) {
1583
1591
  const f = full.payload
1584
1592
  if (!f) return
1585
1593
  if (['host/session-added', 'host/session-removed', 'host/workspace-changed', 'host/workspace-removed', 'host/workspace-order-changed', 'host/archived-sessions-changed'].includes(f.type)) return scheduleRefresh()
1594
+ if (f.type === 'host/session-activity') {
1595
+ const session = state.byId.get(f.sessionId)
1596
+ if (session) { session.updatedAt = Number(f.updatedAt) || Date.now(); renderSessions(); renderOverview() }
1597
+ return
1598
+ }
1586
1599
  if (f.type === 'host/session-status') {
1587
1600
  const s = state.byId.get(f.sessionId)
1588
1601
  if (s) { s.running = f.running; if (state.current === f.sessionId) { renderSessionCards(); updateCancelBtn(); renderSessionSub(); updateSessionStatus() } }
@@ -1597,9 +1610,104 @@ function onHostFrame(full) {
1597
1610
  if (f.type === 'host/remote-event') return scheduleRefresh()
1598
1611
  }
1599
1612
 
1613
+ function activeCompaction(sessionId = state.current) {
1614
+ const compact = state.compactions[sessionId]
1615
+ return compact?.active === true ? compact : null
1616
+ }
1617
+
1618
+ function compactElapsed(startedAt) {
1619
+ const seconds = Math.max(0, Math.floor((Date.now() - Number(startedAt || Date.now())) / 1000))
1620
+ const minutes = Math.floor(seconds / 60)
1621
+ const remain = seconds % 60
1622
+ return minutes > 0 ? `${minutes}:${String(remain).padStart(2, '0')}` : `${remain}s`
1623
+ }
1624
+
1625
+ function setCompactionStatus(sessionId, next) {
1626
+ if (!sessionId) return
1627
+ const previous = state.compactions[sessionId]
1628
+ const active = next?.active === true
1629
+ if (active) {
1630
+ state.compactions[sessionId] = {
1631
+ active: true,
1632
+ phase: next.phase || previous?.phase || 'running',
1633
+ command: String(next.command || previous?.command || 'compact'),
1634
+ startedAt: Number(next.startedAt) || previous?.startedAt || Date.now(),
1635
+ message: String(next.message || ''),
1636
+ source: next.source || previous?.source || 'event',
1637
+ }
1638
+ } else {
1639
+ delete state.compactions[sessionId]
1640
+ if (previous?.active) {
1641
+ const command = previous.command || 'compact'
1642
+ if (next?.phase === 'failed') toast(command === 'compact'
1643
+ ? t('session.compactFailed', { msg: next.message || t('send.failed') })
1644
+ : t('session.commandFailed', { command, msg: next.message || t('send.failed') }), 'err')
1645
+ else toast(command === 'compact' ? t('session.compactComplete') : t('session.commandComplete', { command }), 'ok')
1646
+ }
1647
+ }
1648
+ ensureCompactionMonitoring()
1649
+ if (state.current === sessionId) updateSessionStatus()
1650
+ }
1651
+
1652
+ async function refreshCompactionStatus(sessionId = state.current) {
1653
+ if (!sessionId) return
1654
+ try {
1655
+ const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(8000) : undefined
1656
+ const url = new URL(apiUrl('/remote/api/command-status'), location.href)
1657
+ url.searchParams.set('sessionId', sessionId)
1658
+ const res = await fetch(url, { headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }, ...(signal ? { signal } : {}) })
1659
+ if (res.status === 401) { authFailure(); return }
1660
+ if (!res.ok) return
1661
+ const body = await res.json().catch(() => null)
1662
+ const operation = body?.operation || body?.compact
1663
+ if (!operation) return
1664
+ const pending = state.pendingCommands[sessionId]
1665
+ if (operation.active) {
1666
+ delete state.pendingCommands[sessionId]
1667
+ setCompactionStatus(sessionId, { ...operation, source: 'status' })
1668
+ } else if (activeCompaction(sessionId) && activeCompaction(sessionId).source !== 'event') {
1669
+ setCompactionStatus(sessionId, operation)
1670
+ } else if (pending) {
1671
+ if (operation.phase === 'failed') toast(t('session.commandFailed', { command: pending, msg: operation.message || t('send.failed') }), 'err')
1672
+ delete state.pendingCommands[sessionId]
1673
+ }
1674
+ } catch {}
1675
+ }
1676
+
1677
+ function ensureCompactionMonitoring() {
1678
+ const active = Object.values(state.compactions).some(compact => compact?.active)
1679
+ if (!active) {
1680
+ if (state.compactionPollTimer) clearInterval(state.compactionPollTimer)
1681
+ if (state.compactionClockTimer) clearInterval(state.compactionClockTimer)
1682
+ state.compactionPollTimer = null
1683
+ state.compactionClockTimer = null
1684
+ return
1685
+ }
1686
+ if (!state.compactionClockTimer) {
1687
+ state.compactionClockTimer = setInterval(() => {
1688
+ if (activeCompaction()) updateSessionStatus()
1689
+ }, 1000)
1690
+ }
1691
+ if (!state.compactionPollTimer) {
1692
+ state.compactionPollTimer = setInterval(() => {
1693
+ const compact = activeCompaction()
1694
+ if (compact && compact.source !== 'event') void refreshCompactionStatus()
1695
+ }, 3000)
1696
+ }
1697
+ }
1698
+
1699
+ function observeCompactionEvent(sessionId, event) {
1700
+ if (event?.type === 'compaction/start') {
1701
+ setCompactionStatus(sessionId, { active: true, phase: 'running', startedAt: activeCompaction(sessionId)?.startedAt || Date.now(), source: activeCompaction(sessionId)?.source || 'event' })
1702
+ } else if (event?.type === 'compaction/end' && activeCompaction(sessionId)?.source === 'event') {
1703
+ setCompactionStatus(sessionId, { active: false, phase: 'complete' })
1704
+ }
1705
+ }
1706
+
1600
1707
  function onSessionEvent(sessionId, event) {
1601
1708
  if (!event) return
1602
1709
  const s = state.byId.get(sessionId)
1710
+ observeCompactionEvent(sessionId, event)
1603
1711
  if (event.type === 'turn/start' || event.type === 'turn/end') {
1604
1712
  noteSessionTurnTime(sessionId, event)
1605
1713
  renderSessions()
@@ -2180,6 +2288,7 @@ async function openSession(id) {
2180
2288
  $('history').innerHTML = '<div class="empty">' + t('history.loading') + '</div>'
2181
2289
  renderQueue()
2182
2290
  renderSessionPending()
2291
+ void refreshCompactionStatus(id)
2183
2292
  restoreCachedHistory()
2184
2293
  await loadHistory(true)
2185
2294
  renderSessionCards()
@@ -2274,10 +2383,20 @@ function updateSessionStatus() {
2274
2383
  const head = $('session-head')
2275
2384
  if (!head) return
2276
2385
  const composerStatus = $('composer-status')
2277
- if (composerStatus) composerStatus.classList.toggle('hidden', !s?.running)
2386
+ const compact = activeCompaction()
2387
+ if (composerStatus) {
2388
+ composerStatus.classList.toggle('hidden', !s?.running && !compact)
2389
+ composerStatus.classList.toggle('compacting', !!compact)
2390
+ }
2391
+ const composerText = $('composer-status-text')
2392
+ if (composerText) composerText.textContent = compact
2393
+ ? (compact.command === 'compact'
2394
+ ? t('session.compacting', { elapsed: compactElapsed(compact.startedAt) })
2395
+ : t('session.commandRunning', { command: compact.command, elapsed: compactElapsed(compact.startedAt) }))
2396
+ : t('composer.running')
2278
2397
  head.classList.remove('running', 'interrupted')
2279
2398
  const queued = (state.queues[state.current] || []).some(i => i.placement !== 'context')
2280
- if (s?.running || queued) head.classList.add('running')
2399
+ if (s?.running || queued || compact) head.classList.add('running')
2281
2400
  else if (s?.error) head.classList.add('interrupted')
2282
2401
  }
2283
2402
 
@@ -2876,12 +2995,75 @@ async function interruptSubagent(childId) {
2876
2995
  }
2877
2996
 
2878
2997
  /* ---------------- 发送 / 取消 / 快捷菜单 ---------------- */
2998
+ const NO_FALLBACK_SLASH_COMMANDS = new Set(['compact', 'export'])
2999
+ const SLASH_COMMAND_TIMEOUT_MS = 20_000
3000
+ // 比插件端的 120 秒多留 5 秒,让服务端能返回确定的失败结果而非客户端先中断。
3001
+ const LONG_RUNNING_SLASH_COMMAND_TIMEOUT_MS = 125_000
3002
+
3003
+ function slashCommandName(text) {
3004
+ const match = /^\/+([^\s/]+)/.exec(String(text || '').trim())
3005
+ return match ? match[1].toLowerCase() : ''
3006
+ }
3007
+
3008
+ function sessionLogFilename(sessionId) {
3009
+ return `dsh-session-${String(sessionId).replace(/[^A-Za-z0-9_-]/g, '_')}.zip`
3010
+ }
3011
+
3012
+ function sessionLogExportUrl(sessionId, includeToken = false) {
3013
+ const url = new URL(apiUrl('/api/session.export'), location.href)
3014
+ url.searchParams.set('sessionId', sessionId)
3015
+ url.searchParams.set('includeDescendants', 'true')
3016
+ // 网关下载由浏览器/DownloadManager 发起,无法附加 Bearer 头时才使用短期既有 token
3017
+ // 查询参数兼容通道;同源 DSH 插件页仍只使用它自己的登录 Cookie。
3018
+ if (includeToken && state.token) url.searchParams.set('token', state.token)
3019
+ return url
3020
+ }
3021
+
3022
+ async function downloadSessionExport(sessionId) {
3023
+ const nativePlatform = !!CAP?.isNativePlatform?.()
3024
+ const nativeDownload = !!(nativePlatform && window.NativeFile?.downloadToDownloads)
3025
+ if (nativePlatform && !nativeDownload) {
3026
+ toast(t('fs.downloadUnsupported'), 'err')
3027
+ return
3028
+ }
3029
+ const url = sessionLogExportUrl(sessionId, !nativeDownload && !!state.server)
3030
+ const headers = state.token ? { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() } : {}
3031
+ try {
3032
+ const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
3033
+ ? AbortSignal.timeout(LONG_RUNNING_SLASH_COMMAND_TIMEOUT_MS)
3034
+ : undefined
3035
+ const preflight = await fetch(url, { method: 'HEAD', headers, ...(signal ? { signal } : {}) })
3036
+ if (preflight.status === 401) { authFailure(); return }
3037
+ if (!preflight.ok) throw new Error('HTTP ' + preflight.status)
3038
+ const filename = sessionLogFilename(sessionId)
3039
+ if (nativeDownload) {
3040
+ window.NativeFile.downloadToDownloads(url.href, filename, state.token)
3041
+ toast(t('fs.downloadStarted'), 'ok')
3042
+ return
3043
+ }
3044
+ // 浏览器下载目的地由浏览器自身的下载设置决定;同源插件页无需暴露网关 token。
3045
+ const anchor = document.createElement('a')
3046
+ anchor.href = url.href
3047
+ anchor.download = filename
3048
+ document.body.appendChild(anchor)
3049
+ anchor.click()
3050
+ anchor.remove()
3051
+ toast(t('session.exportStarted'), 'ok')
3052
+ } catch (e) {
3053
+ console.error('session export download failed', e)
3054
+ toast(t('session.exportFailed', { msg: e?.message || '' }), 'err')
3055
+ }
3056
+ }
3057
+
2879
3058
  async function runSlashCommand(text) {
2880
3059
  const clean = String(text || '').trim()
2881
3060
  if (!clean.startsWith('/') || !state.current) return false
3061
+ const command = slashCommandName(clean)
3062
+ const noFallback = NO_FALLBACK_SLASH_COMMANDS.has(command)
3063
+ const longRunning = command === 'export'
2882
3064
  try {
2883
3065
  const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
2884
- ? AbortSignal.timeout(20000)
3066
+ ? AbortSignal.timeout(longRunning ? LONG_RUNNING_SLASH_COMMAND_TIMEOUT_MS : SLASH_COMMAND_TIMEOUT_MS)
2885
3067
  : undefined
2886
3068
  const res = await fetch(apiUrl('/remote/api/command'), {
2887
3069
  method: 'POST',
@@ -2890,12 +3072,31 @@ async function runSlashCommand(text) {
2890
3072
  ...(signal ? { signal } : {})
2891
3073
  })
2892
3074
  if (res.status === 401) { authFailure(); return true }
2893
- if (!res.ok) return false
3075
+ if (!res.ok) {
3076
+ if (noFallback) toast(t('session.commandTimedOut'), 'err')
3077
+ return noFallback
3078
+ }
2894
3079
  const data = await res.json().catch(() => null)
2895
3080
  if (data?.ok === false) { toast(data.message || t('send.failed'), 'err'); return true }
2896
- if (data?.ok && data.executed === true) { toast(t('send.commandExecuted'), 'ok'); return true }
3081
+ if (data?.ok && data.executed === true) {
3082
+ if (data.accepted) {
3083
+ if (command === 'compact') {
3084
+ setCompactionStatus(state.current, { ...(data.operation || data.compact), active: true, command, source: 'command' })
3085
+ } else {
3086
+ const sessionId = state.current
3087
+ state.pendingCommands[sessionId] = command
3088
+ setTimeout(() => { if (state.current === sessionId) void refreshCompactionStatus(sessionId) }, 600)
3089
+ }
3090
+ } else if (command === 'export') await downloadSessionExport(state.current)
3091
+ else toast(t('send.commandExecuted'), 'ok')
3092
+ return true
3093
+ }
2897
3094
  } catch (e) {
2898
3095
  console.error('slash command bridge failed', e)
3096
+ if (noFallback) {
3097
+ toast(t('session.commandTimedOut'), 'err')
3098
+ return true
3099
+ }
2899
3100
  }
2900
3101
  return false
2901
3102
  }
@@ -4429,10 +4630,12 @@ function readSeenAnnouncements() {
4429
4630
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
4430
4631
  } catch { return {} }
4431
4632
  }
4432
- function markAnnouncementSeen(id) {
4433
- if (!id) return
4633
+ function markAnnouncementsSeen(items) {
4634
+ const ids = [...new Set((items || []).map(item => typeof item === 'string' ? item : item?.id).filter(Boolean))]
4635
+ if (!ids.length) return
4434
4636
  const seen = readSeenAnnouncements()
4435
- seen[id] = Date.now()
4637
+ const now = Date.now()
4638
+ for (const id of ids) seen[id] = now
4436
4639
  const keys = Object.keys(seen)
4437
4640
  if (keys.length > 100) {
4438
4641
  keys.sort((a, b) => Number(seen[a]) - Number(seen[b]))
@@ -4441,6 +4644,7 @@ function markAnnouncementSeen(id) {
4441
4644
  LS.set(ANNOUNCEMENTS_KEY, JSON.stringify(seen))
4442
4645
  renderAnnouncementBoard()
4443
4646
  }
4647
+ function markAnnouncementSeen(id) { markAnnouncementsSeen([id]) }
4444
4648
  function readAnnouncementVotes() {
4445
4649
  try {
4446
4650
  const value = JSON.parse(LS.get(ANNOUNCEMENT_VOTES_KEY, '{}'))
@@ -4606,7 +4810,18 @@ function renderAnnouncementPoll(item) {
4606
4810
  submit.classList.toggle('hidden', !!vote)
4607
4811
  submit.disabled = true
4608
4812
  }
4609
- function openAnnouncementModal(item) {
4813
+ function renderAnnouncementPagination() {
4814
+ const total = state.announcementItems.length
4815
+ const nav = $('announcement-pagination')
4816
+ if (!nav) return
4817
+ nav.classList.toggle('hidden', total < 2)
4818
+ $('announcement-page').textContent = total > 1 ? t('announcement.page', { current: state.announcementIndex + 1, total }) : ''
4819
+ $('announcement-prev').disabled = state.announcementIndex <= 0
4820
+ $('announcement-next').disabled = state.announcementIndex >= total - 1
4821
+ }
4822
+ function renderAnnouncementModal() {
4823
+ const item = state.announcementItems[state.announcementIndex]
4824
+ if (!item) return
4610
4825
  state.announcement = item
4611
4826
  $('announcement-title').textContent = item.title
4612
4827
  $('announcement-content').innerHTML = esc(item.content).replace(/\r?\n/g, '<br>')
@@ -4621,9 +4836,23 @@ function openAnnouncementModal(item) {
4621
4836
  action.textContent = ''
4622
4837
  action.classList.add('hidden')
4623
4838
  }
4624
- $('announcement-later').classList.toggle('hidden', item.force)
4839
+ // 同批存在强制公告时,不能借由切到普通公告而绕过“稍后再看”限制。
4840
+ $('announcement-later').classList.toggle('hidden', state.announcementItems.some(entry => entry.force))
4841
+ renderAnnouncementPagination()
4842
+ }
4843
+ function openAnnouncementModal(items, index = 0) {
4844
+ const list = (Array.isArray(items) ? items : [items]).filter(item => item?.id)
4845
+ if (!list.length) return
4846
+ state.announcementItems = list
4847
+ state.announcementIndex = Math.max(0, Math.min(Number(index) || 0, list.length - 1))
4848
+ renderAnnouncementModal()
4625
4849
  $('modal-announcement').classList.remove('hidden')
4626
4850
  }
4851
+ function showAnnouncementAt(index) {
4852
+ if (!state.announcementItems.length) return
4853
+ state.announcementIndex = Math.max(0, Math.min(index, state.announcementItems.length - 1))
4854
+ renderAnnouncementModal()
4855
+ }
4627
4856
  async function submitAnnouncementVote() {
4628
4857
  const item = state.announcement
4629
4858
  const poll = item?.poll
@@ -4687,8 +4916,10 @@ async function submitAnnouncementVote() {
4687
4916
  }
4688
4917
  }
4689
4918
  function closeAnnouncement(markSeen) {
4690
- if (markSeen && state.announcement) markAnnouncementSeen(state.announcement.id)
4919
+ if (markSeen) markAnnouncementsSeen(state.announcementItems)
4691
4920
  state.announcement = null
4921
+ state.announcementItems = []
4922
+ state.announcementIndex = 0
4692
4923
  $('modal-announcement').classList.add('hidden')
4693
4924
  }
4694
4925
  async function fetchAnnouncements() {
@@ -4711,7 +4942,7 @@ async function fetchAnnouncements() {
4711
4942
  const items = normalized.filter(item => !seen[item.id])
4712
4943
  .sort((a, b) => b.publishedAt - a.publishedAt)
4713
4944
  if (!items.length || state.announcement) return false
4714
- openAnnouncementModal(items[0])
4945
+ openAnnouncementModal(items)
4715
4946
  return true
4716
4947
  } catch { return false }
4717
4948
  }
@@ -6897,12 +7128,14 @@ function bindUi() {
6897
7128
  $('archive-cancel').addEventListener('click', closeArchiveConfirm)
6898
7129
  $('archive-confirm').addEventListener('click', confirmArchiveSession)
6899
7130
  $('modal-archive').addEventListener('click', (e) => { if (e.target === $('modal-archive')) closeArchiveConfirm() })
7131
+ $('announcement-prev').addEventListener('click', () => showAnnouncementAt(state.announcementIndex - 1))
7132
+ $('announcement-next').addEventListener('click', () => showAnnouncementAt(state.announcementIndex + 1))
6900
7133
  $('announcement-later').addEventListener('click', () => closeAnnouncement(false))
6901
7134
  $('announcement-confirm').addEventListener('click', () => closeAnnouncement(true))
6902
7135
  $('announcement-poll-options').addEventListener('change', () => { $('announcement-poll-submit').disabled = false })
6903
7136
  $('announcement-poll-submit').addEventListener('click', submitAnnouncementVote)
6904
7137
  $('modal-announcement').addEventListener('click', (e) => {
6905
- if (e.target === $('modal-announcement') && !state.announcement?.force) closeAnnouncement(false)
7138
+ if (e.target === $('modal-announcement') && !state.announcementItems.some(item => item.force)) closeAnnouncement(false)
6906
7139
  })
6907
7140
  $('announcement-history-close').addEventListener('click', closeAnnouncementHistory)
6908
7141
  $('announcement-history-list').addEventListener('click', (e) => {
@@ -166,6 +166,9 @@ html.reorder-scroll-lock body { overscroll-behavior: none; }
166
166
  }
167
167
  .ds-fb-textarea:focus, .ds-fb-input:focus { border-color: var(--dsr-accent-line); }
168
168
  .ds-fb-input { min-height: 42px; }
169
+ .ds-fb-diagnostics { display:grid; grid-template-columns:auto minmax(0,1fr); gap:5px 8px; align-items:start; margin:-1px 1px 4px; color:var(--dsr-text); font-size:13px; line-height:1.45; cursor:pointer; }
170
+ .ds-fb-diagnostics input { width:17px; height:17px; margin:1px 0 0; accent-color:var(--dsr-accent); }
171
+ .ds-fb-diagnostics small { grid-column:2; color:var(--dsr-muted); font-size:11px; line-height:1.45; }
169
172
 
170
173
  .ds-main { flex: 1; min-width: 0; display: flex; flex-direction: column; background: radial-gradient(900px 560px at 96% -18%, var(--dsr-accent-soft), transparent 64%), linear-gradient(180deg, var(--dsr-bg), var(--dsr-bg-2)); }
171
174
  .ds-topbar { height: 58px; flex: none; display: flex; align-items: center; gap: 10px; padding: 0 20px; border-bottom: 1px solid var(--dsr-divider); background: linear-gradient(180deg, var(--dsr-head-bg), transparent); backdrop-filter: blur(16px); }
@@ -169,7 +169,7 @@
169
169
  <section id="session-pending" class="ds-session-pending hidden" aria-live="polite"></section>
170
170
  <div id="history" class="ds-history" aria-live="polite"></div>
171
171
  <div class="ds-composer">
172
- <div id="composer-status" class="ds-composer-status hidden" role="status" aria-live="polite"><span class="ds-composer-status-dot" aria-hidden="true"></span><span data-i18n="ds.composerRunning">运行中…</span></div>
172
+ <div id="composer-status" class="ds-composer-status hidden" role="status" aria-live="polite"><span class="ds-composer-status-dot" aria-hidden="true"></span><span id="composer-status-text" data-i18n="ds.composerRunning">运行中…</span></div>
173
173
  <textarea id="composer" rows="1" data-i18n-placeholder="ds.composerPlaceholder" placeholder="输入消息…"></textarea>
174
174
  <div class="ds-composer-actions">
175
175
  <div class="ds-composer-left">
@@ -368,6 +368,7 @@
368
368
  </div>
369
369
  <textarea id="fb-msg" class="ds-fb-textarea" rows="5" maxlength="2000" data-i18n-placeholder="ds.feedbackMessagePlaceholder" placeholder="请描述遇到的问题或建议(必填,≤2000 字)"></textarea>
370
370
  <input id="fb-contact" class="ds-fb-input" maxlength="200" data-i18n-placeholder="ds.feedbackContactPlaceholder" placeholder="联系方式(可选):邮箱 / 微信 / B站 ID">
371
+ <label class="ds-fb-diagnostics"><input id="fb-include-diagnostics" type="checkbox"> <span data-i18n="ds.feedbackIncludeDiagnostics">附带兼容性诊断日志(可选)</span><small data-i18n="ds.feedbackDiagnosticsPrivacy">仅上传网关/协议版本、接口失败摘要和实时链路状态;不包含令牌、Cookie、对话内容或文件路径。</small></label>
371
372
  </div>
372
373
  <div class="ds-modal-actions">
373
374
  <button id="fb-cancel" class="ds-btn" data-i18n="ds.feedbackCancel">取消</button>
@@ -477,7 +478,7 @@
477
478
  'ds.files': '文件传输', 'ds.settings': '设置', 'ds.stats': '统计', 'ds.menu': '导航',
478
479
  'ds.send': '发送', 'ds.composerPlaceholder': '输入消息,Enter 发送…', 'ds.composerRunning': '运行中…', 'ds.presets': '预设', 'ds.sessionRename': '重命名会话', 'ds.sessionRenameTitle': '重命名会话', 'ds.sessionRenamePlaceholder': '输入新的会话名称', 'ds.sessionRenameConfirm': '保存', 'ds.sessionRenameFailed': '重命名失败', 'ds.sessionRenameEmpty': '会话名称不能为空', 'ds.sessionRenamed': '会话名称已更新', 'ds.sessionArchive': '归档', 'ds.sessionArchiveConfirm': '归档这个会话?归档后可在“显示已归档”中打开。', 'ds.sessionArchived': '会话已归档', 'ds.sessionStop': '停止本轮', 'ds.sessionStopConfirm': '停止当前回合?排队中的消息不会被删除。', 'ds.sessionStopFailed': '停止失败', 'ds.sessionStopRequested': '已请求停止本轮', 'ds.sessionRecovering': '恢复会话中…', 'ds.sessionReady': '会话已恢复', 'ds.sessionRecoveryFailed': '会话恢复失败',
479
480
  'ds.commands': '指令',
480
- 'ds.cmdCompact': '/compact 压缩对话历史', 'ds.cmdExport': '/export 导出会话日志 ZIP',
481
+ 'ds.cmdCompact': '/compact 压缩对话历史', 'ds.cmdExport': '/export 导出会话日志 ZIP', 'ds.commandTimedOut': '命令执行超时,未作为普通消息发送', 'ds.exportStarted': '会话日志已开始下载,请在浏览器下载中查看', 'ds.exportFailed': '无法导出会话日志:{msg}', 'ds.compacting': '正在压缩对话 · 已用时 {elapsed}', 'ds.compactComplete': '对话压缩完成', 'ds.compactFailed': '对话压缩未完成:{msg}', 'ds.commandRunning': '正在执行 /{command} · 已用时 {elapsed}', 'ds.commandComplete': '/{command} 已完成', 'ds.commandFailed': '/{command} 未完成:{msg}',
481
482
  'ds.cmdFeedback': '/feedback 反馈当前会话', 'ds.cmdGoal': '/goal 设置/查看任务目标',
482
483
  'ds.cmdPermission': '/permission 切换权限预设', 'ds.cmdPlan': '/plan 进入/退出计划模式',
483
484
  'ds.fsUp': '上级', 'ds.fsRoot': '允许根目录', 'ds.fsNewWorkspace': '新建工作区', 'ds.fsRefresh': '刷新', 'ds.fsEmpty': '目录为空',
@@ -497,7 +498,7 @@
497
498
  'ds.feedback': '反馈', 'ds.feedbackGithubDesc': '反馈 bug / 提建议', 'ds.feedbackGiteeDesc': '国内镜像,无需代理',
498
499
  'ds.feedbackBiliDesc': 'UP 动态页交流', 'ds.feedbackCopyLink': '复制项目链接', 'ds.feedbackCopyDesc': '手动分享给朋友',
499
500
  'ds.feedbackCopied': '项目链接已复制', 'ds.feedbackCopyFailed': '复制失败,请手动复制',
500
- 'ds.feedbackWrite': '写反馈', 'ds.feedbackWriteDesc': 'App 内直接提交', 'ds.feedbackModalTitle': '写反馈',
501
+ 'ds.feedbackWrite': '写反馈', 'ds.feedbackWriteDesc': 'App 内直接提交', 'ds.feedbackModalTitle': '写反馈', 'ds.feedbackIncludeDiagnostics': '附带兼容性诊断日志(可选)', 'ds.feedbackDiagnosticsPrivacy': '仅上传网关/协议版本、接口失败摘要和实时链路状态;不包含令牌、Cookie、对话内容或文件路径。',
501
502
  'ds.feedbackTypeBug': 'Bug', 'ds.feedbackTypeSuggestion': '建议', 'ds.feedbackTypeOther': '其他',
502
503
  'ds.feedbackMessagePlaceholder': '请描述遇到的问题或建议(必填,≤2000 字)',
503
504
  'ds.feedbackContactPlaceholder': '联系方式(可选):邮箱 / 微信 / B站 ID',
@@ -582,7 +583,7 @@
582
583
  'ds.files': 'Files', 'ds.settings': 'Settings', 'ds.stats': 'Stats', 'ds.menu': 'Menu',
583
584
  'ds.send': 'Send', 'ds.composerPlaceholder': 'Type a message, Enter to send…', 'ds.composerRunning': 'Running…', 'ds.presets': 'Presets', 'ds.sessionRename': 'Rename session', 'ds.sessionRenameTitle': 'Rename session', 'ds.sessionRenamePlaceholder': 'Enter a new session name', 'ds.sessionRenameConfirm': 'Save', 'ds.sessionRenameFailed': 'Rename failed', 'ds.sessionRenameEmpty': 'Session name cannot be empty', 'ds.sessionRenamed': 'Session name updated', 'ds.sessionArchive': 'Archive', 'ds.sessionArchiveConfirm': 'Archive this session? You can open it from “Show archived”.', 'ds.sessionArchived': 'Session archived', 'ds.sessionStop': 'Stop turn', 'ds.sessionStopConfirm': 'Stop the current turn? Queued messages will be kept.', 'ds.sessionStopFailed': 'Stop failed', 'ds.sessionStopRequested': 'Stop requested', 'ds.sessionRecovering': 'Restoring session…', 'ds.sessionReady': 'Session ready', 'ds.sessionRecoveryFailed': 'Session restore failed',
584
585
  'ds.commands': 'Commands',
585
- 'ds.cmdCompact': '/compact Compress conversation history', 'ds.cmdExport': '/export Export session log ZIP',
586
+ 'ds.cmdCompact': '/compact Compress conversation history', 'ds.cmdExport': '/export Export session log ZIP', 'ds.commandTimedOut': 'Command timed out and was not sent as a chat message', 'ds.exportStarted': 'Session log download started; check your browser downloads', 'ds.exportFailed': 'Could not export session log: {msg}', 'ds.compacting': 'Compressing conversation · {elapsed} elapsed', 'ds.compactComplete': 'Conversation compression complete', 'ds.compactFailed': 'Conversation compression did not finish: {msg}', 'ds.commandRunning': 'Running /{command} · {elapsed} elapsed', 'ds.commandComplete': '/{command} complete', 'ds.commandFailed': '/{command} did not finish: {msg}',
586
587
  'ds.cmdFeedback': '/feedback Feedback current session', 'ds.cmdGoal': '/goal Set/view task goal',
587
588
  'ds.cmdPermission': '/permission Switch permission preset', 'ds.cmdPlan': '/plan Enter/exit plan mode',
588
589
  'ds.fsUp': 'Up', 'ds.fsRoot': 'Allowed root', 'ds.fsNewWorkspace': 'New workspace', 'ds.fsRefresh': 'Refresh', 'ds.fsEmpty': 'Empty directory',
@@ -602,7 +603,7 @@
602
603
  'ds.feedback': 'Feedback', 'ds.feedbackGithubDesc': 'Report bugs · suggest features', 'ds.feedbackGiteeDesc': 'Mirror in China, no proxy needed',
603
604
  'ds.feedbackBiliDesc': 'Chat on the UP\'s Bilibili page', 'ds.feedbackCopyLink': 'Copy project link', 'ds.feedbackCopyDesc': 'Share it manually',
604
605
  'ds.feedbackCopied': 'Project link copied', 'ds.feedbackCopyFailed': 'Copy failed, copy manually',
605
- 'ds.feedbackWrite': 'Write feedback', 'ds.feedbackWriteDesc': 'Submit from the app', 'ds.feedbackModalTitle': 'Write feedback',
606
+ 'ds.feedbackWrite': 'Write feedback', 'ds.feedbackWriteDesc': 'Submit from the app', 'ds.feedbackModalTitle': 'Write feedback', 'ds.feedbackIncludeDiagnostics': 'Include compatibility diagnostics (optional)', 'ds.feedbackDiagnosticsPrivacy': 'Uploads only gateway/protocol versions, interface failure summaries, and realtime status; no token, cookie, conversation content, or file path.',
606
607
  'ds.feedbackTypeBug': 'Bug', 'ds.feedbackTypeSuggestion': 'Suggestion', 'ds.feedbackTypeOther': 'Other',
607
608
  'ds.feedbackMessagePlaceholder': 'Describe the bug or suggestion (required, ≤2000 chars)',
608
609
  'ds.feedbackContactPlaceholder': 'Contact (optional): email / WeChat / Bilibili ID',