dsh-remote-plugin 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apk/dsh-remote.apk +0 -0
- package/package.json +1 -1
- package/public/admin.html +245 -45
- package/public/admin.js +137 -68
- package/public/app.js +294 -201
- package/public/i18n.js +52 -0
- package/public/index.html +328 -67
- package/public/styles.css +542 -173
- package/public/theme.js +57 -0
- package/public/update.json +3 -3
- package/public/version.json +1 -1
package/public/app.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/* DSH Remote 移动控制台 · 零依赖 */
|
|
2
2
|
'use strict'
|
|
3
3
|
|
|
4
|
+
const I18N = window.I18N
|
|
5
|
+
const t = (k, v) => I18N.t(k, v)
|
|
6
|
+
I18N.init(window.APP_STR)
|
|
7
|
+
|
|
4
8
|
/* ---------------- 状态 ---------------- */
|
|
5
9
|
const LS = {
|
|
6
10
|
get(k, d) { try { return localStorage.getItem(k) ?? d } catch { return d } },
|
|
@@ -71,9 +75,9 @@ function toast(text, kind = '') {
|
|
|
71
75
|
function fmtTime(ts) {
|
|
72
76
|
if (!ts) return ''
|
|
73
77
|
const diff = Date.now() - ts
|
|
74
|
-
if (diff < 60e3) return '
|
|
75
|
-
if (diff < 3600e3) return Math.floor(diff / 60e3) + '
|
|
76
|
-
if (diff < 86400e3) return Math.floor(diff / 3600e3) + '
|
|
78
|
+
if (diff < 60e3) return t('time.justNow')
|
|
79
|
+
if (diff < 3600e3) return Math.floor(diff / 60e3) + t('time.minAgo')
|
|
80
|
+
if (diff < 86400e3) return Math.floor(diff / 3600e3) + t('time.hourAgo')
|
|
77
81
|
const d = new Date(ts)
|
|
78
82
|
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
79
83
|
}
|
|
@@ -113,10 +117,10 @@ async function rpc(method, payload = {}) {
|
|
|
113
117
|
if (res.status === 401) throw new Error('AUTH')
|
|
114
118
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
115
119
|
const full = await res.json()
|
|
116
|
-
if (!full?.result) throw new Error('
|
|
120
|
+
if (!full?.result) throw new Error(t('err.badResponse'))
|
|
117
121
|
if (!full.result.ok) {
|
|
118
122
|
const err = full.result.error || {}
|
|
119
|
-
throw new Error(err.message || '
|
|
123
|
+
throw new Error(err.message || t('err.dshError'))
|
|
120
124
|
}
|
|
121
125
|
return full.result.value
|
|
122
126
|
}
|
|
@@ -142,9 +146,9 @@ async function safeRpc(method, payload, errText) {
|
|
|
142
146
|
}
|
|
143
147
|
|
|
144
148
|
function authFailure() {
|
|
145
|
-
toast('
|
|
149
|
+
toast(t('err.accessDenied'), 'err')
|
|
146
150
|
showView('view-settings')
|
|
147
|
-
$('token-desc').textContent = '
|
|
151
|
+
$('token-desc').textContent = t('token.invalid')
|
|
148
152
|
}
|
|
149
153
|
|
|
150
154
|
/* ---------------- 多服务器 + 自动选优 ---------------- */
|
|
@@ -195,7 +199,7 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
|
|
|
195
199
|
if (state.selectingServer) return null
|
|
196
200
|
state.selectingServer = true
|
|
197
201
|
try {
|
|
198
|
-
if (!silent) toast('
|
|
202
|
+
if (!silent) toast(t('speed.testing'))
|
|
199
203
|
const candidates = serverCandidates()
|
|
200
204
|
for (const u of candidates) state.serverLatency[u] = await pingServer(u)
|
|
201
205
|
const best = candidates
|
|
@@ -208,11 +212,11 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
|
|
|
208
212
|
if (chosen !== state.server) {
|
|
209
213
|
state.server = chosen
|
|
210
214
|
saveServers()
|
|
211
|
-
if (!silent) toast(
|
|
215
|
+
if (!silent) toast(t('speed.switched', { url: chosen || t('speed.origin'), ms: state.serverLatency[best] }), 'ok')
|
|
212
216
|
if (reconnect && state.token) { openStreams(); refreshAll() }
|
|
213
217
|
} else if (!silent) {
|
|
214
|
-
if (best) toast(
|
|
215
|
-
else toast('
|
|
218
|
+
if (best) toast(t('speed.alreadyBest', { url: chosen || t('speed.origin'), ms: state.serverLatency[best] }), 'ok')
|
|
219
|
+
else toast(t('speed.allDown'), 'err')
|
|
216
220
|
}
|
|
217
221
|
return chosen
|
|
218
222
|
} finally {
|
|
@@ -224,41 +228,41 @@ function renderServers() {
|
|
|
224
228
|
const box = $('server-list')
|
|
225
229
|
if (!box) return
|
|
226
230
|
if (!state.servers.length) {
|
|
227
|
-
box.innerHTML = '<div class="server-empty"
|
|
231
|
+
box.innerHTML = '<div class="server-empty">' + t('servers.empty') + '</div>'
|
|
228
232
|
} else {
|
|
229
233
|
box.innerHTML = state.servers.map(u => {
|
|
230
234
|
const ms = state.serverLatency[u]
|
|
231
|
-
let badge = '<span class="server-badge"
|
|
232
|
-
if (Number.isFinite(ms)) badge = `<span class="server-badge ${u === state.server ? 'good' : ''}">${ms}ms${u === state.server ? '
|
|
233
|
-
else if (ms !== undefined) badge = '<span class="server-badge bad"
|
|
235
|
+
let badge = '<span class="server-badge">' + t('servers.untested') + '</span>'
|
|
236
|
+
if (Number.isFinite(ms)) badge = `<span class="server-badge ${u === state.server ? 'good' : ''}">${ms}ms${u === state.server ? t('servers.current') : ''}</span>`
|
|
237
|
+
else if (ms !== undefined) badge = '<span class="server-badge bad">' + t('servers.unreachable') + '</span>'
|
|
234
238
|
return `<div class="server-row ${u === state.server ? 'active' : ''}">
|
|
235
239
|
<span class="server-url">${esc(u)}</span>${badge}
|
|
236
|
-
<button class="server-del" data-del="${esc(u)}" aria-label="
|
|
240
|
+
<button class="server-del" data-del="${esc(u)}" aria-label="${t('servers.delete')}">✕</button>
|
|
237
241
|
</div>`
|
|
238
242
|
}).join('')
|
|
239
243
|
box.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', () => removeServer(b.dataset.del)))
|
|
240
244
|
}
|
|
241
245
|
$('server-desc').textContent = state.server
|
|
242
|
-
?
|
|
243
|
-
: (CAP?.isNativePlatform?.() ? '
|
|
246
|
+
? t('servers.currentDesc', { url: state.server })
|
|
247
|
+
: (CAP?.isNativePlatform?.() ? t('servers.notSet') : t('servers.defaultDesc'))
|
|
244
248
|
}
|
|
245
249
|
|
|
246
250
|
async function addServer() {
|
|
247
251
|
const input = $('server-input')
|
|
248
252
|
let raw = (input?.value || '').trim().replace(/\/+$/, '')
|
|
249
|
-
if (!raw) return toast('
|
|
253
|
+
if (!raw) return toast(t('servers.needAddress'), 'err')
|
|
250
254
|
try {
|
|
251
255
|
const u = new URL(raw)
|
|
252
256
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad')
|
|
253
257
|
} catch {
|
|
254
|
-
return toast('
|
|
258
|
+
return toast(t('servers.badProtocol'), 'err')
|
|
255
259
|
}
|
|
256
|
-
if (state.servers.includes(raw)) return toast('
|
|
260
|
+
if (state.servers.includes(raw)) return toast(t('servers.duplicate'))
|
|
257
261
|
state.servers.push(raw)
|
|
258
262
|
saveServers()
|
|
259
263
|
if (input) input.value = ''
|
|
260
264
|
renderServers()
|
|
261
|
-
toast('
|
|
265
|
+
toast(t('servers.added'), 'ok')
|
|
262
266
|
if (state.token) selectFastestServer({ silent: false })
|
|
263
267
|
}
|
|
264
268
|
|
|
@@ -269,7 +273,7 @@ function removeServer(url) {
|
|
|
269
273
|
saveServers()
|
|
270
274
|
renderServers()
|
|
271
275
|
if (wasActive) {
|
|
272
|
-
toast('
|
|
276
|
+
toast(t('servers.removedActive'))
|
|
273
277
|
selectFastestServer({ silent: true })
|
|
274
278
|
}
|
|
275
279
|
}
|
|
@@ -279,11 +283,13 @@ const streams = {}
|
|
|
279
283
|
state.streamsOk = { mux: false, host: false }
|
|
280
284
|
|
|
281
285
|
function openStreams() {
|
|
286
|
+
if (!state.token) return
|
|
282
287
|
openStream('mux', onMuxFrame, true)
|
|
283
288
|
openStream('host', onHostFrame, false)
|
|
284
289
|
}
|
|
285
290
|
|
|
286
291
|
function openStream(kind, handler, refreshOnOpen) {
|
|
292
|
+
if (!state.token) return
|
|
287
293
|
let base
|
|
288
294
|
if (state.server) {
|
|
289
295
|
base = state.server.replace(/^http/, 'ws')
|
|
@@ -321,7 +327,7 @@ function openStream(kind, handler, refreshOnOpen) {
|
|
|
321
327
|
state.streamsOk[kind] = false
|
|
322
328
|
state.errCount++
|
|
323
329
|
updateConn()
|
|
324
|
-
if (state.errCount === 3) toast('
|
|
330
|
+
if (state.errCount === 3) toast(t('conn.reconnecting'), 'err')
|
|
325
331
|
// 多服务器: 连续掉线若干次就重测速, 自动换到当前可达的最快地址
|
|
326
332
|
if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
|
|
327
333
|
// 无条件重连; 页面被挂起时定时器暂停, visibilitychange 会再触发一次
|
|
@@ -351,12 +357,12 @@ document.addEventListener('visibilitychange', () => {
|
|
|
351
357
|
if (document.visibilityState === 'visible') {
|
|
352
358
|
onResume()
|
|
353
359
|
if (state.servers.length) selectFastestServer({ silent: true })
|
|
354
|
-
else if (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN) openStreams()
|
|
360
|
+
else if (state.token && (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN)) openStreams()
|
|
355
361
|
}
|
|
356
362
|
})
|
|
357
363
|
window.addEventListener('pageshow', onResume)
|
|
358
364
|
setInterval(() => {
|
|
359
|
-
if (document.visibilityState === 'visible') {
|
|
365
|
+
if (document.visibilityState === 'visible' && state.token) {
|
|
360
366
|
if (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN) openStreams()
|
|
361
367
|
}
|
|
362
368
|
}, 15000)
|
|
@@ -372,21 +378,21 @@ function onMuxFrame(full) {
|
|
|
372
378
|
if (f.type === 'approval/requested') {
|
|
373
379
|
state.approvals = state.approvals.filter(a => a.approvalId !== f.approvalId)
|
|
374
380
|
state.approvals.push({ ...f, rpcId: full.rpcId })
|
|
375
|
-
notify('
|
|
381
|
+
notify(t('notify.approvalTitle'), t('notify.approvalBody', { tool: f.toolName || t('tool.unknown') }))
|
|
376
382
|
renderPending(); return
|
|
377
383
|
}
|
|
378
384
|
if (f.type === 'approval/resolved') { state.approvals = state.approvals.filter(a => a.approvalId !== f.approvalId); renderPending(); return }
|
|
379
385
|
if (f.type === 'question/requested') {
|
|
380
386
|
state.questions = state.questions.filter(q => q.rpcId !== full.rpcId)
|
|
381
387
|
state.questions.push({ ...f, rpcId: full.rpcId })
|
|
382
|
-
notify('
|
|
388
|
+
notify(t('notify.questionTitle'), f.questions?.map(q => q.question).join(' / ') || t('notify.questionBody'))
|
|
383
389
|
renderPending(); return
|
|
384
390
|
}
|
|
385
391
|
if (f.type === 'question/resolved') { state.questions = state.questions.filter(q => q.rpcId !== f.questionRpcId); renderPending(); return }
|
|
386
392
|
if (f.type === 'session/queue') { state.queues[f.sessionId] = f.items || []; renderQueue(); return }
|
|
387
393
|
if (f.type === 'session/jobs') { state.jobs[f.sessionId] = f.jobs || []; renderJobs(); return }
|
|
388
394
|
if (f.type === 'session/projection') { applyProjection(f.sessionId, f.key, f.value, f.seq); return }
|
|
389
|
-
if (f.type === 'stream/error') { toast('
|
|
395
|
+
if (f.type === 'stream/error') { toast(t('stream.error', { msg: f.error?.message || '' }), 'err') }
|
|
390
396
|
}
|
|
391
397
|
function onHostFrame(full) {
|
|
392
398
|
const f = full.payload
|
|
@@ -401,7 +407,7 @@ function onHostFrame(full) {
|
|
|
401
407
|
const s = state.byId.get(f.sessionId)
|
|
402
408
|
if (s) { s.error = true; s.running = false }
|
|
403
409
|
if (state.current === f.sessionId) { renderSessionSub(); updateSessionStatus() }
|
|
404
|
-
return toast(
|
|
410
|
+
return toast(t('session.errorMsg', { msg: f.message }), 'err')
|
|
405
411
|
}
|
|
406
412
|
if (f.type === 'host/remote-event') return scheduleRefresh()
|
|
407
413
|
}
|
|
@@ -435,7 +441,7 @@ async function refreshAll() {
|
|
|
435
441
|
}
|
|
436
442
|
|
|
437
443
|
async function refreshSessions() {
|
|
438
|
-
const v = await safeRpc('session.list', {}, '
|
|
444
|
+
const v = await safeRpc('session.list', {}, t('err.sessionList'))
|
|
439
445
|
if (!v) {
|
|
440
446
|
// 网关不可达: 用上次成功的会话列表兜底, 用户仍能打开历史缓存
|
|
441
447
|
if (!state.sessions.length) {
|
|
@@ -444,7 +450,7 @@ async function refreshSessions() {
|
|
|
444
450
|
state.sessions = cached
|
|
445
451
|
state.byId = new Map(cached.map(s => [s.sessionId, s]))
|
|
446
452
|
renderSessions()
|
|
447
|
-
toast('
|
|
453
|
+
toast(t('sessions.cacheFallback'), 'ok')
|
|
448
454
|
}
|
|
449
455
|
}
|
|
450
456
|
return
|
|
@@ -492,14 +498,14 @@ function renderSessions() {
|
|
|
492
498
|
const dots = []
|
|
493
499
|
if (s.running) dots.push('running')
|
|
494
500
|
if (pending) dots.push('pending')
|
|
495
|
-
const badge = goal ? `<span class="sc-badge ${goal.phase === 'active' ? 'goal-active' : ''}"
|
|
496
|
-
const queueBadge = queueN ? `<span class="sc-badge"
|
|
501
|
+
const badge = goal ? `<span class="sc-badge ${goal.phase === 'active' ? 'goal-active' : ''}">${esc(t('sessions.goalBadge', { phase: goal.phase || '?' }))}</span>` : ''
|
|
502
|
+
const queueBadge = queueN ? `<span class="sc-badge">${esc(t('sessions.queueBadge', { n: queueN }))}</span>` : ''
|
|
497
503
|
return `<div class="session-card ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
|
|
498
504
|
<div class="sc-title">${esc(title)}</div>
|
|
499
505
|
<div class="sc-meta">
|
|
500
506
|
<span class="sc-dot ${dots.join(' ')}"></span>
|
|
501
507
|
<span>${fmtTime(s.updatedAt)}</span>
|
|
502
|
-
${s.running ? '<span
|
|
508
|
+
${s.running ? '<span>' + t('sessions.running') + '</span>' : ''}
|
|
503
509
|
${badge}${queueBadge}
|
|
504
510
|
</div>
|
|
505
511
|
<span class="sc-arrow">›</span>
|
|
@@ -509,9 +515,9 @@ function renderSessions() {
|
|
|
509
515
|
const running = state.sessions.filter(s => s.running).length
|
|
510
516
|
const pending = state.approvals.length + state.questions.length
|
|
511
517
|
$('stat-strip').innerHTML = `
|
|
512
|
-
<div class="stat running"><div class="v">${running}</div><div class="k"
|
|
513
|
-
<div class="stat pending"><div class="v">${pending}</div><div class="k"
|
|
514
|
-
<div class="stat ctx"><div class="v">${items.length}</div><div class="k"
|
|
518
|
+
<div class="stat running"><div class="v">${running}</div><div class="k">${t('sessions.statRunning')}</div></div>
|
|
519
|
+
<div class="stat pending"><div class="v">${pending}</div><div class="k">${t('sessions.statPending')}</div></div>
|
|
520
|
+
<div class="stat ctx"><div class="v">${items.length}</div><div class="k">${t('sessions.statTotal')}</div></div>`
|
|
515
521
|
updatePendingBadge()
|
|
516
522
|
}
|
|
517
523
|
|
|
@@ -522,7 +528,7 @@ async function openSession(id) {
|
|
|
522
528
|
document.body.classList.add('in-session')
|
|
523
529
|
showView('view-session')
|
|
524
530
|
renderSessionTitle(); renderSessionSub(); updateCancelBtn(); updateSessionStatus()
|
|
525
|
-
$('history').innerHTML = '<div class="empty"
|
|
531
|
+
$('history').innerHTML = '<div class="empty">' + t('history.loading') + '</div>'
|
|
526
532
|
await loadHistory(true)
|
|
527
533
|
renderSessionCards()
|
|
528
534
|
refreshSessions()
|
|
@@ -555,7 +561,7 @@ function bindNativeBack() {
|
|
|
555
561
|
|
|
556
562
|
function renderSessionTitle() {
|
|
557
563
|
const s = state.byId.get(state.current)
|
|
558
|
-
$('session-title').textContent = s ? titleOf(s) : '
|
|
564
|
+
$('session-title').textContent = s ? titleOf(s) : t('session.unknown')
|
|
559
565
|
}
|
|
560
566
|
|
|
561
567
|
function renderSessionSub() {
|
|
@@ -563,8 +569,8 @@ function renderSessionSub() {
|
|
|
563
569
|
if (!s) { $('session-sub').textContent = ''; return }
|
|
564
570
|
const parts = [short(s.sessionId)]
|
|
565
571
|
if (s.cwd) parts.push(s.cwd)
|
|
566
|
-
if (s.running) parts.push('
|
|
567
|
-
else if (s.error) parts.push('
|
|
572
|
+
if (s.running) parts.push(t('session.running'))
|
|
573
|
+
else if (s.error) parts.push(t('session.interrupted'))
|
|
568
574
|
$('session-sub').textContent = parts.join(' · ')
|
|
569
575
|
}
|
|
570
576
|
|
|
@@ -642,7 +648,7 @@ function restoreCachedHistory() {
|
|
|
642
648
|
}
|
|
643
649
|
h.visible.sort((a, b) => a.seq - b.seq)
|
|
644
650
|
state.history = h
|
|
645
|
-
$('history-hint').textContent =
|
|
651
|
+
$('history-hint').textContent = t('history.offlineCache', { n: h.visible.length })
|
|
646
652
|
renderHistory(true)
|
|
647
653
|
return true
|
|
648
654
|
}
|
|
@@ -662,8 +668,8 @@ async function loadHistory(reset) {
|
|
|
662
668
|
} catch (e) {
|
|
663
669
|
state.history.loading = false
|
|
664
670
|
if (e.message === 'AUTH') { authFailure(); return }
|
|
665
|
-
if (restoreCachedHistory()) toast('
|
|
666
|
-
else toast('
|
|
671
|
+
if (restoreCachedHistory()) toast(t('history.cacheFallback'), 'ok')
|
|
672
|
+
else toast(t('history.loadFailed', { msg: e.message }), 'err')
|
|
667
673
|
return
|
|
668
674
|
}
|
|
669
675
|
|
|
@@ -688,7 +694,7 @@ async function loadHistory(reset) {
|
|
|
688
694
|
if (reset) renderHistory(true)
|
|
689
695
|
else if (added) renderHistory(false, 'keep')
|
|
690
696
|
if (moreBtn) moreBtn.classList.toggle('hidden', !state.history.hasMore)
|
|
691
|
-
$('history-hint').textContent = state.history.visible.length ?
|
|
697
|
+
$('history-hint').textContent = state.history.visible.length ? t('history.count', { n: state.history.visible.length }) : ''
|
|
692
698
|
scheduleHistoryCacheSave()
|
|
693
699
|
}
|
|
694
700
|
|
|
@@ -727,7 +733,7 @@ function renderHistory(reset, mode = 'bottom') {
|
|
|
727
733
|
const filtered = filteredEntries()
|
|
728
734
|
const len = filtered.length
|
|
729
735
|
if (!len) {
|
|
730
|
-
box.innerHTML = '<div class="empty"
|
|
736
|
+
box.innerHTML = '<div class="empty">' + t('history.empty') + '</div>'
|
|
731
737
|
h.renderStart = 0; h.renderEnd = 0
|
|
732
738
|
updateRail()
|
|
733
739
|
return
|
|
@@ -843,19 +849,19 @@ function eventHtml(entry, ctx = {}) {
|
|
|
843
849
|
const msg = data.message || {}
|
|
844
850
|
const role = data.role || msg.role || (type.startsWith('user') ? 'user' : 'assistant')
|
|
845
851
|
const blocks = msg.content || data.content || []
|
|
846
|
-
inner = `<div class="msg ${esc(role)}" data-seq="${seq}"><div class="role">${esc(role === 'user' ? '
|
|
852
|
+
inner = `<div class="msg ${esc(role)}" data-seq="${seq}"><div class="role">${esc(role === 'user' ? t('role.me') : t('role.dsh'))}</div>${blocks.map(blockHtml).join('')}</div>`
|
|
847
853
|
} else if (type === 'tool/call') {
|
|
848
|
-
const name = data.name || data.toolName || '
|
|
854
|
+
const name = data.name || data.toolName || t('tool.default')
|
|
849
855
|
const step = (data.turn != null ? ` · turn ${data.turn}` : '') + (data.step != null ? `.${data.step}` : '')
|
|
850
856
|
inner = `<details class="tool" data-seq="${seq}"><summary>🔧 ${esc(name)}<span class="tool-meta-inline">${esc(step)}</span></summary><pre>${esc(safeJson(data.arguments ?? data.args ?? data.input ?? data))}</pre></details>`
|
|
851
857
|
} else if (type === 'tool/result') {
|
|
852
858
|
const callId = data.callId || data.message?.source?.callId
|
|
853
|
-
const name = (callId && ctx.toolNames?.get(callId)) || '
|
|
859
|
+
const name = (callId && ctx.toolNames?.get(callId)) || t('tool.result')
|
|
854
860
|
const err = data.error || data.ok === false
|
|
855
|
-
inner = `<details class="tool result ${err ? 'error' : ''}" data-seq="${seq}"><summary>📦 ${esc(name)}<span class="tool-meta-inline"
|
|
861
|
+
inner = `<details class="tool result ${err ? 'error' : ''}" data-seq="${seq}"><summary>📦 ${esc(name)}<span class="tool-meta-inline">${t('tool.result')}</span></summary><pre>${esc(truncate(safeJson(data.result ?? data.output ?? data.message ?? data), 4000))}</pre></details>`
|
|
856
862
|
} else if (type === 'agent/status') {
|
|
857
863
|
const running = !!data.running
|
|
858
|
-
inner = `<div class="event" data-seq="${seq}">${running ? '
|
|
864
|
+
inner = `<div class="event" data-seq="${seq}">${running ? t('event.taskStart') : t('event.taskEnd')}</div>`
|
|
859
865
|
} else if (type === 'llm/usage') {
|
|
860
866
|
inner = `<div class="event" data-seq="${seq}">tokens ${fmtTokens(data.inputTokens)} → ${fmtTokens(data.outputTokens)}</div>`
|
|
861
867
|
} else if (type === 'checkpoint/created' || type === 'compaction/complete' || type === 'compaction/summary') {
|
|
@@ -871,16 +877,16 @@ function blockHtml(b) {
|
|
|
871
877
|
if ((b.type === 'tool-call' || b.type === 'tool-result') && LS.get('showTools', '1') === '0') return ''
|
|
872
878
|
switch (b.type) {
|
|
873
879
|
case 'text': return `<div>${renderMarkdown(b.text ?? '')}</div>`
|
|
874
|
-
case 'image': return `<img alt="
|
|
880
|
+
case 'image': return `<img alt="${t('block.image')}" src="data:${esc(b.mediaType || 'image/png')};base64,${esc(b.data || '')}">`
|
|
875
881
|
case 'thinking':
|
|
876
882
|
case 'reasoning':
|
|
877
|
-
return `<details class="tool"><summary
|
|
883
|
+
return `<details class="tool"><summary>${t('block.thinking')}</summary><div class="tool-text">${esc(truncate(String(b.text ?? b.content ?? safeJson(b)), 6000))}</div></details>`
|
|
878
884
|
case 'code': return `<pre>${esc(b.content ?? b.code ?? '')}</pre>`
|
|
879
885
|
case 'tool-call':
|
|
880
|
-
return `<details class="tool"><summary>🔧 ${esc(b.name || b.toolName || '
|
|
886
|
+
return `<details class="tool"><summary>🔧 ${esc(b.name || b.toolName || t('block.toolCall'))}</summary><pre>${esc(truncate(safeJson(b.arguments ?? b), 4000))}</pre></details>`
|
|
881
887
|
case 'tool-result':
|
|
882
|
-
return `<details class="tool result"><summary>📦 ${esc(b.name || b.toolName || '
|
|
883
|
-
default: return `<details class="tool"><summary
|
|
888
|
+
return `<details class="tool result"><summary>📦 ${esc(b.name || b.toolName || t('block.toolResult'))}</summary><pre>${esc(truncate(safeJson(b.content ?? b), 4000))}</pre></details>`
|
|
889
|
+
default: return `<details class="tool"><summary>${esc(t('block.unknown', { type: b.type || '?' }))}</summary><pre>${esc(truncate(safeJson(b), 2000))}</pre></details>`
|
|
884
890
|
}
|
|
885
891
|
}
|
|
886
892
|
|
|
@@ -902,7 +908,7 @@ function safeJson(v) {
|
|
|
902
908
|
try { return typeof v === 'string' ? v : JSON.stringify(v, null, 2) }
|
|
903
909
|
catch { return String(v) }
|
|
904
910
|
}
|
|
905
|
-
function truncate(s, n) { return String(s).length > n ? String(s).slice(0, n) + '
|
|
911
|
+
function truncate(s, n) { return String(s).length > n ? String(s).slice(0, n) + t('truncated') : s }
|
|
906
912
|
|
|
907
913
|
/* 会话卡片(goal/todo/subagents); 统计进顶栏 📊 弹窗 */
|
|
908
914
|
function statsHtml(s) {
|
|
@@ -913,15 +919,15 @@ function statsHtml(s) {
|
|
|
913
919
|
let html = ''
|
|
914
920
|
if (stats) {
|
|
915
921
|
const llmMin = stats.llmMs ? (stats.llmMs / 60000).toFixed(1) : null
|
|
916
|
-
html += `<div class="card"><div class="card-title"
|
|
917
|
-
<div class="card-row"><span class="k"
|
|
918
|
-
<div class="card-row"><span class="k"
|
|
919
|
-
${usage ? `<div class="card-row"><span class="k"
|
|
920
|
-
${ctx ? `<div class="card-row"><span class="k"
|
|
921
|
-
${perms?.currentValue ? `<div class="card-row"><span class="k"
|
|
922
|
+
html += `<div class="card"><div class="card-title">${t('stats.roundTitle')}</div>
|
|
923
|
+
<div class="card-row"><span class="k">${t('stats.turnsSteps')}</span><span class="v">${stats.turns ?? '—'} / ${stats.steps ?? '—'}</span></div>
|
|
924
|
+
<div class="card-row"><span class="k">${t('stats.llmTime')}</span><span class="v">${llmMin ? llmMin + t('stats.minutes') : '—'}</span></div>
|
|
925
|
+
${usage ? `<div class="card-row"><span class="k">${t('stats.outputCache')}</span><span class="v">${fmtTokens(usage.outputTokens)} / ${fmtTokens(usage.cacheReadTokens)}</span></div>` : ''}
|
|
926
|
+
${ctx ? `<div class="card-row"><span class="k">${t('stats.ctxPressure')}</span><span class="v">${fmtTokens(ctx.pressureTokens)} / ${fmtTokens(ctx.contextWindow)}</span></div>` : ''}
|
|
927
|
+
${perms?.currentValue ? `<div class="card-row"><span class="k">${t('stats.permission')}</span><span class="v">${esc(perms.currentValue)}</span></div>` : ''}
|
|
922
928
|
</div>`
|
|
923
929
|
}
|
|
924
|
-
return html || '<div class="empty"
|
|
930
|
+
return html || '<div class="empty">' + t('stats.empty') + '</div>'
|
|
925
931
|
}
|
|
926
932
|
|
|
927
933
|
async function renderSessionCards() {
|
|
@@ -935,18 +941,18 @@ async function renderSessionCards() {
|
|
|
935
941
|
let html = ''
|
|
936
942
|
|
|
937
943
|
if (goal) {
|
|
938
|
-
html += `<div class="card"><div class="card-title"
|
|
944
|
+
html += `<div class="card"><div class="card-title">${t('goal.title')}</div>
|
|
939
945
|
<div class="goal-obj">${esc(goal.objective || '')}</div>
|
|
940
946
|
<div class="goal-phase">phase: ${esc(goal.phase || '?')} · revision ${goal.revision ?? '?'}</div>
|
|
941
947
|
<div class="goal-actions">
|
|
942
|
-
${goal.phase === 'active' ? '<button class="mini-btn" data-goal="pause"
|
|
943
|
-
<button class="mini-btn" data-goal="complete"
|
|
944
|
-
<button class="mini-btn" data-goal="edit"
|
|
945
|
-
<button class="mini-btn" data-goal="clear"
|
|
948
|
+
${goal.phase === 'active' ? '<button class="mini-btn" data-goal="pause">' + t('goal.pause') + '</button>' : '<button class="mini-btn" data-goal="resume">' + t('goal.resume') + '</button>'}
|
|
949
|
+
<button class="mini-btn" data-goal="complete">${t('goal.complete')}</button>
|
|
950
|
+
<button class="mini-btn" data-goal="edit">${t('goal.edit')}</button>
|
|
951
|
+
<button class="mini-btn" data-goal="clear">${t('goal.clear')}</button>
|
|
946
952
|
</div></div>`
|
|
947
953
|
}
|
|
948
954
|
if (todos?.items?.length) {
|
|
949
|
-
html += `<div class="card"><div class="card-title"
|
|
955
|
+
html += `<div class="card"><div class="card-title">${t('todos.title')}</div>${todos.items.map(t =>
|
|
950
956
|
`<div><span class="pill ${t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'active' : ''}">${esc(t.status || 'pending')}</span>${esc(t.content || '')}</div>`
|
|
951
957
|
).join('')}</div>`
|
|
952
958
|
}
|
|
@@ -958,12 +964,12 @@ async function renderSessionCards() {
|
|
|
958
964
|
const sub = await safeRpc('subagent.list', { parentSessionId: state.current })
|
|
959
965
|
if (sub?.entries?.length) {
|
|
960
966
|
const rows = sub.entries.map(e => {
|
|
961
|
-
if (e.kind === 'diagnostic') return `<div class="card-row"><span class="k"
|
|
967
|
+
if (e.kind === 'diagnostic') return `<div class="card-row"><span class="k">${t('subagent.diagnostic')}</span><span class="v">${esc(e.reason)}</span></div>`
|
|
962
968
|
const label = e.label || short(e.id)
|
|
963
969
|
const running = e.activity === 'running'
|
|
964
|
-
return `<div class="card-row"><span class="k">${running ? '▶ ' : ''}${esc(label)}</span><span class="v">${esc(e.mode)} ${running ? '
|
|
970
|
+
return `<div class="card-row"><span class="k">${running ? '▶ ' : ''}${esc(label)}</span><span class="v">${esc(e.mode)} ${running ? t('subagent.running') : ''}${e.mode === 'continuable' && running ? ` <button class="mini-btn" data-sub-interrupt="${esc(e.id)}">${t('subagent.interrupt')}</button>` : ''}</span></div>`
|
|
965
971
|
}).join('')
|
|
966
|
-
box.insertAdjacentHTML('beforeend', `<div class="card"><div class="card-title"
|
|
972
|
+
box.insertAdjacentHTML('beforeend', `<div class="card"><div class="card-title">${t('subagent.title')}</div>${rows}</div>`)
|
|
967
973
|
box.querySelectorAll('[data-sub-interrupt]').forEach(btn =>
|
|
968
974
|
btn.addEventListener('click', () => interruptSubagent(btn.dataset.subInterrupt)))
|
|
969
975
|
}
|
|
@@ -972,23 +978,23 @@ async function renderSessionCards() {
|
|
|
972
978
|
async function goalAction(kind) {
|
|
973
979
|
const s = state.byId.get(state.current)
|
|
974
980
|
const goal = goalOf(s)
|
|
975
|
-
if (!goal) return toast('
|
|
981
|
+
if (!goal) return toast(t('goal.none'))
|
|
976
982
|
const ref = { id: goal.id, revision: goal.revision }
|
|
977
983
|
if (kind === 'edit') return openGoalModal(goal)
|
|
978
984
|
const map = { pause: 'goal.pause', resume: 'goal.resume', complete: 'goal.complete', clear: 'goal.clear' }
|
|
979
985
|
const method = map[kind]
|
|
980
986
|
if (!method) return
|
|
981
|
-
if (kind === 'clear' && !confirm(
|
|
982
|
-
if (kind === 'complete' && !confirm('
|
|
983
|
-
await safeRpc(method, { sessionId: state.current, ref }, '
|
|
984
|
-
toast('
|
|
987
|
+
if (kind === 'clear' && !confirm(t('goal.confirmClear'))) return
|
|
988
|
+
if (kind === 'complete' && !confirm(t('goal.confirmComplete'))) return
|
|
989
|
+
await safeRpc(method, { sessionId: state.current, ref }, t('goal.actionFailed'))
|
|
990
|
+
toast(t('goal.actionSubmitted'), 'ok')
|
|
985
991
|
scheduleRefresh()
|
|
986
992
|
}
|
|
987
993
|
|
|
988
994
|
async function interruptSubagent(childId) {
|
|
989
|
-
if (!confirm('
|
|
990
|
-
await safeRpc('subagent.interrupt', { parentSessionId: state.current, childSessionId: childId, mode: 'continuable' }, '
|
|
991
|
-
toast('
|
|
995
|
+
if (!confirm(t('subagent.confirmInterrupt'))) return
|
|
996
|
+
await safeRpc('subagent.interrupt', { parentSessionId: state.current, childSessionId: childId, mode: 'continuable' }, t('subagent.interruptFailed'))
|
|
997
|
+
toast(t('subagent.interruptSubmitted'), 'ok')
|
|
992
998
|
setTimeout(renderSessionCards, 600)
|
|
993
999
|
}
|
|
994
1000
|
|
|
@@ -1001,10 +1007,10 @@ async function sendSessionText(text) {
|
|
|
1001
1007
|
sessionId: state.current,
|
|
1002
1008
|
mode: 'queue',
|
|
1003
1009
|
content: [{ type: 'text', text: clean }]
|
|
1004
|
-
}, '
|
|
1010
|
+
}, t('send.failed'))
|
|
1005
1011
|
$('btn-send').disabled = false
|
|
1006
|
-
if (v?.accepted) { toast(clean.startsWith('/') ? '
|
|
1007
|
-
if (v?.command?.text) { toast('
|
|
1012
|
+
if (v?.accepted) { toast(clean.startsWith('/') ? t('send.commandSent') : t('send.sent'), 'ok'); return true }
|
|
1013
|
+
if (v?.command?.text) { toast(t('send.commandExecuted'), 'ok'); return true }
|
|
1008
1014
|
return false
|
|
1009
1015
|
}
|
|
1010
1016
|
|
|
@@ -1040,7 +1046,7 @@ async function loadSessionModels() {
|
|
|
1040
1046
|
state.models.loaded = true
|
|
1041
1047
|
} catch (e) {
|
|
1042
1048
|
if (e.message === 'AUTH') { authFailure(); return }
|
|
1043
|
-
toast('
|
|
1049
|
+
toast(t('models.loadFailed', { msg: e.message }), 'err')
|
|
1044
1050
|
}
|
|
1045
1051
|
state.models.loading = false
|
|
1046
1052
|
renderModelMenu()
|
|
@@ -1049,10 +1055,10 @@ async function loadSessionModels() {
|
|
|
1049
1055
|
function renderModelMenu() {
|
|
1050
1056
|
const box = $('menu-models')
|
|
1051
1057
|
if (!box) return
|
|
1052
|
-
if (state.models.loading) { box.innerHTML = '<span
|
|
1058
|
+
if (state.models.loading) { box.innerHTML = '<span>' + t('models.loading') + '</span>'; return }
|
|
1053
1059
|
const groups = state.models.groups || []
|
|
1054
1060
|
if (!groups.length) {
|
|
1055
|
-
box.innerHTML = '<span>' + ((state.models.failures || []).map(f => f.name + '
|
|
1061
|
+
box.innerHTML = '<span>' + ((state.models.failures || []).map(f => f.name + ' ' + t('models.unavailable')).join(';') || t('models.none')) + '</span>'
|
|
1056
1062
|
const effortGroup = $('menu-effort-group')
|
|
1057
1063
|
if (effortGroup) effortGroup.classList.add('hidden')
|
|
1058
1064
|
return
|
|
@@ -1093,11 +1099,11 @@ async function selectSessionEffort(effortId) {
|
|
|
1093
1099
|
if (!state.current || !cur) return
|
|
1094
1100
|
const v = await safeRpc('session.selectModel', {
|
|
1095
1101
|
sessionId: state.current, provider: cur.provider, model: cur.model, reasoningEffort: effortId
|
|
1096
|
-
}, '
|
|
1102
|
+
}, t('models.effortFailed'))
|
|
1097
1103
|
if (v?.selected) {
|
|
1098
1104
|
state.models.current = v.selected
|
|
1099
1105
|
renderEffortMenu()
|
|
1100
|
-
toast(
|
|
1106
|
+
toast(t('models.effortSwitched', { effort: effortId }), 'ok')
|
|
1101
1107
|
}
|
|
1102
1108
|
}
|
|
1103
1109
|
|
|
@@ -1108,25 +1114,25 @@ async function selectSessionModel(provider, modelId) {
|
|
|
1108
1114
|
const payload = { sessionId: state.current, provider, model: modelId }
|
|
1109
1115
|
const effort = model?.reasoning?.defaultEffort || model?.reasoning?.efforts?.[0]?.id
|
|
1110
1116
|
if (effort) payload.reasoningEffort = effort
|
|
1111
|
-
const v = await safeRpc('session.selectModel', payload, '
|
|
1117
|
+
const v = await safeRpc('session.selectModel', payload, t('models.switchFailed'))
|
|
1112
1118
|
if (v?.selected) {
|
|
1113
1119
|
state.models.current = v.selected
|
|
1114
1120
|
renderModelMenu()
|
|
1115
|
-
toast(
|
|
1121
|
+
toast(t('models.switched', { model: v.selected.model }), 'ok')
|
|
1116
1122
|
}
|
|
1117
1123
|
}
|
|
1118
1124
|
|
|
1119
1125
|
async function cancelSession() {
|
|
1120
1126
|
if (!state.current) return
|
|
1121
|
-
if (!confirm('
|
|
1122
|
-
const v = await safeRpc('session.cancel', { sessionId: state.current }, '
|
|
1123
|
-
if (v?.accepted) toast('
|
|
1127
|
+
if (!confirm(t('session.confirmStop'))) return
|
|
1128
|
+
const v = await safeRpc('session.cancel', { sessionId: state.current }, t('session.stopFailed'))
|
|
1129
|
+
if (v?.accepted) toast(t('session.stopRequested'), 'ok')
|
|
1124
1130
|
}
|
|
1125
1131
|
|
|
1126
1132
|
async function newSession() {
|
|
1127
|
-
const v = await safeRpc('session.create', {}, '
|
|
1133
|
+
const v = await safeRpc('session.create', {}, t('home.createFailed'))
|
|
1128
1134
|
if (!v?.sessionId) return
|
|
1129
|
-
toast('
|
|
1135
|
+
toast(t('home.created'), 'ok')
|
|
1130
1136
|
await refreshSessions()
|
|
1131
1137
|
openSession(v.sessionId)
|
|
1132
1138
|
}
|
|
@@ -1138,27 +1144,27 @@ function renderPending() {
|
|
|
1138
1144
|
...state.approvals.map(a => ({ kind: 'approval', a })),
|
|
1139
1145
|
...state.questions.map(q => ({ kind: 'question', q }))
|
|
1140
1146
|
]
|
|
1141
|
-
$('pending-count').textContent = items.length ?
|
|
1147
|
+
$('pending-count').textContent = items.length ? t('pending.count', { n: items.length }) : ''
|
|
1142
1148
|
list.innerHTML = items.length ? items.map(it => {
|
|
1143
1149
|
if (it.kind === 'approval') {
|
|
1144
1150
|
const a = it.a
|
|
1145
1151
|
const title = titleOf(state.byId.get(a.sessionId))
|
|
1146
1152
|
return `<div class="pending-card approval" data-approval="${esc(a.approvalId)}">
|
|
1147
|
-
<div class="pc-title"
|
|
1148
|
-
<div class="pc-desc">${esc(a.reason || '
|
|
1153
|
+
<div class="pc-title">${esc(t('pending.approvalTitle', { tool: a.toolName || t('tool.default') }))}</div>
|
|
1154
|
+
<div class="pc-desc">${esc(a.reason || t('pending.noReason'))}</div>
|
|
1149
1155
|
<div class="pc-session">${esc(title)}</div>
|
|
1150
|
-
<div class="goal-actions"><button class="mini-btn" data-approve="1"
|
|
1156
|
+
<div class="goal-actions"><button class="mini-btn" data-approve="1">${t('pending.allow')}</button><button class="mini-btn" data-approve="0">${t('pending.reject')}</button></div>
|
|
1151
1157
|
</div>`
|
|
1152
1158
|
}
|
|
1153
1159
|
const q = it.q
|
|
1154
1160
|
const title = titleOf(state.byId.get(q.sessionId))
|
|
1155
1161
|
return `<div class="pending-card question" data-question="${esc(q.rpcId)}">
|
|
1156
|
-
<div class="pc-title">❓ ${esc(q.questions?.[0]?.question || '
|
|
1157
|
-
<div class="pc-desc">${q.questions?.length > 1 ?
|
|
1162
|
+
<div class="pc-title">❓ ${esc(q.questions?.[0]?.question || t('notify.questionTitle'))}</div>
|
|
1163
|
+
<div class="pc-desc">${q.questions?.length > 1 ? t('pending.questionCount', { n: q.questions.length }) : ''}</div>
|
|
1158
1164
|
<div class="pc-session">${esc(title)}</div>
|
|
1159
|
-
<div class="goal-actions"><button class="mini-btn" data-answer="1"
|
|
1165
|
+
<div class="goal-actions"><button class="mini-btn" data-answer="1">${t('pending.answer')}</button></div>
|
|
1160
1166
|
</div>`
|
|
1161
|
-
}).join('') : '<div class="empty"
|
|
1167
|
+
}).join('') : '<div class="empty">' + t('pending.empty') + '</div>'
|
|
1162
1168
|
list.querySelectorAll('[data-approve]').forEach(btn => {
|
|
1163
1169
|
const card = btn.closest('[data-approval]')
|
|
1164
1170
|
btn.addEventListener('click', () => approveApproval(card?.dataset.approval || '', btn.dataset.approve === '1'))
|
|
@@ -1172,7 +1178,7 @@ async function approveApproval(id, allow) {
|
|
|
1172
1178
|
const a = state.approvals.find(x => x.approvalId === id)
|
|
1173
1179
|
if (!a) return
|
|
1174
1180
|
const ok = await respond(a.rpcId, { sessionId: a.sessionId, approvalId: a.approvalId, outcome: allow ? 'allowed-once' : 'rejected' })
|
|
1175
|
-
toast(ok ? (allow ? '
|
|
1181
|
+
toast(ok ? (allow ? t('pending.allowed') : t('pending.rejected')) : t('pending.stale'), ok ? 'ok' : 'err')
|
|
1176
1182
|
state.approvals = state.approvals.filter(x => x.approvalId !== id)
|
|
1177
1183
|
renderPending()
|
|
1178
1184
|
}
|
|
@@ -1185,7 +1191,7 @@ function openQuestionModal(q) {
|
|
|
1185
1191
|
<div class="q-text">${esc(item.header ? item.header + ':' : '')}${esc(item.question)}</div>
|
|
1186
1192
|
${(item.options || []).map((o, j) => `
|
|
1187
1193
|
<label class="q-option"><input type="${item.multiSelect ? 'checkbox' : 'radio'}" name="q${i}" value="${esc(o.label)}" data-q="${i}"><span>${esc(o.label)}${o.description ? `<div class="muted">${esc(o.description)}</div>` : ''}</span></label>`).join('')}
|
|
1188
|
-
<textarea rows="2" placeholder="
|
|
1194
|
+
<textarea rows="2" placeholder="${t('question.customPlaceholder')}" data-qcustom="${i}"></textarea>
|
|
1189
1195
|
</div>`).join('')
|
|
1190
1196
|
$('modal-question').classList.remove('hidden')
|
|
1191
1197
|
}
|
|
@@ -1201,10 +1207,10 @@ async function submitQuestion() {
|
|
|
1201
1207
|
if (!sel.length && !custom) return null
|
|
1202
1208
|
return ans
|
|
1203
1209
|
}).filter(Boolean)
|
|
1204
|
-
if (!answers.length) return toast('
|
|
1210
|
+
if (!answers.length) return toast(t('question.needAnswer'), 'err')
|
|
1205
1211
|
const ok = await respond(q.rpcId, { sessionId: q.sessionId, answer: { answers } })
|
|
1206
|
-
if (ok) { toast('
|
|
1207
|
-
else toast('
|
|
1212
|
+
if (ok) { toast(t('question.submitted'), 'ok'); $('modal-question').classList.add('hidden'); state.questions = state.questions.filter(x => x.rpcId !== q.rpcId); renderPending() }
|
|
1213
|
+
else toast(t('question.stale'), 'err')
|
|
1208
1214
|
}
|
|
1209
1215
|
|
|
1210
1216
|
/* ---------------- 后台任务 ---------------- */
|
|
@@ -1214,14 +1220,14 @@ function renderQueue() {
|
|
|
1214
1220
|
const items = state.queues[state.current] || []
|
|
1215
1221
|
updateCancelBtn()
|
|
1216
1222
|
// 队列数量在会话列表已显示; 详情页不重复大 UI
|
|
1217
|
-
$('history-hint').textContent = items.length ?
|
|
1223
|
+
$('history-hint').textContent = items.length ? t('history.queueAndCount', { q: items.length, n: state.history.visible.length }) : t('history.countOnly', { n: state.history.visible.length })
|
|
1218
1224
|
renderSessions()
|
|
1219
1225
|
}
|
|
1220
1226
|
|
|
1221
1227
|
function renderJobs() {
|
|
1222
1228
|
const box = $('jobs-list')
|
|
1223
1229
|
const all = Object.entries(state.jobs).filter(([, jobs]) => jobs?.length)
|
|
1224
|
-
if (!all.length) { box.innerHTML = '<div class="empty"
|
|
1230
|
+
if (!all.length) { box.innerHTML = '<div class="empty">' + t('jobs.empty') + '</div>'; return }
|
|
1225
1231
|
box.innerHTML = all.flatMap(([sid, jobs]) => jobs.map(j => {
|
|
1226
1232
|
const title = titleOf(state.byId.get(sid))
|
|
1227
1233
|
return `<div class="job-card">
|
|
@@ -1264,21 +1270,21 @@ function fsAuthError(status) {
|
|
|
1264
1270
|
|
|
1265
1271
|
async function loadFs(dir, { silent = false, resetRoot = false } = {}) {
|
|
1266
1272
|
if (!state.token) {
|
|
1267
|
-
$('fs-path').textContent = '
|
|
1268
|
-
$('fs-list').innerHTML = '<div class="empty"
|
|
1273
|
+
$('fs-path').textContent = t('fs.noToken')
|
|
1274
|
+
$('fs-list').innerHTML = '<div class="empty">' + t('fs.goSettings') + '</div>'
|
|
1269
1275
|
return
|
|
1270
1276
|
}
|
|
1271
1277
|
if (resetRoot) { state.fs.initial = null; state.fs.path = null }
|
|
1272
1278
|
const target = dir ?? state.fs.path ?? ''
|
|
1273
1279
|
if (!silent) {
|
|
1274
|
-
$('fs-list').innerHTML = '<div class="empty"
|
|
1275
|
-
$('fs-path').textContent = target ? '…' + target.slice(-40) : '
|
|
1280
|
+
$('fs-list').innerHTML = '<div class="empty">' + t('fs.loading') + '</div>'
|
|
1281
|
+
$('fs-path').textContent = target ? '…' + target.slice(-40) : t('fs.loading')
|
|
1276
1282
|
}
|
|
1277
1283
|
try {
|
|
1278
1284
|
const res = await fetch(fsApiUrl('/list', target ? { path: target } : {}), { headers: fsHeaders() })
|
|
1279
1285
|
if (res.status === 401) { fsAuthError(401); return }
|
|
1280
1286
|
const data = await res.json().catch(() => ({}))
|
|
1281
|
-
if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error === 'not-found' ? '
|
|
1287
|
+
if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error === 'not-found' ? t('fs.notFound') : data.error === 'forbidden' ? t('fs.forbidden') : data.error || ('HTTP ' + res.status))
|
|
1282
1288
|
state.fs.path = data.path
|
|
1283
1289
|
if (!state.fs.initial) state.fs.initial = data.path
|
|
1284
1290
|
state.fs.loaded = true
|
|
@@ -1286,8 +1292,8 @@ async function loadFs(dir, { silent = false, resetRoot = false } = {}) {
|
|
|
1286
1292
|
} catch (e) {
|
|
1287
1293
|
if (e.message === 'AUTH') return
|
|
1288
1294
|
$('fs-path').textContent = target || '~'
|
|
1289
|
-
$('fs-list').innerHTML = `<div class="empty"
|
|
1290
|
-
if (!silent) toast('
|
|
1295
|
+
$('fs-list').innerHTML = `<div class="empty">${esc(t('fs.loadFailed', { msg: e.message || t('fs.networkError') }))}</div>`
|
|
1296
|
+
if (!silent) toast(t('fs.loadFailedToast', { msg: e.message }), 'err')
|
|
1291
1297
|
}
|
|
1292
1298
|
}
|
|
1293
1299
|
|
|
@@ -1295,7 +1301,7 @@ function renderFs(data) {
|
|
|
1295
1301
|
$('fs-path').textContent = data.path || '~'
|
|
1296
1302
|
const list = $('fs-list')
|
|
1297
1303
|
if (!data.entries.length) {
|
|
1298
|
-
list.innerHTML = '<div class="empty"
|
|
1304
|
+
list.innerHTML = '<div class="empty">' + t('fs.emptyDir') + '</div>'
|
|
1299
1305
|
return
|
|
1300
1306
|
}
|
|
1301
1307
|
list.innerHTML = data.entries.map(e => {
|
|
@@ -1304,7 +1310,7 @@ function renderFs(data) {
|
|
|
1304
1310
|
<span class="fs-ico">${isDir ? '📁' : '📄'}</span>
|
|
1305
1311
|
<span class="fs-meta">
|
|
1306
1312
|
<span class="fs-name">${esc(e.name)}</span>
|
|
1307
|
-
<span class="fs-sub">${isDir ? '
|
|
1313
|
+
<span class="fs-sub">${isDir ? t('fs.dir') : fmtSize(e.size)} · ${fmtFullTime(e.mtimeMs)}</span>
|
|
1308
1314
|
</span>
|
|
1309
1315
|
<span class="fs-arrow">${isDir ? '›' : '↓'}</span>
|
|
1310
1316
|
</div>`
|
|
@@ -1327,13 +1333,13 @@ function downloadFsFile(name) {
|
|
|
1327
1333
|
if (window.NativeFile?.downloadToDownloads) {
|
|
1328
1334
|
try {
|
|
1329
1335
|
window.NativeFile.downloadToDownloads(url, name, state.token)
|
|
1330
|
-
toast('
|
|
1336
|
+
toast(t('fs.downloadStarted'), 'ok')
|
|
1331
1337
|
} catch (e) {
|
|
1332
|
-
toast('
|
|
1338
|
+
toast(t('fs.downloadFailed', { msg: e?.message || '' }), 'err')
|
|
1333
1339
|
}
|
|
1334
1340
|
return
|
|
1335
1341
|
}
|
|
1336
|
-
toast('
|
|
1342
|
+
toast(t('fs.downloadUnsupported'), 'err')
|
|
1337
1343
|
return
|
|
1338
1344
|
}
|
|
1339
1345
|
// 浏览器控制台: <a download> + ?token= 兜底(主通道仍是 Bearer 头)
|
|
@@ -1368,7 +1374,7 @@ function setFsButtons(show, paused = false) {
|
|
|
1368
1374
|
if (!pauseBtn || !cancelBtn) return
|
|
1369
1375
|
pauseBtn.classList.toggle('hidden', !show)
|
|
1370
1376
|
cancelBtn.classList.toggle('hidden', !show)
|
|
1371
|
-
if (show) pauseBtn.textContent = paused ? '
|
|
1377
|
+
if (show) pauseBtn.textContent = paused ? t('fs.resume') : t('fs.pause')
|
|
1372
1378
|
}
|
|
1373
1379
|
|
|
1374
1380
|
function pauseFsUpload() {
|
|
@@ -1402,7 +1408,7 @@ async function cancelFsUpload() {
|
|
|
1402
1408
|
state.fs.upload = null
|
|
1403
1409
|
hideFsProgress()
|
|
1404
1410
|
setFsButtons(false)
|
|
1405
|
-
toast('
|
|
1411
|
+
toast(t('fs.uploadCancelled'))
|
|
1406
1412
|
}
|
|
1407
1413
|
|
|
1408
1414
|
const FS_CHUNK_SIZE = 4 * 1024 * 1024 // 4MB/块, 断线后重选同一文件自动续传
|
|
@@ -1413,7 +1419,7 @@ async function hashFsRange(file, hasher, start, end, up) {
|
|
|
1413
1419
|
for (let off = start; off < end; off += step) {
|
|
1414
1420
|
const buf = await file.slice(off, Math.min(off + step, end)).arrayBuffer()
|
|
1415
1421
|
if (up?.pauseRequested) {
|
|
1416
|
-
const err = new Error('
|
|
1422
|
+
const err = new Error(t('fs.pausedErr')); err.code = 'PAUSED'; throw err
|
|
1417
1423
|
}
|
|
1418
1424
|
hasher.update(new Uint8Array(buf))
|
|
1419
1425
|
}
|
|
@@ -1421,9 +1427,9 @@ async function hashFsRange(file, hasher, start, end, up) {
|
|
|
1421
1427
|
|
|
1422
1428
|
function uploadFsFile(file) {
|
|
1423
1429
|
if (!file) return
|
|
1424
|
-
if (!state.token) { toast('
|
|
1425
|
-
if (file.size > 2 * 1024 * 1024 * 1024) { toast('
|
|
1426
|
-
if (state.fs.upload?.active) { toast('
|
|
1430
|
+
if (!state.token) { toast(t('fs.noTokenToast'), 'err'); showView('view-settings'); return }
|
|
1431
|
+
if (file.size > 2 * 1024 * 1024 * 1024) { toast(t('fs.tooLarge'), 'err'); return }
|
|
1432
|
+
if (state.fs.upload?.active) { toast(t('fs.uploadBusy'), 'err'); return }
|
|
1427
1433
|
|
|
1428
1434
|
const prev = state.fs.upload
|
|
1429
1435
|
if (prev && prev.path === state.fs.path && prev.name === file.name && prev.size === file.size) {
|
|
@@ -1475,11 +1481,11 @@ async function runFsUpload(up) {
|
|
|
1475
1481
|
try { json = JSON.parse(xhr.responseText || '{}') } catch {}
|
|
1476
1482
|
resolve({ status: xhr.status, json })
|
|
1477
1483
|
}
|
|
1478
|
-
xhr.onerror = () => { if (up.xhr === xhr) up.xhr = null; reject(new Error('
|
|
1479
|
-
xhr.upload.onerror = () => { if (up.xhr === xhr) up.xhr = null; reject(new Error('
|
|
1484
|
+
xhr.onerror = () => { if (up.xhr === xhr) up.xhr = null; reject(new Error(t('fs.networkError'))) }
|
|
1485
|
+
xhr.upload.onerror = () => { if (up.xhr === xhr) up.xhr = null; reject(new Error(t('fs.networkInterrupt'))) }
|
|
1480
1486
|
xhr.onabort = () => {
|
|
1481
1487
|
if (up.xhr === xhr) up.xhr = null
|
|
1482
|
-
const err = new Error(up.cancelled ? '
|
|
1488
|
+
const err = new Error(t(up.cancelled ? 'fs.cancelledErr' : 'fs.pausedErr'))
|
|
1483
1489
|
err.code = up.cancelled ? 'CANCELLED' : 'PAUSED'
|
|
1484
1490
|
reject(err)
|
|
1485
1491
|
}
|
|
@@ -1502,7 +1508,7 @@ async function runFsUpload(up) {
|
|
|
1502
1508
|
if (info === null || up.cancelled) return
|
|
1503
1509
|
if (info.partialSize > 0) wasResumed = true
|
|
1504
1510
|
if (info.targetExists && info.targetSize === up.size && !overwrite) {
|
|
1505
|
-
if (!confirm('
|
|
1511
|
+
if (!confirm(t('fs.confirmOverwrite'))) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
|
|
1506
1512
|
overwrite = true
|
|
1507
1513
|
}
|
|
1508
1514
|
if (up.offset > 0) await hashFsRange(up.file, hasher, 0, up.offset, up)
|
|
@@ -1515,7 +1521,7 @@ async function runFsUpload(up) {
|
|
|
1515
1521
|
const before = hasher.clone()
|
|
1516
1522
|
const chunkBytes = new Uint8Array(await blob.arrayBuffer())
|
|
1517
1523
|
if (up.pauseRequested) {
|
|
1518
|
-
const err = new Error('
|
|
1524
|
+
const err = new Error(t('fs.pausedErr')); err.code = 'PAUSED'; throw err
|
|
1519
1525
|
}
|
|
1520
1526
|
hasher.update(chunkBytes)
|
|
1521
1527
|
const isLast = end >= up.size
|
|
@@ -1528,19 +1534,19 @@ async function runFsUpload(up) {
|
|
|
1528
1534
|
if (r.status === 201) {
|
|
1529
1535
|
const expected = params.sha256 || hasher.hex()
|
|
1530
1536
|
if (r.json.sha256 && r.json.sha256 !== expected) {
|
|
1531
|
-
const err = new Error('
|
|
1537
|
+
const err = new Error(t('fs.checksumMismatch')); err.checksum = true
|
|
1532
1538
|
throw err
|
|
1533
1539
|
}
|
|
1534
1540
|
hideFsProgress()
|
|
1535
1541
|
setFsButtons(false)
|
|
1536
1542
|
state.fs.upload = null
|
|
1537
|
-
toast(
|
|
1543
|
+
toast(t('fs.uploadDone', { name: up.name, resumed: wasResumed ? t('fs.resumedSuffix') : '' }), 'ok')
|
|
1538
1544
|
loadFs()
|
|
1539
1545
|
return
|
|
1540
1546
|
}
|
|
1541
1547
|
if (r.status === 409 && r.json.error === 'conflict') {
|
|
1542
1548
|
hasher = before // 这段数据没被写入, 回退哈希状态后带 overwrite=1 重发
|
|
1543
|
-
if (!confirm('
|
|
1549
|
+
if (!confirm(t('fs.confirmOverwrite2'))) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
|
|
1544
1550
|
overwrite = true
|
|
1545
1551
|
continue
|
|
1546
1552
|
}
|
|
@@ -1550,7 +1556,7 @@ async function runFsUpload(up) {
|
|
|
1550
1556
|
continue
|
|
1551
1557
|
}
|
|
1552
1558
|
if (r.status === 422 && r.json.error === 'checksum-mismatch') {
|
|
1553
|
-
const err = new Error('
|
|
1559
|
+
const err = new Error(t('fs.checksumCleared'))
|
|
1554
1560
|
err.checksum = true
|
|
1555
1561
|
throw err
|
|
1556
1562
|
}
|
|
@@ -1566,24 +1572,24 @@ async function runFsUpload(up) {
|
|
|
1566
1572
|
const r = await uploadChunk(params, new Blob([]))
|
|
1567
1573
|
if (r.status === 401) { fsAuthError(401); return }
|
|
1568
1574
|
if (r.status === 409 && r.json.error === 'conflict') {
|
|
1569
|
-
if (!confirm('
|
|
1575
|
+
if (!confirm(t('fs.confirmOverwrite2'))) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
|
|
1570
1576
|
params.overwrite = '1'
|
|
1571
1577
|
return runFsUpload(up) // 目标冲突未写入, 重新走 probe + 空 finish
|
|
1572
1578
|
}
|
|
1573
1579
|
if (r.status === 422 && r.json.error === 'checksum-mismatch') {
|
|
1574
|
-
const err = new Error('
|
|
1580
|
+
const err = new Error(t('fs.checksumCleared'))
|
|
1575
1581
|
err.checksum = true
|
|
1576
1582
|
throw err
|
|
1577
1583
|
}
|
|
1578
1584
|
if (r.status !== 201) throw new Error(r.json.error || ('HTTP ' + r.status))
|
|
1579
1585
|
if (r.json.sha256 && r.json.sha256 !== expected) {
|
|
1580
|
-
const err = new Error('
|
|
1586
|
+
const err = new Error(t('fs.checksumMismatch')); err.checksum = true
|
|
1581
1587
|
throw err
|
|
1582
1588
|
}
|
|
1583
1589
|
hideFsProgress()
|
|
1584
1590
|
setFsButtons(false)
|
|
1585
1591
|
state.fs.upload = null
|
|
1586
|
-
toast(
|
|
1592
|
+
toast(t('fs.uploadDone', { name: up.name, resumed: wasResumed ? t('fs.resumedSuffix') : '' }), 'ok')
|
|
1587
1593
|
loadFs()
|
|
1588
1594
|
return
|
|
1589
1595
|
}
|
|
@@ -1596,8 +1602,8 @@ async function runFsUpload(up) {
|
|
|
1596
1602
|
if (e?.code === 'PAUSED') {
|
|
1597
1603
|
up.paused = true
|
|
1598
1604
|
setFsButtons(true, true)
|
|
1599
|
-
setFsProgressText(
|
|
1600
|
-
toast('
|
|
1605
|
+
setFsProgressText(t('fs.pausedPct', { pct: Math.round(up.offset / Math.max(1, up.size) * 100) }))
|
|
1606
|
+
toast(t('fs.pausedToast'), 'ok')
|
|
1601
1607
|
return
|
|
1602
1608
|
}
|
|
1603
1609
|
if (e?.checksum) {
|
|
@@ -1610,20 +1616,20 @@ async function runFsUpload(up) {
|
|
|
1610
1616
|
})
|
|
1611
1617
|
} catch {}
|
|
1612
1618
|
setFsButtons(true, true)
|
|
1613
|
-
setFsProgressText('
|
|
1619
|
+
setFsProgressText(t('fs.checksumFailed'))
|
|
1614
1620
|
toast(e.message, 'err')
|
|
1615
1621
|
return
|
|
1616
1622
|
}
|
|
1617
1623
|
hideFsProgress()
|
|
1618
1624
|
setFsButtons(false)
|
|
1619
|
-
toast(
|
|
1625
|
+
toast(t('fs.uploadInterrupted', { msg: e.message }), 'err')
|
|
1620
1626
|
}
|
|
1621
1627
|
}
|
|
1622
1628
|
|
|
1623
1629
|
function fsUp() {
|
|
1624
1630
|
if (!state.fs.path || !state.fs.initial) return
|
|
1625
1631
|
if (state.fs.path === state.fs.initial) {
|
|
1626
|
-
toast('
|
|
1632
|
+
toast(t('fs.alreadyRoot'))
|
|
1627
1633
|
return
|
|
1628
1634
|
}
|
|
1629
1635
|
loadFs(fsParent(state.fs.path))
|
|
@@ -1642,7 +1648,7 @@ function bindFsPullRefresh() {
|
|
|
1642
1648
|
const dy = e.touches[0].clientY - startY
|
|
1643
1649
|
if (dy > 4 && window.scrollY <= 0) {
|
|
1644
1650
|
pull.style.height = Math.min(64, dy / 2) + 'px'
|
|
1645
|
-
pull.textContent = dy > 80 ? '
|
|
1651
|
+
pull.textContent = dy > 80 ? t('fs.pullRelease') : t('fs.pullDown')
|
|
1646
1652
|
}
|
|
1647
1653
|
}, { passive: true })
|
|
1648
1654
|
view.addEventListener('touchend', () => {
|
|
@@ -1650,7 +1656,7 @@ function bindFsPullRefresh() {
|
|
|
1650
1656
|
const h = parseFloat(pull.style.height || '0')
|
|
1651
1657
|
startY = null
|
|
1652
1658
|
if (h >= 40) {
|
|
1653
|
-
pull.textContent = '
|
|
1659
|
+
pull.textContent = t('fs.refreshing')
|
|
1654
1660
|
loadFs(null, { silent: true })
|
|
1655
1661
|
}
|
|
1656
1662
|
pull.style.height = '0px'
|
|
@@ -1672,10 +1678,10 @@ async function submitGoalEdit() {
|
|
|
1672
1678
|
const goal = state.goalEdit
|
|
1673
1679
|
if (!goal) return
|
|
1674
1680
|
const objective = $('goal-edit-text')?.value?.trim()
|
|
1675
|
-
if (!objective) return toast('
|
|
1676
|
-
await safeRpc('goal.edit', { sessionId: state.current, ref: { id: goal.id, revision: goal.revision }, objective }, '
|
|
1681
|
+
if (!objective) return toast(t('goal.cannotEmpty'), 'err')
|
|
1682
|
+
await safeRpc('goal.edit', { sessionId: state.current, ref: { id: goal.id, revision: goal.revision }, objective }, t('goal.updateFailed'))
|
|
1677
1683
|
$('modal-goal').classList.add('hidden')
|
|
1678
|
-
toast('
|
|
1684
|
+
toast(t('goal.updated'), 'ok')
|
|
1679
1685
|
scheduleRefresh()
|
|
1680
1686
|
}
|
|
1681
1687
|
|
|
@@ -1707,41 +1713,66 @@ function cmpVersion(a, b) {
|
|
|
1707
1713
|
return 0
|
|
1708
1714
|
}
|
|
1709
1715
|
|
|
1716
|
+
function resetUpdateExpand() {
|
|
1717
|
+
const desc = $('update-desc')
|
|
1718
|
+
if (desc) desc.classList.remove('expanded')
|
|
1719
|
+
const btn = $('btn-update-expand')
|
|
1720
|
+
if (btn) btn.classList.add('hidden')
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
function renderUpdateExpandBtn() {
|
|
1724
|
+
const btn = $('btn-update-expand')
|
|
1725
|
+
if (!btn || btn.classList.contains('hidden')) return
|
|
1726
|
+
btn.textContent = t($('update-desc').classList.contains('expanded') ? 'update.collapse' : 'update.expand')
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
function toggleUpdateExpand() {
|
|
1730
|
+
$('update-desc').classList.toggle('expanded')
|
|
1731
|
+
renderUpdateExpandBtn()
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1710
1734
|
async function loadLocalVersion() {
|
|
1711
1735
|
try {
|
|
1712
1736
|
const res = await fetch('version.json?t=' + Date.now())
|
|
1713
1737
|
if (res.ok) state.localVersion = (await res.json())?.version || ''
|
|
1714
1738
|
} catch {}
|
|
1715
|
-
$('update-desc').textContent = state.localVersion ?
|
|
1739
|
+
$('update-desc').textContent = state.localVersion ? t('update.currentV', { version: state.localVersion }) : t('update.noVersion')
|
|
1716
1740
|
}
|
|
1717
1741
|
|
|
1718
1742
|
async function checkUpdate(silent) {
|
|
1719
1743
|
const base = state.server
|
|
1720
1744
|
if (!base) {
|
|
1721
|
-
if (!silent) toast('
|
|
1722
|
-
$('update-desc').textContent = state.localVersion ?
|
|
1745
|
+
if (!silent) toast(t('update.needServer'), 'err')
|
|
1746
|
+
$('update-desc').textContent = state.localVersion ? `${t('update.currentV', { version: state.localVersion })} · ${t('update.needServer')}` : t('update.needServer')
|
|
1747
|
+
resetUpdateExpand()
|
|
1723
1748
|
return
|
|
1724
1749
|
}
|
|
1725
|
-
if (!silent) toast('
|
|
1750
|
+
if (!silent) toast(t('update.checking'))
|
|
1726
1751
|
try {
|
|
1727
1752
|
const res = await fetch(base + '/update.json?t=' + Date.now() + '&local=' + encodeURIComponent(state.localVersion))
|
|
1728
1753
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
1729
1754
|
const info = await res.json()
|
|
1730
1755
|
if (info.version && cmpVersion(info.version, state.localVersion) > 0) {
|
|
1731
1756
|
state.updateInfo = info
|
|
1732
|
-
|
|
1757
|
+
const hasNotes = !!(info.notes && String(info.notes).trim())
|
|
1758
|
+
$('update-desc').textContent = t('update.found', { version: info.version, notes: hasNotes ? ':' + info.notes : '' })
|
|
1759
|
+
$('update-desc').classList.remove('expanded')
|
|
1760
|
+
$('btn-update-expand').classList.toggle('hidden', !hasNotes)
|
|
1761
|
+
renderUpdateExpandBtn()
|
|
1733
1762
|
$('btn-download-update').classList.remove('hidden')
|
|
1734
|
-
if (!silent) toast(
|
|
1735
|
-
else notify('
|
|
1763
|
+
if (!silent) toast(t('update.found', { version: info.version }), 'ok')
|
|
1764
|
+
else notify(t('update.foundTitle'), t('update.foundBody', { version: info.version }))
|
|
1736
1765
|
} else {
|
|
1737
1766
|
state.updateInfo = null
|
|
1738
|
-
$('update-desc').textContent = state.localVersion ?
|
|
1767
|
+
$('update-desc').textContent = state.localVersion ? t('update.latestV', { version: state.localVersion }) : t('update.latestRemote', { version: info.version || '?' })
|
|
1739
1768
|
$('btn-download-update').classList.add('hidden')
|
|
1740
|
-
|
|
1769
|
+
resetUpdateExpand()
|
|
1770
|
+
if (!silent) toast(t('update.latestToast'), 'ok')
|
|
1741
1771
|
}
|
|
1742
1772
|
} catch (e) {
|
|
1743
|
-
$('update-desc').textContent = '
|
|
1744
|
-
|
|
1773
|
+
$('update-desc').textContent = t('update.checkFailedDesc', { msg: e.message || t('fs.networkError') })
|
|
1774
|
+
resetUpdateExpand()
|
|
1775
|
+
if (!silent) toast(t('update.checkFailed', { msg: e.message }), 'err')
|
|
1745
1776
|
}
|
|
1746
1777
|
}
|
|
1747
1778
|
|
|
@@ -1757,14 +1788,14 @@ function downloadUpdate() {
|
|
|
1757
1788
|
if (window.NativeUpdate?.downloadAndInstall) {
|
|
1758
1789
|
try {
|
|
1759
1790
|
window.NativeUpdate.downloadAndInstall(url)
|
|
1760
|
-
toast('
|
|
1791
|
+
toast(t('update.downloadStarted'), 'ok')
|
|
1761
1792
|
} catch (e) {
|
|
1762
|
-
toast('
|
|
1793
|
+
toast(t('update.downloadFailed', { msg: e?.message || '' }), 'err')
|
|
1763
1794
|
}
|
|
1764
1795
|
return
|
|
1765
1796
|
}
|
|
1766
1797
|
// 兜底: 旧版 App 没有原生桥时用浏览器下载
|
|
1767
|
-
toast('
|
|
1798
|
+
toast(t('update.installUnsupported'), 'err')
|
|
1768
1799
|
}
|
|
1769
1800
|
// 浏览器: 直接触发下载
|
|
1770
1801
|
location.href = url
|
|
@@ -1781,7 +1812,7 @@ async function ensureNotify() {
|
|
|
1781
1812
|
const p = await L.requestPermissions()
|
|
1782
1813
|
return p?.display === 'granted'
|
|
1783
1814
|
} catch (e) {
|
|
1784
|
-
toast('
|
|
1815
|
+
toast(t('notify.permissionFailed', { msg: e?.message || '' }), 'err')
|
|
1785
1816
|
return false
|
|
1786
1817
|
}
|
|
1787
1818
|
}
|
|
@@ -1825,7 +1856,7 @@ function showView(id) {
|
|
|
1825
1856
|
function updateConn() {
|
|
1826
1857
|
const ok = !!state.streamsOk?.mux
|
|
1827
1858
|
const el = $('conn-badge')
|
|
1828
|
-
el.textContent = ok ? '
|
|
1859
|
+
el.textContent = ok ? t('conn.on') : t('conn.off')
|
|
1829
1860
|
el.className = 'conn-badge ' + (ok ? 'on' : 'off')
|
|
1830
1861
|
}
|
|
1831
1862
|
|
|
@@ -1840,16 +1871,16 @@ function applyPairUrl(url) {
|
|
|
1840
1871
|
try {
|
|
1841
1872
|
const u = new URL(String(url).trim())
|
|
1842
1873
|
if (u.protocol !== 'dshremote:' || u.hostname !== 'pair') return false
|
|
1843
|
-
const
|
|
1874
|
+
const tok = (u.searchParams.get('token') || '').trim()
|
|
1844
1875
|
const server = (u.searchParams.get('server') || '').trim().replace(/\/+$/, '')
|
|
1845
|
-
if (!
|
|
1846
|
-
state.token =
|
|
1847
|
-
LS.set('token',
|
|
1876
|
+
if (!tok || !/^https?:\/\//i.test(server)) return false
|
|
1877
|
+
state.token = tok
|
|
1878
|
+
LS.set('token', tok)
|
|
1848
1879
|
state.server = server
|
|
1849
1880
|
if (!state.servers.includes(server)) state.servers.unshift(server)
|
|
1850
1881
|
saveServers()
|
|
1851
1882
|
renderServers()
|
|
1852
|
-
$('token-desc').textContent = '
|
|
1883
|
+
$('token-desc').textContent = t('token.savedScan')
|
|
1853
1884
|
return true
|
|
1854
1885
|
} catch {
|
|
1855
1886
|
return false
|
|
@@ -1861,7 +1892,7 @@ async function decodeQrDataUrl(dataUrl) {
|
|
|
1861
1892
|
const img = new Image()
|
|
1862
1893
|
await new Promise((resolve, reject) => {
|
|
1863
1894
|
img.onload = resolve
|
|
1864
|
-
img.onerror = () => reject(new Error('
|
|
1895
|
+
img.onerror = () => reject(new Error(t('scan.imageLoadFailed')))
|
|
1865
1896
|
img.src = dataUrl
|
|
1866
1897
|
})
|
|
1867
1898
|
const maxSide = 1600
|
|
@@ -1872,7 +1903,7 @@ async function decodeQrDataUrl(dataUrl) {
|
|
|
1872
1903
|
canvas.width = w
|
|
1873
1904
|
canvas.height = h
|
|
1874
1905
|
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
|
1875
|
-
if (!ctx) throw new Error('
|
|
1906
|
+
if (!ctx) throw new Error(t('scan.decodeUnsupported'))
|
|
1876
1907
|
ctx.drawImage(img, 0, 0, w, h)
|
|
1877
1908
|
const imageData = ctx.getImageData(0, 0, w, h)
|
|
1878
1909
|
const code = window.jsQR?.(imageData.data, w, h, { inversionAttempts: 'attemptBoth' })
|
|
@@ -1884,37 +1915,37 @@ async function decodeQrDataUrl(dataUrl) {
|
|
|
1884
1915
|
* 冗余路径 2: 设置页手动粘贴令牌。 */
|
|
1885
1916
|
async function scanPair() {
|
|
1886
1917
|
if (!CAP?.isNativePlatform?.()) {
|
|
1887
|
-
toast('
|
|
1918
|
+
toast(t('scan.browserHint'), 'err')
|
|
1888
1919
|
return
|
|
1889
1920
|
}
|
|
1890
1921
|
const camera = CAP.Plugins?.Camera
|
|
1891
|
-
if (!camera?.getPhoto) { toast('
|
|
1922
|
+
if (!camera?.getPhoto) { toast(t('scan.unsupported'), 'err'); return }
|
|
1892
1923
|
try {
|
|
1893
1924
|
const perm = await camera.requestPermissions?.({ permissions: ['camera'] })
|
|
1894
|
-
if (perm && perm.camera !== 'granted') { toast('
|
|
1925
|
+
if (perm && perm.camera !== 'granted') { toast(t('scan.permissionDenied'), 'err'); return }
|
|
1895
1926
|
const photo = await camera.getPhoto({
|
|
1896
1927
|
resultType: 'dataUrl',
|
|
1897
1928
|
source: 'PROMPT', // 拍照 / 从相册选择都行, 相册可扫截图
|
|
1898
1929
|
quality: 85,
|
|
1899
1930
|
correctOrientation: true,
|
|
1900
1931
|
saveToGallery: false,
|
|
1901
|
-
promptLabelHeader: '
|
|
1902
|
-
promptLabelPhoto: '
|
|
1903
|
-
promptLabelPicture: '
|
|
1932
|
+
promptLabelHeader: t('scan.promptHeader'),
|
|
1933
|
+
promptLabelPhoto: t('scan.promptPhoto'),
|
|
1934
|
+
promptLabelPicture: t('scan.promptGallery'),
|
|
1904
1935
|
})
|
|
1905
|
-
if (!photo?.dataUrl) { toast('
|
|
1936
|
+
if (!photo?.dataUrl) { toast(t('scan.noPhoto'), 'err'); return }
|
|
1906
1937
|
const raw = await decodeQrDataUrl(photo.dataUrl)
|
|
1907
|
-
if (!raw) { toast('
|
|
1938
|
+
if (!raw) { toast(t('scan.noQr'), 'err'); return }
|
|
1908
1939
|
if (applyPairUrl(raw)) {
|
|
1909
|
-
toast('
|
|
1940
|
+
toast(t('scan.paired'), 'ok')
|
|
1910
1941
|
openStreams()
|
|
1911
1942
|
refreshAll()
|
|
1912
1943
|
} else {
|
|
1913
|
-
toast('
|
|
1944
|
+
toast(t('scan.notPair'), 'err')
|
|
1914
1945
|
}
|
|
1915
1946
|
} catch (e) {
|
|
1916
1947
|
const msg = String(e?.message || e || '')
|
|
1917
|
-
toast(/cancel/i.test(msg) ? '
|
|
1948
|
+
toast(/cancel/i.test(msg) ? t('scan.cancelled') : t('scan.failed', { msg }), 'err')
|
|
1918
1949
|
}
|
|
1919
1950
|
}
|
|
1920
1951
|
|
|
@@ -1924,7 +1955,7 @@ function bindNativeLinks() {
|
|
|
1924
1955
|
try {
|
|
1925
1956
|
CAP.Plugins?.App?.addListener?.('appUrlOpen', (data) => {
|
|
1926
1957
|
if (data?.url && applyPairUrl(data.url)) {
|
|
1927
|
-
toast('
|
|
1958
|
+
toast(t('scan.pairedLink'), 'ok')
|
|
1928
1959
|
openStreams()
|
|
1929
1960
|
refreshAll()
|
|
1930
1961
|
}
|
|
@@ -1945,11 +1976,71 @@ function initToken() {
|
|
|
1945
1976
|
state.token = LS.get('token', '')
|
|
1946
1977
|
}
|
|
1947
1978
|
loadServers()
|
|
1948
|
-
$('token-desc').textContent = state.token ? '
|
|
1949
|
-
$('server-desc').textContent = state.server || '
|
|
1979
|
+
$('token-desc').textContent = state.token ? t('token.savedLocal') : t('token.notSet')
|
|
1980
|
+
$('server-desc').textContent = state.server || t('servers.defaultDesc')
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
function renderLangBtn() {
|
|
1984
|
+
const btn = $('btn-lang')
|
|
1985
|
+
if (btn) btn.textContent = I18N.lang === 'zh' ? 'EN' : '中文'
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
const THEME_META = [
|
|
1989
|
+
{ id: 'default', sw: ['#0B0E1A', '#151B33', '#5B8CFF'] },
|
|
1990
|
+
{ id: 'dark', sw: ['#05348B', '#0D438F', '#F9A647'] },
|
|
1991
|
+
{ id: 'light', sw: ['#EFEEEC', '#FAF8F5', '#E6BC7B'] },
|
|
1992
|
+
{ id: 'neutral', sw: ['#DDD4B8', '#585818', '#832D15'] }
|
|
1993
|
+
]
|
|
1994
|
+
|
|
1995
|
+
function renderThemeBtn() {
|
|
1996
|
+
const btn = $('btn-theme')
|
|
1997
|
+
if (btn) btn.textContent = t('theme.' + window.DSHTheme.get())
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
function renderThemeOptions() {
|
|
2001
|
+
const box = $('theme-options')
|
|
2002
|
+
if (!box) return
|
|
2003
|
+
const cur = window.DSHTheme.get()
|
|
2004
|
+
box.innerHTML = THEME_META.map(m => `
|
|
2005
|
+
<button class="theme-option ${m.id === cur ? 'current' : ''}" data-theme="${m.id}" title="${t('theme.' + m.id)}">
|
|
2006
|
+
<span class="theme-swatches">${m.sw.map(c => `<i style="background:${c}"></i>`).join('')}</span>
|
|
2007
|
+
<span class="theme-name">${t('theme.' + m.id)}</span>
|
|
2008
|
+
<span class="theme-check">${m.id === cur ? '✓' : ''}</span>
|
|
2009
|
+
</button>`).join('')
|
|
2010
|
+
box.querySelectorAll('.theme-option').forEach(btn =>
|
|
2011
|
+
btn.addEventListener('click', () => {
|
|
2012
|
+
window.DSHTheme.set(btn.dataset.theme)
|
|
2013
|
+
renderThemeBtn()
|
|
2014
|
+
renderThemeOptions()
|
|
2015
|
+
$('modal-theme').classList.add('hidden')
|
|
2016
|
+
}))
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
function openThemePanel() {
|
|
2020
|
+
renderThemeOptions()
|
|
2021
|
+
$('modal-theme').classList.remove('hidden')
|
|
1950
2022
|
}
|
|
1951
2023
|
|
|
1952
2024
|
function bindUi() {
|
|
2025
|
+
renderLangBtn()
|
|
2026
|
+
renderThemeBtn()
|
|
2027
|
+
$('btn-lang').addEventListener('click', () => {
|
|
2028
|
+
I18N.setLang(I18N.lang === 'zh' ? 'en' : 'zh')
|
|
2029
|
+
renderLangBtn()
|
|
2030
|
+
renderThemeBtn()
|
|
2031
|
+
renderUpdateExpandBtn()
|
|
2032
|
+
renderServers()
|
|
2033
|
+
renderSessions()
|
|
2034
|
+
renderPending(); renderQueue(); renderJobs()
|
|
2035
|
+
updateConn()
|
|
2036
|
+
if (state.current) { renderSessionTitle(); renderSessionSub(); renderSessionCards(); renderHistory(true) }
|
|
2037
|
+
else renderModelMenu()
|
|
2038
|
+
loadLocalVersion()
|
|
2039
|
+
if (state.hostInfo) $('host-desc').textContent = t('settings.hostDesc', { version: state.hostInfo.version, cwd: state.hostInfo.cwd, n: state.hostInfo.attachedSessions })
|
|
2040
|
+
$('token-desc').textContent = state.token ? t('token.savedLocal') : t('token.notSet')
|
|
2041
|
+
})
|
|
2042
|
+
$('btn-theme').addEventListener('click', openThemePanel)
|
|
2043
|
+
$('theme-close').addEventListener('click', () => $('modal-theme').classList.add('hidden'))
|
|
1953
2044
|
renderServers()
|
|
1954
2045
|
// 底部导航
|
|
1955
2046
|
document.querySelectorAll('.nav-btn').forEach(b =>
|
|
@@ -1962,7 +2053,7 @@ function bindUi() {
|
|
|
1962
2053
|
$('btn-back').addEventListener('click', closeSession)
|
|
1963
2054
|
$('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
|
|
1964
2055
|
$('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
|
|
1965
|
-
$('btn-refresh').addEventListener('click', () => { toast('
|
|
2056
|
+
$('btn-refresh').addEventListener('click', () => { toast(t('common.refreshing')); openStreams(); refreshAll() })
|
|
1966
2057
|
$('btn-admin').addEventListener('click', () => {
|
|
1967
2058
|
location.href = state.server ? state.server.replace(/\/+$/, '') + '/admin' : 'admin'
|
|
1968
2059
|
})
|
|
@@ -1999,8 +2090,8 @@ function bindUi() {
|
|
|
1999
2090
|
// 设置
|
|
2000
2091
|
$('btn-scan-pair').addEventListener('click', scanPair)
|
|
2001
2092
|
$('btn-change-token').addEventListener('click', () => {
|
|
2002
|
-
const
|
|
2003
|
-
if (
|
|
2093
|
+
const input = prompt(t('token.prompt'), state.token)
|
|
2094
|
+
if (input && input.trim()) { state.token = input.trim(); LS.set('token', input.trim()); $('token-desc').textContent = t('token.saved'); toast(t('token.savedReconnect'), 'ok'); openStreams(); refreshAll() }
|
|
2004
2095
|
})
|
|
2005
2096
|
$('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
|
|
2006
2097
|
$('btn-server-add').addEventListener('click', addServer)
|
|
@@ -2008,16 +2099,17 @@ function bindUi() {
|
|
|
2008
2099
|
if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); addServer() }
|
|
2009
2100
|
})
|
|
2010
2101
|
$('btn-host-describe').addEventListener('click', async () => {
|
|
2011
|
-
const v = await safeRpc('host.describe', {}, '
|
|
2102
|
+
const v = await safeRpc('host.describe', {}, t('settings.probeFailed'))
|
|
2012
2103
|
if (v) {
|
|
2013
2104
|
state.hostInfo = v
|
|
2014
|
-
$('host-desc').textContent =
|
|
2105
|
+
$('host-desc').textContent = t('settings.hostDesc', { version: v.version, cwd: v.cwd, n: v.attachedSessions })
|
|
2015
2106
|
}
|
|
2016
2107
|
})
|
|
2017
2108
|
$('btn-check-update').addEventListener('click', () => checkUpdate(false))
|
|
2018
2109
|
$('btn-download-update').addEventListener('click', downloadUpdate)
|
|
2110
|
+
$('btn-update-expand').addEventListener('click', toggleUpdateExpand)
|
|
2019
2111
|
$('btn-reset').addEventListener('click', () => {
|
|
2020
|
-
if (!confirm('
|
|
2112
|
+
if (!confirm(t('settings.confirmReset'))) return
|
|
2021
2113
|
LS.del('token'); LS.del('notify'); LS.del('server')
|
|
2022
2114
|
location.reload()
|
|
2023
2115
|
})
|
|
@@ -2025,7 +2117,7 @@ function bindUi() {
|
|
|
2025
2117
|
$('opt-notify').addEventListener('change', async (e) => {
|
|
2026
2118
|
if (e.target.checked) {
|
|
2027
2119
|
const ok = await ensureNotify()
|
|
2028
|
-
if (!ok) { e.target.checked = false; return toast('
|
|
2120
|
+
if (!ok) { e.target.checked = false; return toast(t('settings.notifyDenied')) }
|
|
2029
2121
|
}
|
|
2030
2122
|
LS.set('notify', e.target.checked ? '1' : '0')
|
|
2031
2123
|
})
|
|
@@ -2033,12 +2125,12 @@ function bindUi() {
|
|
|
2033
2125
|
$('opt-tools').addEventListener('change', (e) => {
|
|
2034
2126
|
LS.set('showTools', e.target.checked ? '1' : '0')
|
|
2035
2127
|
if (state.current) renderHistory(true)
|
|
2036
|
-
toast(e.target.checked ? '
|
|
2128
|
+
toast(e.target.checked ? t('settings.toolsShown') : t('settings.toolsHidden'), 'ok')
|
|
2037
2129
|
})
|
|
2038
2130
|
|
|
2039
2131
|
// 文件页
|
|
2040
2132
|
$('fs-up').addEventListener('click', fsUp)
|
|
2041
|
-
$('fs-refresh').addEventListener('click', () => { toast('
|
|
2133
|
+
$('fs-refresh').addEventListener('click', () => { toast(t('common.refreshing')); loadFs() })
|
|
2042
2134
|
$('fs-upload-btn').addEventListener('click', () => $('fs-file-input').click())
|
|
2043
2135
|
$('fs-file-input').addEventListener('change', (e) => {
|
|
2044
2136
|
const f = e.target.files?.[0]
|
|
@@ -2088,6 +2180,7 @@ function applyNativeInsets() {
|
|
|
2088
2180
|
async function boot() {
|
|
2089
2181
|
initToken()
|
|
2090
2182
|
bindUi()
|
|
2183
|
+
renderLangBtn()
|
|
2091
2184
|
bindNativeBack()
|
|
2092
2185
|
bindNativeLinks()
|
|
2093
2186
|
applyNativeInsets()
|
|
@@ -2095,14 +2188,14 @@ async function boot() {
|
|
|
2095
2188
|
loadLocalVersion()
|
|
2096
2189
|
if (!state.token) {
|
|
2097
2190
|
showView('view-settings')
|
|
2098
|
-
$('token-desc').textContent = '
|
|
2191
|
+
$('token-desc').textContent = t('token.notSetHint')
|
|
2099
2192
|
} else {
|
|
2100
2193
|
// 多服务器: 启动时静默测速一次, 选最快的连接(同源页面也参与比较)
|
|
2101
2194
|
await selectFastestServer({ silent: true, reconnect: false })
|
|
2102
2195
|
openStreams()
|
|
2103
2196
|
await refreshAll()
|
|
2104
2197
|
const host = await safeRpc('host.describe', {}, '')
|
|
2105
|
-
if (host) { state.hostInfo = host; $('host-desc').textContent =
|
|
2198
|
+
if (host) { state.hostInfo = host; $('host-desc').textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions }) }
|
|
2106
2199
|
// 启动后自动检查一次更新(静默)
|
|
2107
2200
|
setTimeout(() => checkUpdate(true), 4000)
|
|
2108
2201
|
}
|