dsh-remote-plugin 0.6.21 → 0.6.22

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.
Binary file
package/index.mjs CHANGED
@@ -19,6 +19,13 @@ export const inject = ['webServer', 'commands', 'agents', 'connection']
19
19
  const MOUNT = '/remote'
20
20
  const PUBLIC_DIR = fileURLToPath(new URL('./public/', import.meta.url))
21
21
  const INDEX_FILE = 'index.html'
22
+ const LONG_RUNNING_COMMAND_TIMEOUT_MS = 120_000
23
+ const DEFAULT_COMMAND_TIMEOUT_MS = 30_000
24
+ const LONG_RUNNING_COMMANDS = new Set(['export'])
25
+ const COMMAND_OPERATION_RETENTION_MS = 10 * 60_000
26
+ // 已登记的命令由 HTTP 请求之外的独立 promise 驱动。这样浏览器断连或等待到期
27
+ // 都不会中止长操作;状态仅保存在进程内,DSH 重启后会话事件重新成为权威来源。
28
+ const commandOperations = new Map()
22
29
  const GATEWAY_SCRIPT = fileURLToPath(new URL('./gateway.cjs', import.meta.url))
23
30
  const gatewayInstalled = existsSync(GATEWAY_SCRIPT)
24
31
  // 本地网关管理 API 代理: 让插件抽屉显示与网关管理页完全一致的数据。
@@ -530,6 +537,56 @@ function commandWasExecuted(result) {
530
537
  return result !== false && result?.executed !== false
531
538
  }
532
539
 
540
+ function remoteCommandAuthorized(req) {
541
+ const expected = gatewayToken()
542
+ return !!expected && req.headers.authorization === `Bearer ${expected}`
543
+ }
544
+
545
+ function commandOperationSnapshot(operation) {
546
+ if (!operation) return { active: false, phase: 'idle', command: '', startedAt: 0, endedAt: 0, message: '' }
547
+ return {
548
+ active: operation.active === true,
549
+ phase: operation.phase || 'idle',
550
+ command: String(operation.command || ''),
551
+ startedAt: Number(operation.startedAt) || 0,
552
+ endedAt: Number(operation.endedAt) || 0,
553
+ message: String(operation.message || ''),
554
+ }
555
+ }
556
+
557
+ async function executeRegisteredCommand(ctx, agent, line, signal) {
558
+ return ctx.commands.execute.length === 3
559
+ ? ctx.commands.execute(agent, line, signal)
560
+ : ctx.commands.execute(agent, line, [], signal)
561
+ }
562
+
563
+ function beginCommandOperation(ctx, agent, sessionId, line, command) {
564
+ const existing = commandOperations.get(sessionId)
565
+ if (existing?.active) return { operation: existing, reused: true }
566
+ const operation = { active: true, phase: 'running', command, startedAt: Date.now(), endedAt: 0, message: '' }
567
+ commandOperations.set(sessionId, operation)
568
+ // 不把已登记命令绑定到 HTTP 超时:真实 command/done 才是完成边界。
569
+ // 处理器仍接收可用 AbortSignal,以符合命令执行器的契约。
570
+ const signal = new AbortController().signal
571
+ void executeRegisteredCommand(ctx, agent, line, signal).then((result) => {
572
+ operation.active = false
573
+ operation.endedAt = Date.now()
574
+ operation.phase = result?.result?.kind === 'error' ? 'failed' : 'complete'
575
+ operation.message = String(result?.result?.text || '')
576
+ }, (error) => {
577
+ operation.active = false
578
+ operation.endedAt = Date.now()
579
+ operation.phase = 'failed'
580
+ operation.message = error?.message || String(error)
581
+ }).finally(() => {
582
+ const cleanup = setTimeout(() => {
583
+ if (commandOperations.get(sessionId) === operation && !operation.active) commandOperations.delete(sessionId)
584
+ }, COMMAND_OPERATION_RETENTION_MS)
585
+ cleanup.unref?.()
586
+ })
587
+ return { operation, reused: false }
588
+ }
589
+
533
590
  async function resolveFile(pathname) {
534
591
  let abs = targetPath(pathname)
535
592
  if (abs === null) return null
@@ -757,6 +814,28 @@ async function serveStatic(req, res, ctx) {
757
814
  return
758
815
  }
759
816
 
817
+ // 命令状态:由插件进程跟踪长期操作,客户端重连后可恢复可见状态。
818
+ if (pathname === `${MOUNT}/api/command-status`) {
819
+ if (req.method !== 'GET') {
820
+ res.writeHead(405, { allow: 'GET' })
821
+ res.end()
822
+ return
823
+ }
824
+ if (!remoteCommandAuthorized(req)) {
825
+ sendJson(res, 401, { ok: false, message: 'unauthorized' })
826
+ return
827
+ }
828
+ const sessionId = new URL(req.url ?? '/', 'http://x').searchParams.get('sessionId') || ''
829
+ if (!sessionId) {
830
+ sendJson(res, 400, { ok: false, message: 'sessionId required' })
831
+ return
832
+ }
833
+ const operation = commandOperationSnapshot(commandOperations.get(sessionId))
834
+ // compact 为上一版客户端保留,新的客户端统一读取 operation。
835
+ sendJson(res, 200, { ok: true, operation, compact: operation.command === 'compact' ? operation : commandOperationSnapshot(null) })
836
+ return
837
+ }
838
+
760
839
  // 斜杠命令桥接:客户端 → 网关 → 插件端点 → ctx.commands.execute
761
840
  if (pathname === `${MOUNT}/api/command`) {
762
841
  if (req.method !== 'POST') {
@@ -764,9 +843,7 @@ async function serveStatic(req, res, ctx) {
764
843
  res.end()
765
844
  return
766
845
  }
767
- const auth = req.headers.authorization || ''
768
- const expected = gatewayToken()
769
- if (!expected || auth !== `Bearer ${expected}`) {
846
+ if (!remoteCommandAuthorized(req)) {
770
847
  sendJson(res, 401, { ok: false, message: 'unauthorized' })
771
848
  return
772
849
  }
@@ -804,10 +881,25 @@ async function serveStatic(req, res, ctx) {
804
881
  sendJson(res, 200, { ok: true, executed: false, debug: { resolvePath, commandNames, reason: 'unknown-command' } })
805
882
  return
806
883
  }
807
- const signal = AbortSignal.timeout(30000)
808
- const result = ctx.commands.execute.length === 3
809
- ? await ctx.commands.execute(agent, line, signal)
810
- : await ctx.commands.execute(agent, line, [], signal)
884
+ // /export 的成功结果必须先回到客户端,由客户端再发起 ZIP 下载;其他已登记
885
+ // 命令统一异步受理,避免把未知耗时绑定到一条 HTTP 请求。
886
+ if (name && name !== 'export') {
887
+ const { operation, reused } = beginCommandOperation(ctx, agent, sessionId, line, name)
888
+ sendJson(res, 202, {
889
+ ok: true,
890
+ executed: true,
891
+ accepted: true,
892
+ operation: commandOperationSnapshot(operation),
893
+ compact: name === 'compact' ? commandOperationSnapshot(operation) : undefined,
894
+ debug: { resolvePath, commandNames, reused },
895
+ })
896
+ return
897
+ }
898
+ // /export 在持久化历史较大时会等待刷新;其他同步命令沿用较短超时。
899
+ const signal = AbortSignal.timeout(LONG_RUNNING_COMMANDS.has(name)
900
+ ? LONG_RUNNING_COMMAND_TIMEOUT_MS
901
+ : DEFAULT_COMMAND_TIMEOUT_MS)
902
+ const result = await executeRegisteredCommand(ctx, agent, line, signal)
811
903
  sendJson(res, 200, { ok: true, executed: commandWasExecuted(result), debug: { resolvePath, commandNames } })
812
904
  } catch (e) {
813
905
  sendJson(res, 200, { ok: false, message: e?.message || String(e) })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.6.21",
3
+ "version": "0.6.22",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -108,6 +108,14 @@
108
108
  { "id": "other", "label": "其他品牌" }
109
109
  ]
110
110
  }
111
+ },
112
+ {
113
+ "id": "2026-08-31-plugin-management-demand",
114
+ "title": "关于下个大版本插件管理功能的需求征集",
115
+ "content": "大家好,dsh-Remote 下个大版本将尝试引入插件管理相关功能,目前仍处于规划和探索阶段,具体功能范围会根据实际需求进一步评估。\n\n如果你有常用的管理插件,或者希望 dsh-Remote 支持某些插件管理及插件内功能,欢迎通过反馈渠道提交建议。也可以尽量说明具体使用场景、希望实现的操作以及当前遇到的不便,这会帮助我们开发出更符合实际需求的功能。\n\n如果需要加入用户交流群,QQ群号可以在往期公告中找到。\n\n感谢大家的支持与反馈!",
116
+ "minVersion": "",
117
+ "maxVersion": "",
118
+ "publishedAt": "2026-08-31T12:38:43+08:00"
111
119
  }
112
120
  ]
