dsh-remote-plugin 0.6.20 → 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 代理: 让插件抽屉显示与网关管理页完全一致的数据。
@@ -513,6 +520,73 @@ async function resolveAgent(ctx, sessionId) {
513
520
  }
514
521
  }
515
522
 
523
+ function commandHead(line) {
524
+ const match = /^\/+(\S+)/.exec(String(line || '').trim())
525
+ return match ? match[1].replace(/^\/+/, '') : ''
526
+ }
527
+
528
+ function commandEntryName(entry) {
529
+ const value = typeof entry === 'string' ? entry : entry?.name
530
+ return String(value || '').trim().replace(/^\/+/, '')
531
+ }
532
+
533
+ function commandWasExecuted(result) {
534
+ // DSH 0.1.x 的 commands.execute() 成功时可以返回 void。只要 Promise 正常
535
+ // 完成,就不应把同一条 /command 再降级为 session.prompt 的普通文本;只有
536
+ // 执行器明确返回 false / { executed: false } 才表示未处理。
537
+ return result !== false && result?.executed !== false
538
+ }
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
+
516
590
  async function resolveFile(pathname) {
517
591
  let abs = targetPath(pathname)
518
592
  if (abs === null) return null
@@ -740,6 +814,28 @@ async function serveStatic(req, res, ctx) {
740
814
  return
741
815
  }
742
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
+
743
839
  // 斜杠命令桥接:客户端 → 网关 → 插件端点 → ctx.commands.execute
744
840
  if (pathname === `${MOUNT}/api/command`) {
745
841
  if (req.method !== 'POST') {
@@ -747,9 +843,7 @@ async function serveStatic(req, res, ctx) {
747
843
  res.end()
748
844
  return
749
845
  }
750
- const auth = req.headers.authorization || ''
751
- const expected = gatewayToken()
752
- if (!expected || auth !== `Bearer ${expected}`) {
846
+ if (!remoteCommandAuthorized(req)) {
753
847
  sendJson(res, 401, { ok: false, message: 'unauthorized' })
754
848
  return
755
849
  }
@@ -771,17 +865,42 @@ async function serveStatic(req, res, ctx) {
771
865
  sendJson(res, 200, { ok: false, message: 'agent not found', debug: { resolvePath, commandNames: [] } })
772
866
  return
773
867
  }
868
+ let commandEntries = null
774
869
  let commandNames = []
775
870
  try {
776
- commandNames = (await ctx.commands.list(agent)).map(c => c.name)
871
+ const listed = await ctx.commands.list(agent)
872
+ commandEntries = Array.isArray(listed) ? listed : null
873
+ commandNames = commandEntries ? commandEntries.map(commandEntryName).filter(Boolean) : []
777
874
  } catch (e) {
778
875
  commandNames = ['list-error: ' + (e?.message || String(e))]
779
876
  }
780
- const signal = AbortSignal.timeout(30000)
781
- const result = ctx.commands.execute.length === 3
782
- ? await ctx.commands.execute(agent, line, signal)
783
- : await ctx.commands.execute(agent, line, [], signal)
784
- sendJson(res, 200, { ok: true, executed: result !== undefined, debug: { resolvePath, commandNames } })
877
+ const name = commandHead(line)
878
+ if (commandEntries && (!name || !commandEntries.some(entry => commandEntryName(entry) === name))) {
879
+ // 仅把 DSH 已登记的 slash command 截留到命令服务。未知 /xxx 仍让客户端
880
+ // 按普通文本发送,保持 DSH 自己处理自定义提示词的兼容行为。
881
+ sendJson(res, 200, { ok: true, executed: false, debug: { resolvePath, commandNames, reason: 'unknown-command' } })
882
+ return
883
+ }
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)
903
+ sendJson(res, 200, { ok: true, executed: commandWasExecuted(result), debug: { resolvePath, commandNames } })
785
904
  } catch (e) {
786
905
  sendJson(res, 200, { ok: false, message: e?.message || String(e) })
787
906
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.6.20",
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(), // 已发送消息或已执行命令的会话
@@ -938,10 +944,81 @@ function closeGroupMenu() {
938
944
  if (menu) menu.classList.add('hidden')
939
945
  }
940
946
 
947
+ let groupDrawerReturnFocus = null
948
+
949
+ function groupDrawerOpen() {
950
+ return !$('group-drawer')?.classList.contains('hidden')
951
+ }
952
+
953
+ function renderGroupDrawer() {
954
+ const trigger = $('group-drawer-trigger')
955
+ const brandGroup = $('brand-group-name')
956
+ const current = $('group-drawer-current')
957
+ const list = $('group-drawer-list')
958
+ if (brandGroup) brandGroup.textContent = state.activeGroup
959
+ if (trigger) trigger.setAttribute('aria-label', t('groups.drawerOpen') + ':' + state.activeGroup)
960
+ if (current) current.textContent = state.activeGroup
961
+ if (!list) return
962
+ list.innerHTML = state.groups.map(group => {
963
+ const servers = groupServers(group)
964
+ const selected = group === state.activeGroup
965
+ return `<button type="button" class="group-drawer-option ${selected ? 'current' : ''}" data-quick-group="${esc(group)}" ${selected ? 'aria-current="true"' : ''}>
966
+ <span class="group-drawer-option-mark" aria-hidden="true"></span>
967
+ <span class="group-drawer-option-copy"><span class="group-drawer-option-name">${esc(group)}</span><span class="group-drawer-option-meta">${t('groups.serverCount', { count: servers.length })}</span></span>
968
+ <span class="group-drawer-option-check" aria-hidden="true">${selected ? '✓' : ''}</span>
969
+ </button>`
970
+ }).join('')
971
+ }
972
+
973
+ function openGroupDrawer() {
974
+ const drawer = $('group-drawer')
975
+ const backdrop = $('group-drawer-backdrop')
976
+ const trigger = $('group-drawer-trigger')
977
+ if (!drawer || !backdrop || !trigger) return
978
+ renderGroupDrawer()
979
+ groupDrawerReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : trigger
980
+ drawer.classList.remove('hidden')
981
+ backdrop.classList.remove('hidden')
982
+ document.body.classList.add('group-drawer-open')
983
+ trigger.setAttribute('aria-expanded', 'true')
984
+ requestAnimationFrame(() => (drawer.querySelector('[data-quick-group].current') || drawer.querySelector('[data-quick-group]') || drawer).focus({ preventScroll: true }))
985
+ }
986
+
987
+ function closeGroupDrawer({ restoreFocus = true } = {}) {
988
+ const drawer = $('group-drawer')
989
+ const backdrop = $('group-drawer-backdrop')
990
+ const trigger = $('group-drawer-trigger')
991
+ if (!drawer || !backdrop || drawer.classList.contains('hidden')) return
992
+ drawer.classList.add('hidden')
993
+ backdrop.classList.add('hidden')
994
+ document.body.classList.remove('group-drawer-open')
995
+ trigger?.setAttribute('aria-expanded', 'false')
996
+ if (restoreFocus) (groupDrawerReturnFocus || trigger)?.focus({ preventScroll: true })
997
+ groupDrawerReturnFocus = null
998
+ }
999
+
1000
+ function keepGroupDrawerFocus(event) {
1001
+ if (!groupDrawerOpen()) return
1002
+ if (event.key === 'Escape') {
1003
+ event.preventDefault()
1004
+ closeGroupDrawer()
1005
+ return
1006
+ }
1007
+ if (event.key !== 'Tab') return
1008
+ const drawer = $('group-drawer')
1009
+ const targets = [...drawer.querySelectorAll('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])')]
1010
+ if (!targets.length) return
1011
+ const first = targets[0]
1012
+ const last = targets[targets.length - 1]
1013
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
1014
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
1015
+ }
1016
+
941
1017
  function renderServers() {
942
1018
  const box = $('server-list')
943
1019
  if (!box) return
944
1020
  renderGroupSelect()
1021
+ renderGroupDrawer()
945
1022
  const groupsHtml = state.groups.map(g => {
946
1023
  const list = groupServers(g)
947
1024
  const auto = state.autoSelect[g] !== false
@@ -1526,9 +1603,104 @@ function onHostFrame(full) {
1526
1603
  if (f.type === 'host/remote-event') return scheduleRefresh()
1527
1604
  }
1528
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
+
1529
1700
  function onSessionEvent(sessionId, event) {
1530
1701
  if (!event) return
1531
1702
  const s = state.byId.get(sessionId)
1703
+ observeCompactionEvent(sessionId, event)
1532
1704
  if (event.type === 'turn/start' || event.type === 'turn/end') {
1533
1705
  noteSessionTurnTime(sessionId, event)
1534
1706
  renderSessions()
@@ -2109,6 +2281,7 @@ async function openSession(id) {
2109
2281
  $('history').innerHTML = '<div class="empty">' + t('history.loading') + '</div>'
2110
2282
  renderQueue()
2111
2283
  renderSessionPending()
2284
+ void refreshCompactionStatus(id)
2112
2285
  restoreCachedHistory()
2113
2286
  await loadHistory(true)
2114
2287
  renderSessionCards()
@@ -2203,10 +2376,20 @@ function updateSessionStatus() {
2203
2376
  const head = $('session-head')
2204
2377
  if (!head) return
2205
2378
  const composerStatus = $('composer-status')
2206
- 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')
2207
2390
  head.classList.remove('running', 'interrupted')
2208
2391
  const queued = (state.queues[state.current] || []).some(i => i.placement !== 'context')
2209
- if (s?.running || queued) head.classList.add('running')
2392
+ if (s?.running || queued || compact) head.classList.add('running')
2210
2393
  else if (s?.error) head.classList.add('interrupted')
2211
2394
  }
2212
2395
 
@@ -2805,12 +2988,75 @@ async function interruptSubagent(childId) {
2805
2988
  }
2806
2989
 
2807
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
+
2808
3051
  async function runSlashCommand(text) {
2809
3052
  const clean = String(text || '').trim()
2810
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'
2811
3057
  try {
2812
3058
  const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
2813
- ? AbortSignal.timeout(20000)
3059
+ ? AbortSignal.timeout(longRunning ? LONG_RUNNING_SLASH_COMMAND_TIMEOUT_MS : SLASH_COMMAND_TIMEOUT_MS)
2814
3060
  : undefined
2815
3061
  const res = await fetch(apiUrl('/remote/api/command'), {
2816
3062
  method: 'POST',
@@ -2819,12 +3065,31 @@ async function runSlashCommand(text) {
2819
3065
  ...(signal ? { signal } : {})
2820
3066
  })
2821
3067
  if (res.status === 401) { authFailure(); return true }
2822
- if (!res.ok) return false
3068
+ if (!res.ok) {
3069
+ if (noFallback) toast(t('session.commandTimedOut'), 'err')
3070
+ return noFallback
3071
+ }
2823
3072
  const data = await res.json().catch(() => null)
2824
3073
  if (data?.ok === false) { toast(data.message || t('send.failed'), 'err'); return true }
2825
- 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
+ }
2826
3087
  } catch (e) {
2827
3088
  console.error('slash command bridge failed', e)
3089
+ if (noFallback) {
3090
+ toast(t('session.commandTimedOut'), 'err')
3091
+ return true
3092
+ }
2828
3093
  }
2829
3094
  return false
2830
3095
  }
@@ -4358,10 +4623,12 @@ function readSeenAnnouncements() {
4358
4623
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
4359
4624
  } catch { return {} }
4360
4625
  }
4361
- function markAnnouncementSeen(id) {
4362
- 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
4363
4629
  const seen = readSeenAnnouncements()
4364
- seen[id] = Date.now()
4630
+ const now = Date.now()
4631
+ for (const id of ids) seen[id] = now
4365
4632
  const keys = Object.keys(seen)
4366
4633
  if (keys.length > 100) {
4367
4634
  keys.sort((a, b) => Number(seen[a]) - Number(seen[b]))
@@ -4370,6 +4637,7 @@ function markAnnouncementSeen(id) {
4370
4637
  LS.set(ANNOUNCEMENTS_KEY, JSON.stringify(seen))
4371
4638
  renderAnnouncementBoard()
4372
4639
  }
4640
+ function markAnnouncementSeen(id) { markAnnouncementsSeen([id]) }
4373
4641
  function readAnnouncementVotes() {
4374
4642
  try {
4375
4643
  const value = JSON.parse(LS.get(ANNOUNCEMENT_VOTES_KEY, '{}'))
@@ -4535,7 +4803,18 @@ function renderAnnouncementPoll(item) {
4535
4803
  submit.classList.toggle('hidden', !!vote)
4536
4804
  submit.disabled = true
4537
4805
  }
4538
- 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
4539
4818
  state.announcement = item
4540
4819
  $('announcement-title').textContent = item.title
4541
4820
  $('announcement-content').innerHTML = esc(item.content).replace(/\r?\n/g, '<br>')
@@ -4550,9 +4829,23 @@ function openAnnouncementModal(item) {
4550
4829
  action.textContent = ''
4551
4830
  action.classList.add('hidden')
4552
4831
  }
4553
- $('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()
4554
4842
  $('modal-announcement').classList.remove('hidden')
4555
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
+ }
4556
4849
  async function submitAnnouncementVote() {
4557
4850
  const item = state.announcement
4558
4851
  const poll = item?.poll
@@ -4616,8 +4909,10 @@ async function submitAnnouncementVote() {
4616
4909
  }
4617
4910
  }
4618
4911
  function closeAnnouncement(markSeen) {
4619
- if (markSeen && state.announcement) markAnnouncementSeen(state.announcement.id)
4912
+ if (markSeen) markAnnouncementsSeen(state.announcementItems)
4620
4913
  state.announcement = null
4914
+ state.announcementItems = []
4915
+ state.announcementIndex = 0
4621
4916
  $('modal-announcement').classList.add('hidden')
4622
4917
  }
4623
4918
  async function fetchAnnouncements() {
@@ -4640,7 +4935,7 @@ async function fetchAnnouncements() {
4640
4935
  const items = normalized.filter(item => !seen[item.id])
4641
4936
  .sort((a, b) => b.publishedAt - a.publishedAt)
4642
4937
  if (!items.length || state.announcement) return false
4643
- openAnnouncementModal(items[0])
4938
+ openAnnouncementModal(items)
4644
4939
  return true
4645
4940
  } catch { return false }
4646
4941
  }
@@ -6826,12 +7121,14 @@ function bindUi() {
6826
7121
  $('archive-cancel').addEventListener('click', closeArchiveConfirm)
6827
7122
  $('archive-confirm').addEventListener('click', confirmArchiveSession)
6828
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))
6829
7126
  $('announcement-later').addEventListener('click', () => closeAnnouncement(false))
6830
7127
  $('announcement-confirm').addEventListener('click', () => closeAnnouncement(true))
6831
7128
  $('announcement-poll-options').addEventListener('change', () => { $('announcement-poll-submit').disabled = false })
6832
7129
  $('announcement-poll-submit').addEventListener('click', submitAnnouncementVote)
6833
7130
  $('modal-announcement').addEventListener('click', (e) => {
6834
- if (e.target === $('modal-announcement') && !state.announcement?.force) closeAnnouncement(false)
7131
+ if (e.target === $('modal-announcement') && !state.announcementItems.some(item => item.force)) closeAnnouncement(false)
6835
7132
  })
6836
7133
  $('announcement-history-close').addEventListener('click', closeAnnouncementHistory)
6837
7134
  $('announcement-history-list').addEventListener('click', (e) => {
@@ -6879,6 +7176,22 @@ function bindUi() {
6879
7176
  $('btn-server-add').addEventListener('click', addServer)
6880
7177
  $('btn-group-add').addEventListener('click', addGroup)
6881
7178
  $('group-select-btn').addEventListener('click', (e) => { e.stopPropagation(); toggleGroupMenu() })
7179
+ $('group-drawer-trigger').addEventListener('click', () => { groupDrawerOpen() ? closeGroupDrawer() : openGroupDrawer() })
7180
+ $('group-drawer-close').addEventListener('click', () => closeGroupDrawer())
7181
+ $('group-drawer-backdrop').addEventListener('click', () => closeGroupDrawer())
7182
+ $('group-drawer-list').addEventListener('click', (e) => {
7183
+ const option = e.target.closest('[data-quick-group]')
7184
+ if (!option) return
7185
+ const group = option.dataset.quickGroup
7186
+ closeGroupDrawer({ restoreFocus: false })
7187
+ if (group !== state.activeGroup) switchGroup(group)
7188
+ })
7189
+ $('group-drawer-manage').addEventListener('click', () => {
7190
+ closeGroupDrawer({ restoreFocus: false })
7191
+ showView('view-settings')
7192
+ showSettingsPage('servers')
7193
+ })
7194
+ document.addEventListener('keydown', keepGroupDrawerFocus)
6882
7195
  document.addEventListener('click', (e) => {
6883
7196
  if (!e.target.closest('#group-select')) closeGroupMenu()
6884
7197
  })