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.
- package/apk/dsh-remote.apk +0 -0
- package/index.mjs +128 -9
- package/package.json +1 -1
- package/public/announcements.json +8 -0
- package/public/app.js +326 -13
- package/public/desktop/desktop.css +33 -2
- package/public/desktop/desktop.html +23 -4
- package/public/desktop/desktop.js +246 -4
- package/public/index.html +32 -6
- package/public/styles.css +42 -1
- package/public/update.json +12 -12
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
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
|
-
|
|
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
|
-
|
|
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
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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)
|
|
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) {
|
|
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
|
|
4362
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
})
|