113
121
  }
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(), // 已发送消息或已执行命令的会话
@@ -1597,9 +1603,104 @@ function onHostFrame(full) {
1597
1603
  if (f.type === 'host/remote-event') return scheduleRefresh()
1598
1604
  }
1599
1605
 
1606
+ function activeCompaction(sessionId = state.current) {
1607
+ const compact = state.compactions[sessionId]
1608
+ return compact?.active === true ? compact : null
1609
+ }
1610
+
1611
+ function compactElapsed(startedAt) {
1612
+ const seconds = Math.max(0, Math.floor((Date.now() - Number(startedAt || Date.now())) / 1000))
1613
+ const minutes = Math.floor(seconds / 60)
1614
+ const remain = seconds % 60
1615
+ return minutes > 0 ? `${minutes}:${String(remain).padStart(2, '0')}` : `${remain}s`
1616
+ }
1617
+
1618
+ function setCompactionStatus(sessionId, next) {
1619
+ if (!sessionId) return
1620
+ const previous = state.compactions[sessionId]
1621
+ const active = next?.active === true
1622
+ if (active) {
1623
+ state.compactions[sessionId] = {
1624
+ active: true,
1625
+ phase: next.phase || previous?.phase || 'running',
1626
+ command: String(next.command || previous?.command || 'compact'),
1627
+ startedAt: Number(next.startedAt) || previous?.startedAt || Date.now(),
1628
+ message: String(next.message || ''),
1629
+ source: next.source || previous?.source || 'event',
1630
+ }
1631
+ } else {
1632
+ delete state.compactions[sessionId]
1633
+ if (previous?.active) {
1634
+ const command = previous.command || 'compact'
1635
+ if (next?.phase === 'failed') toast(command === 'compact'
1636
+ ? t('session.compactFailed', { msg: next.message || t('send.failed') })
1637
+ : t('session.commandFailed', { command, msg: next.message || t('send.failed') }), 'err')
1638
+ else toast(command === 'compact' ? t('session.compactComplete') : t('session.commandComplete', { command }), 'ok')
1639
+ }
1640
+ }
1641
+ ensureCompactionMonitoring()
1642
+ if (state.current === sessionId) updateSessionStatus()
1643
+ }
1644
+
1645
+ async function refreshCompactionStatus(sessionId = state.current) {
1646
+ if (!sessionId) return
1647
+ try {
1648
+ const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(8000) : undefined
1649
+ const url = new URL(apiUrl('/remote/api/command-status'), location.href)
1650
+ url.searchParams.set('sessionId', sessionId)
1651
+ const res = await fetch(url, { headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }, ...(signal ? { signal } : {}) })
1652
+ if (res.status === 401) { authFailure(); return }
1653
+ if (!res.ok) return
1654
+ const body = await res.json().catch(() => null)
1655
+ const operation = body?.operation || body?.compact
1656
+ if (!operation) return
1657
+ const pending = state.pendingCommands[sessionId]
1658
+ if (operation.active) {
1659
+ delete state.pendingCommands[sessionId]
1660
+ setCompactionStatus(sessionId, { ...operation, source: 'status' })
1661
+ } else if (activeCompaction(sessionId) && activeCompaction(sessionId).source !== 'event') {
1662
+ setCompactionStatus(sessionId, operation)
1663
+ } else if (pending) {
1664
+ if (operation.phase === 'failed') toast(t('session.commandFailed', { command: pending, msg: operation.message || t('send.failed') }), 'err')
1665
+ delete state.pendingCommands[sessionId]
1666
+ }
1667
+ } catch {}
1668
+ }
1669
+
1670
+ function ensureCompactionMonitoring() {
1671
+ const active = Object.values(state.compactions).some(compact => compact?.active)
1672
+ if (!active) {
1673
+ if (state.compactionPollTimer) clearInterval(state.compactionPollTimer)
1674
+ if (state.compactionClockTimer) clearInterval(state.compactionClockTimer)
1675
+ state.compactionPollTimer = null
1676
+ state.compactionClockTimer = null
1677
+ return
1678
+ }
1679
+ if (!state.compactionClockTimer) {
1680
+ state.compactionClockTimer = setInterval(() => {
1681
+ if (activeCompaction()) updateSessionStatus()
1682
+ }, 1000)
1683
+ }
1684
+ if (!state.compactionPollTimer) {
1685
+ state.compactionPollTimer = setInterval(() => {
1686
+ const compact = activeCompaction()
1687
+ if (compact && compact.source !== 'event') void refreshCompactionStatus()
1688
+ }, 3000)
1689
+ }
1690
+ }
1691
+
1692
+ function observeCompactionEvent(sessionId, event) {
1693
+ if (event?.type === 'compaction/start') {
1694
+ setCompactionStatus(sessionId, { active: true, phase: 'running', startedAt: activeCompaction(sessionId)?.startedAt || Date.now(), source: activeCompaction(sessionId)?.source || 'event' })
1695
+ } else if (event?.type === 'compaction/end' && activeCompaction(sessionId)?.source === 'event') {
1696
+ setCompactionStatus(sessionId, { active: false, phase: 'complete' })
1697
+ }
1698
+ }
1699
+
1600
1700
  function onSessionEvent(sessionId, event) {
1601
1701
  if (!event) return
1602
1702
  const s = state.byId.get(sessionId)
1703
+ observeCompactionEvent(sessionId, event)
1603
1704
  if (event.type === 'turn/start' || event.type === 'turn/end') {
1604
1705
  noteSessionTurnTime(sessionId, event)
1605
1706
  renderSessions()
@@ -2180,6 +2281,7 @@ async function openSession(id) {
2180
2281
  $('history').innerHTML = '<div class="empty">' + t('history.loading') + '</div>'
2181
2282
  renderQueue()
2182
2283
  renderSessionPending()
2284
+ void refreshCompactionStatus(id)
2183
2285
  restoreCachedHistory()
2184
2286
  await loadHistory(true)
2185
2287
  renderSessionCards()
@@ -2274,10 +2376,20 @@ function updateSessionStatus() {
2274
2376
  const head = $('session-head')
2275
2377
  if (!head) return
2276
2378
  const composerStatus = $('composer-status')
2277
- if (composerStatus) composerStatus.classList.toggle('hidden', !s?.running)
2379
+ const compact = activeCompaction()
2380
+ if (composerStatus) {
2381
+ composerStatus.classList.toggle('hidden', !s?.running && !compact)
2382
+ composerStatus.classList.toggle('compacting', !!compact)
2383
+ }
2384
+ const composerText = $('composer-status-text')
2385
+ if (composerText) composerText.textContent = compact
2386
+ ? (compact.command === 'compact'
2387
+ ? t('session.compacting', { elapsed: compactElapsed(compact.startedAt) })
2388
+ : t('session.commandRunning', { command: compact.command, elapsed: compactElapsed(compact.startedAt) }))
2389
+ : t('composer.running')
2278
2390
  head.classList.remove('running', 'interrupted')
2279
2391
  const queued = (state.queues[state.current] || []).some(i => i.placement !== 'context')
2280
- if (s?.running || queued) head.classList.add('running')
2392
+ if (s?.running || queued || compact) head.classList.add('running')
2281
2393
  else if (s?.error) head.classList.add('interrupted')
2282
2394
  }
2283
2395
 
@@ -2876,12 +2988,75 @@ async function interruptSubagent(childId) {
2876
2988
  }
2877
2989
 
2878
2990
  /* ---------------- 发送 / 取消 / 快捷菜单 ---------------- */
2991
+ const NO_FALLBACK_SLASH_COMMANDS = new Set(['compact', 'export'])
2992
+ const SLASH_COMMAND_TIMEOUT_MS = 20_000
2993
+ // 比插件端的 120 秒多留 5 秒,让服务端能返回确定的失败结果而非客户端先中断。
2994
+ const LONG_RUNNING_SLASH_COMMAND_TIMEOUT_MS = 125_000
2995
+
2996
+ function slashCommandName(text) {
2997
+ const match = /^\/+([^\s/]+)/.exec(String(text || '').trim())
2998
+ return match ? match[1].toLowerCase() : ''
2999
+ }
3000
+
3001
+ function sessionLogFilename(sessionId) {
3002
+ return `dsh-session-${String(sessionId).replace(/[^A-Za-z0-9_-]/g, '_')}.zip`
3003
+ }
3004
+
3005
+ function sessionLogExportUrl(sessionId, includeToken = false) {
3006
+ const url = new URL(apiUrl('/api/session.export'), location.href)
3007
+ url.searchParams.set('sessionId', sessionId)
3008
+ url.searchParams.set('includeDescendants', 'true')
3009
+ // 网关下载由浏览器/DownloadManager 发起,无法附加 Bearer 头时才使用短期既有 token
3010
+ // 查询参数兼容通道;同源 DSH 插件页仍只使用它自己的登录 Cookie。
3011
+ if (includeToken && state.token) url.searchParams.set('token', state.token)
3012
+ return url
3013
+ }
3014
+
3015
+ async function downloadSessionExport(sessionId) {
3016
+ const nativePlatform = !!CAP?.isNativePlatform?.()
3017
+ const nativeDownload = !!(nativePlatform && window.NativeFile?.downloadToDownloads)
3018
+ if (nativePlatform && !nativeDownload) {
3019
+ toast(t('fs.downloadUnsupported'), 'err')
3020
+ return
3021
+ }
3022
+ const url = sessionLogExportUrl(sessionId, !nativeDownload && !!state.server)
3023
+ const headers = state.token ? { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() } : {}
3024
+ try {
3025
+ const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
3026
+ ? AbortSignal.timeout(LONG_RUNNING_SLASH_COMMAND_TIMEOUT_MS)
3027
+ : undefined
3028
+ const preflight = await fetch(url, { method: 'HEAD', headers, ...(signal ? { signal } : {}) })
3029
+ if (preflight.status === 401) { authFailure(); return }
3030
+ if (!preflight.ok) throw new Error('HTTP ' + preflight.status)
3031
+ const filename = sessionLogFilename(sessionId)
3032
+ if (nativeDownload) {
3033
+ window.NativeFile.downloadToDownloads(url.href, filename, state.token)
3034
+ toast(t('fs.downloadStarted'), 'ok')
3035
+ return
3036
+ }
3037
+ // 浏览器下载目的地由浏览器自身的下载设置决定;同源插件页无需暴露网关 token。
3038
+ const anchor = document.createElement('a')
3039
+ anchor.href = url.href
3040
+ anchor.download = filename
3041
+ document.body.appendChild(anchor)
3042
+ anchor.click()
3043
+ anchor.remove()
3044
+ toast(t('session.exportStarted'), 'ok')
3045
+ } catch (e) {
3046
+ console.error('session export download failed', e)
3047
+ toast(t('session.exportFailed', { msg: e?.message || '' }), 'err')
3048
+ }
3049
+ }
3050
+
2879
3051
  async function runSlashCommand(text) {
2880
3052
  const clean = String(text || '').trim()
2881
3053
  if (!clean.startsWith('/') || !state.current) return false
3054
+ const command = slashCommandName(clean)
3055
+ const noFallback = NO_FALLBACK_SLASH_COMMANDS.has(command)
3056
+ const longRunning = command === 'export'
2882
3057
  try {
2883
3058
  const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
2884
- ? AbortSignal.timeout(20000)
3059
+ ? AbortSignal.timeout(longRunning ? LONG_RUNNING_SLASH_COMMAND_TIMEOUT_MS : SLASH_COMMAND_TIMEOUT_MS)
2885
3060
  : undefined
2886
3061
  const res = await fetch(apiUrl('/remote/api/command'), {
2887
3062
  method: 'POST',
@@ -2890,12 +3065,31 @@ async function runSlashCommand(text) {
2890
3065
  ...(signal ? { signal } : {})
2891
3066
  })
2892
3067
  if (res.status === 401) { authFailure(); return true }
2893
- if (!res.ok) return false
3068
+ if (!res.ok) {
3069
+ if (noFallback) toast(t('session.commandTimedOut'), 'err')
3070
+ return noFallback
3071
+ }
2894
3072
  const data = await res.json().catch(() => null)
2895
3073
  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 }
3074
+ if (data?.ok && data.executed === true) {
3075
+ if (data.accepted) {
3076
+ if (command === 'compact') {
3077
+ setCompactionStatus(state.current, { ...(data.operation || data.compact), active: true, command, source: 'command' })
3078
+ } else {
3079
+ const sessionId = state.current
3080
+ state.pendingCommands[sessionId] = command
3081
+ setTimeout(() => { if (state.current === sessionId) void refreshCompactionStatus(sessionId) }, 600)
3082
+ }
3083
+ } else if (command === 'export') await downloadSessionExport(state.current)
3084
+ else toast(t('send.commandExecuted'), 'ok')
3085
+ return true
3086
+ }
2897
3087
  } catch (e) {
2898
3088
  console.error('slash command bridge failed', e)
3089
+ if (noFallback) {
3090
+ toast(t('session.commandTimedOut'), 'err')
3091
+ return true
3092
+ }
2899
3093
  }
2900
3094
  return false
2901
3095
  }
@@ -4429,10 +4623,12 @@ function readSeenAnnouncements() {
4429
4623
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
4430
4624
  } catch { return {} }
4431
4625
  }
4432
- function markAnnouncementSeen(id) {
4433
- if (!id) return
4626
+ function markAnnouncementsSeen(items) {
4627
+ const ids = [...new Set((items || []).map(item => typeof item === 'string' ? item : item?.id).filter(Boolean))]
4628
+ if (!ids.length) return
4434
4629
  const seen = readSeenAnnouncements()
4435
- seen[id] = Date.now()
4630
+ const now = Date.now()
4631
+ for (const id of ids) seen[id] = now
4436
4632
  const keys = Object.keys(seen)
4437
4633
  if (keys.length > 100) {
4438
4634
  keys.sort((a, b) => Number(seen[a]) - Number(seen[b]))
@@ -4441,6 +4637,7 @@ function markAnnouncementSeen(id) {
4441
4637
  LS.set(ANNOUNCEMENTS_KEY, JSON.stringify(seen))
4442
4638
  renderAnnouncementBoard()
4443
4639
  }
4640
+ function markAnnouncementSeen(id) { markAnnouncementsSeen([id]) }
4444
4641
  function readAnnouncementVotes() {
4445
4642
  try {
4446
4643
  const value = JSON.parse(LS.get(ANNOUNCEMENT_VOTES_KEY, '{}'))
@@ -4606,7 +4803,18 @@ function renderAnnouncementPoll(item) {
4606
4803
  submit.classList.toggle('hidden', !!vote)
4607
4804
  submit.disabled = true
4608
4805
  }
4609
- function openAnnouncementModal(item) {
4806
+ function renderAnnouncementPagination() {
4807
+ const total = state.announcementItems.length
4808
+ const nav = $('announcement-pagination')
4809
+ if (!nav) return
4810
+ nav.classList.toggle('hidden', total < 2)
4811
+ $('announcement-page').textContent = total > 1 ? t('announcement.page', { current: state.announcementIndex + 1, total }) : ''
4812
+ $('announcement-prev').disabled = state.announcementIndex <= 0
4813
+ $('announcement-next').disabled = state.announcementIndex >= total - 1
4814
+ }
4815
+ function renderAnnouncementModal() {
4816
+ const item = state.announcementItems[state.announcementIndex]
4817
+ if (!item) return
4610
4818
  state.announcement = item
4611
4819
  $('announcement-title').textContent = item.title
4612
4820
  $('announcement-content').innerHTML = esc(item.content).replace(/\r?\n/g, '<br>')
@@ -4621,9 +4829,23 @@ function openAnnouncementModal(item) {
4621
4829
  action.textContent = ''
4622
4830
  action.classList.add('hidden')
4623
4831
  }
4624
- $('announcement-later').classList.toggle('hidden', item.force)
4832
+ // 同批存在强制公告时,不能借由切到普通公告而绕过“稍后再看”限制。
4833
+ $('announcement-later').classList.toggle('hidden', state.announcementItems.some(entry => entry.force))
4834
+ renderAnnouncementPagination()
4835
+ }
4836
+ function openAnnouncementModal(items, index = 0) {
4837
+ const list = (Array.isArray(items) ? items : [items]).filter(item => item?.id)
4838
+ if (!list.length) return
4839
+ state.announcementItems = list
4840
+ state.announcementIndex = Math.max(0, Math.min(Number(index) || 0, list.length - 1))
4841
+ renderAnnouncementModal()
4625
4842
  $('modal-announcement').classList.remove('hidden')
4626
4843
  }
4844
+ function showAnnouncementAt(index) {
4845
+ if (!state.announcementItems.length) return
4846
+ state.announcementIndex = Math.max(0, Math.min(index, state.announcementItems.length - 1))
4847
+ renderAnnouncementModal()
4848
+ }
4627
4849
  async function submitAnnouncementVote() {
4628
4850
  const item = state.announcement
4629
4851
  const poll = item?.poll
@@ -4687,8 +4909,10 @@ async function submitAnnouncementVote() {
4687
4909
  }
4688
4910
  }
4689
4911
  function closeAnnouncement(markSeen) {
4690
- if (markSeen && state.announcement) markAnnouncementSeen(state.announcement.id)
4912
+ if (markSeen) markAnnouncementsSeen(state.announcementItems)
4691
4913
  state.announcement = null
4914
+ state.announcementItems = []
4915
+ state.announcementIndex = 0
4692
4916
  $('modal-announcement').classList.add('hidden')
4693
4917
  }
4694
4918
  async function fetchAnnouncements() {
@@ -4711,7 +4935,7 @@ async function fetchAnnouncements() {
4711
4935
  const items = normalized.filter(item => !seen[item.id])
4712
4936
  .sort((a, b) => b.publishedAt - a.publishedAt)
4713
4937
  if (!items.length || state.announcement) return false
4714
- openAnnouncementModal(items[0])
4938
+ openAnnouncementModal(items)
4715
4939
  return true
4716
4940
  } catch { return false }
4717
4941
  }
@@ -6897,12 +7121,14 @@ function bindUi() {
6897
7121
  $('archive-cancel').addEventListener('click', closeArchiveConfirm)
6898
7122
  $('archive-confirm').addEventListener('click', confirmArchiveSession)
6899
7123
  $('modal-archive').addEventListener('click', (e) => { if (e.target === $('modal-archive')) closeArchiveConfirm() })
7124
+ $('announcement-prev').addEventListener('click', () => showAnnouncementAt(state.announcementIndex - 1))
7125
+ $('announcement-next').addEventListener('click', () => showAnnouncementAt(state.announcementIndex + 1))
6900
7126
  $('announcement-later').addEventListener('click', () => closeAnnouncement(false))
6901
7127
  $('announcement-confirm').addEventListener('click', () => closeAnnouncement(true))
6902
7128
  $('announcement-poll-options').addEventListener('change', () => { $('announcement-poll-submit').disabled = false })
6903
7129
  $('announcement-poll-submit').addEventListener('click', submitAnnouncementVote)
6904
7130
  $('modal-announcement').addEventListener('click', (e) => {
6905
- if (e.target === $('modal-announcement') && !state.announcement?.force) closeAnnouncement(false)
7131
+ if (e.target === $('modal-announcement') && !state.announcementItems.some(item => item.force)) closeAnnouncement(false)
6906
7132
  })
6907
7133
  $('announcement-history-close').addEventListener('click', closeAnnouncementHistory)
6908
7134
  $('announcement-history-list').addEventListener('click', (e) => {
@@ -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">
@@ -477,7 +477,7 @@
477
477
  'ds.files': '文件传输', 'ds.settings': '设置', 'ds.stats': '统计', 'ds.menu': '导航',
478
478
  '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
479
  'ds.commands': '指令',
480
- 'ds.cmdCompact': '/compact 压缩对话历史', 'ds.cmdExport': '/export 导出会话日志 ZIP',
480
+ '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
481
  'ds.cmdFeedback': '/feedback 反馈当前会话', 'ds.cmdGoal': '/goal 设置/查看任务目标',
482
482
  'ds.cmdPermission': '/permission 切换权限预设', 'ds.cmdPlan': '/plan 进入/退出计划模式',
483
483
  'ds.fsUp': '上级', 'ds.fsRoot': '允许根目录', 'ds.fsNewWorkspace': '新建工作区', 'ds.fsRefresh': '刷新', 'ds.fsEmpty': '目录为空',
@@ -582,7 +582,7 @@
582
582
  'ds.files': 'Files', 'ds.settings': 'Settings', 'ds.stats': 'Stats', 'ds.menu': 'Menu',
583
583
  '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
584
  'ds.commands': 'Commands',
585
- 'ds.cmdCompact': '/compact Compress conversation history', 'ds.cmdExport': '/export Export session log ZIP',
585
+ '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
586
  'ds.cmdFeedback': '/feedback Feedback current session', 'ds.cmdGoal': '/goal Set/view task goal',
587
587
  'ds.cmdPermission': '/permission Switch permission preset', 'ds.cmdPlan': '/plan Enter/exit plan mode',
588
588
  'ds.fsUp': 'Up', 'ds.fsRoot': 'Allowed root', 'ds.fsNewWorkspace': 'New workspace', 'ds.fsRefresh': 'Refresh', 'ds.fsEmpty': 'Empty directory',
@@ -79,6 +79,10 @@ const state = {
79
79
  questions: [],
80
80
  queues: {},
81
81
  queueSteering: {},
82
+ compactions: {},
83
+ pendingCommands: {},
84
+ compactionPollTimer: null,
85
+ compactionClockTimer: null,
82
86
  sessionTurnTimes: {},
83
87
  questionModal: null,
84
88
  streamsOk: { mux: false, host: false },
@@ -1375,6 +1379,88 @@ function onHostFrame(full) {
1375
1379
  if (s) { s.running = f.running; if (state.current === f.sessionId) { renderSessions(); renderQueue(); updateComposerStatus() } renderOverviewDesktop() }
1376
1380
  }
1377
1381
  }
1382
+ function activeCompaction(sessionId = state.current) {
1383
+ const compact = state.compactions[sessionId]
1384
+ return compact?.active === true ? compact : null
1385
+ }
1386
+ function compactElapsed(startedAt) {
1387
+ const seconds = Math.max(0, Math.floor((Date.now() - Number(startedAt || Date.now())) / 1000))
1388
+ const minutes = Math.floor(seconds / 60)
1389
+ const remain = seconds % 60
1390
+ return minutes > 0 ? `${minutes}:${String(remain).padStart(2, '0')}` : `${remain}s`
1391
+ }
1392
+ function setCompactionStatus(sessionId, next) {
1393
+ if (!sessionId) return
1394
+ const previous = state.compactions[sessionId]
1395
+ if (next?.active === true) {
1396
+ state.compactions[sessionId] = {
1397
+ active: true,
1398
+ phase: next.phase || previous?.phase || 'running',
1399
+ command: String(next.command || previous?.command || 'compact'),
1400
+ startedAt: Number(next.startedAt) || previous?.startedAt || Date.now(),
1401
+ message: String(next.message || ''),
1402
+ source: next.source || previous?.source || 'event',
1403
+ }
1404
+ } else {
1405
+ delete state.compactions[sessionId]
1406
+ if (previous?.active) {
1407
+ const command = previous.command || 'compact'
1408
+ if (next?.phase === 'failed') toast(command === 'compact'
1409
+ ? t('ds.compactFailed', { msg: next.message || t('ds.sessionRecoveryFailed') })
1410
+ : t('ds.commandFailed', { command, msg: next.message || t('ds.sessionRecoveryFailed') }), 'err')
1411
+ else toast(command === 'compact' ? t('ds.compactComplete') : t('ds.commandComplete', { command }), 'ok')
1412
+ }
1413
+ }
1414
+ ensureCompactionMonitoring()
1415
+ if (state.current === sessionId) updateComposerStatus()
1416
+ }
1417
+ async function refreshCompactionStatus(sessionId = state.current) {
1418
+ if (!sessionId) return
1419
+ try {
1420
+ const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(8000) : undefined
1421
+ const url = new URL(apiUrl('/remote/api/command-status'), location.href)
1422
+ url.searchParams.set('sessionId', sessionId)
1423
+ const res = await fetch(url, { headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() }, ...(signal ? { signal } : {}) })
1424
+ if (res.status === 401) { toast(t('ds.toastAuth'), 'err'); return }
1425
+ if (!res.ok) return
1426
+ const body = await res.json().catch(() => null)
1427
+ const operation = body?.operation || body?.compact
1428
+ if (!operation) return
1429
+ const pending = state.pendingCommands[sessionId]
1430
+ if (operation.active) {
1431
+ delete state.pendingCommands[sessionId]
1432
+ setCompactionStatus(sessionId, { ...operation, source: 'status' })
1433
+ } else if (activeCompaction(sessionId) && activeCompaction(sessionId).source !== 'event') {
1434
+ setCompactionStatus(sessionId, operation)
1435
+ } else if (pending) {
1436
+ if (operation.phase === 'failed') toast(t('ds.commandFailed', { command: pending, msg: operation.message || t('ds.sessionRecoveryFailed') }), 'err')
1437
+ delete state.pendingCommands[sessionId]
1438
+ }
1439
+ } catch {}
1440
+ }
1441
+ function ensureCompactionMonitoring() {
1442
+ const active = Object.values(state.compactions).some(compact => compact?.active)
1443
+ if (!active) {
1444
+ if (state.compactionPollTimer) clearInterval(state.compactionPollTimer)
1445
+ if (state.compactionClockTimer) clearInterval(state.compactionClockTimer)
1446
+ state.compactionPollTimer = null
1447
+ state.compactionClockTimer = null
1448
+ return
1449
+ }
1450
+ if (!state.compactionClockTimer) state.compactionClockTimer = setInterval(() => { if (activeCompaction()) updateComposerStatus() }, 1000)
1451
+ if (!state.compactionPollTimer) state.compactionPollTimer = setInterval(() => {
1452
+ const compact = activeCompaction()
1453
+ if (compact && compact.source !== 'event') void refreshCompactionStatus()
1454
+ }, 3000)
1455
+ }
1456
+ function observeCompactionEvent(sessionId, event) {
1457
+ if (event?.type === 'compaction/start') {
1458
+ const existing = activeCompaction(sessionId)
1459
+ setCompactionStatus(sessionId, { active: true, phase: 'running', startedAt: existing?.startedAt || Date.now(), source: existing?.source || 'event' })
1460
+ } else if (event?.type === 'compaction/end' && activeCompaction(sessionId)?.source === 'event') {
1461
+ setCompactionStatus(sessionId, { active: false, phase: 'complete' })
1462
+ }
1463
+ }
1378
1464
  function hydrateSessionProjections(sessionId, projections) {
1379
1465
  const s = state.byId.get(sessionId)
1380
1466
  if (!s || !projections || typeof projections !== 'object') return
@@ -1470,6 +1556,7 @@ function setGoalCollapsed(sessionId, goal, collapsed) {
1470
1556
  LS.set(COLLAPSED_GOALS_KEY, JSON.stringify(next.slice(-100)))
1471
1557
  }
1472
1558
  function onSessionEvent(sessionId, event) {
1559
+ observeCompactionEvent(sessionId, event)
1473
1560
  if (event?.type === 'turn/start' || event?.type === 'turn/end') {
1474
1561
  noteSessionTurnTime(sessionId, event)
1475
1562
  renderSessions()
@@ -1637,6 +1724,7 @@ async function openSession(id) {
1637
1724
  renderQueue()
1638
1725
  renderSessionPendingDesktop()
1639
1726
  updateComposerStatus()
1727
+ void refreshCompactionStatus(id)
1640
1728
  await loadHistory()
1641
1729
  }
1642
1730
  function closeSession() {
@@ -1938,12 +2026,55 @@ async function interruptSubagent(childId) {
1938
2026
  setTimeout(renderSessionCards, 600)
1939
2027
  }
1940
2028
 
2029
+ const NO_FALLBACK_SLASH_COMMANDS = new Set(['compact', 'export'])
2030
+ const SLASH_COMMAND_TIMEOUT_MS = 20_000
2031
+ // 比插件端的 120 秒多留 5 秒,让服务端能返回确定的失败结果而非客户端先中断。
2032
+ const LONG_RUNNING_SLASH_COMMAND_TIMEOUT_MS = 125_000
2033
+
2034
+ function slashCommandName(text) {
2035
+ const match = /^\/+([^\s/]+)/.exec(String(text || '').trim())
2036
+ return match ? match[1].toLowerCase() : ''
2037
+ }
2038
+
2039
+ function sessionLogFilename(sessionId) {
2040
+ return `dsh-session-${String(sessionId).replace(/[^A-Za-z0-9_-]/g, '_')}.zip`
2041
+ }
2042
+
2043
+ async function downloadSessionExport(sessionId) {
2044
+ const url = new URL(apiUrl('/api/session.export'), location.href)
2045
+ url.searchParams.set('sessionId', sessionId)
2046
+ url.searchParams.set('includeDescendants', 'true')
2047
+ if (state.server && state.token) url.searchParams.set('token', state.token)
2048
+ const headers = state.token ? { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() } : {}
2049
+ try {
2050
+ const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
2051
+ ? AbortSignal.timeout(LONG_RUNNING_SLASH_COMMAND_TIMEOUT_MS)
2052
+ : undefined
2053
+ const preflight = await fetch(url, { method: 'HEAD', headers, ...(signal ? { signal } : {}) })
2054
+ if (preflight.status === 401) { toast(t('ds.toastAuth'), 'err'); return }
2055
+ if (!preflight.ok) throw new Error('HTTP ' + preflight.status)
2056
+ const anchor = document.createElement('a')
2057
+ anchor.href = url.href
2058
+ anchor.download = sessionLogFilename(sessionId)
2059
+ document.body.appendChild(anchor)
2060
+ anchor.click()
2061
+ anchor.remove()
2062
+ toast(t('ds.exportStarted'), 'ok')
2063
+ } catch (e) {
2064
+ console.error('session export download failed', e)
2065
+ toast(t('ds.exportFailed', { msg: e?.message || '' }), 'err')
2066
+ }
2067
+ }
2068
+
1941
2069
  async function runSlashCommand(text) {
1942
2070
  const clean = String(text || '').trim()
1943
2071
  if (!clean.startsWith('/') || !state.current) return false
2072
+ const command = slashCommandName(clean)
2073
+ const noFallback = NO_FALLBACK_SLASH_COMMANDS.has(command)
2074
+ const longRunning = command === 'export'
1944
2075
  try {
1945
2076
  const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
1946
- ? AbortSignal.timeout(20000)
2077
+ ? AbortSignal.timeout(longRunning ? LONG_RUNNING_SLASH_COMMAND_TIMEOUT_MS : SLASH_COMMAND_TIMEOUT_MS)
1947
2078
  : undefined
1948
2079
  const res = await fetch(apiUrl('/remote/api/command'), {
1949
2080
  method: 'POST',
@@ -1952,12 +2083,30 @@ async function runSlashCommand(text) {
1952
2083
  ...(signal ? { signal } : {})
1953
2084
  })
1954
2085
  if (res.status === 401) { toast(t('ds.toastAuth'), 'err'); return true }
1955
- if (!res.ok) return false
2086
+ if (!res.ok) {
2087
+ if (noFallback) toast(t('ds.commandTimedOut'), 'err')
2088
+ return noFallback
2089
+ }
1956
2090
  const data = await res.json().catch(() => null)
1957
2091
  if (data?.ok === false) return true
1958
- return data?.ok === true && data.executed === true
2092
+ if (data?.ok === true && data.executed === true) {
2093
+ if (data.accepted) {
2094
+ if (command === 'compact') {
2095
+ setCompactionStatus(state.current, { ...(data.operation || data.compact), active: true, command, source: 'command' })
2096
+ } else {
2097
+ const sessionId = state.current
2098
+ state.pendingCommands[sessionId] = command
2099
+ setTimeout(() => { if (state.current === sessionId) void refreshCompactionStatus(sessionId) }, 600)
2100
+ }
2101
+ } else if (command === 'export') await downloadSessionExport(state.current)
2102
+ return true
2103
+ }
1959
2104
  } catch (e) {
1960
2105
  console.error('slash command bridge failed', e)
2106
+ if (noFallback) {
2107
+ toast(t('ds.commandTimedOut'), 'err')
2108
+ return true
2109
+ }
1961
2110
  }
1962
2111
  return false
1963
2112
  }
@@ -2033,7 +2182,15 @@ async function archiveCurrentSession() {
2033
2182
  function updateComposerStatus() {
2034
2183
  const status = $('composer-status')
2035
2184
  if (!status) return
2036
- status.classList.toggle('hidden', !state.byId.get(state.current)?.running)
2185
+ const compact = activeCompaction()
2186
+ status.classList.toggle('hidden', !state.byId.get(state.current)?.running && !compact)
2187
+ status.classList.toggle('compacting', !!compact)
2188
+ const text = $('composer-status-text')
2189
+ if (text) text.textContent = compact
2190
+ ? (compact.command === 'compact'
2191
+ ? t('ds.compacting', { elapsed: compactElapsed(compact.startedAt) })
2192
+ : t('ds.commandRunning', { command: compact.command, elapsed: compactElapsed(compact.startedAt) }))
2193
+ : t('ds.composerRunning')
2037
2194
  updateSessionActions()
2038
2195
  }
2039
2196
  function queuePreview(item) {
package/public/index.html CHANGED
@@ -135,7 +135,7 @@
135
135
  </div>
136
136
 
137
137
  <div id="composer-wrap" class="composer-wrap">
138
- <div id="composer-status" class="composer-status hidden" role="status" aria-live="polite"><span class="composer-status-dot" aria-hidden="true"></span><span data-i18n="composer.running">运行中…</span></div>
138
+ <div id="composer-status" class="composer-status hidden" role="status" aria-live="polite"><span class="composer-status-dot" aria-hidden="true"></span><span id="composer-status-text" data-i18n="composer.running">运行中…</span></div>
139
139
  <div id="composer-menu" class="composer-menu hidden">
140
140
  <div class="menu-group">
141
141
  <div class="menu-title" data-i18n="menu.commandsTitle">输入指令</div>
@@ -881,6 +881,11 @@
881
881
  <div id="modal-announcement" class="modal hidden" role="dialog" aria-modal="true">
882
882
  <div class="modal-card announcement-card">
883
883
  <div class="announcement-kicker" data-i18n="announcement.label">公告</div>
884
+ <div id="announcement-pagination" class="announcement-pagination hidden" aria-live="polite">
885
+ <button id="announcement-prev" class="mini-btn" type="button" data-i18n-aria="announcement.previous" data-i18n-title="announcement.previous" aria-label="上一条公告" title="上一条公告">‹</button>
886
+ <span id="announcement-page"></span>
887
+ <button id="announcement-next" class="mini-btn" type="button" data-i18n-aria="announcement.next" data-i18n-title="announcement.next" aria-label="下一条公告" title="下一条公告">›</button>
888
+ </div>
884
889
  <div id="announcement-title" class="modal-title"></div>
885
890
  <div id="announcement-content" class="modal-body announcement-content"></div>
886
891
  <div id="announcement-poll" class="announcement-poll hidden">
@@ -1003,6 +1008,7 @@
1003
1008
  'subagent.confirmInterrupt': '中断这个子代理当前回合?', 'subagent.interruptFailed': '中断失败', 'subagent.interruptSubmitted': '中断请求已提交',
1004
1009
  'queue.title': '排队消息', 'queue.steer': '插话', 'queue.steerUnavailable': '仅运行中可插话', 'queue.steerSubmitted': '插话请求已提交', 'queue.steerFailed': '插话失败:{msg}', 'queue.image': '图片消息',
1005
1010
  'send.failed': '发送失败', 'send.commandSent': '指令已发送', 'send.sent': '已发送', 'send.imageSent': '图片已发送', 'send.commandExecuted': '命令已执行',
1011
+ 'session.commandTimedOut': '命令执行超时,未作为普通消息发送', 'session.exportStarted': '会话日志已开始下载,请在浏览器下载中查看', 'session.exportFailed': '无法导出会话日志:{msg}', 'session.compacting': '正在压缩对话 · 已用时 {elapsed}', 'session.compactComplete': '对话压缩完成', 'session.compactFailed': '对话压缩未完成:{msg}', 'session.commandRunning': '正在执行 /{command} · 已用时 {elapsed}', 'session.commandComplete': '/{command} 已完成', 'session.commandFailed': '/{command} 未完成:{msg}',
1006
1012
  'models.loading': '模型加载中…', 'models.loadFailed': '模型列表加载失败:{msg}', 'models.unavailable': '不可用', 'models.none': '没有可用模型',
1007
1013
  'models.switchFailed': '切换模型失败', 'models.switched': '已切换模型:{model}',
1008
1014
  'models.effortFailed': '切换思考深度失败', 'models.effortSwitched': '思考深度:{effort}', 'models.effortLow': '低', 'models.effortHigh': '高', 'models.effortMax': '极高', 'models.effortOff': '关闭', 'models.effortCustomHint': '该路由未公布档位,按 DSH 兼容值尝试;不支持时不会更改当前设置。',
@@ -1139,7 +1145,7 @@
1139
1145
  'modal.approvalTitle': '工具审批', 'modal.reject': '拒绝', 'modal.allowOnce': '允许一次',
1140
1146
  'modal.questionTitle': 'DSH 需要你回答', 'modal.later': '稍后', 'modal.submit': '提交',
1141
1147
  'modal.goalTitle': '目标控制', 'modal.close': '关闭', 'modal.updateGoal': '更新目标',
1142
- 'announcement.label': '公告', 'announcement.later': '稍后再看', 'announcement.gotIt': '知道了', 'announcement.open': '查看详情', 'announcement.historyLabel': '公告记录', 'announcement.historyTitle': '历史公告', 'announcement.historyEmpty': '暂无历史公告', 'announcement.close': '关闭', 'announcement.noDate': '未标注日期',
1148
+ 'announcement.label': '公告', 'announcement.later': '稍后再看', 'announcement.gotIt': '全部知道了', 'announcement.open': '查看详情', 'announcement.previous': '上一条公告', 'announcement.next': '下一条公告', 'announcement.page': '第 {current} / {total} 条', 'announcement.historyLabel': '公告记录', 'announcement.historyTitle': '历史公告', 'announcement.historyEmpty': '暂无历史公告', 'announcement.close': '关闭', 'announcement.noDate': '未标注日期',
1143
1149
  'announcement.voteSubmit': '提交投票', 'announcement.voteChoose': '请先选择一项', 'announcement.voteThanks': '已投票:{option}', 'announcement.voteFailed': '投票失败:{msg}', 'announcement.voteNetworkError': '网络错误', 'announcement.voteAgainLater': '提交太频繁,请稍后再试', 'announcement.voteFromHistory': '参与投票',
1144
1150
  'modal.statsTitle': '本轮统计',
1145
1151
  'time.justNow': '刚刚', 'time.minAgo': ' 分钟前', 'time.hourAgo': ' 小时前'
@@ -1235,6 +1241,7 @@
1235
1241
  'subagent.confirmInterrupt': 'Interrupt this subagent\'s current turn?', 'subagent.interruptFailed': 'Interrupt failed', 'subagent.interruptSubmitted': 'Interrupt requested',
1236
1242
  'queue.title': 'Queued messages', 'queue.steer': 'Interject', 'queue.steerUnavailable': 'Only available while running', 'queue.steerSubmitted': 'Interjection requested', 'queue.steerFailed': 'Interjection failed: {msg}', 'queue.image': 'Image message',
1237
1243
  'send.failed': 'Send failed', 'send.commandSent': 'Command sent', 'send.sent': 'Sent', 'send.imageSent': 'Image sent', 'send.commandExecuted': 'Command executed',
1244
+ 'session.commandTimedOut': 'Command timed out and was not sent as a chat message', 'session.exportStarted': 'Session log download started; check your browser downloads', 'session.exportFailed': 'Could not export session log: {msg}', 'session.compacting': 'Compressing conversation · {elapsed} elapsed', 'session.compactComplete': 'Conversation compression complete', 'session.compactFailed': 'Conversation compression did not finish: {msg}', 'session.commandRunning': 'Running /{command} · {elapsed} elapsed', 'session.commandComplete': '/{command} complete', 'session.commandFailed': '/{command} did not finish: {msg}',
1238
1245
  'models.loading': 'Loading models…', 'models.loadFailed': 'Failed to load models: {msg}', 'models.unavailable': 'unavailable', 'models.none': 'No models available',
1239
1246
  'models.switchFailed': 'Model switch failed', 'models.switched': 'Switched model: {model}',
1240
1247
  'models.effortFailed': 'Failed to switch reasoning effort', 'models.effortSwitched': 'Reasoning effort: {effort}', 'models.effortLow': 'Low', 'models.effortHigh': 'High', 'models.effortMax': 'Max', 'models.effortOff': 'Off', 'models.effortCustomHint': 'This route does not publish effort metadata. DSH compatibility values are tried; unsupported values leave the current setting unchanged.',
@@ -1371,7 +1378,7 @@
1371
1378
  'modal.approvalTitle': 'Tool approval', 'modal.reject': 'Reject', 'modal.allowOnce': 'Allow once',
1372
1379
  'modal.questionTitle': 'DSH needs your answer', 'modal.later': 'Later', 'modal.submit': 'Submit',
1373
1380
  'modal.goalTitle': 'Goal control', 'modal.close': 'Close', 'modal.updateGoal': 'Update goal',
1374
- 'announcement.label': 'Announcement', 'announcement.later': 'Later', 'announcement.gotIt': 'Got it', 'announcement.open': 'View details', 'announcement.historyLabel': 'Announcement archive', 'announcement.historyTitle': 'Announcement history', 'announcement.historyEmpty': 'No announcement history', 'announcement.close': 'Close', 'announcement.noDate': 'No date',
1381
+ 'announcement.label': 'Announcement', 'announcement.later': 'Later', 'announcement.gotIt': 'Mark all read', 'announcement.open': 'View details', 'announcement.previous': 'Previous announcement', 'announcement.next': 'Next announcement', 'announcement.page': 'Announcement {current} of {total}', 'announcement.historyLabel': 'Announcement archive', 'announcement.historyTitle': 'Announcement history', 'announcement.historyEmpty': 'No announcement history', 'announcement.close': 'Close', 'announcement.noDate': 'No date',
1375
1382
  'announcement.voteSubmit': 'Submit vote', 'announcement.voteChoose': 'Choose an option first', 'announcement.voteThanks': 'Voted: {option}', 'announcement.voteFailed': 'Vote failed: {msg}', 'announcement.voteNetworkError': 'Network error', 'announcement.voteAgainLater': 'Too many submissions; try again later', 'announcement.voteFromHistory': 'Vote now',
1376
1383
  'modal.statsTitle': 'This round',
1377
1384
  'time.justNow': 'just now', 'time.minAgo': ' min ago', 'time.hourAgo': ' hr ago'
package/public/styles.css CHANGED
@@ -1215,6 +1215,9 @@ button.overview-attention-item, button.overview-session-item { cursor:pointer; }
1215
1215
  .archive-confirm-btn:active { filter: brightness(.9); }
1216
1216
  .announcement-card { border-top: 3px solid var(--dsr-accent); }
1217
1217
  .announcement-kicker { color: var(--dsr-accent-strong); font-size: 12px; font-weight: 700; letter-spacing: .04em; margin-bottom: 6px; }
1218
+ .announcement-pagination { display:flex; align-items:center; justify-content:flex-end; gap:8px; margin:-2px 0 8px; color:var(--dsr-muted); font-size:12px; font-weight:650; }
1219
+ .announcement-pagination .mini-btn { min-width:34px; min-height:30px; padding:2px 9px; font-size:19px; line-height:1; }
1220
+ .announcement-pagination .mini-btn:disabled { opacity:.38; cursor:default; }
1218
1221
  .announcement-content { line-height: 1.7; overflow-wrap: anywhere; word-break: break-word; }
1219
1222
  .announcement-poll { display: grid; gap: 10px; margin-top: 14px; padding: 12px; border: 1px solid var(--dsr-line); border-radius: 12px; background: var(--dsr-panel); }
1220
1223
  .announcement-poll-question { font-size: 14px; font-weight: 700; line-height: 1.5; }
@@ -1,10 +1,14 @@
1
1
  {
2
- "version": "0.6.21",
2
+ "version": "0.6.22",
3
3
  "apkUrl": "dsh-remote.apk",
4
- "sha256": "46fc5409dbe59300e352ae6dddfbc001856a349a85b81be025e958316a1c5dc0",
5
- "releasedAt": "2026-08-31T04:10:09.396Z",
6
- "notes": "0.6.21:左上角 DSH Remote 名称可展开服务器组快捷切换抽屉,当前组、组内服务器数量和管理入口一目了然,手机与桌面端均可一键切换;修复部分新版 DSH 命令执行成功返回 void 时被误判为未执行、继而把 /命令作为普通文本发送的问题,同时兼容旧版三参数、新版四参数及默认参数签名。",
4
+ "sha256": "0d814d40e58e12794cd1d4729a104ee94756e6f4306a67d47aec91b6e167e4af",
5
+ "releasedAt": "2026-09-01T03:24:11.551Z",
6
+ "notes": "0.6.22:斜杠命令统一后台受理并显示运行状态与耗时,/compact 不再受两分钟请求等待限制;/export 可在手机保存到系统下载目录、在浏览器按下载设置保存会话 ZIP;修复命令桥接超时后被误作为普通消息发送的问题。首次有多条未读公告时改为单一分页窗口,可左右切换,确认一次即可全部标为已读。",
7
7
  "history": [
8
+ {
9
+ "version": "0.6.22",
10
+ "notes": "0.6.22:斜杠命令统一后台受理并显示运行状态与耗时,/compact 不再受两分钟请求等待限制;/export 可在手机保存到系统下载目录、在浏览器按下载设置保存会话 ZIP;修复命令桥接超时后被误作为普通消息发送的问题。首次有多条未读公告时改为单一分页窗口,可左右切换,确认一次即可全部标为已读。"
11
+ },
8
12
  {
9
13
  "version": "0.6.21",
10
14
  "notes": "0.6.21:左上角 DSH Remote 名称可展开服务器组快捷切换抽屉,当前组、组内服务器数量和管理入口一目了然,手机与桌面端均可一键切换;修复部分新版 DSH 命令执行成功返回 void 时被误判为未执行、继而把 /命令作为普通文本发送的问题,同时兼容旧版三参数、新版四参数及默认参数签名。"
@@ -40,10 +44,6 @@
40
44
  {
41
45
  "version": "0.6.13",
42
46
  "notes": "0.6.13 正式版:新增首次连接 Doctor,集中检查 DSH 服务、远程网关、局域网地址、防火墙、终端配对和实时消息通道;网关控制台新增可选的独立设备密钥,支持设备备注、最近 IP、二维码、令牌轮换、复制和退出,共享令牌在启用后仅保留管理权限;手机端和桌面端支持实时思考内容,并为未声明推理档位的模型提供 low、high、max 三档选择;普通会话列表、工作区树和主页统计不再混入子代理内部会话;Android App 落后于网关版本时显示明确更新提醒;新增网关协议与能力协商并兼容旧网关,同时补充设备隔离、重启持久化、推理显示、会话过滤和版本差异回归测试。"
43
- },
44
- {
45
- "version": "0.6.12",
46
- "notes": "0.6.12:修复远程启动和重启 DSH 时统一报 HTTP 502 且无法确认结果的问题;改为异步追踪 systemd 服务检查、命令提交、进程启动、DSH HTTP 恢复和 mux/host 实时通道重连阶段,成功时显示 PID、HTTP 状态和用时,失败时区分服务不存在、systemd 不可用、权限不足、命令失败、服务失败、启动超时、HTTP 恢复超时和实时通道恢复超时;优化工作区会话筛选、文件预览和应用内选择器;新增中央投票公告与反馈成功确认;主页公告栏常驻并在无未读公告时显示空状态,同时修正设置页异常右箭头、主页刷新图标居中和周末谷时提醒。"
47
47
  }
48
48
  ]
49
49
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.6.21"
2
+ "version": "0.6.22"
3
3
  }