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.
- package/apk/dsh-remote.apk +0 -0
- package/index.mjs +99 -7
- package/package.json +1 -1
- package/public/announcements.json +8 -0
- package/public/app.js +239 -13
- package/public/desktop/desktop.html +3 -3
- package/public/desktop/desktop.js +161 -4
- package/public/index.html +10 -3
- package/public/styles.css +3 -0
- package/public/update.json +8 -8
- 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 代理: 让插件抽屉显示与网关管理页完全一致的数据。
|
|
@@ -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
|
-
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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)
|
|
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) {
|
|
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
|
|
4433
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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(
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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': '
|
|
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': '
|
|
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; }
|
package/public/update.json
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.6.
|
|
2
|
+
"version": "0.6.22",
|
|
3
3
|
"apkUrl": "dsh-remote.apk",
|
|
4
|
-
"sha256": "
|
|
5
|
-
"releasedAt": "2026-
|
|
6
|
-
"notes": "0.6.
|
|
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
|
}
|
package/public/version.json
CHANGED