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
|
@@ -49,6 +49,7 @@ const state = {
|
|
|
49
49
|
serverLatency: {},
|
|
50
50
|
selectingServer: false,
|
|
51
51
|
sessions: [],
|
|
52
|
+
sessionSort: LS.get('sessionSort', 'time') === 'workspace' ? 'workspace' : 'time',
|
|
52
53
|
byId: new Map(),
|
|
53
54
|
current: null,
|
|
54
55
|
history: { seqs: new Set(), visible: [], hasMore: false, loading: false, minSeq: Infinity },
|
|
@@ -66,6 +67,8 @@ const state = {
|
|
|
66
67
|
const streams = {}
|
|
67
68
|
let pollTimer = null
|
|
68
69
|
let wsRetryTimer = null
|
|
70
|
+
let connTickTimer = null
|
|
71
|
+
let reconnectInfo = null
|
|
69
72
|
|
|
70
73
|
function esc(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])) }
|
|
71
74
|
function short(id) { return '…' + String(id).slice(-8) }
|
|
@@ -493,12 +496,16 @@ function hideTip() { const tip = $('ds-tip'); if (tip) tip.classList.add('hidden
|
|
|
493
496
|
|
|
494
497
|
/* ---------------- API ---------------- */
|
|
495
498
|
function apiUrl(path) { return (state.server || '') + path }
|
|
496
|
-
async function rpc(method, payload = {}) {
|
|
497
|
-
const
|
|
499
|
+
async function rpc(method, payload = {}, timeoutMs = 45000) {
|
|
500
|
+
const opts = {
|
|
498
501
|
method: 'POST',
|
|
499
502
|
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' },
|
|
500
503
|
body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
|
|
501
|
-
}
|
|
504
|
+
}
|
|
505
|
+
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
506
|
+
opts.signal = AbortSignal.timeout(timeoutMs)
|
|
507
|
+
}
|
|
508
|
+
const res = await fetch(apiUrl('/api/' + method), opts)
|
|
502
509
|
if (res.status === 401) throw new Error('AUTH')
|
|
503
510
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
504
511
|
const full = await res.json()
|
|
@@ -507,11 +514,15 @@ async function rpc(method, payload = {}) {
|
|
|
507
514
|
return full.result.value
|
|
508
515
|
}
|
|
509
516
|
async function respond(rpcId, value) {
|
|
510
|
-
const
|
|
517
|
+
const opts = {
|
|
511
518
|
method: 'POST',
|
|
512
519
|
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' },
|
|
513
520
|
body: JSON.stringify({ type: 'client-response', rpcId, result: { ok: true, value } })
|
|
514
|
-
}
|
|
521
|
+
}
|
|
522
|
+
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
523
|
+
opts.signal = AbortSignal.timeout(15000)
|
|
524
|
+
}
|
|
525
|
+
const res = await fetch(apiUrl('/api/respond'), opts)
|
|
515
526
|
if (res.status === 401) throw new Error('AUTH')
|
|
516
527
|
const receipt = await res.json()
|
|
517
528
|
return receipt?.accepted === true
|
|
@@ -608,7 +619,8 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
|
|
|
608
619
|
let best = null
|
|
609
620
|
let ms = Infinity
|
|
610
621
|
if (state.autoSelect[state.activeGroup] !== false) {
|
|
611
|
-
|
|
622
|
+
const measured = await Promise.all(candidates.map(async (u) => [u, await pingServer(u)]))
|
|
623
|
+
for (const [u, latency] of measured) state.serverLatency[u] = latency
|
|
612
624
|
best = candidates.filter(u => Number.isFinite(state.serverLatency[u])).sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0] || null
|
|
613
625
|
chosen = best || (state.server || '')
|
|
614
626
|
ms = best ? state.serverLatency[best] : Infinity
|
|
@@ -803,14 +815,50 @@ function deleteGroup(name) {
|
|
|
803
815
|
}
|
|
804
816
|
|
|
805
817
|
/* ---------------- 事件流 (WebSocket + 轮询降级) ---------------- */
|
|
818
|
+
function clearStreamTimers(ws) {
|
|
819
|
+
if (!ws) return
|
|
820
|
+
clearInterval(ws._hbTimer)
|
|
821
|
+
clearInterval(ws._staleTimer)
|
|
822
|
+
ws._hbTimer = null
|
|
823
|
+
ws._staleTimer = null
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function clearConnTick() {
|
|
827
|
+
clearInterval(connTickTimer)
|
|
828
|
+
connTickTimer = null
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function startConnTick() {
|
|
832
|
+
if (connTickTimer) return
|
|
833
|
+
connTickTimer = setInterval(() => {
|
|
834
|
+
updateConn()
|
|
835
|
+
if (!reconnectInfo || state.streamMode === 'poll' || !navigator.onLine ||
|
|
836
|
+
(streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN)) {
|
|
837
|
+
clearConnTick()
|
|
838
|
+
}
|
|
839
|
+
}, 1000)
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function setReconnect(delay) {
|
|
843
|
+
reconnectInfo = { at: Date.now() + delay }
|
|
844
|
+
startConnTick()
|
|
845
|
+
updateConn()
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function clearReconnect() {
|
|
849
|
+
reconnectInfo = null
|
|
850
|
+
clearConnTick()
|
|
851
|
+
}
|
|
852
|
+
|
|
806
853
|
function openStreams() {
|
|
807
854
|
if (!state.token) return
|
|
808
855
|
if (state.streamMode === 'poll') stopPolling()
|
|
809
856
|
state.streamMode = 'ws'
|
|
857
|
+
clearReconnect()
|
|
810
858
|
openStream('mux', onMuxFrame, true)
|
|
811
859
|
openStream('host', onHostFrame, false)
|
|
812
860
|
}
|
|
813
|
-
function openStream(kind, handler, refreshOnOpen) {
|
|
861
|
+
function openStream(kind, handler, refreshOnOpen, isRestore) {
|
|
814
862
|
if (!state.token) return
|
|
815
863
|
let base
|
|
816
864
|
if (state.server) base = state.server.replace(/^http/, 'ws')
|
|
@@ -818,28 +866,64 @@ function openStream(kind, handler, refreshOnOpen) {
|
|
|
818
866
|
const ws = new WebSocket(`${base}/api/events.${kind}?token=${encodeURIComponent(state.token)}&client=web`)
|
|
819
867
|
try { streams[kind]?.close() } catch {}
|
|
820
868
|
streams[kind] = ws
|
|
869
|
+
ws._attempt = 0
|
|
870
|
+
ws._lastMsgAt = 0
|
|
871
|
+
ws._isRestore = !!isRestore
|
|
821
872
|
ws.onopen = () => {
|
|
822
873
|
state.streamsOk[kind] = true
|
|
823
874
|
state.errCount = 0
|
|
875
|
+
ws._attempt = 0
|
|
876
|
+
ws._lastMsgAt = Date.now()
|
|
877
|
+
clearStreamTimers(ws)
|
|
878
|
+
// 应用层心跳: 25s 发纯文本 ping, 防 NAT/WiFi 切换后的 WS 半开假活
|
|
879
|
+
ws._hbTimer = setInterval(() => {
|
|
880
|
+
try { if (ws.readyState === WebSocket.OPEN) ws.send('ping') } catch {}
|
|
881
|
+
}, 25000)
|
|
882
|
+
// 60s 没有任何消息(含 pong/业务帧)就主动断开, 触发指数退避重连
|
|
883
|
+
ws._staleTimer = setInterval(() => {
|
|
884
|
+
if (ws.readyState === WebSocket.OPEN && Date.now() - (ws._lastMsgAt || 0) > 60000) {
|
|
885
|
+
try { ws.close() } catch {}
|
|
886
|
+
}
|
|
887
|
+
}, 10000)
|
|
824
888
|
if (state.streamMode === 'poll') { stopPolling(); state.streamMode = 'ws' }
|
|
889
|
+
if (streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN) clearReconnect()
|
|
825
890
|
updateConn()
|
|
826
891
|
if (kind === 'mux') { state.approvals = []; state.questions = []; renderNotifStack() }
|
|
827
892
|
if (refreshOnOpen) refreshSessions()
|
|
828
893
|
}
|
|
829
894
|
ws.onmessage = (msg) => {
|
|
895
|
+
ws._lastMsgAt = Date.now()
|
|
830
896
|
state.streamsOk[kind] = true
|
|
831
897
|
state.errCount = 0
|
|
832
898
|
updateConn()
|
|
833
899
|
try { handler(JSON.parse(msg.data)) } catch {}
|
|
834
900
|
}
|
|
835
901
|
ws.onclose = () => {
|
|
902
|
+
clearStreamTimers(ws)
|
|
836
903
|
state.streamsOk[kind] = false
|
|
837
904
|
state.errCount++
|
|
838
905
|
updateConn()
|
|
839
|
-
if (
|
|
906
|
+
if (!navigator.onLine) { clearReconnect(); return }
|
|
907
|
+
if (state.streamMode === 'poll') {
|
|
908
|
+
// 降级轮询期间: 恢复尝试失败也用退避, 避免 30s 固定间隔内空转
|
|
909
|
+
if (ws._isRestore && streams[kind] === ws) {
|
|
910
|
+
const attempt = ws._attempt || 0
|
|
911
|
+
ws._attempt = attempt + 1
|
|
912
|
+
const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
|
|
913
|
+
const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
|
|
914
|
+
setTimeout(() => openStream(kind, handler, refreshOnOpen, true), delay)
|
|
915
|
+
}
|
|
916
|
+
return
|
|
917
|
+
}
|
|
840
918
|
if (state.errCount >= 3) { enterPollMode(); return }
|
|
841
919
|
if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
|
|
842
|
-
|
|
920
|
+
// 指数退避 + 20% 抖动: min(1200 * 2^attempt, 30000)
|
|
921
|
+
const attempt = ws._attempt || 0
|
|
922
|
+
ws._attempt = attempt + 1
|
|
923
|
+
const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
|
|
924
|
+
const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
|
|
925
|
+
setReconnect(delay)
|
|
926
|
+
if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), delay)
|
|
843
927
|
}
|
|
844
928
|
ws.onerror = () => { try { ws.close() } catch {} }
|
|
845
929
|
}
|
|
@@ -898,7 +982,12 @@ async function pollKind(kind) {
|
|
|
898
982
|
let data
|
|
899
983
|
try { data = await res.json() } catch { return }
|
|
900
984
|
if (!data || !Array.isArray(data.events)) return
|
|
901
|
-
|
|
985
|
+
const reset = data.truncated === true || (typeof data.latestSeq === 'number' && data.latestSeq < since)
|
|
986
|
+
if (reset) {
|
|
987
|
+
state.pollSeq[kind] = 0
|
|
988
|
+
if (kind === 'mux') renderNotifStack()
|
|
989
|
+
refreshSessions()
|
|
990
|
+
}
|
|
902
991
|
for (const item of data.events) {
|
|
903
992
|
if (item.seq > (state.pollSeq[kind] || 0)) {
|
|
904
993
|
state.pollSeq[kind] = item.seq
|
|
@@ -911,9 +1000,32 @@ async function pollKind(kind) {
|
|
|
911
1000
|
function tryRestoreWs() {
|
|
912
1001
|
if (state.streamMode !== 'poll' || !state.token) return
|
|
913
1002
|
// 轮询继续跑,等 WS 真正 onopen 后再切回,避免重连窗口丢事件
|
|
914
|
-
openStream('mux', onMuxFrame, true)
|
|
915
|
-
openStream('host', onHostFrame, false)
|
|
1003
|
+
openStream('mux', onMuxFrame, true, true)
|
|
1004
|
+
openStream('host', onHostFrame, false, true)
|
|
916
1005
|
}
|
|
1006
|
+
|
|
1007
|
+
/* 网络感知: 离线立刻关 WS + 显示离线, 在线立即重连 */
|
|
1008
|
+
window.addEventListener('offline', () => {
|
|
1009
|
+
clearReconnect()
|
|
1010
|
+
try { streams.mux?.close() } catch {}
|
|
1011
|
+
try { streams.host?.close() } catch {}
|
|
1012
|
+
if (state.streamMode === 'poll') stopPolling()
|
|
1013
|
+
updateConn()
|
|
1014
|
+
})
|
|
1015
|
+
window.addEventListener('online', () => {
|
|
1016
|
+
if (!state.token) { updateConn(); return }
|
|
1017
|
+
state.errCount = 0
|
|
1018
|
+
clearReconnect()
|
|
1019
|
+
openStreams()
|
|
1020
|
+
updateConn()
|
|
1021
|
+
})
|
|
1022
|
+
document.addEventListener('visibilitychange', () => {
|
|
1023
|
+
if (document.visibilityState === 'visible' && state.token &&
|
|
1024
|
+
(streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN)) {
|
|
1025
|
+
openStreams()
|
|
1026
|
+
}
|
|
1027
|
+
})
|
|
1028
|
+
|
|
917
1029
|
function onMuxFrame(full) {
|
|
918
1030
|
const f = full.payload
|
|
919
1031
|
if (!f) return
|
|
@@ -959,7 +1071,7 @@ function applyProjection(sessionId, key, value, seq) {
|
|
|
959
1071
|
if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) refreshSessions()
|
|
960
1072
|
}
|
|
961
1073
|
function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
|
|
962
|
-
function titleOf(s) { return proj(s, 'title') || short(s.sessionId) }
|
|
1074
|
+
function titleOf(s) { return proj(s, 'title') || (s?.sessionId ? short(s.sessionId) : t('ds.sessions')) }
|
|
963
1075
|
const GOAL_TERMINAL_PHASES = new Set(['complete', 'cleared'])
|
|
964
1076
|
function isGoalTerminal(goal) {
|
|
965
1077
|
return !!goal && GOAL_TERMINAL_PHASES.has(goal.phase)
|
|
@@ -991,17 +1103,55 @@ async function refreshSessions() {
|
|
|
991
1103
|
state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
|
|
992
1104
|
renderSessions()
|
|
993
1105
|
}
|
|
1106
|
+
function sessionCwd(s) { return typeof s?.cwd === 'string' ? s.cwd.trim() : '' }
|
|
1107
|
+
function sessionWorkspaceLabel(s) {
|
|
1108
|
+
const cwd = sessionCwd(s)
|
|
1109
|
+
return cwd || t('ds.workspaceUnknown')
|
|
1110
|
+
}
|
|
1111
|
+
function workspaceDisplayName(label) {
|
|
1112
|
+
const value = String(label || '').trim()
|
|
1113
|
+
if (!value || value === t('ds.workspaceUnknown')) return value || t('ds.workspaceUnknown')
|
|
1114
|
+
const clean = value.replace(/[\\/]+$/, '')
|
|
1115
|
+
const parts = clean.split(/[\\/]/).filter(Boolean)
|
|
1116
|
+
return parts[parts.length - 1] || value
|
|
1117
|
+
}
|
|
1118
|
+
function sortedSessions() {
|
|
1119
|
+
const items = [...state.sessions]
|
|
1120
|
+
if (state.sessionSort === 'workspace') {
|
|
1121
|
+
return items.sort((a, b) => {
|
|
1122
|
+
const aw = sessionCwd(a) || '\uffff'
|
|
1123
|
+
const bw = sessionCwd(b) || '\uffff'
|
|
1124
|
+
const byWorkspace = aw.localeCompare(bw, undefined, { numeric: true, sensitivity: 'base' })
|
|
1125
|
+
return byWorkspace || ((b.updatedAt || 0) - (a.updatedAt || 0))
|
|
1126
|
+
})
|
|
1127
|
+
}
|
|
1128
|
+
return items.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
|
1129
|
+
}
|
|
994
1130
|
function renderSessions() {
|
|
995
|
-
const items =
|
|
996
|
-
|
|
1131
|
+
const items = sortedSessions()
|
|
1132
|
+
let lastWorkspace = null
|
|
1133
|
+
const rows = []
|
|
1134
|
+
for (const s of items) {
|
|
1135
|
+
const workspace = sessionWorkspaceLabel(s)
|
|
1136
|
+
const workspaceName = workspaceDisplayName(workspace)
|
|
1137
|
+
if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
|
|
1138
|
+
rows.push(`<div class="ds-session-group" title="${esc(workspace)}"><span class="ds-session-group-icon" aria-hidden="true">⌂</span><span class="ds-session-group-name">${esc(workspaceName)}</span></div>`)
|
|
1139
|
+
lastWorkspace = workspace
|
|
1140
|
+
}
|
|
997
1141
|
const title = titleOf(s)
|
|
998
|
-
|
|
1142
|
+
rows.push(`<button class="ds-session-item ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
|
|
999
1143
|
<span class="ds-session-title">${esc(title)}</span>
|
|
1144
|
+
<span class="ds-session-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</span>
|
|
1000
1145
|
<span class="ds-session-meta"><span class="ds-session-dot ${s.running ? 'running' : ''}"></span>${fmtTime(s.updatedAt)}</span>
|
|
1001
|
-
</button>`
|
|
1002
|
-
}
|
|
1146
|
+
</button>`)
|
|
1147
|
+
}
|
|
1148
|
+
const html = rows.join('') || `<div class="ds-empty">${t('ds.sessionsEmpty')}</div>`
|
|
1003
1149
|
$('session-list').innerHTML = html
|
|
1004
1150
|
$('mobile-session-list').innerHTML = html
|
|
1151
|
+
$('session-list').classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
|
|
1152
|
+
$('mobile-session-list').classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
|
|
1153
|
+
const sort = $('session-sort')
|
|
1154
|
+
if (sort) sort.value = state.sessionSort
|
|
1005
1155
|
document.querySelectorAll('[data-id]').forEach(b => b.addEventListener('click', () => openSession(b.dataset.id)))
|
|
1006
1156
|
}
|
|
1007
1157
|
|
|
@@ -1174,7 +1324,8 @@ async function goalAction(kind) {
|
|
|
1174
1324
|
const objective = prompt(t('goal.editPrompt'), goal.objective || '')
|
|
1175
1325
|
if (objective === null) return
|
|
1176
1326
|
if (!objective.trim()) return toast(t('goal.cannotEmpty'), 'err')
|
|
1177
|
-
await safeRpc('goal.edit', { sessionId: state.current, ref, objective: objective.trim() }, t('goal.updateFailed'))
|
|
1327
|
+
const result = await safeRpc('goal.edit', { sessionId: state.current, ref, objective: objective.trim() }, t('goal.updateFailed'))
|
|
1328
|
+
if (result == null) return
|
|
1178
1329
|
toast(t('goal.updated'), 'ok')
|
|
1179
1330
|
refreshSessions()
|
|
1180
1331
|
renderSessionCards()
|
|
@@ -1185,7 +1336,8 @@ async function goalAction(kind) {
|
|
|
1185
1336
|
if (!method) return
|
|
1186
1337
|
if (kind === 'clear' && !confirm(t('goal.confirmClear'))) return
|
|
1187
1338
|
if (kind === 'complete' && !confirm(t('goal.confirmComplete'))) return
|
|
1188
|
-
await safeRpc(method, { sessionId: state.current, ref }, t('goal.actionFailed'))
|
|
1339
|
+
const result = await safeRpc(method, { sessionId: state.current, ref }, t('goal.actionFailed'))
|
|
1340
|
+
if (result == null) return
|
|
1189
1341
|
if (kind === 'complete') setGoalPhaseLocal('complete')
|
|
1190
1342
|
if (kind === 'clear') setGoalPhaseLocal('cleared')
|
|
1191
1343
|
toast(t('goal.actionSubmitted'), 'ok')
|
|
@@ -1195,7 +1347,8 @@ async function goalAction(kind) {
|
|
|
1195
1347
|
|
|
1196
1348
|
async function interruptSubagent(childId) {
|
|
1197
1349
|
if (!confirm(t('subagent.confirmInterrupt'))) return
|
|
1198
|
-
await safeRpc('subagent.interrupt', { parentSessionId: state.current, childSessionId: childId, mode: 'continuable' }, t('subagent.interruptFailed'))
|
|
1350
|
+
const result = await safeRpc('subagent.interrupt', { parentSessionId: state.current, childSessionId: childId, mode: 'continuable' }, t('subagent.interruptFailed'))
|
|
1351
|
+
if (result == null) return
|
|
1199
1352
|
toast(t('subagent.interruptSubmitted'), 'ok')
|
|
1200
1353
|
setTimeout(renderSessionCards, 600)
|
|
1201
1354
|
}
|
|
@@ -1230,7 +1383,11 @@ async function sendMessage() {
|
|
|
1230
1383
|
if (!text || !state.current) return
|
|
1231
1384
|
if (await runSlashCommand(text)) { input.value = ''; return }
|
|
1232
1385
|
input.value = ''
|
|
1233
|
-
const v = await safeRpc('session.prompt', {
|
|
1386
|
+
const v = await safeRpc('session.prompt', {
|
|
1387
|
+
sessionId: state.current,
|
|
1388
|
+
mode: 'queue',
|
|
1389
|
+
content: [{ type: 'text', text }]
|
|
1390
|
+
}, '')
|
|
1234
1391
|
if (v) toast(t('ds.toastSent'), 'ok')
|
|
1235
1392
|
}
|
|
1236
1393
|
|
|
@@ -1283,7 +1440,14 @@ function renderNotifStack() {
|
|
|
1283
1440
|
async function approveApproval(id, allow) {
|
|
1284
1441
|
const a = state.approvals.find(x => x.approvalId === id)
|
|
1285
1442
|
if (!a) return
|
|
1286
|
-
|
|
1443
|
+
let ok
|
|
1444
|
+
try {
|
|
1445
|
+
ok = await respond(a.rpcId, { sessionId: a.sessionId, approvalId: a.approvalId, outcome: allow ? 'allowed-once' : 'rejected' })
|
|
1446
|
+
} catch (e) {
|
|
1447
|
+
if (e.message === 'AUTH') toast(t('ds.toastAuth'), 'err')
|
|
1448
|
+
else toast(t('ds.pendingSubmitFailed', { msg: e.message || t('ds.feedbackNetworkError') }), 'err')
|
|
1449
|
+
return
|
|
1450
|
+
}
|
|
1287
1451
|
toast(ok ? (allow ? t('ds.allowed') : t('ds.rejected')) : t('ds.stale'), ok ? 'ok' : 'err')
|
|
1288
1452
|
state.approvals = state.approvals.filter(x => x.approvalId !== id)
|
|
1289
1453
|
renderNotifStack()
|
|
@@ -1312,7 +1476,14 @@ async function submitQuestion() {
|
|
|
1312
1476
|
return ans
|
|
1313
1477
|
}).filter(Boolean)
|
|
1314
1478
|
if (!answers.length) return toast(t('ds.questionNeedAnswer'), 'err')
|
|
1315
|
-
|
|
1479
|
+
let ok
|
|
1480
|
+
try {
|
|
1481
|
+
ok = await respond(q.rpcId, { sessionId: q.sessionId, answer: { answers } })
|
|
1482
|
+
} catch (e) {
|
|
1483
|
+
if (e.message === 'AUTH') toast(t('ds.toastAuth'), 'err')
|
|
1484
|
+
else toast(t('ds.pendingSubmitFailed', { msg: e.message || t('ds.feedbackNetworkError') }), 'err')
|
|
1485
|
+
return
|
|
1486
|
+
}
|
|
1316
1487
|
if (ok) {
|
|
1317
1488
|
toast(t('ds.questionSubmitted'), 'ok')
|
|
1318
1489
|
$('modal-question').classList.add('hidden')
|
|
@@ -1338,6 +1509,48 @@ function fsParent(p) {
|
|
|
1338
1509
|
parts.pop()
|
|
1339
1510
|
return parts.length ? '/' + parts.join('/') : '/'
|
|
1340
1511
|
}
|
|
1512
|
+
async function openWorkspaceModal() {
|
|
1513
|
+
if (!state.token) { toast(t('ds.toastAuth'), 'err'); showView('view-settings'); return }
|
|
1514
|
+
if (!state.fs.path) await loadFs(null, true)
|
|
1515
|
+
$('workspace-parent-path').textContent = state.fs.path || '~'
|
|
1516
|
+
$('workspace-name').value = ''
|
|
1517
|
+
$('modal-workspace').classList.remove('hidden')
|
|
1518
|
+
setTimeout(() => $('workspace-name').focus(), 50)
|
|
1519
|
+
}
|
|
1520
|
+
function closeWorkspaceModal() { $('modal-workspace').classList.add('hidden') }
|
|
1521
|
+
async function createWorkspace() {
|
|
1522
|
+
if (createWorkspace.busy) return
|
|
1523
|
+
const name = $('workspace-name').value.trim()
|
|
1524
|
+
if (!name) { toast(t('ds.workspaceNameRequired'), 'err'); $('workspace-name').focus(); return }
|
|
1525
|
+
createWorkspace.busy = true
|
|
1526
|
+
const parent = state.fs.path || ''
|
|
1527
|
+
const button = $('workspace-create')
|
|
1528
|
+
button.disabled = true
|
|
1529
|
+
try {
|
|
1530
|
+
const res = await fetch(fsApiUrl('/mkdir', { path: parent, name }), { method: 'POST', headers: fsHeaders() })
|
|
1531
|
+
if (res.status === 401) { toast(t('ds.toastAuth'), 'err'); return }
|
|
1532
|
+
const data = await res.json().catch(() => ({}))
|
|
1533
|
+
if (!res.ok) {
|
|
1534
|
+
const msg = data.error === 'exists' ? t('ds.workspaceExists') : data.error === 'bad-name' ? t('ds.workspaceInvalidName') : data.error || ('HTTP ' + res.status)
|
|
1535
|
+
throw new Error(msg)
|
|
1536
|
+
}
|
|
1537
|
+
closeWorkspaceModal()
|
|
1538
|
+
await loadFs(parent || null, true)
|
|
1539
|
+
const v = await safeRpc('session.create', { cwd: data.path }, t('ds.toastOpFailed'))
|
|
1540
|
+
await refreshSessions()
|
|
1541
|
+
if (v?.sessionId) {
|
|
1542
|
+
toast(t('ds.workspaceCreated'), 'ok')
|
|
1543
|
+
openSession(v.sessionId)
|
|
1544
|
+
} else {
|
|
1545
|
+
toast(t('ds.workspaceCreatedNoSession'), 'ok')
|
|
1546
|
+
}
|
|
1547
|
+
} catch (e) {
|
|
1548
|
+
toast(`${t('ds.workspaceCreateFailed')}:${e.message || t('ds.feedbackNetworkError')}`, 'err')
|
|
1549
|
+
} finally {
|
|
1550
|
+
createWorkspace.busy = false
|
|
1551
|
+
button.disabled = false
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1341
1554
|
async function loadFs(dir, silent) {
|
|
1342
1555
|
if (!state.token) {
|
|
1343
1556
|
$('fs-path').textContent = t('ds.toastAuth')
|
|
@@ -1473,27 +1686,63 @@ function updateConn() {
|
|
|
1473
1686
|
const cur = state.servers.find(s => s.url === state.server)
|
|
1474
1687
|
const group = cur ? cur.group : state.activeGroup
|
|
1475
1688
|
const label = cur ? (cur.note || cur.url) : (state.server || t('ds.origin'))
|
|
1689
|
+
const serverText = t('ds.currentServer', { group, url: label })
|
|
1690
|
+
if (!navigator.onLine) {
|
|
1691
|
+
el.textContent = t('ds.connOffline')
|
|
1692
|
+
el.className = 'ds-conn off'
|
|
1693
|
+
el.title = serverText
|
|
1694
|
+
$('server-badge').textContent = serverText
|
|
1695
|
+
return
|
|
1696
|
+
}
|
|
1476
1697
|
if (state.streamMode === 'poll') {
|
|
1477
1698
|
el.textContent = '●'
|
|
1478
1699
|
el.className = 'ds-conn off'
|
|
1479
1700
|
el.title = t('ds.connPollTitle')
|
|
1480
|
-
$('server-badge').textContent =
|
|
1701
|
+
$('server-badge').textContent = serverText
|
|
1481
1702
|
return
|
|
1482
1703
|
}
|
|
1483
1704
|
const any = Object.values(state.streamsOk).some(Boolean)
|
|
1484
1705
|
const all = state.streamsOk.mux && state.streamsOk.host
|
|
1706
|
+
if (!all && reconnectInfo) {
|
|
1707
|
+
const remain = Math.max(0, Math.ceil((reconnectInfo.at - Date.now()) / 1000))
|
|
1708
|
+
el.textContent = remain > 0 ? t('ds.connReconnectIn', { n: remain }) : t('ds.connReconnecting')
|
|
1709
|
+
el.className = 'ds-conn ing'
|
|
1710
|
+
el.title = t('ds.connReconnecting') + ' · ' + serverText
|
|
1711
|
+
$('server-badge').textContent = serverText
|
|
1712
|
+
return
|
|
1713
|
+
}
|
|
1714
|
+
if (!all && state.errCount > 0 && !any) {
|
|
1715
|
+
el.textContent = t('ds.connFailed')
|
|
1716
|
+
el.className = 'ds-conn off'
|
|
1717
|
+
el.title = serverText
|
|
1718
|
+
$('server-badge').textContent = serverText
|
|
1719
|
+
return
|
|
1720
|
+
}
|
|
1485
1721
|
el.textContent = '●'
|
|
1486
1722
|
el.className = 'ds-conn ' + (all ? 'on' : any ? 'ing' : '')
|
|
1487
1723
|
el.title = all ? t('ds.connOn') : any ? t('ds.connIng') : t('ds.connOff')
|
|
1488
|
-
$('server-badge').textContent =
|
|
1724
|
+
$('server-badge').textContent = serverText
|
|
1489
1725
|
}
|
|
1490
1726
|
|
|
1491
1727
|
/* ---------------- 初始化 ---------------- */
|
|
1492
1728
|
function bindUi() {
|
|
1493
1729
|
$('btn-new-session').addEventListener('click', async () => {
|
|
1494
|
-
|
|
1730
|
+
let payload = {}
|
|
1731
|
+
// 与移动端保持一致:新会话继承 DSH 当前工作目录;查询失败时兼容回退。
|
|
1732
|
+
try {
|
|
1733
|
+
const host = await rpc('host.describe', {}, 5000)
|
|
1734
|
+
const cwd = typeof host?.cwd === 'string' ? host.cwd.trim() : ''
|
|
1735
|
+
if (cwd) payload = { cwd }
|
|
1736
|
+
} catch {}
|
|
1737
|
+
const v = await safeRpc('session.create', payload, '')
|
|
1495
1738
|
if (v?.sessionId) { await refreshSessions(); openSession(v.sessionId) }
|
|
1496
1739
|
})
|
|
1740
|
+
$('btn-new-workspace').addEventListener('click', openWorkspaceModal)
|
|
1741
|
+
$('session-sort')?.addEventListener('change', (e) => {
|
|
1742
|
+
state.sessionSort = e.target.value === 'workspace' ? 'workspace' : 'time'
|
|
1743
|
+
LS.set('sessionSort', state.sessionSort)
|
|
1744
|
+
renderSessions()
|
|
1745
|
+
})
|
|
1497
1746
|
$('btn-mobile-nav').addEventListener('click', () => {
|
|
1498
1747
|
const list = $('mobile-session-list')
|
|
1499
1748
|
list.style.display = list.style.display === 'none' ? 'flex' : 'none'
|
|
@@ -1552,6 +1801,10 @@ function bindUi() {
|
|
|
1552
1801
|
$('notes-prev').addEventListener('click', () => scrollNotes(-1))
|
|
1553
1802
|
$('notes-next').addEventListener('click', () => scrollNotes(1))
|
|
1554
1803
|
$('notes-pages').addEventListener('scroll', updateNotesPage)
|
|
1804
|
+
$('workspace-cancel').addEventListener('click', closeWorkspaceModal)
|
|
1805
|
+
$('workspace-create').addEventListener('click', createWorkspace)
|
|
1806
|
+
$('workspace-name').addEventListener('keydown', (e) => { if (e.key === 'Enter') createWorkspace() })
|
|
1807
|
+
$('modal-workspace').addEventListener('click', (e) => { if (e.target === $('modal-workspace')) closeWorkspaceModal() })
|
|
1555
1808
|
$('modal-notes').addEventListener('click', (e) => { if (e.target === $('modal-notes')) closeNotesModal() })
|
|
1556
1809
|
// 反馈
|
|
1557
1810
|
$('btn-feedback').addEventListener('click', (e) => { e.stopPropagation(); toggleFeedbackMenu() })
|
|
@@ -1614,6 +1867,7 @@ function bindUi() {
|
|
|
1614
1867
|
renderServers(); renderSessions(); updateConn(); themeApply()
|
|
1615
1868
|
})
|
|
1616
1869
|
$('fs-up').addEventListener('click', fsUp)
|
|
1870
|
+
$('fs-new-workspace').addEventListener('click', openWorkspaceModal)
|
|
1617
1871
|
$('fs-refresh').addEventListener('click', () => loadFs(state.fs.path || null))
|
|
1618
1872
|
$('btn-question-submit').addEventListener('click', submitQuestion)
|
|
1619
1873
|
$('btn-question-cancel').addEventListener('click', () => { $('modal-question').classList.add('hidden'); toast(t('ds.ignored'), 'ok') })
|