dsh-remote-plugin 0.5.2 → 0.5.3
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 +177 -27
- package/public/admin.js +89 -68
- package/public/app.js +227 -201
- package/public/i18n.js +52 -0
- package/public/index.html +301 -66
- 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
|
|
|
@@ -1712,36 +1718,36 @@ async function loadLocalVersion() {
|
|
|
1712
1718
|
const res = await fetch('version.json?t=' + Date.now())
|
|
1713
1719
|
if (res.ok) state.localVersion = (await res.json())?.version || ''
|
|
1714
1720
|
} catch {}
|
|
1715
|
-
$('update-desc').textContent = state.localVersion ?
|
|
1721
|
+
$('update-desc').textContent = state.localVersion ? t('update.currentV', { version: state.localVersion }) : t('update.noVersion')
|
|
1716
1722
|
}
|
|
1717
1723
|
|
|
1718
1724
|
async function checkUpdate(silent) {
|
|
1719
1725
|
const base = state.server
|
|
1720
1726
|
if (!base) {
|
|
1721
|
-
if (!silent) toast('
|
|
1722
|
-
$('update-desc').textContent = state.localVersion ?
|
|
1727
|
+
if (!silent) toast(t('update.needServer'), 'err')
|
|
1728
|
+
$('update-desc').textContent = state.localVersion ? `${t('update.currentV', { version: state.localVersion })} · ${t('update.needServer')}` : t('update.needServer')
|
|
1723
1729
|
return
|
|
1724
1730
|
}
|
|
1725
|
-
if (!silent) toast('
|
|
1731
|
+
if (!silent) toast(t('update.checking'))
|
|
1726
1732
|
try {
|
|
1727
1733
|
const res = await fetch(base + '/update.json?t=' + Date.now() + '&local=' + encodeURIComponent(state.localVersion))
|
|
1728
1734
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
1729
1735
|
const info = await res.json()
|
|
1730
1736
|
if (info.version && cmpVersion(info.version, state.localVersion) > 0) {
|
|
1731
1737
|
state.updateInfo = info
|
|
1732
|
-
$('update-desc').textContent =
|
|
1738
|
+
$('update-desc').textContent = t('update.found', { version: info.version, notes: info.notes ? ':' + info.notes : '' })
|
|
1733
1739
|
$('btn-download-update').classList.remove('hidden')
|
|
1734
|
-
if (!silent) toast(
|
|
1735
|
-
else notify('
|
|
1740
|
+
if (!silent) toast(t('update.found', { version: info.version }), 'ok')
|
|
1741
|
+
else notify(t('update.foundTitle'), t('update.foundBody', { version: info.version }))
|
|
1736
1742
|
} else {
|
|
1737
1743
|
state.updateInfo = null
|
|
1738
|
-
$('update-desc').textContent = state.localVersion ?
|
|
1744
|
+
$('update-desc').textContent = state.localVersion ? t('update.latestV', { version: state.localVersion }) : t('update.latestRemote', { version: info.version || '?' })
|
|
1739
1745
|
$('btn-download-update').classList.add('hidden')
|
|
1740
|
-
if (!silent) toast('
|
|
1746
|
+
if (!silent) toast(t('update.latestToast'), 'ok')
|
|
1741
1747
|
}
|
|
1742
1748
|
} catch (e) {
|
|
1743
|
-
$('update-desc').textContent = '
|
|
1744
|
-
if (!silent) toast('
|
|
1749
|
+
$('update-desc').textContent = t('update.checkFailedDesc', { msg: e.message || t('fs.networkError') })
|
|
1750
|
+
if (!silent) toast(t('update.checkFailed', { msg: e.message }), 'err')
|
|
1745
1751
|
}
|
|
1746
1752
|
}
|
|
1747
1753
|
|
|
@@ -1757,14 +1763,14 @@ function downloadUpdate() {
|
|
|
1757
1763
|
if (window.NativeUpdate?.downloadAndInstall) {
|
|
1758
1764
|
try {
|
|
1759
1765
|
window.NativeUpdate.downloadAndInstall(url)
|
|
1760
|
-
toast('
|
|
1766
|
+
toast(t('update.downloadStarted'), 'ok')
|
|
1761
1767
|
} catch (e) {
|
|
1762
|
-
toast('
|
|
1768
|
+
toast(t('update.downloadFailed', { msg: e?.message || '' }), 'err')
|
|
1763
1769
|
}
|
|
1764
1770
|
return
|
|
1765
1771
|
}
|
|
1766
1772
|
// 兜底: 旧版 App 没有原生桥时用浏览器下载
|
|
1767
|
-
toast('
|
|
1773
|
+
toast(t('update.installUnsupported'), 'err')
|
|
1768
1774
|
}
|
|
1769
1775
|
// 浏览器: 直接触发下载
|
|
1770
1776
|
location.href = url
|
|
@@ -1781,7 +1787,7 @@ async function ensureNotify() {
|
|
|
1781
1787
|
const p = await L.requestPermissions()
|
|
1782
1788
|
return p?.display === 'granted'
|
|
1783
1789
|
} catch (e) {
|
|
1784
|
-
toast('
|
|
1790
|
+
toast(t('notify.permissionFailed', { msg: e?.message || '' }), 'err')
|
|
1785
1791
|
return false
|
|
1786
1792
|
}
|
|
1787
1793
|
}
|
|
@@ -1825,7 +1831,7 @@ function showView(id) {
|
|
|
1825
1831
|
function updateConn() {
|
|
1826
1832
|
const ok = !!state.streamsOk?.mux
|
|
1827
1833
|
const el = $('conn-badge')
|
|
1828
|
-
el.textContent = ok ? '
|
|
1834
|
+
el.textContent = ok ? t('conn.on') : t('conn.off')
|
|
1829
1835
|
el.className = 'conn-badge ' + (ok ? 'on' : 'off')
|
|
1830
1836
|
}
|
|
1831
1837
|
|
|
@@ -1840,16 +1846,16 @@ function applyPairUrl(url) {
|
|
|
1840
1846
|
try {
|
|
1841
1847
|
const u = new URL(String(url).trim())
|
|
1842
1848
|
if (u.protocol !== 'dshremote:' || u.hostname !== 'pair') return false
|
|
1843
|
-
const
|
|
1849
|
+
const tok = (u.searchParams.get('token') || '').trim()
|
|
1844
1850
|
const server = (u.searchParams.get('server') || '').trim().replace(/\/+$/, '')
|
|
1845
|
-
if (!
|
|
1846
|
-
state.token =
|
|
1847
|
-
LS.set('token',
|
|
1851
|
+
if (!tok || !/^https?:\/\//i.test(server)) return false
|
|
1852
|
+
state.token = tok
|
|
1853
|
+
LS.set('token', tok)
|
|
1848
1854
|
state.server = server
|
|
1849
1855
|
if (!state.servers.includes(server)) state.servers.unshift(server)
|
|
1850
1856
|
saveServers()
|
|
1851
1857
|
renderServers()
|
|
1852
|
-
$('token-desc').textContent = '
|
|
1858
|
+
$('token-desc').textContent = t('token.savedScan')
|
|
1853
1859
|
return true
|
|
1854
1860
|
} catch {
|
|
1855
1861
|
return false
|
|
@@ -1861,7 +1867,7 @@ async function decodeQrDataUrl(dataUrl) {
|
|
|
1861
1867
|
const img = new Image()
|
|
1862
1868
|
await new Promise((resolve, reject) => {
|
|
1863
1869
|
img.onload = resolve
|
|
1864
|
-
img.onerror = () => reject(new Error('
|
|
1870
|
+
img.onerror = () => reject(new Error(t('scan.imageLoadFailed')))
|
|
1865
1871
|
img.src = dataUrl
|
|
1866
1872
|
})
|
|
1867
1873
|
const maxSide = 1600
|
|
@@ -1872,7 +1878,7 @@ async function decodeQrDataUrl(dataUrl) {
|
|
|
1872
1878
|
canvas.width = w
|
|
1873
1879
|
canvas.height = h
|
|
1874
1880
|
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
|
1875
|
-
if (!ctx) throw new Error('
|
|
1881
|
+
if (!ctx) throw new Error(t('scan.decodeUnsupported'))
|
|
1876
1882
|
ctx.drawImage(img, 0, 0, w, h)
|
|
1877
1883
|
const imageData = ctx.getImageData(0, 0, w, h)
|
|
1878
1884
|
const code = window.jsQR?.(imageData.data, w, h, { inversionAttempts: 'attemptBoth' })
|
|
@@ -1884,37 +1890,37 @@ async function decodeQrDataUrl(dataUrl) {
|
|
|
1884
1890
|
* 冗余路径 2: 设置页手动粘贴令牌。 */
|
|
1885
1891
|
async function scanPair() {
|
|
1886
1892
|
if (!CAP?.isNativePlatform?.()) {
|
|
1887
|
-
toast('
|
|
1893
|
+
toast(t('scan.browserHint'), 'err')
|
|
1888
1894
|
return
|
|
1889
1895
|
}
|
|
1890
1896
|
const camera = CAP.Plugins?.Camera
|
|
1891
|
-
if (!camera?.getPhoto) { toast('
|
|
1897
|
+
if (!camera?.getPhoto) { toast(t('scan.unsupported'), 'err'); return }
|
|
1892
1898
|
try {
|
|
1893
1899
|
const perm = await camera.requestPermissions?.({ permissions: ['camera'] })
|
|
1894
|
-
if (perm && perm.camera !== 'granted') { toast('
|
|
1900
|
+
if (perm && perm.camera !== 'granted') { toast(t('scan.permissionDenied'), 'err'); return }
|
|
1895
1901
|
const photo = await camera.getPhoto({
|
|
1896
1902
|
resultType: 'dataUrl',
|
|
1897
1903
|
source: 'PROMPT', // 拍照 / 从相册选择都行, 相册可扫截图
|
|
1898
1904
|
quality: 85,
|
|
1899
1905
|
correctOrientation: true,
|
|
1900
1906
|
saveToGallery: false,
|
|
1901
|
-
promptLabelHeader: '
|
|
1902
|
-
promptLabelPhoto: '
|
|
1903
|
-
promptLabelPicture: '
|
|
1907
|
+
promptLabelHeader: t('scan.promptHeader'),
|
|
1908
|
+
promptLabelPhoto: t('scan.promptPhoto'),
|
|
1909
|
+
promptLabelPicture: t('scan.promptGallery'),
|
|
1904
1910
|
})
|
|
1905
|
-
if (!photo?.dataUrl) { toast('
|
|
1911
|
+
if (!photo?.dataUrl) { toast(t('scan.noPhoto'), 'err'); return }
|
|
1906
1912
|
const raw = await decodeQrDataUrl(photo.dataUrl)
|
|
1907
|
-
if (!raw) { toast('
|
|
1913
|
+
if (!raw) { toast(t('scan.noQr'), 'err'); return }
|
|
1908
1914
|
if (applyPairUrl(raw)) {
|
|
1909
|
-
toast('
|
|
1915
|
+
toast(t('scan.paired'), 'ok')
|
|
1910
1916
|
openStreams()
|
|
1911
1917
|
refreshAll()
|
|
1912
1918
|
} else {
|
|
1913
|
-
toast('
|
|
1919
|
+
toast(t('scan.notPair'), 'err')
|
|
1914
1920
|
}
|
|
1915
1921
|
} catch (e) {
|
|
1916
1922
|
const msg = String(e?.message || e || '')
|
|
1917
|
-
toast(/cancel/i.test(msg) ? '
|
|
1923
|
+
toast(/cancel/i.test(msg) ? t('scan.cancelled') : t('scan.failed', { msg }), 'err')
|
|
1918
1924
|
}
|
|
1919
1925
|
}
|
|
1920
1926
|
|
|
@@ -1924,7 +1930,7 @@ function bindNativeLinks() {
|
|
|
1924
1930
|
try {
|
|
1925
1931
|
CAP.Plugins?.App?.addListener?.('appUrlOpen', (data) => {
|
|
1926
1932
|
if (data?.url && applyPairUrl(data.url)) {
|
|
1927
|
-
toast('
|
|
1933
|
+
toast(t('scan.pairedLink'), 'ok')
|
|
1928
1934
|
openStreams()
|
|
1929
1935
|
refreshAll()
|
|
1930
1936
|
}
|
|
@@ -1945,11 +1951,30 @@ function initToken() {
|
|
|
1945
1951
|
state.token = LS.get('token', '')
|
|
1946
1952
|
}
|
|
1947
1953
|
loadServers()
|
|
1948
|
-
$('token-desc').textContent = state.token ? '
|
|
1949
|
-
$('server-desc').textContent = state.server || '
|
|
1954
|
+
$('token-desc').textContent = state.token ? t('token.savedLocal') : t('token.notSet')
|
|
1955
|
+
$('server-desc').textContent = state.server || t('servers.defaultDesc')
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
function renderLangBtn() {
|
|
1959
|
+
const btn = $('btn-lang')
|
|
1960
|
+
if (btn) btn.textContent = I18N.lang === 'zh' ? 'EN' : '中文'
|
|
1950
1961
|
}
|
|
1951
1962
|
|
|
1952
1963
|
function bindUi() {
|
|
1964
|
+
renderLangBtn()
|
|
1965
|
+
$('btn-lang').addEventListener('click', () => {
|
|
1966
|
+
I18N.setLang(I18N.lang === 'zh' ? 'en' : 'zh')
|
|
1967
|
+
renderLangBtn()
|
|
1968
|
+
renderServers()
|
|
1969
|
+
renderSessions()
|
|
1970
|
+
renderPending(); renderQueue(); renderJobs()
|
|
1971
|
+
updateConn()
|
|
1972
|
+
if (state.current) { renderSessionTitle(); renderSessionSub(); renderSessionCards(); renderHistory(true) }
|
|
1973
|
+
else renderModelMenu()
|
|
1974
|
+
loadLocalVersion()
|
|
1975
|
+
if (state.hostInfo) $('host-desc').textContent = t('settings.hostDesc', { version: state.hostInfo.version, cwd: state.hostInfo.cwd, n: state.hostInfo.attachedSessions })
|
|
1976
|
+
$('token-desc').textContent = state.token ? t('token.savedLocal') : t('token.notSet')
|
|
1977
|
+
})
|
|
1953
1978
|
renderServers()
|
|
1954
1979
|
// 底部导航
|
|
1955
1980
|
document.querySelectorAll('.nav-btn').forEach(b =>
|
|
@@ -1962,7 +1987,7 @@ function bindUi() {
|
|
|
1962
1987
|
$('btn-back').addEventListener('click', closeSession)
|
|
1963
1988
|
$('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
|
|
1964
1989
|
$('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
|
|
1965
|
-
$('btn-refresh').addEventListener('click', () => { toast('
|
|
1990
|
+
$('btn-refresh').addEventListener('click', () => { toast(t('common.refreshing')); openStreams(); refreshAll() })
|
|
1966
1991
|
$('btn-admin').addEventListener('click', () => {
|
|
1967
1992
|
location.href = state.server ? state.server.replace(/\/+$/, '') + '/admin' : 'admin'
|
|
1968
1993
|
})
|
|
@@ -1999,8 +2024,8 @@ function bindUi() {
|
|
|
1999
2024
|
// 设置
|
|
2000
2025
|
$('btn-scan-pair').addEventListener('click', scanPair)
|
|
2001
2026
|
$('btn-change-token').addEventListener('click', () => {
|
|
2002
|
-
const
|
|
2003
|
-
if (
|
|
2027
|
+
const input = prompt(t('token.prompt'), state.token)
|
|
2028
|
+
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
2029
|
})
|
|
2005
2030
|
$('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
|
|
2006
2031
|
$('btn-server-add').addEventListener('click', addServer)
|
|
@@ -2008,16 +2033,16 @@ function bindUi() {
|
|
|
2008
2033
|
if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); addServer() }
|
|
2009
2034
|
})
|
|
2010
2035
|
$('btn-host-describe').addEventListener('click', async () => {
|
|
2011
|
-
const v = await safeRpc('host.describe', {}, '
|
|
2036
|
+
const v = await safeRpc('host.describe', {}, t('settings.probeFailed'))
|
|
2012
2037
|
if (v) {
|
|
2013
2038
|
state.hostInfo = v
|
|
2014
|
-
$('host-desc').textContent =
|
|
2039
|
+
$('host-desc').textContent = t('settings.hostDesc', { version: v.version, cwd: v.cwd, n: v.attachedSessions })
|
|
2015
2040
|
}
|
|
2016
2041
|
})
|
|
2017
2042
|
$('btn-check-update').addEventListener('click', () => checkUpdate(false))
|
|
2018
2043
|
$('btn-download-update').addEventListener('click', downloadUpdate)
|
|
2019
2044
|
$('btn-reset').addEventListener('click', () => {
|
|
2020
|
-
if (!confirm('
|
|
2045
|
+
if (!confirm(t('settings.confirmReset'))) return
|
|
2021
2046
|
LS.del('token'); LS.del('notify'); LS.del('server')
|
|
2022
2047
|
location.reload()
|
|
2023
2048
|
})
|
|
@@ -2025,7 +2050,7 @@ function bindUi() {
|
|
|
2025
2050
|
$('opt-notify').addEventListener('change', async (e) => {
|
|
2026
2051
|
if (e.target.checked) {
|
|
2027
2052
|
const ok = await ensureNotify()
|
|
2028
|
-
if (!ok) { e.target.checked = false; return toast('
|
|
2053
|
+
if (!ok) { e.target.checked = false; return toast(t('settings.notifyDenied')) }
|
|
2029
2054
|
}
|
|
2030
2055
|
LS.set('notify', e.target.checked ? '1' : '0')
|
|
2031
2056
|
})
|
|
@@ -2033,12 +2058,12 @@ function bindUi() {
|
|
|
2033
2058
|
$('opt-tools').addEventListener('change', (e) => {
|
|
2034
2059
|
LS.set('showTools', e.target.checked ? '1' : '0')
|
|
2035
2060
|
if (state.current) renderHistory(true)
|
|
2036
|
-
toast(e.target.checked ? '
|
|
2061
|
+
toast(e.target.checked ? t('settings.toolsShown') : t('settings.toolsHidden'), 'ok')
|
|
2037
2062
|
})
|
|
2038
2063
|
|
|
2039
2064
|
// 文件页
|
|
2040
2065
|
$('fs-up').addEventListener('click', fsUp)
|
|
2041
|
-
$('fs-refresh').addEventListener('click', () => { toast('
|
|
2066
|
+
$('fs-refresh').addEventListener('click', () => { toast(t('common.refreshing')); loadFs() })
|
|
2042
2067
|
$('fs-upload-btn').addEventListener('click', () => $('fs-file-input').click())
|
|
2043
2068
|
$('fs-file-input').addEventListener('change', (e) => {
|
|
2044
2069
|
const f = e.target.files?.[0]
|
|
@@ -2088,6 +2113,7 @@ function applyNativeInsets() {
|
|
|
2088
2113
|
async function boot() {
|
|
2089
2114
|
initToken()
|
|
2090
2115
|
bindUi()
|
|
2116
|
+
renderLangBtn()
|
|
2091
2117
|
bindNativeBack()
|
|
2092
2118
|
bindNativeLinks()
|
|
2093
2119
|
applyNativeInsets()
|
|
@@ -2095,14 +2121,14 @@ async function boot() {
|
|
|
2095
2121
|
loadLocalVersion()
|
|
2096
2122
|
if (!state.token) {
|
|
2097
2123
|
showView('view-settings')
|
|
2098
|
-
$('token-desc').textContent = '
|
|
2124
|
+
$('token-desc').textContent = t('token.notSetHint')
|
|
2099
2125
|
} else {
|
|
2100
2126
|
// 多服务器: 启动时静默测速一次, 选最快的连接(同源页面也参与比较)
|
|
2101
2127
|
await selectFastestServer({ silent: true, reconnect: false })
|
|
2102
2128
|
openStreams()
|
|
2103
2129
|
await refreshAll()
|
|
2104
2130
|
const host = await safeRpc('host.describe', {}, '')
|
|
2105
|
-
if (host) { state.hostInfo = host; $('host-desc').textContent =
|
|
2131
|
+
if (host) { state.hostInfo = host; $('host-desc').textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions }) }
|
|
2106
2132
|
// 启动后自动检查一次更新(静默)
|
|
2107
2133
|
setTimeout(() => checkUpdate(true), 4000)
|
|
2108
2134
|
}
|