dsh-remote-plugin 0.6.4 → 0.6.6
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/README.en.md +1 -1
- package/README.md +1 -1
- package/apk/dsh-remote.apk +0 -0
- package/gateway-stats.cjs +9 -2
- package/gateway.cjs +209 -9
- package/index.mjs +52 -12
- package/package.json +1 -1
- package/public/app.js +360 -36
- package/public/desktop/desktop.css +34 -3
- package/public/desktop/desktop.html +37 -7
- package/public/desktop/desktop.js +281 -27
- package/public/index.html +52 -11
- package/public/styles.css +38 -4
- package/public/update.json +12 -4
- package/public/version.json +1 -1
package/public/app.js
CHANGED
|
@@ -46,6 +46,7 @@ const state = {
|
|
|
46
46
|
serverLatency: {}, // url -> 最近一次 /health 测速毫秒数
|
|
47
47
|
selectingServer: false, // 防重入: 测速/切换中
|
|
48
48
|
sessions: [],
|
|
49
|
+
sessionSort: LS.get('sessionSort', 'time') === 'workspace' ? 'workspace' : 'time',
|
|
49
50
|
byId: new Map(),
|
|
50
51
|
current: null, // 当前打开的 sessionId
|
|
51
52
|
hostInfo: null,
|
|
@@ -183,6 +184,24 @@ function apiUrl(path) {
|
|
|
183
184
|
return (state.server || '') + path
|
|
184
185
|
}
|
|
185
186
|
|
|
187
|
+
function updateBase() {
|
|
188
|
+
const configured = String(state.server || '').replace(/\/+$/, '')
|
|
189
|
+
if (configured) return configured
|
|
190
|
+
if (!/^https?:$/.test(location.protocol)) return ''
|
|
191
|
+
const origin = location.origin.replace(/\/+$/, '')
|
|
192
|
+
return location.pathname === '/remote' || location.pathname.startsWith('/remote/')
|
|
193
|
+
? origin + '/remote'
|
|
194
|
+
: origin
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function adminApiUrl(path) {
|
|
198
|
+
const base = String(state.server || '').replace(/\/+$/, '')
|
|
199
|
+
if (base) return base + path
|
|
200
|
+
// DSH 抽屉页面同源运行在 /remote/ 前缀下,管理接口也必须带此前缀;
|
|
201
|
+
// 独立网关根页面则继续使用 /admin/api/*。
|
|
202
|
+
return location.pathname.startsWith('/remote/') ? '/remote' + path : path
|
|
203
|
+
}
|
|
204
|
+
|
|
186
205
|
function fmtCost(n) {
|
|
187
206
|
return '¥' + (Number(n) || 0).toFixed(2)
|
|
188
207
|
}
|
|
@@ -268,14 +287,14 @@ function renderStats(days) {
|
|
|
268
287
|
</div>`
|
|
269
288
|
}).join('')
|
|
270
289
|
}
|
|
271
|
-
async function rpc(method, payload = {}) {
|
|
290
|
+
async function rpc(method, payload = {}, timeoutMs = 45000) {
|
|
272
291
|
const opts = {
|
|
273
292
|
method: 'POST',
|
|
274
293
|
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
|
|
275
294
|
body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
|
|
276
295
|
}
|
|
277
296
|
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
278
|
-
opts.signal = AbortSignal.timeout(
|
|
297
|
+
opts.signal = AbortSignal.timeout(timeoutMs)
|
|
279
298
|
}
|
|
280
299
|
const res = await fetch(apiUrl('/api/' + method), opts)
|
|
281
300
|
if (res.status === 401) throw new Error('AUTH')
|
|
@@ -290,11 +309,15 @@ async function rpc(method, payload = {}) {
|
|
|
290
309
|
}
|
|
291
310
|
|
|
292
311
|
async function respond(rpcId, value) {
|
|
293
|
-
const
|
|
312
|
+
const opts = {
|
|
294
313
|
method: 'POST',
|
|
295
314
|
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
|
|
296
315
|
body: JSON.stringify({ type: 'client-response', rpcId, result: { ok: true, value } })
|
|
297
|
-
}
|
|
316
|
+
}
|
|
317
|
+
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
318
|
+
opts.signal = AbortSignal.timeout(15000)
|
|
319
|
+
}
|
|
320
|
+
const res = await fetch(apiUrl('/api/respond'), opts)
|
|
298
321
|
if (res.status === 401) throw new Error('AUTH')
|
|
299
322
|
const receipt = await res.json()
|
|
300
323
|
return receipt?.accepted === true
|
|
@@ -430,7 +453,8 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
|
|
|
430
453
|
let ms = Infinity
|
|
431
454
|
|
|
432
455
|
if (state.autoSelect[state.activeGroup] !== false) {
|
|
433
|
-
|
|
456
|
+
const measured = await Promise.all(candidates.map(async (u) => [u, await pingServer(u)]))
|
|
457
|
+
for (const [u, latency] of measured) state.serverLatency[u] = latency
|
|
434
458
|
best = candidates
|
|
435
459
|
.filter(u => Number.isFinite(state.serverLatency[u]))
|
|
436
460
|
.sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0] || null
|
|
@@ -709,16 +733,54 @@ const streams = {}
|
|
|
709
733
|
state.streamsOk = { mux: false, host: false }
|
|
710
734
|
let pollTimer = null
|
|
711
735
|
let wsRetryTimer = null
|
|
736
|
+
let connTickTimer = null
|
|
737
|
+
let reconnectInfo = null
|
|
738
|
+
|
|
739
|
+
function clearStreamTimers(ws) {
|
|
740
|
+
if (!ws) return
|
|
741
|
+
clearInterval(ws._hbTimer)
|
|
742
|
+
clearInterval(ws._staleTimer)
|
|
743
|
+
ws._hbTimer = null
|
|
744
|
+
ws._staleTimer = null
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function clearConnTick() {
|
|
748
|
+
clearInterval(connTickTimer)
|
|
749
|
+
connTickTimer = null
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function startConnTick() {
|
|
753
|
+
if (connTickTimer) return
|
|
754
|
+
connTickTimer = setInterval(() => {
|
|
755
|
+
updateConn()
|
|
756
|
+
if (!reconnectInfo || state.streamMode === 'poll' || !navigator.onLine ||
|
|
757
|
+
(streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN)) {
|
|
758
|
+
clearConnTick()
|
|
759
|
+
}
|
|
760
|
+
}, 1000)
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function setReconnect(delay) {
|
|
764
|
+
reconnectInfo = { at: Date.now() + delay }
|
|
765
|
+
startConnTick()
|
|
766
|
+
updateConn()
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function clearReconnect() {
|
|
770
|
+
reconnectInfo = null
|
|
771
|
+
clearConnTick()
|
|
772
|
+
}
|
|
712
773
|
|
|
713
774
|
function openStreams() {
|
|
714
775
|
if (!state.token) return
|
|
715
776
|
if (state.streamMode === 'poll') stopPolling()
|
|
716
777
|
state.streamMode = 'ws'
|
|
778
|
+
clearReconnect()
|
|
717
779
|
openStream('mux', onMuxFrame, true)
|
|
718
780
|
openStream('host', onHostFrame, false)
|
|
719
781
|
}
|
|
720
782
|
|
|
721
|
-
function openStream(kind, handler, refreshOnOpen) {
|
|
783
|
+
function openStream(kind, handler, refreshOnOpen, isRestore) {
|
|
722
784
|
if (!state.token) return
|
|
723
785
|
let base
|
|
724
786
|
if (state.server) {
|
|
@@ -731,11 +793,28 @@ function openStream(kind, handler, refreshOnOpen) {
|
|
|
731
793
|
const ws = new WebSocket(`${base}/api/events.${kind}?token=${encodeURIComponent(state.token)}&client=${clientMark}`)
|
|
732
794
|
try { streams[kind]?.close() } catch {}
|
|
733
795
|
streams[kind] = ws
|
|
796
|
+
ws._attempt = 0
|
|
797
|
+
ws._lastMsgAt = 0
|
|
798
|
+
ws._isRestore = !!isRestore
|
|
734
799
|
ws.onopen = () => {
|
|
735
800
|
state.streamsOk[kind] = true
|
|
736
801
|
state.errCount = 0
|
|
802
|
+
ws._attempt = 0
|
|
803
|
+
ws._lastMsgAt = Date.now()
|
|
804
|
+
clearStreamTimers(ws)
|
|
805
|
+
// 应用层心跳: 25s 发纯文本 ping, 防 NAT/WiFi 切换后的 WS 半开假活
|
|
806
|
+
ws._hbTimer = setInterval(() => {
|
|
807
|
+
try { if (ws.readyState === WebSocket.OPEN) ws.send('ping') } catch {}
|
|
808
|
+
}, 25000)
|
|
809
|
+
// 60s 没有任何消息(含 pong/业务帧)就主动断开, 触发指数退避重连
|
|
810
|
+
ws._staleTimer = setInterval(() => {
|
|
811
|
+
if (ws.readyState === WebSocket.OPEN && Date.now() - (ws._lastMsgAt || 0) > 60000) {
|
|
812
|
+
try { ws.close() } catch {}
|
|
813
|
+
}
|
|
814
|
+
}, 10000)
|
|
737
815
|
// 重连成功:切回 WS 并停止轮询
|
|
738
816
|
if (state.streamMode === 'poll') { stopPolling(); state.streamMode = 'ws' }
|
|
817
|
+
if (streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN) clearReconnect()
|
|
739
818
|
updateConn()
|
|
740
819
|
// mux 每次(重)连接都会重放“仍待处理”的审批/提问基线:
|
|
741
820
|
// 先清空旧列表, 避免“桌面已自定义回答, 手机漏收 question/resolved”后永久残留。
|
|
@@ -747,6 +826,7 @@ function openStream(kind, handler, refreshOnOpen) {
|
|
|
747
826
|
if (refreshOnOpen) refreshAll()
|
|
748
827
|
}
|
|
749
828
|
ws.onmessage = (msg) => {
|
|
829
|
+
ws._lastMsgAt = Date.now()
|
|
750
830
|
state.streamsOk[kind] = true
|
|
751
831
|
state.errCount = 0
|
|
752
832
|
updateConn()
|
|
@@ -756,16 +836,34 @@ function openStream(kind, handler, refreshOnOpen) {
|
|
|
756
836
|
} catch {}
|
|
757
837
|
}
|
|
758
838
|
ws.onclose = () => {
|
|
839
|
+
clearStreamTimers(ws)
|
|
759
840
|
state.streamsOk[kind] = false
|
|
760
841
|
state.errCount++
|
|
761
842
|
updateConn()
|
|
762
|
-
if (
|
|
843
|
+
if (!navigator.onLine) { clearReconnect(); return }
|
|
844
|
+
if (state.streamMode === 'poll') {
|
|
845
|
+
// 降级轮询期间: 恢复尝试失败也用退避, 避免 30s 固定间隔内空转
|
|
846
|
+
if (ws._isRestore && streams[kind] === ws) {
|
|
847
|
+
const attempt = ws._attempt || 0
|
|
848
|
+
ws._attempt = attempt + 1
|
|
849
|
+
const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
|
|
850
|
+
const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
|
|
851
|
+
setTimeout(() => openStream(kind, handler, refreshOnOpen, true), delay)
|
|
852
|
+
}
|
|
853
|
+
return
|
|
854
|
+
}
|
|
763
855
|
// 连续失败 3 次 -> 降级为轮询
|
|
764
856
|
if (state.errCount >= 3) { enterPollMode(); return }
|
|
765
857
|
// 多服务器: 连续掉线若干次就重测速, 自动换到当前可达的最快地址
|
|
766
858
|
if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
|
|
767
|
-
//
|
|
768
|
-
|
|
859
|
+
// 指数退避 + 20% 抖动: min(1200 * 2^attempt, 30000)
|
|
860
|
+
const attempt = ws._attempt || 0
|
|
861
|
+
ws._attempt = attempt + 1
|
|
862
|
+
const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
|
|
863
|
+
const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
|
|
864
|
+
setReconnect(delay)
|
|
865
|
+
// 页面被挂起时定时器暂停, visibilitychange 会再触发一次
|
|
866
|
+
if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), delay)
|
|
769
867
|
}
|
|
770
868
|
ws.onerror = () => { try { ws.close() } catch {} }
|
|
771
869
|
}
|
|
@@ -824,8 +922,18 @@ async function pollKind(kind) {
|
|
|
824
922
|
let data
|
|
825
923
|
try { data = await res.json() } catch { return }
|
|
826
924
|
if (!data || !Array.isArray(data.events)) return
|
|
827
|
-
//
|
|
828
|
-
|
|
925
|
+
// 网关重启或客户端离线过久后,游标可能落后于内存环形缓冲;
|
|
926
|
+
// 从当前缓冲重新接收,并刷新权威会话列表,避免静默漏事件。
|
|
927
|
+
const reset = data.truncated === true || (typeof data.latestSeq === 'number' && data.latestSeq < since)
|
|
928
|
+
if (reset) {
|
|
929
|
+
state.pollSeq[kind] = 0
|
|
930
|
+
if (kind === 'mux') {
|
|
931
|
+
state.approvals = []
|
|
932
|
+
state.questions = []
|
|
933
|
+
renderPending()
|
|
934
|
+
}
|
|
935
|
+
scheduleRefresh()
|
|
936
|
+
}
|
|
829
937
|
for (const item of data.events) {
|
|
830
938
|
if (item.seq > (state.pollSeq[kind] || 0)) {
|
|
831
939
|
state.pollSeq[kind] = item.seq
|
|
@@ -838,8 +946,8 @@ async function pollKind(kind) {
|
|
|
838
946
|
function tryRestoreWs() {
|
|
839
947
|
if (state.streamMode !== 'poll' || !state.token) return
|
|
840
948
|
// 轮询继续跑,等 WS 真正 onopen 后再切回,避免重连窗口丢事件
|
|
841
|
-
openStream('mux', onMuxFrame, true)
|
|
842
|
-
openStream('host', onHostFrame, false)
|
|
949
|
+
openStream('mux', onMuxFrame, true, true)
|
|
950
|
+
openStream('host', onHostFrame, false, true)
|
|
843
951
|
}
|
|
844
952
|
|
|
845
953
|
/* 回前台恢复: 强制重排修复 MIUI WebView 后台切回时 sticky 顶栏不绘制的问题 */
|
|
@@ -876,6 +984,23 @@ setInterval(() => {
|
|
|
876
984
|
setInterval(() => {
|
|
877
985
|
if (document.visibilityState === 'visible' && state.servers.length) selectFastestServer({ silent: true })
|
|
878
986
|
}, 300000)
|
|
987
|
+
|
|
988
|
+
/* 网络感知: 离线立刻关 WS + 显示离线, 在线立即重连 */
|
|
989
|
+
window.addEventListener('offline', () => {
|
|
990
|
+
clearReconnect()
|
|
991
|
+
try { streams.mux?.close() } catch {}
|
|
992
|
+
try { streams.host?.close() } catch {}
|
|
993
|
+
if (state.streamMode === 'poll') stopPolling()
|
|
994
|
+
updateConn()
|
|
995
|
+
})
|
|
996
|
+
window.addEventListener('online', () => {
|
|
997
|
+
if (!state.token) { updateConn(); return }
|
|
998
|
+
state.errCount = 0
|
|
999
|
+
clearReconnect()
|
|
1000
|
+
openStreams()
|
|
1001
|
+
updateConn()
|
|
1002
|
+
})
|
|
1003
|
+
|
|
879
1004
|
function onMuxFrame(full) {
|
|
880
1005
|
const f = full.payload
|
|
881
1006
|
if (!f) return
|
|
@@ -979,7 +1104,8 @@ function applyProjection(sessionId, key, value, seq) {
|
|
|
979
1104
|
if (state.current === sessionId) { renderSessionTitle(); renderSessionCards() }
|
|
980
1105
|
if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) scheduleRefresh()
|
|
981
1106
|
else renderSessions()
|
|
982
|
-
}
|
|
1107
|
+
}
|
|
1108
|
+
function titleOf(s) { return proj(s, 'title') || (s?.sessionId ? short(s.sessionId) : t('session.unknown')) }
|
|
983
1109
|
function short(id) { return '…' + String(id).slice(-8) }
|
|
984
1110
|
const GOAL_TERMINAL_PHASES = new Set(['complete', 'cleared'])
|
|
985
1111
|
function isGoalTerminal(goal) {
|
|
@@ -997,10 +1123,42 @@ function updatePendingBadge() {
|
|
|
997
1123
|
if (pending) $('nav-pending').textContent = pending
|
|
998
1124
|
}
|
|
999
1125
|
|
|
1126
|
+
function sessionCwd(s) { return typeof s?.cwd === 'string' ? s.cwd.trim() : '' }
|
|
1127
|
+
function sessionWorkspaceLabel(s) {
|
|
1128
|
+
const cwd = sessionCwd(s)
|
|
1129
|
+
return cwd || t('sessions.workspaceUnknown')
|
|
1130
|
+
}
|
|
1131
|
+
function workspaceDisplayName(label) {
|
|
1132
|
+
const value = String(label || '').trim()
|
|
1133
|
+
if (!value || value === t('sessions.workspaceUnknown')) return value || t('sessions.workspaceUnknown')
|
|
1134
|
+
const clean = value.replace(/[\\/]+$/, '')
|
|
1135
|
+
const parts = clean.split(/[\\/]/).filter(Boolean)
|
|
1136
|
+
return parts[parts.length - 1] || value
|
|
1137
|
+
}
|
|
1138
|
+
function sortedSessions() {
|
|
1139
|
+
const items = [...state.sessions]
|
|
1140
|
+
if (state.sessionSort === 'workspace') {
|
|
1141
|
+
return items.sort((a, b) => {
|
|
1142
|
+
const aw = sessionCwd(a) || '\uffff'
|
|
1143
|
+
const bw = sessionCwd(b) || '\uffff'
|
|
1144
|
+
const byWorkspace = aw.localeCompare(bw, undefined, { numeric: true, sensitivity: 'base' })
|
|
1145
|
+
return byWorkspace || ((b.updatedAt || 0) - (a.updatedAt || 0))
|
|
1146
|
+
})
|
|
1147
|
+
}
|
|
1148
|
+
return items.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
|
1149
|
+
}
|
|
1000
1150
|
function renderSessions() {
|
|
1001
1151
|
const list = $('session-list')
|
|
1002
|
-
const items =
|
|
1003
|
-
|
|
1152
|
+
const items = sortedSessions()
|
|
1153
|
+
let lastWorkspace = null
|
|
1154
|
+
const rows = []
|
|
1155
|
+
for (const s of items) {
|
|
1156
|
+
const workspace = sessionWorkspaceLabel(s)
|
|
1157
|
+
const workspaceName = workspaceDisplayName(workspace)
|
|
1158
|
+
if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
|
|
1159
|
+
rows.push(`<div class="session-group-label" title="${esc(workspace)}"><span class="session-group-icon" aria-hidden="true">⌂</span><span class="session-group-name">${esc(workspaceName)}</span></div>`)
|
|
1160
|
+
lastWorkspace = workspace
|
|
1161
|
+
}
|
|
1004
1162
|
const title = titleOf(s)
|
|
1005
1163
|
const goal = goalOf(s)
|
|
1006
1164
|
const pending = (state.approvals.some(a => a.sessionId === s.sessionId) || state.questions.some(q => q.sessionId === s.sessionId)) ? 'pending' : ''
|
|
@@ -1010,7 +1168,7 @@ function renderSessions() {
|
|
|
1010
1168
|
if (pending) dots.push('pending')
|
|
1011
1169
|
const badge = goal ? `<span class="sc-badge ${goal.phase === 'active' ? 'goal-active' : ''}">${esc(t('sessions.goalBadge', { phase: goal.phase || '?' }))}</span>` : ''
|
|
1012
1170
|
const queueBadge = queueN ? `<span class="sc-badge">${esc(t('sessions.queueBadge', { n: queueN }))}</span>` : ''
|
|
1013
|
-
|
|
1171
|
+
rows.push(`<div class="session-card ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
|
|
1014
1172
|
<div class="sc-title">${esc(title)}</div>
|
|
1015
1173
|
<div class="sc-meta">
|
|
1016
1174
|
<span class="sc-dot ${dots.join(' ')}"></span>
|
|
@@ -1018,9 +1176,14 @@ function renderSessions() {
|
|
|
1018
1176
|
${s.running ? '<span>' + t('sessions.running') + '</span>' : ''}
|
|
1019
1177
|
${badge}${queueBadge}
|
|
1020
1178
|
</div>
|
|
1179
|
+
<div class="sc-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</div>
|
|
1021
1180
|
<span class="sc-arrow">›</span>
|
|
1022
|
-
</div>`
|
|
1023
|
-
}
|
|
1181
|
+
</div>`)
|
|
1182
|
+
}
|
|
1183
|
+
list.innerHTML = rows.join('')
|
|
1184
|
+
list.classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
|
|
1185
|
+
const sort = $('session-sort')
|
|
1186
|
+
if (sort) sort.value = state.sessionSort
|
|
1024
1187
|
$('home-empty').classList.toggle('hidden', items.length > 0)
|
|
1025
1188
|
const running = state.sessions.filter(s => s.running).length
|
|
1026
1189
|
const pending = state.approvals.length + state.questions.length
|
|
@@ -1154,7 +1317,7 @@ function restoreCachedHistory() {
|
|
|
1154
1317
|
if (!cached?.events?.length) return false
|
|
1155
1318
|
const h = emptyHistory()
|
|
1156
1319
|
for (const e of cached.events) {
|
|
1157
|
-
if (
|
|
1320
|
+
if (e?.seq == null) continue
|
|
1158
1321
|
h.seqs.add(e.seq)
|
|
1159
1322
|
h.visible.push(e)
|
|
1160
1323
|
}
|
|
@@ -1522,7 +1685,8 @@ async function goalAction(kind) {
|
|
|
1522
1685
|
if (!method) return
|
|
1523
1686
|
if (kind === 'clear' && !confirm(t('goal.confirmClear'))) return
|
|
1524
1687
|
if (kind === 'complete' && !confirm(t('goal.confirmComplete'))) return
|
|
1525
|
-
await safeRpc(method, { sessionId: state.current, ref }, t('goal.actionFailed'))
|
|
1688
|
+
const result = await safeRpc(method, { sessionId: state.current, ref }, t('goal.actionFailed'))
|
|
1689
|
+
if (result == null) return
|
|
1526
1690
|
if (kind === 'complete') setGoalPhaseLocal('complete')
|
|
1527
1691
|
if (kind === 'clear') setGoalPhaseLocal('cleared')
|
|
1528
1692
|
toast(t('goal.actionSubmitted'), 'ok')
|
|
@@ -1531,7 +1695,8 @@ async function goalAction(kind) {
|
|
|
1531
1695
|
|
|
1532
1696
|
async function interruptSubagent(childId) {
|
|
1533
1697
|
if (!confirm(t('subagent.confirmInterrupt'))) return
|
|
1534
|
-
await safeRpc('subagent.interrupt', { parentSessionId: state.current, childSessionId: childId, mode: 'continuable' }, t('subagent.interruptFailed'))
|
|
1698
|
+
const result = await safeRpc('subagent.interrupt', { parentSessionId: state.current, childSessionId: childId, mode: 'continuable' }, t('subagent.interruptFailed'))
|
|
1699
|
+
if (result == null) return
|
|
1535
1700
|
toast(t('subagent.interruptSubmitted'), 'ok')
|
|
1536
1701
|
setTimeout(renderSessionCards, 600)
|
|
1537
1702
|
}
|
|
@@ -1693,7 +1858,18 @@ async function cancelSession() {
|
|
|
1693
1858
|
}
|
|
1694
1859
|
|
|
1695
1860
|
async function newSession() {
|
|
1696
|
-
|
|
1861
|
+
let payload = {}
|
|
1862
|
+
// DSH 的 host.describe 返回当前工作目录。每次创建前短暂刷新一次,
|
|
1863
|
+
// 避免用户在桌面端切换工作区后,手机仍沿用启动时的旧 cwd。
|
|
1864
|
+
try {
|
|
1865
|
+
const host = await rpc('host.describe', {}, 5000)
|
|
1866
|
+
const cwd = typeof host?.cwd === 'string' ? host.cwd.trim() : ''
|
|
1867
|
+
if (cwd) {
|
|
1868
|
+
state.hostInfo = host
|
|
1869
|
+
payload = { cwd }
|
|
1870
|
+
}
|
|
1871
|
+
} catch {}
|
|
1872
|
+
const v = await safeRpc('session.create', payload, t('home.createFailed'))
|
|
1697
1873
|
if (!v?.sessionId) return
|
|
1698
1874
|
toast(t('home.created'), 'ok')
|
|
1699
1875
|
await refreshSessions()
|
|
@@ -1740,7 +1916,14 @@ function renderPending() {
|
|
|
1740
1916
|
async function approveApproval(id, allow) {
|
|
1741
1917
|
const a = state.approvals.find(x => x.approvalId === id)
|
|
1742
1918
|
if (!a) return
|
|
1743
|
-
|
|
1919
|
+
let ok
|
|
1920
|
+
try {
|
|
1921
|
+
ok = await respond(a.rpcId, { sessionId: a.sessionId, approvalId: a.approvalId, outcome: allow ? 'allowed-once' : 'rejected' })
|
|
1922
|
+
} catch (e) {
|
|
1923
|
+
if (e.message === 'AUTH') authFailure()
|
|
1924
|
+
else toast(t('pending.submitFailed', { msg: e.message || t('fs.networkError') }), 'err')
|
|
1925
|
+
return
|
|
1926
|
+
}
|
|
1744
1927
|
toast(ok ? (allow ? t('pending.allowed') : t('pending.rejected')) : t('pending.stale'), ok ? 'ok' : 'err')
|
|
1745
1928
|
state.approvals = state.approvals.filter(x => x.approvalId !== id)
|
|
1746
1929
|
renderPending()
|
|
@@ -1771,7 +1954,14 @@ async function submitQuestion() {
|
|
|
1771
1954
|
return ans
|
|
1772
1955
|
}).filter(Boolean)
|
|
1773
1956
|
if (!answers.length) return toast(t('question.needAnswer'), 'err')
|
|
1774
|
-
|
|
1957
|
+
let ok
|
|
1958
|
+
try {
|
|
1959
|
+
ok = await respond(q.rpcId, { sessionId: q.sessionId, answer: { answers } })
|
|
1960
|
+
} catch (e) {
|
|
1961
|
+
if (e.message === 'AUTH') authFailure()
|
|
1962
|
+
else toast(t('question.submitFailed', { msg: e.message || t('fs.networkError') }), 'err')
|
|
1963
|
+
return
|
|
1964
|
+
}
|
|
1775
1965
|
if (ok) { toast(t('question.submitted'), 'ok'); $('modal-question').classList.add('hidden'); state.questions = state.questions.filter(x => x.rpcId !== q.rpcId); renderPending() }
|
|
1776
1966
|
else toast(t('question.stale'), 'err')
|
|
1777
1967
|
}
|
|
@@ -1819,6 +2009,50 @@ function fsParent(p) {
|
|
|
1819
2009
|
return clean.slice(0, idx)
|
|
1820
2010
|
}
|
|
1821
2011
|
|
|
2012
|
+
async function openWorkspaceModal() {
|
|
2013
|
+
if (!state.token) { toast(t('fs.noTokenToast'), 'err'); showView('view-settings'); return }
|
|
2014
|
+
if (!state.fs.path) await loadFs(null, { silent: true })
|
|
2015
|
+
$('workspace-parent-path').textContent = state.fs.path || '~'
|
|
2016
|
+
$('workspace-name').value = ''
|
|
2017
|
+
$('modal-workspace').classList.remove('hidden')
|
|
2018
|
+
setTimeout(() => $('workspace-name').focus(), 50)
|
|
2019
|
+
}
|
|
2020
|
+
function closeWorkspaceModal() { $('modal-workspace').classList.add('hidden') }
|
|
2021
|
+
async function createWorkspace() {
|
|
2022
|
+
if (createWorkspace.busy) return
|
|
2023
|
+
const name = $('workspace-name').value.trim()
|
|
2024
|
+
if (!name) { toast(t('workspace.nameRequired'), 'err'); $('workspace-name').focus(); return }
|
|
2025
|
+
createWorkspace.busy = true
|
|
2026
|
+
const parent = state.fs.path || ''
|
|
2027
|
+
const button = $('workspace-create')
|
|
2028
|
+
button.disabled = true
|
|
2029
|
+
try {
|
|
2030
|
+
const res = await fetch(fsApiUrl('/mkdir', { path: parent, name }), { method: 'POST', headers: fsHeaders() })
|
|
2031
|
+
if (res.status === 401) { fsAuthError(401); return }
|
|
2032
|
+
const data = await res.json().catch(() => ({}))
|
|
2033
|
+
if (!res.ok) {
|
|
2034
|
+
const msg = data.error === 'exists' ? t('workspace.exists') : data.error === 'bad-name' ? t('workspace.invalidName') : data.error || ('HTTP ' + res.status)
|
|
2035
|
+
throw new Error(msg)
|
|
2036
|
+
}
|
|
2037
|
+
closeWorkspaceModal()
|
|
2038
|
+
await loadFs(parent || null, { silent: true })
|
|
2039
|
+
const v = await safeRpc('session.create', { cwd: data.path }, t('home.createFailed'))
|
|
2040
|
+
await refreshSessions()
|
|
2041
|
+
if (v?.sessionId) {
|
|
2042
|
+
toast(t('workspace.created'), 'ok')
|
|
2043
|
+
openSession(v.sessionId)
|
|
2044
|
+
} else {
|
|
2045
|
+
toast(t('workspace.createdNoSession'), 'ok')
|
|
2046
|
+
}
|
|
2047
|
+
} catch (e) {
|
|
2048
|
+
if (e.message === 'AUTH') fsAuthError(401)
|
|
2049
|
+
else toast(t('workspace.createFailed', { msg: e.message || t('fs.networkError') }), 'err')
|
|
2050
|
+
} finally {
|
|
2051
|
+
createWorkspace.busy = false
|
|
2052
|
+
button.disabled = false
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
|
|
1822
2056
|
function fsApiUrl(sub, params = {}) {
|
|
1823
2057
|
const u = new URL(apiUrl('/fs' + sub), location.href)
|
|
1824
2058
|
for (const [k, v] of Object.entries(params)) {
|
|
@@ -2242,7 +2476,8 @@ async function submitGoalEdit() {
|
|
|
2242
2476
|
if (!goal) return
|
|
2243
2477
|
const objective = $('goal-edit-text')?.value?.trim()
|
|
2244
2478
|
if (!objective) return toast(t('goal.cannotEmpty'), 'err')
|
|
2245
|
-
await safeRpc('goal.edit', { sessionId: state.current, ref: { id: goal.id, revision: goal.revision }, objective }, t('goal.updateFailed'))
|
|
2479
|
+
const result = await safeRpc('goal.edit', { sessionId: state.current, ref: { id: goal.id, revision: goal.revision }, objective }, t('goal.updateFailed'))
|
|
2480
|
+
if (result == null) return
|
|
2246
2481
|
$('modal-goal').classList.add('hidden')
|
|
2247
2482
|
toast(t('goal.updated'), 'ok')
|
|
2248
2483
|
scheduleRefresh()
|
|
@@ -2386,7 +2621,7 @@ async function checkUpdate(silent) {
|
|
|
2386
2621
|
if (!silent) toast(t('update.noVersion'), 'err')
|
|
2387
2622
|
return
|
|
2388
2623
|
}
|
|
2389
|
-
const base =
|
|
2624
|
+
const base = updateBase()
|
|
2390
2625
|
if (!base) {
|
|
2391
2626
|
if (!silent) toast(t('update.needServer'), 'err')
|
|
2392
2627
|
$('update-desc').textContent = state.localVersion ? `${t('update.currentV', { version: state.localVersion })} · ${t('update.needServer')}` : t('update.needServer')
|
|
@@ -2463,7 +2698,7 @@ async function verifyUpdateApk(info, url) {
|
|
|
2463
2698
|
async function downloadUpdate() {
|
|
2464
2699
|
const info = state.updateInfo
|
|
2465
2700
|
if (!info) return
|
|
2466
|
-
const base =
|
|
2701
|
+
const base = updateBase()
|
|
2467
2702
|
let url
|
|
2468
2703
|
try { url = new URL(info.apkUrl || 'dsh-remote.apk', base + '/').href }
|
|
2469
2704
|
catch { url = base + '/' + (info.apkUrl || 'dsh-remote.apk') }
|
|
@@ -2678,8 +2913,7 @@ async function schedulePeakReminders() {
|
|
|
2678
2913
|
const b = bgBridge()
|
|
2679
2914
|
if (!b?.startPeakReminder) return false
|
|
2680
2915
|
try {
|
|
2681
|
-
b.startPeakReminder()
|
|
2682
|
-
return true
|
|
2916
|
+
return b.startPeakReminder() !== false
|
|
2683
2917
|
} catch { return false }
|
|
2684
2918
|
}
|
|
2685
2919
|
|
|
@@ -2688,8 +2922,7 @@ async function cancelPeakReminders() {
|
|
|
2688
2922
|
const b = bgBridge()
|
|
2689
2923
|
if (!b?.stopPeakReminder) return false
|
|
2690
2924
|
try {
|
|
2691
|
-
b.stopPeakReminder()
|
|
2692
|
-
return true
|
|
2925
|
+
return b.stopPeakReminder() !== false
|
|
2693
2926
|
} catch { return false }
|
|
2694
2927
|
}
|
|
2695
2928
|
|
|
@@ -2727,16 +2960,36 @@ function updateConn() {
|
|
|
2727
2960
|
const ms = state.serverLatency[state.server]
|
|
2728
2961
|
const curGroup = cur ? cur.group : state.activeGroup
|
|
2729
2962
|
const curLabel = cur ? (cur.note || cur.url) : (state.server || t('speed.origin'))
|
|
2963
|
+
const titleBase = t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
|
|
2964
|
+
if (!navigator.onLine) {
|
|
2965
|
+
el.textContent = t('conn.offline')
|
|
2966
|
+
el.className = 'topbar-btn conn-badge off'
|
|
2967
|
+
el.title = titleBase
|
|
2968
|
+
return
|
|
2969
|
+
}
|
|
2730
2970
|
if (state.streamMode === 'poll') {
|
|
2731
2971
|
el.textContent = t('conn.poll')
|
|
2732
2972
|
el.className = 'topbar-btn conn-badge off'
|
|
2733
|
-
el.title = t('conn.pollTitle') + ' · ' +
|
|
2973
|
+
el.title = t('conn.pollTitle') + ' · ' + titleBase
|
|
2734
2974
|
return
|
|
2735
2975
|
}
|
|
2736
2976
|
const ok = !!state.streamsOk?.mux
|
|
2977
|
+
if (!ok && reconnectInfo) {
|
|
2978
|
+
const remain = Math.max(0, Math.ceil((reconnectInfo.at - Date.now()) / 1000))
|
|
2979
|
+
el.textContent = remain > 0 ? t('conn.reconnectIn', { n: remain }) : t('conn.reconnecting')
|
|
2980
|
+
el.className = 'topbar-btn conn-badge off'
|
|
2981
|
+
el.title = t('conn.reconnecting') + ' · ' + titleBase
|
|
2982
|
+
return
|
|
2983
|
+
}
|
|
2984
|
+
if (!ok && state.errCount > 0) {
|
|
2985
|
+
el.textContent = t('conn.failed')
|
|
2986
|
+
el.className = 'topbar-btn conn-badge off'
|
|
2987
|
+
el.title = titleBase
|
|
2988
|
+
return
|
|
2989
|
+
}
|
|
2737
2990
|
el.textContent = ok ? t('conn.on') : t('conn.off')
|
|
2738
2991
|
el.className = 'topbar-btn conn-badge ' + (ok ? 'on' : 'off')
|
|
2739
|
-
el.title =
|
|
2992
|
+
el.title = titleBase
|
|
2740
2993
|
}
|
|
2741
2994
|
|
|
2742
2995
|
function autosize(el) {
|
|
@@ -2862,6 +3115,55 @@ function initToken() {
|
|
|
2862
3115
|
$('server-desc').textContent = state.server || t('servers.defaultDesc')
|
|
2863
3116
|
}
|
|
2864
3117
|
|
|
3118
|
+
function renderDshControlStatus(v) {
|
|
3119
|
+
const desc = $('dsh-control-desc')
|
|
3120
|
+
if (!desc || !v) return
|
|
3121
|
+
const buttons = [$('btn-dsh-start'), $('btn-dsh-restart')].filter(Boolean)
|
|
3122
|
+
if (v.supported === false) {
|
|
3123
|
+
desc.textContent = v.message || t('settings.dshUnsupported')
|
|
3124
|
+
buttons.forEach(b => { b.disabled = true; b.classList.add('hidden') })
|
|
3125
|
+
return
|
|
3126
|
+
}
|
|
3127
|
+
buttons.forEach(b => { b.disabled = false; b.classList.remove('hidden') })
|
|
3128
|
+
desc.textContent = `${t(v.running ? 'settings.dshRunning' : 'settings.dshStopped')} · ${v.service || 'dsh-web'}`
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
async function loadDshControl() {
|
|
3132
|
+
if (!state.token || !$('dsh-control-desc')) return
|
|
3133
|
+
try {
|
|
3134
|
+
const res = await fetch(adminApiUrl('/admin/api/dsh'), { headers: { authorization: 'Bearer ' + state.token } })
|
|
3135
|
+
if (res.status === 401) return authFailure()
|
|
3136
|
+
renderDshControlStatus(await res.json())
|
|
3137
|
+
} catch {
|
|
3138
|
+
$('dsh-control-desc').textContent = t('settings.dshFailed', { msg: t('conn.off') })
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
async function controlDsh(action) {
|
|
3143
|
+
const label = action === 'start' ? t('settings.dshStart') : t('settings.dshRestart')
|
|
3144
|
+
const buttons = [$('btn-dsh-start'), $('btn-dsh-restart')].filter(Boolean)
|
|
3145
|
+
buttons.forEach(b => { b.disabled = true })
|
|
3146
|
+
const desc = $('dsh-control-desc')
|
|
3147
|
+
if (desc) desc.textContent = t('settings.dshStarting', { action: label })
|
|
3148
|
+
try {
|
|
3149
|
+
const res = await fetch(adminApiUrl('/admin/api/dsh'), {
|
|
3150
|
+
method: 'POST',
|
|
3151
|
+
headers: { authorization: 'Bearer ' + state.token, 'content-type': 'application/json' },
|
|
3152
|
+
body: JSON.stringify({ action }),
|
|
3153
|
+
})
|
|
3154
|
+
if (res.status === 401) return authFailure()
|
|
3155
|
+
const v = await res.json().catch(() => ({}))
|
|
3156
|
+
if (!res.ok || v.ok === false) throw new Error(v.error || v.message || `HTTP ${res.status}`)
|
|
3157
|
+
renderDshControlStatus(v)
|
|
3158
|
+
toast(t('settings.dshStarted', { action: label }), 'ok')
|
|
3159
|
+
} catch (e) {
|
|
3160
|
+
if (desc) desc.textContent = t('settings.dshFailed', { msg: e?.message || e })
|
|
3161
|
+
toast(t('settings.dshFailed', { msg: e?.message || e }), 'err')
|
|
3162
|
+
} finally {
|
|
3163
|
+
buttons.forEach(b => { b.disabled = false })
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
|
|
2865
3167
|
function renderLangBtn() {
|
|
2866
3168
|
const btn = $('btn-lang')
|
|
2867
3169
|
if (btn) btn.textContent = I18N.lang === 'zh' ? 'EN' : '中文'
|
|
@@ -2978,6 +3280,12 @@ function bindUi() {
|
|
|
2978
3280
|
if (e.key === 'Escape' && !$('feedback-sheet').classList.contains('hidden')) { closeFeedbackSheet(); $('btn-feedback').focus() }
|
|
2979
3281
|
})
|
|
2980
3282
|
$('btn-new-session').addEventListener('click', newSession)
|
|
3283
|
+
$('btn-new-workspace').addEventListener('click', openWorkspaceModal)
|
|
3284
|
+
$('session-sort')?.addEventListener('change', (e) => {
|
|
3285
|
+
state.sessionSort = e.target.value === 'workspace' ? 'workspace' : 'time'
|
|
3286
|
+
LS.set('sessionSort', state.sessionSort)
|
|
3287
|
+
renderSessions()
|
|
3288
|
+
})
|
|
2981
3289
|
$('btn-cancel').addEventListener('click', cancelSession)
|
|
2982
3290
|
$('btn-send').addEventListener('click', sendMessage)
|
|
2983
3291
|
$('btn-plus').addEventListener('click', toggleComposerMenu)
|
|
@@ -3025,6 +3333,10 @@ function bindUi() {
|
|
|
3025
3333
|
// goal
|
|
3026
3334
|
$('goal-close').addEventListener('click', () => $('modal-goal').classList.add('hidden'))
|
|
3027
3335
|
$('goal-edit').addEventListener('click', submitGoalEdit)
|
|
3336
|
+
$('workspace-cancel').addEventListener('click', closeWorkspaceModal)
|
|
3337
|
+
$('workspace-create').addEventListener('click', createWorkspace)
|
|
3338
|
+
$('workspace-name').addEventListener('keydown', (e) => { if (e.key === 'Enter') createWorkspace() })
|
|
3339
|
+
$('modal-workspace').addEventListener('click', (e) => { if (e.target === $('modal-workspace')) closeWorkspaceModal() })
|
|
3028
3340
|
// 设置
|
|
3029
3341
|
$('view-settings').addEventListener('click', (e) => {
|
|
3030
3342
|
const group = e.target.closest('[data-settings-group]')
|
|
@@ -3054,6 +3366,8 @@ function bindUi() {
|
|
|
3054
3366
|
$('host-desc').textContent = t('settings.hostDesc', { version: v.version, cwd: v.cwd, n: v.attachedSessions })
|
|
3055
3367
|
}
|
|
3056
3368
|
})
|
|
3369
|
+
$('btn-dsh-start')?.addEventListener('click', () => controlDsh('start'))
|
|
3370
|
+
$('btn-dsh-restart')?.addEventListener('click', () => controlDsh('restart'))
|
|
3057
3371
|
$('btn-check-update').addEventListener('click', () => checkUpdate(false))
|
|
3058
3372
|
$('btn-download-update').addEventListener('click', downloadUpdate)
|
|
3059
3373
|
$('btn-update-expand').addEventListener('click', toggleUpdateExpand)
|
|
@@ -3080,10 +3394,18 @@ function bindUi() {
|
|
|
3080
3394
|
}
|
|
3081
3395
|
const ok = await ensureNotify()
|
|
3082
3396
|
if (!ok) { e.target.checked = false; return toast(t('settings.notifyDenied')) }
|
|
3083
|
-
await schedulePeakReminders()
|
|
3397
|
+
const started = await schedulePeakReminders()
|
|
3398
|
+
if (!started) {
|
|
3399
|
+
e.target.checked = false
|
|
3400
|
+
return toast(t('peakRemind.failed'), 'err')
|
|
3401
|
+
}
|
|
3084
3402
|
toast(t('peakRemind.on'), 'ok')
|
|
3085
3403
|
} else {
|
|
3086
|
-
await cancelPeakReminders()
|
|
3404
|
+
const stopped = await cancelPeakReminders()
|
|
3405
|
+
if (!stopped) {
|
|
3406
|
+
e.target.checked = true
|
|
3407
|
+
return toast(t('peakRemind.failed'), 'err')
|
|
3408
|
+
}
|
|
3087
3409
|
toast(t('peakRemind.off'), 'ok')
|
|
3088
3410
|
}
|
|
3089
3411
|
LS.set('peakRemind', e.target.checked ? '1' : '0')
|
|
@@ -3129,6 +3451,7 @@ function bindUi() {
|
|
|
3129
3451
|
|
|
3130
3452
|
// 文件页
|
|
3131
3453
|
$('fs-up').addEventListener('click', fsUp)
|
|
3454
|
+
$('fs-new-workspace').addEventListener('click', openWorkspaceModal)
|
|
3132
3455
|
$('fs-refresh').addEventListener('click', () => { toast(t('common.refreshing')); loadFs() })
|
|
3133
3456
|
$('fs-upload-btn').addEventListener('click', () => $('fs-file-input').click())
|
|
3134
3457
|
$('fs-file-input').addEventListener('change', (e) => {
|
|
@@ -3195,6 +3518,7 @@ async function boot() {
|
|
|
3195
3518
|
await refreshAll()
|
|
3196
3519
|
const host = await safeRpc('host.describe', {}, '')
|
|
3197
3520
|
if (host) { state.hostInfo = host; $('host-desc').textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions }) }
|
|
3521
|
+
loadDshControl()
|
|
3198
3522
|
// 启动后自动检查一次更新(静默)
|
|
3199
3523
|
setTimeout(() => checkUpdate(true), 4000)
|
|
3200
3524
|
}
|