dsh-remote-plugin 0.6.5 → 0.6.7
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 +292 -7
- package/index.mjs +61 -12
- package/package.json +1 -1
- package/public/app.js +647 -54
- package/public/desktop/desktop.css +79 -5
- package/public/desktop/desktop.html +75 -7
- package/public/desktop/desktop.js +418 -22
- package/public/desktop/i18n.js +33 -1
- package/public/index.html +108 -13
- package/public/styles.css +82 -4
- package/public/update.json +12 -4
- package/public/version.json +1 -1
package/public/app.js
CHANGED
|
@@ -46,11 +46,13 @@ 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,
|
|
52
53
|
localVersion: '',
|
|
53
54
|
updateInfo: null,
|
|
55
|
+
announcement: null,
|
|
54
56
|
approvals: [], // 待处理审批
|
|
55
57
|
questions: [], // 待处理提问
|
|
56
58
|
queues: {}, // sessionId -> queue items
|
|
@@ -61,7 +63,12 @@ const state = {
|
|
|
61
63
|
pollSeq: { mux: 0, host: 0 },
|
|
62
64
|
refreshTimer: null,
|
|
63
65
|
fs: { path: null, initial: null, loaded: false, upload: null },
|
|
64
|
-
models: { loaded: false, loading: false, groups: [], current: null, failures: [] }
|
|
66
|
+
models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
|
|
67
|
+
wb: null,
|
|
68
|
+
wbProjects: [],
|
|
69
|
+
wbArchived: [],
|
|
70
|
+
wbOpen: false,
|
|
71
|
+
wbOpenProjects: {}
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
const $ = (id) => document.getElementById(id)
|
|
@@ -183,6 +190,24 @@ function apiUrl(path) {
|
|
|
183
190
|
return (state.server || '') + path
|
|
184
191
|
}
|
|
185
192
|
|
|
193
|
+
function updateBase() {
|
|
194
|
+
const configured = String(state.server || '').replace(/\/+$/, '')
|
|
195
|
+
if (configured) return configured
|
|
196
|
+
if (!/^https?:$/.test(location.protocol)) return ''
|
|
197
|
+
const origin = location.origin.replace(/\/+$/, '')
|
|
198
|
+
return location.pathname === '/remote' || location.pathname.startsWith('/remote/')
|
|
199
|
+
? origin + '/remote'
|
|
200
|
+
: origin
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function adminApiUrl(path) {
|
|
204
|
+
const base = String(state.server || '').replace(/\/+$/, '')
|
|
205
|
+
if (base) return base + path
|
|
206
|
+
// DSH 抽屉页面同源运行在 /remote/ 前缀下,管理接口也必须带此前缀;
|
|
207
|
+
// 独立网关根页面则继续使用 /admin/api/*。
|
|
208
|
+
return location.pathname.startsWith('/remote/') ? '/remote' + path : path
|
|
209
|
+
}
|
|
210
|
+
|
|
186
211
|
function fmtCost(n) {
|
|
187
212
|
return '¥' + (Number(n) || 0).toFixed(2)
|
|
188
213
|
}
|
|
@@ -268,14 +293,14 @@ function renderStats(days) {
|
|
|
268
293
|
</div>`
|
|
269
294
|
}).join('')
|
|
270
295
|
}
|
|
271
|
-
async function rpc(method, payload = {}) {
|
|
296
|
+
async function rpc(method, payload = {}, timeoutMs = 45000) {
|
|
272
297
|
const opts = {
|
|
273
298
|
method: 'POST',
|
|
274
299
|
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
|
|
275
300
|
body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
|
|
276
301
|
}
|
|
277
302
|
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
278
|
-
opts.signal = AbortSignal.timeout(
|
|
303
|
+
opts.signal = AbortSignal.timeout(timeoutMs)
|
|
279
304
|
}
|
|
280
305
|
const res = await fetch(apiUrl('/api/' + method), opts)
|
|
281
306
|
if (res.status === 401) throw new Error('AUTH')
|
|
@@ -290,11 +315,15 @@ async function rpc(method, payload = {}) {
|
|
|
290
315
|
}
|
|
291
316
|
|
|
292
317
|
async function respond(rpcId, value) {
|
|
293
|
-
const
|
|
318
|
+
const opts = {
|
|
294
319
|
method: 'POST',
|
|
295
320
|
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
|
|
296
321
|
body: JSON.stringify({ type: 'client-response', rpcId, result: { ok: true, value } })
|
|
297
|
-
}
|
|
322
|
+
}
|
|
323
|
+
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
324
|
+
opts.signal = AbortSignal.timeout(15000)
|
|
325
|
+
}
|
|
326
|
+
const res = await fetch(apiUrl('/api/respond'), opts)
|
|
298
327
|
if (res.status === 401) throw new Error('AUTH')
|
|
299
328
|
const receipt = await res.json()
|
|
300
329
|
return receipt?.accepted === true
|
|
@@ -430,7 +459,8 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
|
|
|
430
459
|
let ms = Infinity
|
|
431
460
|
|
|
432
461
|
if (state.autoSelect[state.activeGroup] !== false) {
|
|
433
|
-
|
|
462
|
+
const measured = await Promise.all(candidates.map(async (u) => [u, await pingServer(u)]))
|
|
463
|
+
for (const [u, latency] of measured) state.serverLatency[u] = latency
|
|
434
464
|
best = candidates
|
|
435
465
|
.filter(u => Number.isFinite(state.serverLatency[u]))
|
|
436
466
|
.sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0] || null
|
|
@@ -898,8 +928,18 @@ async function pollKind(kind) {
|
|
|
898
928
|
let data
|
|
899
929
|
try { data = await res.json() } catch { return }
|
|
900
930
|
if (!data || !Array.isArray(data.events)) return
|
|
901
|
-
//
|
|
902
|
-
|
|
931
|
+
// 网关重启或客户端离线过久后,游标可能落后于内存环形缓冲;
|
|
932
|
+
// 从当前缓冲重新接收,并刷新权威会话列表,避免静默漏事件。
|
|
933
|
+
const reset = data.truncated === true || (typeof data.latestSeq === 'number' && data.latestSeq < since)
|
|
934
|
+
if (reset) {
|
|
935
|
+
state.pollSeq[kind] = 0
|
|
936
|
+
if (kind === 'mux') {
|
|
937
|
+
state.approvals = []
|
|
938
|
+
state.questions = []
|
|
939
|
+
renderPending()
|
|
940
|
+
}
|
|
941
|
+
scheduleRefresh()
|
|
942
|
+
}
|
|
903
943
|
for (const item of data.events) {
|
|
904
944
|
if (item.seq > (state.pollSeq[kind] || 0)) {
|
|
905
945
|
state.pollSeq[kind] = item.seq
|
|
@@ -1056,6 +1096,7 @@ async function refreshSessions() {
|
|
|
1056
1096
|
state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
|
|
1057
1097
|
cacheWrite(CACHE.sessions, state.sessions.slice(0, 80))
|
|
1058
1098
|
renderSessions()
|
|
1099
|
+
refreshWorkbench()
|
|
1059
1100
|
}
|
|
1060
1101
|
|
|
1061
1102
|
function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
|
|
@@ -1070,7 +1111,8 @@ function applyProjection(sessionId, key, value, seq) {
|
|
|
1070
1111
|
if (state.current === sessionId) { renderSessionTitle(); renderSessionCards() }
|
|
1071
1112
|
if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) scheduleRefresh()
|
|
1072
1113
|
else renderSessions()
|
|
1073
|
-
}
|
|
1114
|
+
}
|
|
1115
|
+
function titleOf(s) { return proj(s, 'title') || (s?.sessionId ? short(s.sessionId) : t('session.unknown')) }
|
|
1074
1116
|
function short(id) { return '…' + String(id).slice(-8) }
|
|
1075
1117
|
const GOAL_TERMINAL_PHASES = new Set(['complete', 'cleared'])
|
|
1076
1118
|
function isGoalTerminal(goal) {
|
|
@@ -1088,37 +1130,182 @@ function updatePendingBadge() {
|
|
|
1088
1130
|
if (pending) $('nav-pending').textContent = pending
|
|
1089
1131
|
}
|
|
1090
1132
|
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1133
|
+
/* ---------------- 工作台与归档会话 ---------------- */
|
|
1134
|
+
function wbPathKey(p) {
|
|
1135
|
+
let value = String(p || '').replace(/\\/g, '/').replace(/\/+/g, '/')
|
|
1136
|
+
if (value.length > 1) value = value.replace(/\/+$/, '')
|
|
1137
|
+
const windows = /^[A-Za-z]:\//.test(value) || /Windows/i.test(navigator.platform || navigator.userAgent || '')
|
|
1138
|
+
return windows ? value.toLowerCase() : value
|
|
1139
|
+
}
|
|
1140
|
+
function wbBaseName(p) {
|
|
1141
|
+
const value = String(p || '').replace(/[\\/]+$/, '')
|
|
1142
|
+
return value.split(/[\\/]/).pop() || value
|
|
1143
|
+
}
|
|
1144
|
+
function wbStrictInside(pathValue, rootValue) {
|
|
1145
|
+
const pathKey = wbPathKey(pathValue)
|
|
1146
|
+
const rootKey = wbPathKey(rootValue)
|
|
1147
|
+
if (!pathKey || !rootKey || pathKey === rootKey) return false
|
|
1148
|
+
return pathKey.startsWith(rootKey.endsWith('/') ? rootKey : rootKey + '/')
|
|
1149
|
+
}
|
|
1150
|
+
function wbJoin(root, name) {
|
|
1151
|
+
const raw = String(root || '')
|
|
1152
|
+
const separator = raw.includes('\\') ? '\\' : '/'
|
|
1153
|
+
return raw.replace(/[\\/]+$/, '') + separator + String(name || '')
|
|
1154
|
+
}
|
|
1155
|
+
function workbenchRoot() {
|
|
1156
|
+
return state.wb?.bound && state.wb.path ? state.wb.path : ''
|
|
1157
|
+
}
|
|
1158
|
+
async function refreshWorkbench() {
|
|
1159
|
+
if (!state.token) return
|
|
1160
|
+
try {
|
|
1161
|
+
const res = await fetch(apiUrl('/workbench'), {
|
|
1162
|
+
headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
|
|
1163
|
+
})
|
|
1164
|
+
if (res.ok) {
|
|
1165
|
+
const value = await res.json().catch(() => null)
|
|
1166
|
+
if (value && typeof value.bound === 'boolean') state.wb = value
|
|
1167
|
+
}
|
|
1168
|
+
} catch {}
|
|
1169
|
+
try {
|
|
1170
|
+
const value = await rpc('workspace.list', {})
|
|
1171
|
+
state.wbProjects = Array.isArray(value?.items) ? value.items : []
|
|
1172
|
+
state.wbArchived = Array.isArray(value?.archivedSessionIds) ? value.archivedSessionIds : []
|
|
1173
|
+
} catch {
|
|
1174
|
+
state.wbProjects = []
|
|
1175
|
+
state.wbArchived = []
|
|
1176
|
+
}
|
|
1177
|
+
renderWorkbench()
|
|
1178
|
+
renderSessions()
|
|
1179
|
+
}
|
|
1180
|
+
function renderWorkbench() {
|
|
1181
|
+
const bar = $('workbench-bar')
|
|
1182
|
+
if (!bar) return
|
|
1183
|
+
const bound = !!state.wb?.bound && !!state.wb.path
|
|
1184
|
+
const toggle = $('wb-toggle')
|
|
1185
|
+
const panel = $('wb-panel')
|
|
1186
|
+
bar.classList.toggle('bound', bound)
|
|
1187
|
+
bar.classList.toggle('unbound', !bound)
|
|
1188
|
+
if (!bound) {
|
|
1189
|
+
$('wb-label').textContent = t('wb.unbound')
|
|
1190
|
+
toggle.setAttribute('aria-expanded', 'false')
|
|
1191
|
+
panel.classList.add('hidden')
|
|
1192
|
+
panel.innerHTML = ''
|
|
1193
|
+
return
|
|
1194
|
+
}
|
|
1195
|
+
$('wb-label').textContent = t('wb.bound', { title: state.wb.title || wbBaseName(state.wb.path) })
|
|
1196
|
+
toggle.setAttribute('aria-expanded', state.wbOpen ? 'true' : 'false')
|
|
1197
|
+
panel.classList.toggle('hidden', !state.wbOpen)
|
|
1198
|
+
if (!state.wbOpen) { panel.innerHTML = ''; return }
|
|
1199
|
+
const projects = state.wbProjects.filter(w => wbStrictInside(w.path, workbenchRoot()))
|
|
1200
|
+
if (!projects.length) {
|
|
1201
|
+
panel.innerHTML = `<div class="wb-empty">${esc(t('wb.noProjects'))}</div>`
|
|
1202
|
+
return
|
|
1203
|
+
}
|
|
1204
|
+
panel.innerHTML = projects.map(w => {
|
|
1205
|
+
const id = String(w.workspaceId || '')
|
|
1206
|
+
const open = !!state.wbOpenProjects[id]
|
|
1207
|
+
const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(Boolean)
|
|
1208
|
+
const body = open ? `<div class="wb-sessions">${sessions.length ? sessions.map(s => `
|
|
1209
|
+
<button class="wb-session" type="button" data-wb-session="${esc(s.sessionId)}">
|
|
1210
|
+
<span class="wb-session-title">${esc(titleOf(s))}</span>
|
|
1211
|
+
<span class="wb-session-meta">${s.running ? esc(t('sessions.running')) : esc(fmtTime(s.updatedAt))}</span>
|
|
1212
|
+
</button>`).join('') : `<div class="wb-empty">${esc(t('wb.noSessions'))}</div>`}</div>` : ''
|
|
1213
|
+
return `<div class="wb-project ${open ? 'open' : ''}" data-wb-project="${esc(id)}">
|
|
1214
|
+
<div class="wb-project-head">
|
|
1215
|
+
<span class="wb-chevron" aria-hidden="true">${open ? '▾' : '▸'}</span>
|
|
1216
|
+
<span class="wb-project-title">${esc(w.title || wbBaseName(w.path) || w.path)}</span>
|
|
1217
|
+
<button class="mini-btn wb-new" type="button" data-wb-new="${esc(id)}">${esc(t('wb.newSession'))}</button>
|
|
1218
|
+
</div>${body}
|
|
1113
1219
|
</div>`
|
|
1114
1220
|
}).join('')
|
|
1115
|
-
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function sessionCwd(s) { return typeof s?.cwd === 'string' ? s.cwd.trim() : '' }
|
|
1224
|
+
function sessionWorkspaceLabel(s) {
|
|
1225
|
+
const cwd = sessionCwd(s)
|
|
1226
|
+
return cwd || t('sessions.workspaceUnknown')
|
|
1227
|
+
}
|
|
1228
|
+
function workspaceDisplayName(label) {
|
|
1229
|
+
const value = String(label || '').trim()
|
|
1230
|
+
if (!value || value === t('sessions.workspaceUnknown')) return value || t('sessions.workspaceUnknown')
|
|
1231
|
+
const clean = value.replace(/[\\/]+$/, '')
|
|
1232
|
+
const parts = clean.split(/[\\/]/).filter(Boolean)
|
|
1233
|
+
return parts[parts.length - 1] || value
|
|
1234
|
+
}
|
|
1235
|
+
function sortedSessions() {
|
|
1236
|
+
const items = [...state.sessions]
|
|
1237
|
+
if (state.sessionSort === 'workspace') {
|
|
1238
|
+
return items.sort((a, b) => {
|
|
1239
|
+
const aw = sessionCwd(a) || '\uffff'
|
|
1240
|
+
const bw = sessionCwd(b) || '\uffff'
|
|
1241
|
+
const byWorkspace = aw.localeCompare(bw, undefined, { numeric: true, sensitivity: 'base' })
|
|
1242
|
+
return byWorkspace || ((b.updatedAt || 0) - (a.updatedAt || 0))
|
|
1243
|
+
})
|
|
1244
|
+
}
|
|
1245
|
+
return items.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
|
1246
|
+
}
|
|
1247
|
+
function renderSessions() {
|
|
1248
|
+
const list = $('session-list')
|
|
1249
|
+
const allItems = sortedSessions()
|
|
1250
|
+
const wbIds = new Set()
|
|
1251
|
+
if (state.wb?.bound) for (const w of state.wbProjects) for (const id of (w.sessionIds || [])) wbIds.add(id)
|
|
1252
|
+
const root = workbenchRoot()
|
|
1253
|
+
const visible = allItems.filter(s => !(state.wb?.bound && (wbIds.has(s.sessionId) || wbStrictInside(s.cwd, root))))
|
|
1254
|
+
const archivedSet = new Set(state.wbArchived || [])
|
|
1255
|
+
const archived = visible.filter(s => archivedSet.has(s.sessionId))
|
|
1256
|
+
const main = visible.filter(s => !archivedSet.has(s.sessionId))
|
|
1257
|
+
const showArchived = LS.get('showArchivedV1', '0') === '1'
|
|
1258
|
+
const renderItems = (items) => {
|
|
1259
|
+
let lastWorkspace = null
|
|
1260
|
+
const rows = []
|
|
1261
|
+
for (const s of items) {
|
|
1262
|
+
const workspace = sessionWorkspaceLabel(s)
|
|
1263
|
+
const workspaceName = workspaceDisplayName(workspace)
|
|
1264
|
+
if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
|
|
1265
|
+
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>`)
|
|
1266
|
+
lastWorkspace = workspace
|
|
1267
|
+
}
|
|
1268
|
+
const title = titleOf(s)
|
|
1269
|
+
const goal = goalOf(s)
|
|
1270
|
+
const pending = (state.approvals.some(a => a.sessionId === s.sessionId) || state.questions.some(q => q.sessionId === s.sessionId)) ? 'pending' : ''
|
|
1271
|
+
const queueN = (state.queues[s.sessionId] || []).filter(i => i.placement === 'queued').length
|
|
1272
|
+
const dots = []
|
|
1273
|
+
if (s.running) dots.push('running')
|
|
1274
|
+
if (pending) dots.push('pending')
|
|
1275
|
+
const badge = goal ? `<span class="sc-badge ${goal.phase === 'active' ? 'goal-active' : ''}">${esc(t('sessions.goalBadge', { phase: goal.phase || '?' }))}</span>` : ''
|
|
1276
|
+
const queueBadge = queueN ? `<span class="sc-badge">${esc(t('sessions.queueBadge', { n: queueN }))}</span>` : ''
|
|
1277
|
+
const archiveButton = archivedSet.has(s.sessionId) ? '' : `<button type="button" class="sc-archive-btn" data-archive-session="${esc(s.sessionId)}">${esc(t('session.archive'))}</button>`
|
|
1278
|
+
rows.push(`<div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
|
|
1279
|
+
<div class="session-card ${state.current === s.sessionId ? 'current' : ''}">
|
|
1280
|
+
<div class="sc-title">${esc(title)}</div>
|
|
1281
|
+
<div class="sc-meta">
|
|
1282
|
+
<span class="sc-dot ${dots.join(' ')}"></span>
|
|
1283
|
+
<span>${fmtTime(s.updatedAt)}</span>
|
|
1284
|
+
${s.running ? '<span>' + t('sessions.running') + '</span>' : ''}
|
|
1285
|
+
${badge}${queueBadge}
|
|
1286
|
+
</div>
|
|
1287
|
+
<div class="sc-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</div>
|
|
1288
|
+
<span class="sc-arrow">›</span>
|
|
1289
|
+
</div>
|
|
1290
|
+
${archiveButton}
|
|
1291
|
+
</div>`)
|
|
1292
|
+
}
|
|
1293
|
+
return rows.join('')
|
|
1294
|
+
}
|
|
1295
|
+
const divider = archived.length ? `<button class="archived-toggle" type="button" data-archived-toggle>${esc(showArchived ? t('wb.archivedShown') : t('wb.archivedHidden'))}</button>` : ''
|
|
1296
|
+
const rows = renderItems(main) + divider + (showArchived ? renderItems(archived) : '')
|
|
1297
|
+
const hiddenByWorkbench = allItems.length - visible.length
|
|
1298
|
+
list.innerHTML = rows || `<div class="empty">${esc(hiddenByWorkbench ? t('wb.flatHidden', { n: hiddenByWorkbench }) : t('home.empty'))}</div>`
|
|
1299
|
+
list.classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
|
|
1300
|
+
const sort = $('session-sort')
|
|
1301
|
+
if (sort) sort.value = state.sessionSort
|
|
1302
|
+
$('home-empty').classList.toggle('hidden', visible.length > 0)
|
|
1116
1303
|
const running = state.sessions.filter(s => s.running).length
|
|
1117
1304
|
const pending = state.approvals.length + state.questions.length
|
|
1118
1305
|
$('stat-strip').innerHTML = `
|
|
1119
1306
|
<div class="stat running"><div class="v">${running}</div><div class="k">${t('sessions.statRunning')}</div></div>
|
|
1120
1307
|
<div class="stat pending"><div class="v">${pending}</div><div class="k">${t('sessions.statPending')}</div></div>
|
|
1121
|
-
<div class="stat ctx"><div class="v">${
|
|
1308
|
+
<div class="stat ctx"><div class="v">${visible.length}</div><div class="k">${t('sessions.statTotal')}</div></div>`
|
|
1122
1309
|
updatePendingBadge()
|
|
1123
1310
|
}
|
|
1124
1311
|
|
|
@@ -1151,7 +1338,7 @@ function bindNativeBack() {
|
|
|
1151
1338
|
try {
|
|
1152
1339
|
CAP.Plugins?.App?.addListener?.('backButton', () => {
|
|
1153
1340
|
const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
|
|
1154
|
-
if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else openModal.classList.add('hidden'); return } // 先关弹窗
|
|
1341
|
+
if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else if (openModal.id === 'modal-archive') closeArchiveConfirm(); else openModal.classList.add('hidden'); return } // 先关弹窗
|
|
1155
1342
|
if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
|
|
1156
1343
|
if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
|
|
1157
1344
|
if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
|
|
@@ -1245,7 +1432,7 @@ function restoreCachedHistory() {
|
|
|
1245
1432
|
if (!cached?.events?.length) return false
|
|
1246
1433
|
const h = emptyHistory()
|
|
1247
1434
|
for (const e of cached.events) {
|
|
1248
|
-
if (
|
|
1435
|
+
if (e?.seq == null) continue
|
|
1249
1436
|
h.seqs.add(e.seq)
|
|
1250
1437
|
h.visible.push(e)
|
|
1251
1438
|
}
|
|
@@ -1613,7 +1800,8 @@ async function goalAction(kind) {
|
|
|
1613
1800
|
if (!method) return
|
|
1614
1801
|
if (kind === 'clear' && !confirm(t('goal.confirmClear'))) return
|
|
1615
1802
|
if (kind === 'complete' && !confirm(t('goal.confirmComplete'))) return
|
|
1616
|
-
await safeRpc(method, { sessionId: state.current, ref }, t('goal.actionFailed'))
|
|
1803
|
+
const result = await safeRpc(method, { sessionId: state.current, ref }, t('goal.actionFailed'))
|
|
1804
|
+
if (result == null) return
|
|
1617
1805
|
if (kind === 'complete') setGoalPhaseLocal('complete')
|
|
1618
1806
|
if (kind === 'clear') setGoalPhaseLocal('cleared')
|
|
1619
1807
|
toast(t('goal.actionSubmitted'), 'ok')
|
|
@@ -1622,7 +1810,8 @@ async function goalAction(kind) {
|
|
|
1622
1810
|
|
|
1623
1811
|
async function interruptSubagent(childId) {
|
|
1624
1812
|
if (!confirm(t('subagent.confirmInterrupt'))) return
|
|
1625
|
-
await safeRpc('subagent.interrupt', { parentSessionId: state.current, childSessionId: childId, mode: 'continuable' }, t('subagent.interruptFailed'))
|
|
1813
|
+
const result = await safeRpc('subagent.interrupt', { parentSessionId: state.current, childSessionId: childId, mode: 'continuable' }, t('subagent.interruptFailed'))
|
|
1814
|
+
if (result == null) return
|
|
1626
1815
|
toast(t('subagent.interruptSubmitted'), 'ok')
|
|
1627
1816
|
setTimeout(renderSessionCards, 600)
|
|
1628
1817
|
}
|
|
@@ -1678,6 +1867,7 @@ async function sendMessage() {
|
|
|
1678
1867
|
function hideComposerMenu() {
|
|
1679
1868
|
$('composer-menu').classList.add('hidden')
|
|
1680
1869
|
$('btn-plus').classList.remove('active')
|
|
1870
|
+
$('permission-submenu')?.classList.add('hidden')
|
|
1681
1871
|
}
|
|
1682
1872
|
|
|
1683
1873
|
function toggleComposerMenu() {
|
|
@@ -1688,6 +1878,13 @@ function toggleComposerMenu() {
|
|
|
1688
1878
|
if (show && !state.models.loaded && !state.models.loading) loadSessionModels()
|
|
1689
1879
|
}
|
|
1690
1880
|
|
|
1881
|
+
function isMobileDevice() {
|
|
1882
|
+
return !!CAP?.isNativePlatform?.() || /Android|iPhone|iPad|iPod|Mobile|Windows Phone/i.test(navigator.userAgent || '')
|
|
1883
|
+
}
|
|
1884
|
+
function mobileEnterAction() {
|
|
1885
|
+
return LS.get('mobileEnterAction', 'newline') === 'send' ? 'send' : 'newline'
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1691
1888
|
async function loadSessionModels() {
|
|
1692
1889
|
if (!state.current || state.models.loading) return
|
|
1693
1890
|
state.models.loading = true
|
|
@@ -1784,13 +1981,103 @@ async function cancelSession() {
|
|
|
1784
1981
|
}
|
|
1785
1982
|
|
|
1786
1983
|
async function newSession() {
|
|
1787
|
-
|
|
1984
|
+
let payload = {}
|
|
1985
|
+
// DSH 的 host.describe 返回当前工作目录。每次创建前短暂刷新一次,
|
|
1986
|
+
// 避免用户在桌面端切换工作区后,手机仍沿用启动时的旧 cwd。
|
|
1987
|
+
try {
|
|
1988
|
+
const host = await rpc('host.describe', {}, 5000)
|
|
1989
|
+
const cwd = typeof host?.cwd === 'string' ? host.cwd.trim() : ''
|
|
1990
|
+
if (cwd) {
|
|
1991
|
+
state.hostInfo = host
|
|
1992
|
+
payload = { cwd }
|
|
1993
|
+
}
|
|
1994
|
+
} catch {}
|
|
1995
|
+
const v = await safeRpc('session.create', payload, t('home.createFailed'))
|
|
1788
1996
|
if (!v?.sessionId) return
|
|
1789
1997
|
toast(t('home.created'), 'ok')
|
|
1790
1998
|
await refreshSessions()
|
|
1791
1999
|
openSession(v.sessionId)
|
|
1792
2000
|
}
|
|
1793
2001
|
|
|
2002
|
+
let archivePendingSessionId = null
|
|
2003
|
+
function archiveSession(sessionId) {
|
|
2004
|
+
const session = state.byId.get(sessionId)
|
|
2005
|
+
if (!session) return
|
|
2006
|
+
archivePendingSessionId = sessionId
|
|
2007
|
+
$('archive-session-title').textContent = titleOf(session)
|
|
2008
|
+
$('archive-session-workspace').textContent = sessionWorkspaceLabel(session)
|
|
2009
|
+
$('modal-archive').classList.remove('hidden')
|
|
2010
|
+
}
|
|
2011
|
+
function closeArchiveConfirm() {
|
|
2012
|
+
archivePendingSessionId = null
|
|
2013
|
+
$('modal-archive').classList.add('hidden')
|
|
2014
|
+
}
|
|
2015
|
+
async function confirmArchiveSession() {
|
|
2016
|
+
const sessionId = archivePendingSessionId
|
|
2017
|
+
if (!sessionId) return
|
|
2018
|
+
const button = $('archive-confirm')
|
|
2019
|
+
button.disabled = true
|
|
2020
|
+
try {
|
|
2021
|
+
const value = await safeRpc('workspace.archiveSession', { sessionId }, t('session.archiveFailed', { msg: '' }))
|
|
2022
|
+
if (value == null) return
|
|
2023
|
+
closeArchiveConfirm()
|
|
2024
|
+
toast(t('session.archived'), 'ok')
|
|
2025
|
+
await refreshSessions()
|
|
2026
|
+
} finally {
|
|
2027
|
+
button.disabled = false
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
let swipeTracking = null
|
|
2032
|
+
let swipeSuppressClickUntil = 0
|
|
2033
|
+
function closeRevealedSwipes(except = null) {
|
|
2034
|
+
document.querySelectorAll('#session-list .session-swipe.revealed').forEach(row => {
|
|
2035
|
+
if (row !== except) row.classList.remove('revealed')
|
|
2036
|
+
})
|
|
2037
|
+
}
|
|
2038
|
+
function bindSessionSwipe() {
|
|
2039
|
+
const list = $('session-list')
|
|
2040
|
+
list.addEventListener('touchstart', e => {
|
|
2041
|
+
if (e.touches.length !== 1) return
|
|
2042
|
+
const row = e.target.closest('[data-session-swipe]')
|
|
2043
|
+
if (!row) return
|
|
2044
|
+
closeRevealedSwipes(row)
|
|
2045
|
+
const touch = e.touches[0]
|
|
2046
|
+
swipeTracking = {
|
|
2047
|
+
row,
|
|
2048
|
+
startX: touch.clientX,
|
|
2049
|
+
startY: touch.clientY,
|
|
2050
|
+
offset: row.classList.contains('revealed') ? -92 : 0,
|
|
2051
|
+
axis: null
|
|
2052
|
+
}
|
|
2053
|
+
}, { passive: true })
|
|
2054
|
+
list.addEventListener('touchmove', e => {
|
|
2055
|
+
if (!swipeTracking || e.touches.length !== 1) return
|
|
2056
|
+
const touch = e.touches[0]
|
|
2057
|
+
const dx = touch.clientX - swipeTracking.startX
|
|
2058
|
+
const dy = touch.clientY - swipeTracking.startY
|
|
2059
|
+
if (!swipeTracking.axis && Math.max(Math.abs(dx), Math.abs(dy)) >= 8) {
|
|
2060
|
+
swipeTracking.axis = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y'
|
|
2061
|
+
}
|
|
2062
|
+
if (swipeTracking.axis !== 'x') return
|
|
2063
|
+
e.preventDefault()
|
|
2064
|
+
const offset = Math.max(-92, Math.min(0, swipeTracking.offset + dx))
|
|
2065
|
+
swipeTracking.row.style.setProperty('--swipe-x', offset + 'px')
|
|
2066
|
+
}, { passive: false })
|
|
2067
|
+
list.addEventListener('touchend', () => {
|
|
2068
|
+
if (!swipeTracking) return
|
|
2069
|
+
if (swipeTracking.axis === 'x') {
|
|
2070
|
+
const row = swipeTracking.row
|
|
2071
|
+
const offset = parseFloat(row.style.getPropertyValue('--swipe-x') || swipeTracking.offset)
|
|
2072
|
+
row.classList.toggle('revealed', offset <= -46)
|
|
2073
|
+
row.style.removeProperty('--swipe-x')
|
|
2074
|
+
swipeSuppressClickUntil = Date.now() + 350
|
|
2075
|
+
}
|
|
2076
|
+
swipeTracking = null
|
|
2077
|
+
}, { passive: true })
|
|
2078
|
+
list.addEventListener('touchcancel', () => { swipeTracking = null }, { passive: true })
|
|
2079
|
+
}
|
|
2080
|
+
|
|
1794
2081
|
/* ---------------- 待办 ---------------- */
|
|
1795
2082
|
function renderPending() {
|
|
1796
2083
|
const list = $('pending-list')
|
|
@@ -1831,7 +2118,14 @@ function renderPending() {
|
|
|
1831
2118
|
async function approveApproval(id, allow) {
|
|
1832
2119
|
const a = state.approvals.find(x => x.approvalId === id)
|
|
1833
2120
|
if (!a) return
|
|
1834
|
-
|
|
2121
|
+
let ok
|
|
2122
|
+
try {
|
|
2123
|
+
ok = await respond(a.rpcId, { sessionId: a.sessionId, approvalId: a.approvalId, outcome: allow ? 'allowed-once' : 'rejected' })
|
|
2124
|
+
} catch (e) {
|
|
2125
|
+
if (e.message === 'AUTH') authFailure()
|
|
2126
|
+
else toast(t('pending.submitFailed', { msg: e.message || t('fs.networkError') }), 'err')
|
|
2127
|
+
return
|
|
2128
|
+
}
|
|
1835
2129
|
toast(ok ? (allow ? t('pending.allowed') : t('pending.rejected')) : t('pending.stale'), ok ? 'ok' : 'err')
|
|
1836
2130
|
state.approvals = state.approvals.filter(x => x.approvalId !== id)
|
|
1837
2131
|
renderPending()
|
|
@@ -1862,7 +2156,14 @@ async function submitQuestion() {
|
|
|
1862
2156
|
return ans
|
|
1863
2157
|
}).filter(Boolean)
|
|
1864
2158
|
if (!answers.length) return toast(t('question.needAnswer'), 'err')
|
|
1865
|
-
|
|
2159
|
+
let ok
|
|
2160
|
+
try {
|
|
2161
|
+
ok = await respond(q.rpcId, { sessionId: q.sessionId, answer: { answers } })
|
|
2162
|
+
} catch (e) {
|
|
2163
|
+
if (e.message === 'AUTH') authFailure()
|
|
2164
|
+
else toast(t('question.submitFailed', { msg: e.message || t('fs.networkError') }), 'err')
|
|
2165
|
+
return
|
|
2166
|
+
}
|
|
1866
2167
|
if (ok) { toast(t('question.submitted'), 'ok'); $('modal-question').classList.add('hidden'); state.questions = state.questions.filter(x => x.rpcId !== q.rpcId); renderPending() }
|
|
1867
2168
|
else toast(t('question.stale'), 'err')
|
|
1868
2169
|
}
|
|
@@ -1910,6 +2211,50 @@ function fsParent(p) {
|
|
|
1910
2211
|
return clean.slice(0, idx)
|
|
1911
2212
|
}
|
|
1912
2213
|
|
|
2214
|
+
async function openWorkspaceModal() {
|
|
2215
|
+
if (!state.token) { toast(t('fs.noTokenToast'), 'err'); showView('view-settings'); return }
|
|
2216
|
+
if (!state.fs.path) await loadFs(null, { silent: true })
|
|
2217
|
+
$('workspace-parent-path').textContent = state.fs.path || '~'
|
|
2218
|
+
$('workspace-name').value = ''
|
|
2219
|
+
$('modal-workspace').classList.remove('hidden')
|
|
2220
|
+
setTimeout(() => $('workspace-name').focus(), 50)
|
|
2221
|
+
}
|
|
2222
|
+
function closeWorkspaceModal() { $('modal-workspace').classList.add('hidden') }
|
|
2223
|
+
async function createWorkspace() {
|
|
2224
|
+
if (createWorkspace.busy) return
|
|
2225
|
+
const name = $('workspace-name').value.trim()
|
|
2226
|
+
if (!name) { toast(t('workspace.nameRequired'), 'err'); $('workspace-name').focus(); return }
|
|
2227
|
+
createWorkspace.busy = true
|
|
2228
|
+
const parent = state.fs.path || ''
|
|
2229
|
+
const button = $('workspace-create')
|
|
2230
|
+
button.disabled = true
|
|
2231
|
+
try {
|
|
2232
|
+
const res = await fetch(fsApiUrl('/mkdir', { path: parent, name }), { method: 'POST', headers: fsHeaders() })
|
|
2233
|
+
if (res.status === 401) { fsAuthError(401); return }
|
|
2234
|
+
const data = await res.json().catch(() => ({}))
|
|
2235
|
+
if (!res.ok) {
|
|
2236
|
+
const msg = data.error === 'exists' ? t('workspace.exists') : data.error === 'bad-name' ? t('workspace.invalidName') : data.error || ('HTTP ' + res.status)
|
|
2237
|
+
throw new Error(msg)
|
|
2238
|
+
}
|
|
2239
|
+
closeWorkspaceModal()
|
|
2240
|
+
await loadFs(parent || null, { silent: true })
|
|
2241
|
+
const v = await safeRpc('session.create', { cwd: data.path }, t('home.createFailed'))
|
|
2242
|
+
await refreshSessions()
|
|
2243
|
+
if (v?.sessionId) {
|
|
2244
|
+
toast(t('workspace.created'), 'ok')
|
|
2245
|
+
openSession(v.sessionId)
|
|
2246
|
+
} else {
|
|
2247
|
+
toast(t('workspace.createdNoSession'), 'ok')
|
|
2248
|
+
}
|
|
2249
|
+
} catch (e) {
|
|
2250
|
+
if (e.message === 'AUTH') fsAuthError(401)
|
|
2251
|
+
else toast(t('workspace.createFailed', { msg: e.message || t('fs.networkError') }), 'err')
|
|
2252
|
+
} finally {
|
|
2253
|
+
createWorkspace.busy = false
|
|
2254
|
+
button.disabled = false
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
|
|
1913
2258
|
function fsApiUrl(sub, params = {}) {
|
|
1914
2259
|
const u = new URL(apiUrl('/fs' + sub), location.href)
|
|
1915
2260
|
for (const [k, v] of Object.entries(params)) {
|
|
@@ -2333,7 +2678,8 @@ async function submitGoalEdit() {
|
|
|
2333
2678
|
if (!goal) return
|
|
2334
2679
|
const objective = $('goal-edit-text')?.value?.trim()
|
|
2335
2680
|
if (!objective) return toast(t('goal.cannotEmpty'), 'err')
|
|
2336
|
-
await safeRpc('goal.edit', { sessionId: state.current, ref: { id: goal.id, revision: goal.revision }, objective }, t('goal.updateFailed'))
|
|
2681
|
+
const result = await safeRpc('goal.edit', { sessionId: state.current, ref: { id: goal.id, revision: goal.revision }, objective }, t('goal.updateFailed'))
|
|
2682
|
+
if (result == null) return
|
|
2337
2683
|
$('modal-goal').classList.add('hidden')
|
|
2338
2684
|
toast(t('goal.updated'), 'ok')
|
|
2339
2685
|
scheduleRefresh()
|
|
@@ -2393,6 +2739,105 @@ async function loadLocalVersion() {
|
|
|
2393
2739
|
$('update-desc').textContent = state.localVersion ? t('update.currentV', { version: state.localVersion }) : t('update.noVersion')
|
|
2394
2740
|
}
|
|
2395
2741
|
|
|
2742
|
+
/* ---------------- 远程公告 ----------------
|
|
2743
|
+
* 与 update.json 放在同一台服务器上,格式见 README/发布说明。
|
|
2744
|
+
* 公告只读取文本并用 textContent/转义后的换行渲染,不执行服务端下发的 HTML/脚本。
|
|
2745
|
+
*/
|
|
2746
|
+
const ANNOUNCEMENTS_KEY = 'seenAnnouncementsV1'
|
|
2747
|
+
function readSeenAnnouncements() {
|
|
2748
|
+
try {
|
|
2749
|
+
const value = JSON.parse(LS.get(ANNOUNCEMENTS_KEY, '{}'))
|
|
2750
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
|
2751
|
+
} catch { return {} }
|
|
2752
|
+
}
|
|
2753
|
+
function markAnnouncementSeen(id) {
|
|
2754
|
+
if (!id) return
|
|
2755
|
+
const seen = readSeenAnnouncements()
|
|
2756
|
+
seen[id] = Date.now()
|
|
2757
|
+
const keys = Object.keys(seen)
|
|
2758
|
+
if (keys.length > 100) {
|
|
2759
|
+
keys.sort((a, b) => Number(seen[a]) - Number(seen[b]))
|
|
2760
|
+
for (const key of keys.slice(0, keys.length - 100)) delete seen[key]
|
|
2761
|
+
}
|
|
2762
|
+
LS.set(ANNOUNCEMENTS_KEY, JSON.stringify(seen))
|
|
2763
|
+
}
|
|
2764
|
+
function announcementVersionMatch(item) {
|
|
2765
|
+
const min = String(item.minVersion || item.minAppVersion || '').trim()
|
|
2766
|
+
const max = String(item.maxVersion || item.maxAppVersion || '').trim()
|
|
2767
|
+
if (!state.localVersion) return false
|
|
2768
|
+
if (min && cmpVersion(state.localVersion, min) < 0) return false
|
|
2769
|
+
if (max && cmpVersion(state.localVersion, max) > 0) return false
|
|
2770
|
+
return true
|
|
2771
|
+
}
|
|
2772
|
+
function normalizeAnnouncement(item, base) {
|
|
2773
|
+
if (!item || typeof item !== 'object') return null
|
|
2774
|
+
const id = String(item.id || '').trim().slice(0, 120)
|
|
2775
|
+
const title = String(item.title || '').trim().slice(0, 160)
|
|
2776
|
+
const content = String(item.content ?? item.body ?? '').trim().slice(0, 20000)
|
|
2777
|
+
if (!id || !title || !content || !announcementVersionMatch(item)) return null
|
|
2778
|
+
const now = Date.now()
|
|
2779
|
+
const startsAt = Date.parse(item.publishedAt || item.startsAt || '')
|
|
2780
|
+
const expiresAt = Date.parse(item.expiresAt || '')
|
|
2781
|
+
if (Number.isFinite(startsAt) && startsAt > now) return null
|
|
2782
|
+
if (Number.isFinite(expiresAt) && expiresAt <= now) return null
|
|
2783
|
+
let actionUrl = String(item.actionUrl || item.url || '').trim()
|
|
2784
|
+
if (actionUrl) {
|
|
2785
|
+
try {
|
|
2786
|
+
const parsed = new URL(actionUrl, base + '/')
|
|
2787
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) actionUrl = ''
|
|
2788
|
+
else actionUrl = parsed.href
|
|
2789
|
+
} catch { actionUrl = '' }
|
|
2790
|
+
}
|
|
2791
|
+
return {
|
|
2792
|
+
id, title, content, actionUrl,
|
|
2793
|
+
actionText: String(item.actionText || '').trim().slice(0, 80),
|
|
2794
|
+
publishedAt: Number.isFinite(startsAt) ? startsAt : 0,
|
|
2795
|
+
force: item.force === true
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
function openAnnouncementModal(item) {
|
|
2799
|
+
state.announcement = item
|
|
2800
|
+
$('announcement-title').textContent = item.title
|
|
2801
|
+
$('announcement-content').innerHTML = esc(item.content).replace(/\r?\n/g, '<br>')
|
|
2802
|
+
const action = $('announcement-action')
|
|
2803
|
+
if (item.actionUrl) {
|
|
2804
|
+
action.href = item.actionUrl
|
|
2805
|
+
action.textContent = item.actionText || t('announcement.open')
|
|
2806
|
+
action.classList.remove('hidden')
|
|
2807
|
+
} else {
|
|
2808
|
+
action.removeAttribute('href')
|
|
2809
|
+
action.textContent = ''
|
|
2810
|
+
action.classList.add('hidden')
|
|
2811
|
+
}
|
|
2812
|
+
$('announcement-later').classList.toggle('hidden', item.force)
|
|
2813
|
+
$('modal-announcement').classList.remove('hidden')
|
|
2814
|
+
}
|
|
2815
|
+
function closeAnnouncement(markSeen) {
|
|
2816
|
+
if (markSeen && state.announcement) markAnnouncementSeen(state.announcement.id)
|
|
2817
|
+
state.announcement = null
|
|
2818
|
+
$('modal-announcement').classList.add('hidden')
|
|
2819
|
+
}
|
|
2820
|
+
async function checkAnnouncements() {
|
|
2821
|
+
const base = updateBase()
|
|
2822
|
+
if (!base || !state.localVersion) return false
|
|
2823
|
+
try {
|
|
2824
|
+
const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(8000) : undefined
|
|
2825
|
+
const url = base + '/announcements.json?t=' + Date.now()
|
|
2826
|
+
const res = signal ? await fetch(url, { cache: 'no-store', signal }) : await fetch(url, { cache: 'no-store' })
|
|
2827
|
+
if (!res.ok) return false
|
|
2828
|
+
const raw = await res.text()
|
|
2829
|
+
if (raw.length > 512 * 1024) return false
|
|
2830
|
+
const data = JSON.parse(raw)
|
|
2831
|
+
const source = Array.isArray(data) ? data : (Array.isArray(data?.items) ? data.items : [data])
|
|
2832
|
+
const seen = readSeenAnnouncements()
|
|
2833
|
+
const items = source.map(item => normalizeAnnouncement(item, base)).filter(item => item && !seen[item.id])
|
|
2834
|
+
.sort((a, b) => b.publishedAt - a.publishedAt)
|
|
2835
|
+
if (!items.length) return false
|
|
2836
|
+
openAnnouncementModal(items[0])
|
|
2837
|
+
return true
|
|
2838
|
+
} catch { return false }
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2396
2841
|
/* ---------------- 更新内容弹窗 ---------------- */
|
|
2397
2842
|
const NOTES_KEY = 'seenNotesVersion'
|
|
2398
2843
|
let notesVersion = ''
|
|
@@ -2477,7 +2922,7 @@ async function checkUpdate(silent) {
|
|
|
2477
2922
|
if (!silent) toast(t('update.noVersion'), 'err')
|
|
2478
2923
|
return
|
|
2479
2924
|
}
|
|
2480
|
-
const base =
|
|
2925
|
+
const base = updateBase()
|
|
2481
2926
|
if (!base) {
|
|
2482
2927
|
if (!silent) toast(t('update.needServer'), 'err')
|
|
2483
2928
|
$('update-desc').textContent = state.localVersion ? `${t('update.currentV', { version: state.localVersion })} · ${t('update.needServer')}` : t('update.needServer')
|
|
@@ -2554,7 +2999,7 @@ async function verifyUpdateApk(info, url) {
|
|
|
2554
2999
|
async function downloadUpdate() {
|
|
2555
3000
|
const info = state.updateInfo
|
|
2556
3001
|
if (!info) return
|
|
2557
|
-
const base =
|
|
3002
|
+
const base = updateBase()
|
|
2558
3003
|
let url
|
|
2559
3004
|
try { url = new URL(info.apkUrl || 'dsh-remote.apk', base + '/').href }
|
|
2560
3005
|
catch { url = base + '/' + (info.apkUrl || 'dsh-remote.apk') }
|
|
@@ -2769,8 +3214,7 @@ async function schedulePeakReminders() {
|
|
|
2769
3214
|
const b = bgBridge()
|
|
2770
3215
|
if (!b?.startPeakReminder) return false
|
|
2771
3216
|
try {
|
|
2772
|
-
b.startPeakReminder()
|
|
2773
|
-
return true
|
|
3217
|
+
return b.startPeakReminder() !== false
|
|
2774
3218
|
} catch { return false }
|
|
2775
3219
|
}
|
|
2776
3220
|
|
|
@@ -2779,8 +3223,7 @@ async function cancelPeakReminders() {
|
|
|
2779
3223
|
const b = bgBridge()
|
|
2780
3224
|
if (!b?.stopPeakReminder) return false
|
|
2781
3225
|
try {
|
|
2782
|
-
b.stopPeakReminder()
|
|
2783
|
-
return true
|
|
3226
|
+
return b.stopPeakReminder() !== false
|
|
2784
3227
|
} catch { return false }
|
|
2785
3228
|
}
|
|
2786
3229
|
|
|
@@ -2973,6 +3416,55 @@ function initToken() {
|
|
|
2973
3416
|
$('server-desc').textContent = state.server || t('servers.defaultDesc')
|
|
2974
3417
|
}
|
|
2975
3418
|
|
|
3419
|
+
function renderDshControlStatus(v) {
|
|
3420
|
+
const desc = $('dsh-control-desc')
|
|
3421
|
+
if (!desc || !v) return
|
|
3422
|
+
const buttons = [$('btn-dsh-start'), $('btn-dsh-restart')].filter(Boolean)
|
|
3423
|
+
if (v.supported === false) {
|
|
3424
|
+
desc.textContent = v.message || t('settings.dshUnsupported')
|
|
3425
|
+
buttons.forEach(b => { b.disabled = true; b.classList.add('hidden') })
|
|
3426
|
+
return
|
|
3427
|
+
}
|
|
3428
|
+
buttons.forEach(b => { b.disabled = false; b.classList.remove('hidden') })
|
|
3429
|
+
desc.textContent = `${t(v.running ? 'settings.dshRunning' : 'settings.dshStopped')} · ${v.service || 'dsh-web'}`
|
|
3430
|
+
}
|
|
3431
|
+
|
|
3432
|
+
async function loadDshControl() {
|
|
3433
|
+
if (!state.token || !$('dsh-control-desc')) return
|
|
3434
|
+
try {
|
|
3435
|
+
const res = await fetch(adminApiUrl('/admin/api/dsh'), { headers: { authorization: 'Bearer ' + state.token } })
|
|
3436
|
+
if (res.status === 401) return authFailure()
|
|
3437
|
+
renderDshControlStatus(await res.json())
|
|
3438
|
+
} catch {
|
|
3439
|
+
$('dsh-control-desc').textContent = t('settings.dshFailed', { msg: t('conn.off') })
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
|
|
3443
|
+
async function controlDsh(action) {
|
|
3444
|
+
const label = action === 'start' ? t('settings.dshStart') : t('settings.dshRestart')
|
|
3445
|
+
const buttons = [$('btn-dsh-start'), $('btn-dsh-restart')].filter(Boolean)
|
|
3446
|
+
buttons.forEach(b => { b.disabled = true })
|
|
3447
|
+
const desc = $('dsh-control-desc')
|
|
3448
|
+
if (desc) desc.textContent = t('settings.dshStarting', { action: label })
|
|
3449
|
+
try {
|
|
3450
|
+
const res = await fetch(adminApiUrl('/admin/api/dsh'), {
|
|
3451
|
+
method: 'POST',
|
|
3452
|
+
headers: { authorization: 'Bearer ' + state.token, 'content-type': 'application/json' },
|
|
3453
|
+
body: JSON.stringify({ action }),
|
|
3454
|
+
})
|
|
3455
|
+
if (res.status === 401) return authFailure()
|
|
3456
|
+
const v = await res.json().catch(() => ({}))
|
|
3457
|
+
if (!res.ok || v.ok === false) throw new Error(v.error || v.message || `HTTP ${res.status}`)
|
|
3458
|
+
renderDshControlStatus(v)
|
|
3459
|
+
toast(t('settings.dshStarted', { action: label }), 'ok')
|
|
3460
|
+
} catch (e) {
|
|
3461
|
+
if (desc) desc.textContent = t('settings.dshFailed', { msg: e?.message || e })
|
|
3462
|
+
toast(t('settings.dshFailed', { msg: e?.message || e }), 'err')
|
|
3463
|
+
} finally {
|
|
3464
|
+
buttons.forEach(b => { b.disabled = false })
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
|
|
2976
3468
|
function renderLangBtn() {
|
|
2977
3469
|
const btn = $('btn-lang')
|
|
2978
3470
|
if (btn) btn.textContent = I18N.lang === 'zh' ? 'EN' : '中文'
|
|
@@ -3029,6 +3521,7 @@ function bindUi() {
|
|
|
3029
3521
|
renderUpdateExpandBtn()
|
|
3030
3522
|
renderServers()
|
|
3031
3523
|
renderSessions()
|
|
3524
|
+
renderWorkbench()
|
|
3032
3525
|
renderPending(); renderQueue(); renderJobs()
|
|
3033
3526
|
updateConn()
|
|
3034
3527
|
if (state.current) { renderSessionTitle(); renderSessionSub(); renderSessionCards(); renderHistory(true) }
|
|
@@ -3056,9 +3549,53 @@ function bindUi() {
|
|
|
3056
3549
|
b.addEventListener('click', () => showView(b.dataset.view)))
|
|
3057
3550
|
// 会话列表点击
|
|
3058
3551
|
$('session-list').addEventListener('click', (e) => {
|
|
3552
|
+
if (swipeSuppressClickUntil > Date.now()) { swipeSuppressClickUntil = 0; return }
|
|
3553
|
+
if (e.target.closest('[data-archived-toggle]')) {
|
|
3554
|
+
LS.set('showArchivedV1', LS.get('showArchivedV1', '0') === '1' ? '0' : '1')
|
|
3555
|
+
renderSessions()
|
|
3556
|
+
return
|
|
3557
|
+
}
|
|
3558
|
+
const archive = e.target.closest('[data-archive-session]')
|
|
3559
|
+
if (archive) {
|
|
3560
|
+
e.stopPropagation()
|
|
3561
|
+
archiveSession(archive.dataset.archiveSession)
|
|
3562
|
+
return
|
|
3563
|
+
}
|
|
3564
|
+
const swipeRow = e.target.closest('[data-session-swipe]')
|
|
3565
|
+
if (swipeRow?.classList.contains('revealed')) {
|
|
3566
|
+
swipeRow.classList.remove('revealed')
|
|
3567
|
+
return
|
|
3568
|
+
}
|
|
3059
3569
|
const card = e.target.closest('[data-id]')
|
|
3060
3570
|
if (card) openSession(card.dataset.id)
|
|
3061
3571
|
})
|
|
3572
|
+
bindSessionSwipe()
|
|
3573
|
+
$('wb-toggle').addEventListener('click', () => {
|
|
3574
|
+
if (!state.wb?.bound) return
|
|
3575
|
+
state.wbOpen = !state.wbOpen
|
|
3576
|
+
renderWorkbench()
|
|
3577
|
+
})
|
|
3578
|
+
$('wb-panel').addEventListener('click', (e) => {
|
|
3579
|
+
const newButton = e.target.closest('[data-wb-new]')
|
|
3580
|
+
if (newButton) {
|
|
3581
|
+
safeRpc('session.create', { workspaceId: newButton.dataset.wbNew }, t('home.createFailed')).then(async v => {
|
|
3582
|
+
if (!v?.sessionId) return
|
|
3583
|
+
toast(t('home.created'), 'ok')
|
|
3584
|
+
await refreshSessions()
|
|
3585
|
+
openSession(v.sessionId)
|
|
3586
|
+
})
|
|
3587
|
+
return
|
|
3588
|
+
}
|
|
3589
|
+
const head = e.target.closest('.wb-project-head')
|
|
3590
|
+
if (head) {
|
|
3591
|
+
const project = head.closest('[data-wb-project]')
|
|
3592
|
+
const id = project?.dataset.wbProject
|
|
3593
|
+
if (id) { state.wbOpenProjects[id] = !state.wbOpenProjects[id]; renderWorkbench() }
|
|
3594
|
+
return
|
|
3595
|
+
}
|
|
3596
|
+
const session = e.target.closest('[data-wb-session]')
|
|
3597
|
+
if (session) openSession(session.dataset.wbSession)
|
|
3598
|
+
})
|
|
3062
3599
|
$('btn-back').addEventListener('click', closeSession)
|
|
3063
3600
|
$('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
|
|
3064
3601
|
$('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
|
|
@@ -3089,12 +3626,24 @@ function bindUi() {
|
|
|
3089
3626
|
if (e.key === 'Escape' && !$('feedback-sheet').classList.contains('hidden')) { closeFeedbackSheet(); $('btn-feedback').focus() }
|
|
3090
3627
|
})
|
|
3091
3628
|
$('btn-new-session').addEventListener('click', newSession)
|
|
3629
|
+
$('btn-new-workspace').addEventListener('click', openWorkspaceModal)
|
|
3630
|
+
$('session-sort')?.addEventListener('change', (e) => {
|
|
3631
|
+
state.sessionSort = e.target.value === 'workspace' ? 'workspace' : 'time'
|
|
3632
|
+
LS.set('sessionSort', state.sessionSort)
|
|
3633
|
+
renderSessions()
|
|
3634
|
+
})
|
|
3092
3635
|
$('btn-cancel').addEventListener('click', cancelSession)
|
|
3093
3636
|
$('btn-send').addEventListener('click', sendMessage)
|
|
3094
3637
|
$('btn-plus').addEventListener('click', toggleComposerMenu)
|
|
3095
3638
|
$('composer-menu').addEventListener('click', async (e) => {
|
|
3096
3639
|
const chip = e.target.closest('[data-cmd]')
|
|
3097
3640
|
if (chip) {
|
|
3641
|
+
if (chip.dataset.cmd === '/permission') {
|
|
3642
|
+
const submenu = $('permission-submenu')
|
|
3643
|
+
submenu?.classList.toggle('hidden')
|
|
3644
|
+
return
|
|
3645
|
+
}
|
|
3646
|
+
$('permission-submenu')?.classList.add('hidden')
|
|
3098
3647
|
const input = $('composer-input')
|
|
3099
3648
|
input.value = chip.dataset.cmd + ' '
|
|
3100
3649
|
input.focus()
|
|
@@ -3102,6 +3651,15 @@ function bindUi() {
|
|
|
3102
3651
|
hideComposerMenu()
|
|
3103
3652
|
return
|
|
3104
3653
|
}
|
|
3654
|
+
const perm = e.target.closest('[data-perm]')
|
|
3655
|
+
if (perm) {
|
|
3656
|
+
const input = $('composer-input')
|
|
3657
|
+
input.value = '/permission ' + perm.dataset.perm + ' '
|
|
3658
|
+
input.focus()
|
|
3659
|
+
autosize(input)
|
|
3660
|
+
hideComposerMenu()
|
|
3661
|
+
return
|
|
3662
|
+
}
|
|
3105
3663
|
const preset = e.target.closest('[data-preset]')
|
|
3106
3664
|
if (preset) {
|
|
3107
3665
|
const found = readPresets().find(x => x.id === preset.dataset.preset)
|
|
@@ -3118,7 +3676,9 @@ function bindUi() {
|
|
|
3118
3676
|
const input = $('composer-input')
|
|
3119
3677
|
input.addEventListener('input', () => autosize(input))
|
|
3120
3678
|
input.addEventListener('keydown', (e) => {
|
|
3121
|
-
if (e.key
|
|
3679
|
+
if (e.key !== 'Enter' || e.isComposing) return
|
|
3680
|
+
if (isMobileDevice() && mobileEnterAction() !== 'send') return
|
|
3681
|
+
if (!e.shiftKey) { e.preventDefault(); sendMessage() }
|
|
3122
3682
|
})
|
|
3123
3683
|
|
|
3124
3684
|
// 审批
|
|
@@ -3136,6 +3696,18 @@ function bindUi() {
|
|
|
3136
3696
|
// goal
|
|
3137
3697
|
$('goal-close').addEventListener('click', () => $('modal-goal').classList.add('hidden'))
|
|
3138
3698
|
$('goal-edit').addEventListener('click', submitGoalEdit)
|
|
3699
|
+
$('workspace-cancel').addEventListener('click', closeWorkspaceModal)
|
|
3700
|
+
$('workspace-create').addEventListener('click', createWorkspace)
|
|
3701
|
+
$('workspace-name').addEventListener('keydown', (e) => { if (e.key === 'Enter') createWorkspace() })
|
|
3702
|
+
$('modal-workspace').addEventListener('click', (e) => { if (e.target === $('modal-workspace')) closeWorkspaceModal() })
|
|
3703
|
+
$('archive-cancel').addEventListener('click', closeArchiveConfirm)
|
|
3704
|
+
$('archive-confirm').addEventListener('click', confirmArchiveSession)
|
|
3705
|
+
$('modal-archive').addEventListener('click', (e) => { if (e.target === $('modal-archive')) closeArchiveConfirm() })
|
|
3706
|
+
$('announcement-later').addEventListener('click', () => closeAnnouncement(false))
|
|
3707
|
+
$('announcement-confirm').addEventListener('click', () => closeAnnouncement(true))
|
|
3708
|
+
$('modal-announcement').addEventListener('click', (e) => {
|
|
3709
|
+
if (e.target === $('modal-announcement') && !state.announcement?.force) closeAnnouncement(false)
|
|
3710
|
+
})
|
|
3139
3711
|
// 设置
|
|
3140
3712
|
$('view-settings').addEventListener('click', (e) => {
|
|
3141
3713
|
const group = e.target.closest('[data-settings-group]')
|
|
@@ -3165,15 +3737,23 @@ function bindUi() {
|
|
|
3165
3737
|
$('host-desc').textContent = t('settings.hostDesc', { version: v.version, cwd: v.cwd, n: v.attachedSessions })
|
|
3166
3738
|
}
|
|
3167
3739
|
})
|
|
3740
|
+
$('btn-dsh-start')?.addEventListener('click', () => controlDsh('start'))
|
|
3741
|
+
$('btn-dsh-restart')?.addEventListener('click', () => controlDsh('restart'))
|
|
3168
3742
|
$('btn-check-update').addEventListener('click', () => checkUpdate(false))
|
|
3169
3743
|
$('btn-download-update').addEventListener('click', downloadUpdate)
|
|
3170
3744
|
$('btn-update-expand').addEventListener('click', toggleUpdateExpand)
|
|
3171
3745
|
$('btn-reset').addEventListener('click', () => {
|
|
3172
3746
|
if (!confirm(t('settings.confirmReset'))) return
|
|
3173
|
-
LS.del('token'); LS.del('notify'); LS.del('server')
|
|
3747
|
+
LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction')
|
|
3174
3748
|
if (bgBridge()?.saveBackgroundConfig) saveBgConfig(false)
|
|
3175
3749
|
location.reload()
|
|
3176
3750
|
})
|
|
3751
|
+
$('mobile-enter-action').value = mobileEnterAction()
|
|
3752
|
+
$('mobile-enter-action').addEventListener('change', (e) => {
|
|
3753
|
+
const action = e.target.value === 'send' ? 'send' : 'newline'
|
|
3754
|
+
LS.set('mobileEnterAction', action)
|
|
3755
|
+
toast(t(action === 'send' ? 'settings.mobileEnterSend' : 'settings.mobileEnterNewline'), 'ok')
|
|
3756
|
+
})
|
|
3177
3757
|
$('opt-notify').checked = LS.get('notify', '0') === '1'
|
|
3178
3758
|
$('opt-notify').addEventListener('change', async (e) => {
|
|
3179
3759
|
if (e.target.checked) {
|
|
@@ -3191,10 +3771,18 @@ function bindUi() {
|
|
|
3191
3771
|
}
|
|
3192
3772
|
const ok = await ensureNotify()
|
|
3193
3773
|
if (!ok) { e.target.checked = false; return toast(t('settings.notifyDenied')) }
|
|
3194
|
-
await schedulePeakReminders()
|
|
3774
|
+
const started = await schedulePeakReminders()
|
|
3775
|
+
if (!started) {
|
|
3776
|
+
e.target.checked = false
|
|
3777
|
+
return toast(t('peakRemind.failed'), 'err')
|
|
3778
|
+
}
|
|
3195
3779
|
toast(t('peakRemind.on'), 'ok')
|
|
3196
3780
|
} else {
|
|
3197
|
-
await cancelPeakReminders()
|
|
3781
|
+
const stopped = await cancelPeakReminders()
|
|
3782
|
+
if (!stopped) {
|
|
3783
|
+
e.target.checked = true
|
|
3784
|
+
return toast(t('peakRemind.failed'), 'err')
|
|
3785
|
+
}
|
|
3198
3786
|
toast(t('peakRemind.off'), 'ok')
|
|
3199
3787
|
}
|
|
3200
3788
|
LS.set('peakRemind', e.target.checked ? '1' : '0')
|
|
@@ -3240,6 +3828,7 @@ function bindUi() {
|
|
|
3240
3828
|
|
|
3241
3829
|
// 文件页
|
|
3242
3830
|
$('fs-up').addEventListener('click', fsUp)
|
|
3831
|
+
$('fs-new-workspace').addEventListener('click', openWorkspaceModal)
|
|
3243
3832
|
$('fs-refresh').addEventListener('click', () => { toast(t('common.refreshing')); loadFs() })
|
|
3244
3833
|
$('fs-upload-btn').addEventListener('click', () => $('fs-file-input').click())
|
|
3245
3834
|
$('fs-file-input').addEventListener('change', (e) => {
|
|
@@ -3295,7 +3884,7 @@ async function boot() {
|
|
|
3295
3884
|
bindNativeLinks()
|
|
3296
3885
|
applyNativeInsets()
|
|
3297
3886
|
updateConn()
|
|
3298
|
-
loadLocalVersion()
|
|
3887
|
+
await loadLocalVersion()
|
|
3299
3888
|
if (!state.token) {
|
|
3300
3889
|
showView('view-settings')
|
|
3301
3890
|
$('token-desc').textContent = t('token.notSetHint')
|
|
@@ -3306,9 +3895,13 @@ async function boot() {
|
|
|
3306
3895
|
await refreshAll()
|
|
3307
3896
|
const host = await safeRpc('host.describe', {}, '')
|
|
3308
3897
|
if (host) { state.hostInfo = host; $('host-desc').textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions }) }
|
|
3309
|
-
|
|
3310
|
-
setTimeout(() => checkUpdate(true), 4000)
|
|
3898
|
+
loadDshControl()
|
|
3311
3899
|
}
|
|
3900
|
+
// 公告与更新共用当前服务器;公告优先,避免启动时两个弹窗重叠。
|
|
3901
|
+
setTimeout(async () => {
|
|
3902
|
+
const shown = await checkAnnouncements()
|
|
3903
|
+
if (!shown && state.token) checkUpdate(true)
|
|
3904
|
+
}, 4000)
|
|
3312
3905
|
renderPending()
|
|
3313
3906
|
}
|
|
3314
3907
|
|