dsh-remote-plugin 0.5.0 → 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/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 } },
@@ -50,7 +54,8 @@ const state = {
50
54
  history: emptyHistory(),
51
55
  errCount: 0,
52
56
  refreshTimer: null,
53
- fs: { path: null, initial: null, loaded: false, upload: null }
57
+ fs: { path: null, initial: null, loaded: false, upload: null },
58
+ models: { loaded: false, loading: false, groups: [], current: null, failures: [] }
54
59
  }
55
60
 
56
61
  const $ = (id) => document.getElementById(id)
@@ -70,9 +75,9 @@ function toast(text, kind = '') {
70
75
  function fmtTime(ts) {
71
76
  if (!ts) return ''
72
77
  const diff = Date.now() - ts
73
- if (diff < 60e3) return '刚刚'
74
- if (diff < 3600e3) return Math.floor(diff / 60e3) + ' 分钟前'
75
- 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')
76
81
  const d = new Date(ts)
77
82
  return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
78
83
  }
@@ -112,10 +117,10 @@ async function rpc(method, payload = {}) {
112
117
  if (res.status === 401) throw new Error('AUTH')
113
118
  if (!res.ok) throw new Error('HTTP ' + res.status)
114
119
  const full = await res.json()
115
- if (!full?.result) throw new Error('坏响应')
120
+ if (!full?.result) throw new Error(t('err.badResponse'))
116
121
  if (!full.result.ok) {
117
122
  const err = full.result.error || {}
118
- throw new Error(err.message || 'DSH 返回错误')
123
+ throw new Error(err.message || t('err.dshError'))
119
124
  }
120
125
  return full.result.value
121
126
  }
@@ -141,9 +146,9 @@ async function safeRpc(method, payload, errText) {
141
146
  }
142
147
 
143
148
  function authFailure() {
144
- toast('访问被拒绝:请检查令牌', 'err')
149
+ toast(t('err.accessDenied'), 'err')
145
150
  showView('view-settings')
146
- $('token-desc').textContent = '令牌无效,点「更换」重新设置'
151
+ $('token-desc').textContent = t('token.invalid')
147
152
  }
148
153
 
149
154
  /* ---------------- 多服务器 + 自动选优 ---------------- */
@@ -194,7 +199,7 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
194
199
  if (state.selectingServer) return null
195
200
  state.selectingServer = true
196
201
  try {
197
- if (!silent) toast('正在测速各服务器…')
202
+ if (!silent) toast(t('speed.testing'))
198
203
  const candidates = serverCandidates()
199
204
  for (const u of candidates) state.serverLatency[u] = await pingServer(u)
200
205
  const best = candidates
@@ -207,11 +212,11 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
207
212
  if (chosen !== state.server) {
208
213
  state.server = chosen
209
214
  saveServers()
210
- if (!silent) toast(`已切换到最快服务器:${chosen || '当前页面'}(${state.serverLatency[best]}ms)`, 'ok')
215
+ if (!silent) toast(t('speed.switched', { url: chosen || t('speed.origin'), ms: state.serverLatency[best] }), 'ok')
211
216
  if (reconnect && state.token) { openStreams(); refreshAll() }
212
217
  } else if (!silent) {
213
- if (best) toast(`当前已是最快:${chosen || '当前页面'}(${state.serverLatency[best]}ms)`, 'ok')
214
- else toast('全部服务器不可达', 'err')
218
+ if (best) toast(t('speed.alreadyBest', { url: chosen || t('speed.origin'), ms: state.serverLatency[best] }), 'ok')
219
+ else toast(t('speed.allDown'), 'err')
215
220
  }
216
221
  return chosen
217
222
  } finally {
@@ -223,41 +228,41 @@ function renderServers() {
223
228
  const box = $('server-list')
224
229
  if (!box) return
225
230
  if (!state.servers.length) {
226
- box.innerHTML = '<div class="server-empty">未添加备用服务器 · 默认使用当前页面地址</div>'
231
+ box.innerHTML = '<div class="server-empty">' + t('servers.empty') + '</div>'
227
232
  } else {
228
233
  box.innerHTML = state.servers.map(u => {
229
234
  const ms = state.serverLatency[u]
230
- let badge = '<span class="server-badge">未测速</span>'
231
- if (Number.isFinite(ms)) badge = `<span class="server-badge ${u === state.server ? 'good' : ''}">${ms}ms${u === state.server ? ' · 当前' : ''}</span>`
232
- else if (ms !== undefined) badge = '<span class="server-badge bad">不可达</span>'
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>'
233
238
  return `<div class="server-row ${u === state.server ? 'active' : ''}">
234
239
  <span class="server-url">${esc(u)}</span>${badge}
235
- <button class="server-del" data-del="${esc(u)}" aria-label="删除服务器">✕</button>
240
+ <button class="server-del" data-del="${esc(u)}" aria-label="${t('servers.delete')}">✕</button>
236
241
  </div>`
237
242
  }).join('')
238
243
  box.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', () => removeServer(b.dataset.del)))
239
244
  }
240
245
  $('server-desc').textContent = state.server
241
- ? `当前: ${state.server}`
242
- : (CAP?.isNativePlatform?.() ? '未设置服务器' : '默认 = 当前页面地址')
246
+ ? t('servers.currentDesc', { url: state.server })
247
+ : (CAP?.isNativePlatform?.() ? t('servers.notSet') : t('servers.defaultDesc'))
243
248
  }
244
249
 
245
250
  async function addServer() {
246
251
  const input = $('server-input')
247
252
  let raw = (input?.value || '').trim().replace(/\/+$/, '')
248
- if (!raw) return toast('请输入服务器地址', 'err')
253
+ if (!raw) return toast(t('servers.needAddress'), 'err')
249
254
  try {
250
255
  const u = new URL(raw)
251
256
  if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad')
252
257
  } catch {
253
- return toast('地址需以 http:// 或 https:// 开头', 'err')
258
+ return toast(t('servers.badProtocol'), 'err')
254
259
  }
255
- if (state.servers.includes(raw)) return toast('该地址已在列表中')
260
+ if (state.servers.includes(raw)) return toast(t('servers.duplicate'))
256
261
  state.servers.push(raw)
257
262
  saveServers()
258
263
  if (input) input.value = ''
259
264
  renderServers()
260
- toast('已添加服务器', 'ok')
265
+ toast(t('servers.added'), 'ok')
261
266
  if (state.token) selectFastestServer({ silent: false })
262
267
  }
263
268
 
@@ -268,7 +273,7 @@ function removeServer(url) {
268
273
  saveServers()
269
274
  renderServers()
270
275
  if (wasActive) {
271
- toast('已删除当前服务器,重新测速…')
276
+ toast(t('servers.removedActive'))
272
277
  selectFastestServer({ silent: true })
273
278
  }
274
279
  }
@@ -278,11 +283,13 @@ const streams = {}
278
283
  state.streamsOk = { mux: false, host: false }
279
284
 
280
285
  function openStreams() {
286
+ if (!state.token) return
281
287
  openStream('mux', onMuxFrame, true)
282
288
  openStream('host', onHostFrame, false)
283
289
  }
284
290
 
285
291
  function openStream(kind, handler, refreshOnOpen) {
292
+ if (!state.token) return
286
293
  let base
287
294
  if (state.server) {
288
295
  base = state.server.replace(/^http/, 'ws')
@@ -298,6 +305,13 @@ function openStream(kind, handler, refreshOnOpen) {
298
305
  state.streamsOk[kind] = true
299
306
  state.errCount = 0
300
307
  updateConn()
308
+ // mux 每次(重)连接都会重放“仍待处理”的审批/提问基线:
309
+ // 先清空旧列表, 避免“桌面已自定义回答, 手机漏收 question/resolved”后永久残留。
310
+ if (kind === 'mux') {
311
+ state.approvals = []
312
+ state.questions = []
313
+ renderPending()
314
+ }
301
315
  if (refreshOnOpen) refreshAll()
302
316
  }
303
317
  ws.onmessage = (msg) => {
@@ -313,7 +327,7 @@ function openStream(kind, handler, refreshOnOpen) {
313
327
  state.streamsOk[kind] = false
314
328
  state.errCount++
315
329
  updateConn()
316
- if (state.errCount === 3) toast('连接中断,正在重连…', 'err')
330
+ if (state.errCount === 3) toast(t('conn.reconnecting'), 'err')
317
331
  // 多服务器: 连续掉线若干次就重测速, 自动换到当前可达的最快地址
318
332
  if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
319
333
  // 无条件重连; 页面被挂起时定时器暂停, visibilitychange 会再触发一次
@@ -343,12 +357,12 @@ document.addEventListener('visibilitychange', () => {
343
357
  if (document.visibilityState === 'visible') {
344
358
  onResume()
345
359
  if (state.servers.length) selectFastestServer({ silent: true })
346
- 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()
347
361
  }
348
362
  })
349
363
  window.addEventListener('pageshow', onResume)
350
364
  setInterval(() => {
351
- if (document.visibilityState === 'visible') {
365
+ if (document.visibilityState === 'visible' && state.token) {
352
366
  if (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN) openStreams()
353
367
  }
354
368
  }, 15000)
@@ -364,21 +378,21 @@ function onMuxFrame(full) {
364
378
  if (f.type === 'approval/requested') {
365
379
  state.approvals = state.approvals.filter(a => a.approvalId !== f.approvalId)
366
380
  state.approvals.push({ ...f, rpcId: full.rpcId })
367
- notify('工具审批', `${f.toolName || '未知工具'} 需要批准`)
381
+ notify(t('notify.approvalTitle'), t('notify.approvalBody', { tool: f.toolName || t('tool.unknown') }))
368
382
  renderPending(); return
369
383
  }
370
384
  if (f.type === 'approval/resolved') { state.approvals = state.approvals.filter(a => a.approvalId !== f.approvalId); renderPending(); return }
371
385
  if (f.type === 'question/requested') {
372
386
  state.questions = state.questions.filter(q => q.rpcId !== full.rpcId)
373
387
  state.questions.push({ ...f, rpcId: full.rpcId })
374
- notify('DSH 提问', f.questions?.map(q => q.question).join(' / ') || '需要你回答')
388
+ notify(t('notify.questionTitle'), f.questions?.map(q => q.question).join(' / ') || t('notify.questionBody'))
375
389
  renderPending(); return
376
390
  }
377
391
  if (f.type === 'question/resolved') { state.questions = state.questions.filter(q => q.rpcId !== f.questionRpcId); renderPending(); return }
378
392
  if (f.type === 'session/queue') { state.queues[f.sessionId] = f.items || []; renderQueue(); return }
379
393
  if (f.type === 'session/jobs') { state.jobs[f.sessionId] = f.jobs || []; renderJobs(); return }
380
394
  if (f.type === 'session/projection') { applyProjection(f.sessionId, f.key, f.value, f.seq); return }
381
- if (f.type === 'stream/error') { toast('事件流错误:' + (f.error?.message || ''), 'err') }
395
+ if (f.type === 'stream/error') { toast(t('stream.error', { msg: f.error?.message || '' }), 'err') }
382
396
  }
383
397
  function onHostFrame(full) {
384
398
  const f = full.payload
@@ -386,10 +400,15 @@ function onHostFrame(full) {
386
400
  if (['host/session-added', 'host/session-removed', 'host/workspace-changed', 'host/workspace-removed', 'host/workspace-order-changed', 'host/archived-sessions-changed'].includes(f.type)) return scheduleRefresh()
387
401
  if (f.type === 'host/session-status') {
388
402
  const s = state.byId.get(f.sessionId)
389
- if (s) { s.running = f.running; if (state.current === f.sessionId) { renderSessionCards(); updateCancelBtn() } }
403
+ if (s) { s.running = f.running; if (state.current === f.sessionId) { renderSessionCards(); updateCancelBtn(); renderSessionSub(); updateSessionStatus() } }
390
404
  return
391
405
  }
392
- if (f.type === 'host/agent-error') return toast(`会话出错:${f.message}`, 'err')
406
+ if (f.type === 'host/agent-error') {
407
+ const s = state.byId.get(f.sessionId)
408
+ if (s) { s.error = true; s.running = false }
409
+ if (state.current === f.sessionId) { renderSessionSub(); updateSessionStatus() }
410
+ return toast(t('session.errorMsg', { msg: f.message }), 'err')
411
+ }
393
412
  if (f.type === 'host/remote-event') return scheduleRefresh()
394
413
  }
395
414
 
@@ -398,8 +417,8 @@ function onSessionEvent(sessionId, event) {
398
417
  const s = state.byId.get(sessionId)
399
418
  if (s) s.updatedAt = Date.now()
400
419
  if (event.type === 'agent/status') {
401
- if (s) { s.running = !!event.data?.running; s.blank = false }
402
- if (state.current === sessionId) { updateCancelBtn(); renderSessionSub() }
420
+ if (s) { s.running = !!event.data?.running; s.blank = false; if (s.running) s.error = false }
421
+ if (state.current === sessionId) { updateCancelBtn(); renderSessionSub(); updateSessionStatus() }
403
422
  }
404
423
  if (event.type === 'session/title' || event.type === 'title') {
405
424
  if (event.data?.title && s) s.projections.values.title = event.data.title
@@ -417,12 +436,12 @@ function scheduleRefresh() {
417
436
 
418
437
  async function refreshAll() {
419
438
  await refreshSessions()
420
- if (state.current) { renderSessionCards(); renderSessionSub(); updateCancelBtn() }
439
+ if (state.current) { renderSessionCards(); renderSessionSub(); updateCancelBtn(); updateSessionStatus() }
421
440
  renderPending(); renderQueue(); renderJobs(); updateConn()
422
441
  }
423
442
 
424
443
  async function refreshSessions() {
425
- const v = await safeRpc('session.list', {}, '拉取会话列表失败')
444
+ const v = await safeRpc('session.list', {}, t('err.sessionList'))
426
445
  if (!v) {
427
446
  // 网关不可达: 用上次成功的会话列表兜底, 用户仍能打开历史缓存
428
447
  if (!state.sessions.length) {
@@ -431,7 +450,7 @@ async function refreshSessions() {
431
450
  state.sessions = cached
432
451
  state.byId = new Map(cached.map(s => [s.sessionId, s]))
433
452
  renderSessions()
434
- toast('网络不可用:显示本地缓存的会话列表', 'ok')
453
+ toast(t('sessions.cacheFallback'), 'ok')
435
454
  }
436
455
  }
437
456
  return
@@ -479,14 +498,14 @@ function renderSessions() {
479
498
  const dots = []
480
499
  if (s.running) dots.push('running')
481
500
  if (pending) dots.push('pending')
482
- const badge = goal ? `<span class="sc-badge ${goal.phase === 'active' ? 'goal-active' : ''}">目标·${esc(goal.phase || '?')}</span>` : ''
483
- const queueBadge = queueN ? `<span class="sc-badge">队列 ${queueN}</span>` : ''
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>` : ''
484
503
  return `<div class="session-card ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
485
504
  <div class="sc-title">${esc(title)}</div>
486
505
  <div class="sc-meta">
487
506
  <span class="sc-dot ${dots.join(' ')}"></span>
488
507
  <span>${fmtTime(s.updatedAt)}</span>
489
- ${s.running ? '<span>运行中</span>' : ''}
508
+ ${s.running ? '<span>' + t('sessions.running') + '</span>' : ''}
490
509
  ${badge}${queueBadge}
491
510
  </div>
492
511
  <span class="sc-arrow">›</span>
@@ -496,9 +515,9 @@ function renderSessions() {
496
515
  const running = state.sessions.filter(s => s.running).length
497
516
  const pending = state.approvals.length + state.questions.length
498
517
  $('stat-strip').innerHTML = `
499
- <div class="stat running"><div class="v">${running}</div><div class="k">运行中</div></div>
500
- <div class="stat pending"><div class="v">${pending}</div><div class="k">待处理</div></div>
501
- <div class="stat ctx"><div class="v">${items.length}</div><div class="k">会话总数</div></div>`
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>`
502
521
  updatePendingBadge()
503
522
  }
504
523
 
@@ -508,8 +527,8 @@ async function openSession(id) {
508
527
  state.history = emptyHistory()
509
528
  document.body.classList.add('in-session')
510
529
  showView('view-session')
511
- renderSessionTitle(); renderSessionSub(); updateCancelBtn()
512
- $('history').innerHTML = '<div class="empty">加载历史…</div>'
530
+ renderSessionTitle(); renderSessionSub(); updateCancelBtn(); updateSessionStatus()
531
+ $('history').innerHTML = '<div class="empty">' + t('history.loading') + '</div>'
513
532
  await loadHistory(true)
514
533
  renderSessionCards()
515
534
  refreshSessions()
@@ -519,6 +538,7 @@ function closeSession() {
519
538
  state.current = null
520
539
  state.history = emptyHistory()
521
540
  document.body.classList.remove('in-session')
541
+ hideComposerMenu()
522
542
  showView('view-home')
523
543
  }
524
544
 
@@ -541,7 +561,7 @@ function bindNativeBack() {
541
561
 
542
562
  function renderSessionTitle() {
543
563
  const s = state.byId.get(state.current)
544
- $('session-title').textContent = s ? titleOf(s) : '未知会话'
564
+ $('session-title').textContent = s ? titleOf(s) : t('session.unknown')
545
565
  }
546
566
 
547
567
  function renderSessionSub() {
@@ -549,10 +569,22 @@ function renderSessionSub() {
549
569
  if (!s) { $('session-sub').textContent = ''; return }
550
570
  const parts = [short(s.sessionId)]
551
571
  if (s.cwd) parts.push(s.cwd)
552
- if (s.running) parts.push('运行中')
572
+ if (s.running) parts.push(t('session.running'))
573
+ else if (s.error) parts.push(t('session.interrupted'))
553
574
  $('session-sub').textContent = parts.join(' · ')
554
575
  }
555
576
 
577
+ /** 顶栏状态: 运行中=蓝色流动渐变, 中断/出错=橙红渐变, 空闲=原样式 */
578
+ function updateSessionStatus() {
579
+ const s = state.byId.get(state.current)
580
+ const head = $('session-head')
581
+ if (!head) return
582
+ head.classList.remove('running', 'interrupted')
583
+ const queued = (state.queues[state.current] || []).some(i => i.placement !== 'context')
584
+ if (s?.running || queued) head.classList.add('running')
585
+ else if (s?.error) head.classList.add('interrupted')
586
+ }
587
+
556
588
  function updateCancelBtn() {
557
589
  const s = state.byId.get(state.current)
558
590
  const running = s?.running || (state.queues[state.current] || []).some(i => i.placement !== 'context')
@@ -616,7 +648,7 @@ function restoreCachedHistory() {
616
648
  }
617
649
  h.visible.sort((a, b) => a.seq - b.seq)
618
650
  state.history = h
619
- $('history-hint').textContent = `离线缓存 ${h.visible.length} 条`
651
+ $('history-hint').textContent = t('history.offlineCache', { n: h.visible.length })
620
652
  renderHistory(true)
621
653
  return true
622
654
  }
@@ -636,8 +668,8 @@ async function loadHistory(reset) {
636
668
  } catch (e) {
637
669
  state.history.loading = false
638
670
  if (e.message === 'AUTH') { authFailure(); return }
639
- if (restoreCachedHistory()) toast('网络不可用:显示本地缓存的历史', 'ok')
640
- else toast('加载历史失败:' + e.message, 'err')
671
+ if (restoreCachedHistory()) toast(t('history.cacheFallback'), 'ok')
672
+ else toast(t('history.loadFailed', { msg: e.message }), 'err')
641
673
  return
642
674
  }
643
675
 
@@ -662,7 +694,7 @@ async function loadHistory(reset) {
662
694
  if (reset) renderHistory(true)
663
695
  else if (added) renderHistory(false, 'keep')
664
696
  if (moreBtn) moreBtn.classList.toggle('hidden', !state.history.hasMore)
665
- $('history-hint').textContent = state.history.visible.length ? `${state.history.visible.length} 条` : ''
697
+ $('history-hint').textContent = state.history.visible.length ? t('history.count', { n: state.history.visible.length }) : ''
666
698
  scheduleHistoryCacheSave()
667
699
  }
668
700
 
@@ -701,7 +733,7 @@ function renderHistory(reset, mode = 'bottom') {
701
733
  const filtered = filteredEntries()
702
734
  const len = filtered.length
703
735
  if (!len) {
704
- box.innerHTML = '<div class="empty">还没有消息</div>'
736
+ box.innerHTML = '<div class="empty">' + t('history.empty') + '</div>'
705
737
  h.renderStart = 0; h.renderEnd = 0
706
738
  updateRail()
707
739
  return
@@ -817,19 +849,19 @@ function eventHtml(entry, ctx = {}) {
817
849
  const msg = data.message || {}
818
850
  const role = data.role || msg.role || (type.startsWith('user') ? 'user' : 'assistant')
819
851
  const blocks = msg.content || data.content || []
820
- inner = `<div class="msg ${esc(role)}" data-seq="${seq}"><div class="role">${esc(role === 'user' ? '' : 'DSH')}</div>${blocks.map(blockHtml).join('')}</div>`
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>`
821
853
  } else if (type === 'tool/call') {
822
- const name = data.name || data.toolName || '工具'
854
+ const name = data.name || data.toolName || t('tool.default')
823
855
  const step = (data.turn != null ? ` · turn ${data.turn}` : '') + (data.step != null ? `.${data.step}` : '')
824
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>`
825
857
  } else if (type === 'tool/result') {
826
858
  const callId = data.callId || data.message?.source?.callId
827
- const name = (callId && ctx.toolNames?.get(callId)) || '结果'
859
+ const name = (callId && ctx.toolNames?.get(callId)) || t('tool.result')
828
860
  const err = data.error || data.ok === false
829
- inner = `<details class="tool result ${err ? 'error' : ''}" data-seq="${seq}"><summary>📦 ${esc(name)}<span class="tool-meta-inline">结果</span></summary><pre>${esc(truncate(safeJson(data.result ?? data.output ?? data.message ?? data), 4000))}</pre></details>`
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>`
830
862
  } else if (type === 'agent/status') {
831
863
  const running = !!data.running
832
- inner = `<div class="event" data-seq="${seq}">${running ? '▶ 任务开始' : '■ 任务结束'}</div>`
864
+ inner = `<div class="event" data-seq="${seq}">${running ? t('event.taskStart') : t('event.taskEnd')}</div>`
833
865
  } else if (type === 'llm/usage') {
834
866
  inner = `<div class="event" data-seq="${seq}">tokens ${fmtTokens(data.inputTokens)} → ${fmtTokens(data.outputTokens)}</div>`
835
867
  } else if (type === 'checkpoint/created' || type === 'compaction/complete' || type === 'compaction/summary') {
@@ -845,16 +877,16 @@ function blockHtml(b) {
845
877
  if ((b.type === 'tool-call' || b.type === 'tool-result') && LS.get('showTools', '1') === '0') return ''
846
878
  switch (b.type) {
847
879
  case 'text': return `<div>${renderMarkdown(b.text ?? '')}</div>`
848
- case 'image': return `<img alt="图片" src="data:${esc(b.mediaType || 'image/png')};base64,${esc(b.data || '')}">`
880
+ case 'image': return `<img alt="${t('block.image')}" src="data:${esc(b.mediaType || 'image/png')};base64,${esc(b.data || '')}">`
849
881
  case 'thinking':
850
882
  case 'reasoning':
851
- return `<details class="tool"><summary>🧠 思考过程</summary><div class="tool-text">${esc(truncate(String(b.text ?? b.content ?? safeJson(b)), 6000))}</div></details>`
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>`
852
884
  case 'code': return `<pre>${esc(b.content ?? b.code ?? '')}</pre>`
853
885
  case 'tool-call':
854
- return `<details class="tool"><summary>🔧 ${esc(b.name || b.toolName || '工具调用')}</summary><pre>${esc(truncate(safeJson(b.arguments ?? b), 4000))}</pre></details>`
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>`
855
887
  case 'tool-result':
856
- return `<details class="tool result"><summary>📦 ${esc(b.name || b.toolName || '工具结果')}</summary><pre>${esc(truncate(safeJson(b.content ?? b), 4000))}</pre></details>`
857
- default: return `<details class="tool"><summary>块 · ${esc(b.type || '?')}</summary><pre>${esc(truncate(safeJson(b), 2000))}</pre></details>`
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>`
858
890
  }
859
891
  }
860
892
 
@@ -876,7 +908,7 @@ function safeJson(v) {
876
908
  try { return typeof v === 'string' ? v : JSON.stringify(v, null, 2) }
877
909
  catch { return String(v) }
878
910
  }
879
- function truncate(s, n) { return String(s).length > n ? String(s).slice(0, n) + '…(截断)' : s }
911
+ function truncate(s, n) { return String(s).length > n ? String(s).slice(0, n) + t('truncated') : s }
880
912
 
881
913
  /* 会话卡片(goal/todo/subagents); 统计进顶栏 📊 弹窗 */
882
914
  function statsHtml(s) {
@@ -887,15 +919,15 @@ function statsHtml(s) {
887
919
  let html = ''
888
920
  if (stats) {
889
921
  const llmMin = stats.llmMs ? (stats.llmMs / 60000).toFixed(1) : null
890
- html += `<div class="card"><div class="card-title">本轮统计</div>
891
- <div class="card-row"><span class="k">轮次 / 步骤</span><span class="v">${stats.turns ?? '—'} / ${stats.steps ?? '—'}</span></div>
892
- <div class="card-row"><span class="k">模型耗时</span><span class="v">${llmMin ? llmMin + ' 分钟' : '—'}</span></div>
893
- ${usage ? `<div class="card-row"><span class="k">输出 / 缓存读</span><span class="v">${fmtTokens(usage.outputTokens)} / ${fmtTokens(usage.cacheReadTokens)}</span></div>` : ''}
894
- ${ctx ? `<div class="card-row"><span class="k">上下文压力</span><span class="v">${fmtTokens(ctx.pressureTokens)} / ${fmtTokens(ctx.contextWindow)}</span></div>` : ''}
895
- ${perms?.currentValue ? `<div class="card-row"><span class="k">权限</span><span class="v">${esc(perms.currentValue)}</span></div>` : ''}
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>` : ''}
896
928
  </div>`
897
929
  }
898
- return html || '<div class="empty">暂无统计数据</div>'
930
+ return html || '<div class="empty">' + t('stats.empty') + '</div>'
899
931
  }
900
932
 
901
933
  async function renderSessionCards() {
@@ -909,18 +941,18 @@ async function renderSessionCards() {
909
941
  let html = ''
910
942
 
911
943
  if (goal) {
912
- html += `<div class="card"><div class="card-title">目标</div>
944
+ html += `<div class="card"><div class="card-title">${t('goal.title')}</div>
913
945
  <div class="goal-obj">${esc(goal.objective || '')}</div>
914
946
  <div class="goal-phase">phase: ${esc(goal.phase || '?')} · revision ${goal.revision ?? '?'}</div>
915
947
  <div class="goal-actions">
916
- ${goal.phase === 'active' ? '<button class="mini-btn" data-goal="pause">暂停</button>' : '<button class="mini-btn" data-goal="resume">继续</button>'}
917
- <button class="mini-btn" data-goal="complete">完成</button>
918
- <button class="mini-btn" data-goal="edit">改目标</button>
919
- <button class="mini-btn" data-goal="clear">清除</button>
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>
920
952
  </div></div>`
921
953
  }
922
954
  if (todos?.items?.length) {
923
- html += `<div class="card"><div class="card-title">任务清单</div>${todos.items.map(t =>
955
+ html += `<div class="card"><div class="card-title">${t('todos.title')}</div>${todos.items.map(t =>
924
956
  `<div><span class="pill ${t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'active' : ''}">${esc(t.status || 'pending')}</span>${esc(t.content || '')}</div>`
925
957
  ).join('')}</div>`
926
958
  }
@@ -932,12 +964,12 @@ async function renderSessionCards() {
932
964
  const sub = await safeRpc('subagent.list', { parentSessionId: state.current })
933
965
  if (sub?.entries?.length) {
934
966
  const rows = sub.entries.map(e => {
935
- if (e.kind === 'diagnostic') return `<div class="card-row"><span class="k">诊断项</span><span class="v">${esc(e.reason)}</span></div>`
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>`
936
968
  const label = e.label || short(e.id)
937
969
  const running = e.activity === 'running'
938
- return `<div class="card-row"><span class="k">${running ? '▶ ' : ''}${esc(label)}</span><span class="v">${esc(e.mode)} ${running ? '· 运行中' : ''}${e.mode === 'continuable' && running ? ` <button class="mini-btn" data-sub-interrupt="${esc(e.id)}">中断</button>` : ''}</span></div>`
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>`
939
971
  }).join('')
940
- box.insertAdjacentHTML('beforeend', `<div class="card"><div class="card-title">子代理</div>${rows}</div>`)
972
+ box.insertAdjacentHTML('beforeend', `<div class="card"><div class="card-title">${t('subagent.title')}</div>${rows}</div>`)
941
973
  box.querySelectorAll('[data-sub-interrupt]').forEach(btn =>
942
974
  btn.addEventListener('click', () => interruptSubagent(btn.dataset.subInterrupt)))
943
975
  }
@@ -946,53 +978,161 @@ async function renderSessionCards() {
946
978
  async function goalAction(kind) {
947
979
  const s = state.byId.get(state.current)
948
980
  const goal = goalOf(s)
949
- if (!goal) return toast('当前会话没有目标')
981
+ if (!goal) return toast(t('goal.none'))
950
982
  const ref = { id: goal.id, revision: goal.revision }
951
983
  if (kind === 'edit') return openGoalModal(goal)
952
984
  const map = { pause: 'goal.pause', resume: 'goal.resume', complete: 'goal.complete', clear: 'goal.clear' }
953
985
  const method = map[kind]
954
986
  if (!method) return
955
- if (kind === 'clear' && !confirm('清除当前目标?(不会删除会话)')) return
956
- if (kind === 'complete' && !confirm('将目标标记为完成?')) return
957
- await safeRpc(method, { sessionId: state.current, ref }, '目标操作失败')
958
- toast('目标操作已提交', 'ok')
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')
959
991
  scheduleRefresh()
960
992
  }
961
993
 
962
994
  async function interruptSubagent(childId) {
963
- if (!confirm('中断这个子代理当前回合?')) return
964
- await safeRpc('subagent.interrupt', { parentSessionId: state.current, childSessionId: childId, mode: 'continuable' }, '中断失败')
965
- toast('中断请求已提交', 'ok')
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')
966
998
  setTimeout(renderSessionCards, 600)
967
999
  }
968
1000
 
969
- /* ---------------- 发送 / 取消 ---------------- */
970
- async function sendMessage() {
971
- const input = $('composer-input')
972
- const text = input.value.trim()
973
- if (!text || !state.current) return
1001
+ /* ---------------- 发送 / 取消 / 快捷菜单 ---------------- */
1002
+ async function sendSessionText(text) {
1003
+ const clean = String(text || '').trim()
1004
+ if (!clean || !state.current) return false
974
1005
  $('btn-send').disabled = true
975
1006
  const v = await safeRpc('session.prompt', {
976
1007
  sessionId: state.current,
977
1008
  mode: 'queue',
978
- content: [{ type: 'text', text }]
979
- }, '发送失败')
1009
+ content: [{ type: 'text', text: clean }]
1010
+ }, t('send.failed'))
980
1011
  $('btn-send').disabled = false
981
- if (v?.accepted) { input.value = ''; autosize(input); toast('已发送', 'ok') }
982
- else 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 }
1014
+ return false
1015
+ }
1016
+
1017
+ async function sendMessage() {
1018
+ const input = $('composer-input')
1019
+ const text = input.value.trim()
1020
+ if (!text || !state.current) return
1021
+ if (await sendSessionText(text)) { input.value = ''; autosize(input) }
1022
+ }
1023
+
1024
+ function hideComposerMenu() {
1025
+ $('composer-menu').classList.add('hidden')
1026
+ $('btn-plus').classList.remove('active')
1027
+ }
1028
+
1029
+ function toggleComposerMenu() {
1030
+ const menu = $('composer-menu')
1031
+ const show = menu.classList.contains('hidden')
1032
+ menu.classList.toggle('hidden', !show)
1033
+ $('btn-plus').classList.toggle('active', show)
1034
+ if (show && !state.models.loaded && !state.models.loading) loadSessionModels()
1035
+ }
1036
+
1037
+ async function loadSessionModels() {
1038
+ if (!state.current || state.models.loading) return
1039
+ state.models.loading = true
1040
+ renderModelMenu()
1041
+ try {
1042
+ const v = await rpc('session.models', { sessionId: state.current })
1043
+ state.models.groups = v.groups || []
1044
+ state.models.current = v.current || null
1045
+ state.models.failures = v.failures || []
1046
+ state.models.loaded = true
1047
+ } catch (e) {
1048
+ if (e.message === 'AUTH') { authFailure(); return }
1049
+ toast(t('models.loadFailed', { msg: e.message }), 'err')
1050
+ }
1051
+ state.models.loading = false
1052
+ renderModelMenu()
1053
+ }
1054
+
1055
+ function renderModelMenu() {
1056
+ const box = $('menu-models')
1057
+ if (!box) return
1058
+ if (state.models.loading) { box.innerHTML = '<span>' + t('models.loading') + '</span>'; return }
1059
+ const groups = state.models.groups || []
1060
+ if (!groups.length) {
1061
+ box.innerHTML = '<span>' + ((state.models.failures || []).map(f => f.name + ' ' + t('models.unavailable')).join(';') || t('models.none')) + '</span>'
1062
+ const effortGroup = $('menu-effort-group')
1063
+ if (effortGroup) effortGroup.classList.add('hidden')
1064
+ return
1065
+ }
1066
+ const cur = state.models.current
1067
+ box.innerHTML = groups.map(g => `
1068
+ <div style="width:100%">
1069
+ <div class="model-provider">${esc(g.name || g.id)}</div>
1070
+ <div class="menu-chips">${(g.models || []).map(m => {
1071
+ const isCur = cur && cur.provider === g.id && cur.model === m.id
1072
+ return `<button class="model-chip ${isCur ? 'current' : ''}" data-model="${esc(m.id)}" data-provider="${esc(g.id)}">${esc(m.name || m.id)}</button>`
1073
+ }).join('')}</div>
1074
+ </div>`).join('')
1075
+ box.querySelectorAll('[data-model]').forEach(btn =>
1076
+ btn.addEventListener('click', () => selectSessionModel(btn.dataset.provider, btn.dataset.model)))
1077
+ renderEffortMenu()
1078
+ }
1079
+
1080
+ function renderEffortMenu() {
1081
+ const group = $('menu-effort-group')
1082
+ const box = $('menu-efforts')
1083
+ if (!group || !box) return
1084
+ const cur = state.models.current
1085
+ const provider = (state.models.groups || []).find(g => g.id === cur?.provider)
1086
+ const model = (provider?.models || []).find(m => m.id === cur?.model)
1087
+ const efforts = model?.reasoning?.efforts || []
1088
+ group.classList.toggle('hidden', !efforts.length)
1089
+ box.innerHTML = efforts.map(e => {
1090
+ const isCur = cur?.reasoningEffort === e.id || (!cur?.reasoningEffort && e.id === model.reasoning.defaultEffort)
1091
+ return `<button class="menu-chip ${isCur ? 'current' : ''}" data-effort="${esc(e.id)}" title="${esc(e.description || '')}">${esc(e.name || e.id)}</button>`
1092
+ }).join('')
1093
+ box.querySelectorAll('[data-effort]').forEach(btn =>
1094
+ btn.addEventListener('click', () => selectSessionEffort(btn.dataset.effort)))
1095
+ }
1096
+
1097
+ async function selectSessionEffort(effortId) {
1098
+ const cur = state.models.current
1099
+ if (!state.current || !cur) return
1100
+ const v = await safeRpc('session.selectModel', {
1101
+ sessionId: state.current, provider: cur.provider, model: cur.model, reasoningEffort: effortId
1102
+ }, t('models.effortFailed'))
1103
+ if (v?.selected) {
1104
+ state.models.current = v.selected
1105
+ renderEffortMenu()
1106
+ toast(t('models.effortSwitched', { effort: effortId }), 'ok')
1107
+ }
1108
+ }
1109
+
1110
+ async function selectSessionModel(provider, modelId) {
1111
+ if (!state.current) return
1112
+ const group = (state.models.groups || []).find(g => g.id === provider)
1113
+ const model = (group?.models || []).find(m => m.id === modelId)
1114
+ const payload = { sessionId: state.current, provider, model: modelId }
1115
+ const effort = model?.reasoning?.defaultEffort || model?.reasoning?.efforts?.[0]?.id
1116
+ if (effort) payload.reasoningEffort = effort
1117
+ const v = await safeRpc('session.selectModel', payload, t('models.switchFailed'))
1118
+ if (v?.selected) {
1119
+ state.models.current = v.selected
1120
+ renderModelMenu()
1121
+ toast(t('models.switched', { model: v.selected.model }), 'ok')
1122
+ }
983
1123
  }
984
1124
 
985
1125
  async function cancelSession() {
986
1126
  if (!state.current) return
987
- if (!confirm('停止当前会话正在运行的任务?')) return
988
- const v = await safeRpc('session.cancel', { sessionId: state.current }, '停止失败')
989
- if (v?.accepted) toast('已请求停止', 'ok')
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')
990
1130
  }
991
1131
 
992
1132
  async function newSession() {
993
- const v = await safeRpc('session.create', {}, '新建会话失败')
1133
+ const v = await safeRpc('session.create', {}, t('home.createFailed'))
994
1134
  if (!v?.sessionId) return
995
- toast('会话已创建', 'ok')
1135
+ toast(t('home.created'), 'ok')
996
1136
  await refreshSessions()
997
1137
  openSession(v.sessionId)
998
1138
  }
@@ -1004,27 +1144,27 @@ function renderPending() {
1004
1144
  ...state.approvals.map(a => ({ kind: 'approval', a })),
1005
1145
  ...state.questions.map(q => ({ kind: 'question', q }))
1006
1146
  ]
1007
- $('pending-count').textContent = items.length ? `${items.length} 项` : ''
1147
+ $('pending-count').textContent = items.length ? t('pending.count', { n: items.length }) : ''
1008
1148
  list.innerHTML = items.length ? items.map(it => {
1009
1149
  if (it.kind === 'approval') {
1010
1150
  const a = it.a
1011
1151
  const title = titleOf(state.byId.get(a.sessionId))
1012
1152
  return `<div class="pending-card approval" data-approval="${esc(a.approvalId)}">
1013
- <div class="pc-title">🔧 ${esc(a.toolName || '工具')} 请求批准</div>
1014
- <div class="pc-desc">${esc(a.reason || '无说明')}</div>
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>
1015
1155
  <div class="pc-session">${esc(title)}</div>
1016
- <div class="goal-actions"><button class="mini-btn" data-approve="1">允许</button><button class="mini-btn" data-approve="0">拒绝</button></div>
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>
1017
1157
  </div>`
1018
1158
  }
1019
1159
  const q = it.q
1020
1160
  const title = titleOf(state.byId.get(q.sessionId))
1021
1161
  return `<div class="pending-card question" data-question="${esc(q.rpcId)}">
1022
- <div class="pc-title">❓ ${esc(q.questions?.[0]?.question || 'DSH 提问')}</div>
1023
- <div class="pc-desc">${q.questions?.length > 1 ? `共 ${q.questions.length} 个问题` : ''}</div>
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>
1024
1164
  <div class="pc-session">${esc(title)}</div>
1025
- <div class="goal-actions"><button class="mini-btn" data-answer="1">去回答</button></div>
1165
+ <div class="goal-actions"><button class="mini-btn" data-answer="1">${t('pending.answer')}</button></div>
1026
1166
  </div>`
1027
- }).join('') : '<div class="empty">暂无待处理事项</div>'
1167
+ }).join('') : '<div class="empty">' + t('pending.empty') + '</div>'
1028
1168
  list.querySelectorAll('[data-approve]').forEach(btn => {
1029
1169
  const card = btn.closest('[data-approval]')
1030
1170
  btn.addEventListener('click', () => approveApproval(card?.dataset.approval || '', btn.dataset.approve === '1'))
@@ -1038,7 +1178,7 @@ async function approveApproval(id, allow) {
1038
1178
  const a = state.approvals.find(x => x.approvalId === id)
1039
1179
  if (!a) return
1040
1180
  const ok = await respond(a.rpcId, { sessionId: a.sessionId, approvalId: a.approvalId, outcome: allow ? 'allowed-once' : 'rejected' })
1041
- toast(ok ? (allow ? '已允许' : '已拒绝') : '审批已不在待处理状态', ok ? 'ok' : 'err')
1181
+ toast(ok ? (allow ? t('pending.allowed') : t('pending.rejected')) : t('pending.stale'), ok ? 'ok' : 'err')
1042
1182
  state.approvals = state.approvals.filter(x => x.approvalId !== id)
1043
1183
  renderPending()
1044
1184
  }
@@ -1051,7 +1191,7 @@ function openQuestionModal(q) {
1051
1191
  <div class="q-text">${esc(item.header ? item.header + ':' : '')}${esc(item.question)}</div>
1052
1192
  ${(item.options || []).map((o, j) => `
1053
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('')}
1054
- <textarea rows="2" placeholder="其他 / 自定义回答(可选)" data-qcustom="${i}"></textarea>
1194
+ <textarea rows="2" placeholder="${t('question.customPlaceholder')}" data-qcustom="${i}"></textarea>
1055
1195
  </div>`).join('')
1056
1196
  $('modal-question').classList.remove('hidden')
1057
1197
  }
@@ -1067,10 +1207,10 @@ async function submitQuestion() {
1067
1207
  if (!sel.length && !custom) return null
1068
1208
  return ans
1069
1209
  }).filter(Boolean)
1070
- if (!answers.length) return toast('请先选择或填写回答', 'err')
1210
+ if (!answers.length) return toast(t('question.needAnswer'), 'err')
1071
1211
  const ok = await respond(q.rpcId, { sessionId: q.sessionId, answer: { answers } })
1072
- if (ok) { toast('已提交回答', 'ok'); $('modal-question').classList.add('hidden'); state.questions = state.questions.filter(x => x.rpcId !== q.rpcId); renderPending() }
1073
- else toast('提问已不在待处理状态', 'err')
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')
1074
1214
  }
1075
1215
 
1076
1216
  /* ---------------- 后台任务 ---------------- */
@@ -1080,14 +1220,14 @@ function renderQueue() {
1080
1220
  const items = state.queues[state.current] || []
1081
1221
  updateCancelBtn()
1082
1222
  // 队列数量在会话列表已显示; 详情页不重复大 UI
1083
- $('history-hint').textContent = items.length ? `队列 ${items.length} · 历史 ${state.history.visible.length}` : `历史 ${state.history.visible.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 })
1084
1224
  renderSessions()
1085
1225
  }
1086
1226
 
1087
1227
  function renderJobs() {
1088
1228
  const box = $('jobs-list')
1089
1229
  const all = Object.entries(state.jobs).filter(([, jobs]) => jobs?.length)
1090
- if (!all.length) { box.innerHTML = '<div class="empty">暂无后台任务</div>'; return }
1230
+ if (!all.length) { box.innerHTML = '<div class="empty">' + t('jobs.empty') + '</div>'; return }
1091
1231
  box.innerHTML = all.flatMap(([sid, jobs]) => jobs.map(j => {
1092
1232
  const title = titleOf(state.byId.get(sid))
1093
1233
  return `<div class="job-card">
@@ -1130,21 +1270,21 @@ function fsAuthError(status) {
1130
1270
 
1131
1271
  async function loadFs(dir, { silent = false, resetRoot = false } = {}) {
1132
1272
  if (!state.token) {
1133
- $('fs-path').textContent = '未设置令牌'
1134
- $('fs-list').innerHTML = '<div class="empty">请先到「设置」页粘贴网关令牌</div>'
1273
+ $('fs-path').textContent = t('fs.noToken')
1274
+ $('fs-list').innerHTML = '<div class="empty">' + t('fs.goSettings') + '</div>'
1135
1275
  return
1136
1276
  }
1137
1277
  if (resetRoot) { state.fs.initial = null; state.fs.path = null }
1138
1278
  const target = dir ?? state.fs.path ?? ''
1139
1279
  if (!silent) {
1140
- $('fs-list').innerHTML = '<div class="empty">加载中…</div>'
1141
- $('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')
1142
1282
  }
1143
1283
  try {
1144
1284
  const res = await fetch(fsApiUrl('/list', target ? { path: target } : {}), { headers: fsHeaders() })
1145
1285
  if (res.status === 401) { fsAuthError(401); return }
1146
1286
  const data = await res.json().catch(() => ({}))
1147
- if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error === 'not-found' ? '目录不存在' : data.error === 'forbidden' ? '路径不在允许范围内' : data.error || ('HTTP ' + res.status))
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))
1148
1288
  state.fs.path = data.path
1149
1289
  if (!state.fs.initial) state.fs.initial = data.path
1150
1290
  state.fs.loaded = true
@@ -1152,8 +1292,8 @@ async function loadFs(dir, { silent = false, resetRoot = false } = {}) {
1152
1292
  } catch (e) {
1153
1293
  if (e.message === 'AUTH') return
1154
1294
  $('fs-path').textContent = target || '~'
1155
- $('fs-list').innerHTML = `<div class="empty">加载失败:${esc(e.message || '网络错误')}</div>`
1156
- if (!silent) toast('文件列表加载失败:' + e.message, 'err')
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')
1157
1297
  }
1158
1298
  }
1159
1299
 
@@ -1161,7 +1301,7 @@ function renderFs(data) {
1161
1301
  $('fs-path').textContent = data.path || '~'
1162
1302
  const list = $('fs-list')
1163
1303
  if (!data.entries.length) {
1164
- list.innerHTML = '<div class="empty">空目录</div>'
1304
+ list.innerHTML = '<div class="empty">' + t('fs.emptyDir') + '</div>'
1165
1305
  return
1166
1306
  }
1167
1307
  list.innerHTML = data.entries.map(e => {
@@ -1170,7 +1310,7 @@ function renderFs(data) {
1170
1310
  <span class="fs-ico">${isDir ? '📁' : '📄'}</span>
1171
1311
  <span class="fs-meta">
1172
1312
  <span class="fs-name">${esc(e.name)}</span>
1173
- <span class="fs-sub">${isDir ? '目录' : fmtSize(e.size)} · ${fmtFullTime(e.mtimeMs)}</span>
1313
+ <span class="fs-sub">${isDir ? t('fs.dir') : fmtSize(e.size)} · ${fmtFullTime(e.mtimeMs)}</span>
1174
1314
  </span>
1175
1315
  <span class="fs-arrow">${isDir ? '›' : '↓'}</span>
1176
1316
  </div>`
@@ -1193,13 +1333,13 @@ function downloadFsFile(name) {
1193
1333
  if (window.NativeFile?.downloadToDownloads) {
1194
1334
  try {
1195
1335
  window.NativeFile.downloadToDownloads(url, name, state.token)
1196
- toast('开始下载到「下载/dsh-remote」目录', 'ok')
1336
+ toast(t('fs.downloadStarted'), 'ok')
1197
1337
  } catch (e) {
1198
- toast('无法启动下载:' + (e?.message || ''), 'err')
1338
+ toast(t('fs.downloadFailed', { msg: e?.message || '' }), 'err')
1199
1339
  }
1200
1340
  return
1201
1341
  }
1202
- toast('当前 App 版本不支持系统下载,请先更新 App', 'err')
1342
+ toast(t('fs.downloadUnsupported'), 'err')
1203
1343
  return
1204
1344
  }
1205
1345
  // 浏览器控制台: <a download> + ?token= 兜底(主通道仍是 Bearer 头)
@@ -1234,7 +1374,7 @@ function setFsButtons(show, paused = false) {
1234
1374
  if (!pauseBtn || !cancelBtn) return
1235
1375
  pauseBtn.classList.toggle('hidden', !show)
1236
1376
  cancelBtn.classList.toggle('hidden', !show)
1237
- if (show) pauseBtn.textContent = paused ? '继续' : '暂停'
1377
+ if (show) pauseBtn.textContent = paused ? t('fs.resume') : t('fs.pause')
1238
1378
  }
1239
1379
 
1240
1380
  function pauseFsUpload() {
@@ -1268,7 +1408,7 @@ async function cancelFsUpload() {
1268
1408
  state.fs.upload = null
1269
1409
  hideFsProgress()
1270
1410
  setFsButtons(false)
1271
- toast('已取消上传')
1411
+ toast(t('fs.uploadCancelled'))
1272
1412
  }
1273
1413
 
1274
1414
  const FS_CHUNK_SIZE = 4 * 1024 * 1024 // 4MB/块, 断线后重选同一文件自动续传
@@ -1279,7 +1419,7 @@ async function hashFsRange(file, hasher, start, end, up) {
1279
1419
  for (let off = start; off < end; off += step) {
1280
1420
  const buf = await file.slice(off, Math.min(off + step, end)).arrayBuffer()
1281
1421
  if (up?.pauseRequested) {
1282
- const err = new Error('已暂停'); err.code = 'PAUSED'; throw err
1422
+ const err = new Error(t('fs.pausedErr')); err.code = 'PAUSED'; throw err
1283
1423
  }
1284
1424
  hasher.update(new Uint8Array(buf))
1285
1425
  }
@@ -1287,9 +1427,9 @@ async function hashFsRange(file, hasher, start, end, up) {
1287
1427
 
1288
1428
  function uploadFsFile(file) {
1289
1429
  if (!file) return
1290
- if (!state.token) { toast('请先到「设置」页设置令牌', 'err'); showView('view-settings'); return }
1291
- if (file.size > 2 * 1024 * 1024 * 1024) { toast('单文件超过 2GB 上限', 'err'); return }
1292
- if (state.fs.upload?.active) { toast('已有上传任务,先暂停或取消', 'err'); return }
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 }
1293
1433
 
1294
1434
  const prev = state.fs.upload
1295
1435
  if (prev && prev.path === state.fs.path && prev.name === file.name && prev.size === file.size) {
@@ -1341,11 +1481,11 @@ async function runFsUpload(up) {
1341
1481
  try { json = JSON.parse(xhr.responseText || '{}') } catch {}
1342
1482
  resolve({ status: xhr.status, json })
1343
1483
  }
1344
- xhr.onerror = () => { if (up.xhr === xhr) up.xhr = null; reject(new Error('网络错误')) }
1345
- 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'))) }
1346
1486
  xhr.onabort = () => {
1347
1487
  if (up.xhr === xhr) up.xhr = null
1348
- const err = new Error(up.cancelled ? '已取消' : '已暂停')
1488
+ const err = new Error(t(up.cancelled ? 'fs.cancelledErr' : 'fs.pausedErr'))
1349
1489
  err.code = up.cancelled ? 'CANCELLED' : 'PAUSED'
1350
1490
  reject(err)
1351
1491
  }
@@ -1368,7 +1508,7 @@ async function runFsUpload(up) {
1368
1508
  if (info === null || up.cancelled) return
1369
1509
  if (info.partialSize > 0) wasResumed = true
1370
1510
  if (info.targetExists && info.targetSize === up.size && !overwrite) {
1371
- if (!confirm('文件已存在(大小相同),覆盖它?')) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
1511
+ if (!confirm(t('fs.confirmOverwrite'))) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
1372
1512
  overwrite = true
1373
1513
  }
1374
1514
  if (up.offset > 0) await hashFsRange(up.file, hasher, 0, up.offset, up)
@@ -1381,7 +1521,7 @@ async function runFsUpload(up) {
1381
1521
  const before = hasher.clone()
1382
1522
  const chunkBytes = new Uint8Array(await blob.arrayBuffer())
1383
1523
  if (up.pauseRequested) {
1384
- const err = new Error('已暂停'); err.code = 'PAUSED'; throw err
1524
+ const err = new Error(t('fs.pausedErr')); err.code = 'PAUSED'; throw err
1385
1525
  }
1386
1526
  hasher.update(chunkBytes)
1387
1527
  const isLast = end >= up.size
@@ -1394,19 +1534,19 @@ async function runFsUpload(up) {
1394
1534
  if (r.status === 201) {
1395
1535
  const expected = params.sha256 || hasher.hex()
1396
1536
  if (r.json.sha256 && r.json.sha256 !== expected) {
1397
- const err = new Error('文件校验不一致(SHA-256)'); err.checksum = true
1537
+ const err = new Error(t('fs.checksumMismatch')); err.checksum = true
1398
1538
  throw err
1399
1539
  }
1400
1540
  hideFsProgress()
1401
1541
  setFsButtons(false)
1402
1542
  state.fs.upload = null
1403
- toast(`已上传 ${up.name}(SHA-256 已校验${wasResumed ? ' · 断点续传' : ''})`, 'ok')
1543
+ toast(t('fs.uploadDone', { name: up.name, resumed: wasResumed ? t('fs.resumedSuffix') : '' }), 'ok')
1404
1544
  loadFs()
1405
1545
  return
1406
1546
  }
1407
1547
  if (r.status === 409 && r.json.error === 'conflict') {
1408
1548
  hasher = before // 这段数据没被写入, 回退哈希状态后带 overwrite=1 重发
1409
- if (!confirm('文件已存在,覆盖它?')) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
1549
+ if (!confirm(t('fs.confirmOverwrite2'))) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
1410
1550
  overwrite = true
1411
1551
  continue
1412
1552
  }
@@ -1416,7 +1556,7 @@ async function runFsUpload(up) {
1416
1556
  continue
1417
1557
  }
1418
1558
  if (r.status === 422 && r.json.error === 'checksum-mismatch') {
1419
- const err = new Error('文件校验不一致(SHA-256),已清除坏分片')
1559
+ const err = new Error(t('fs.checksumCleared'))
1420
1560
  err.checksum = true
1421
1561
  throw err
1422
1562
  }
@@ -1432,24 +1572,24 @@ async function runFsUpload(up) {
1432
1572
  const r = await uploadChunk(params, new Blob([]))
1433
1573
  if (r.status === 401) { fsAuthError(401); return }
1434
1574
  if (r.status === 409 && r.json.error === 'conflict') {
1435
- if (!confirm('文件已存在,覆盖它?')) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
1575
+ if (!confirm(t('fs.confirmOverwrite2'))) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
1436
1576
  params.overwrite = '1'
1437
1577
  return runFsUpload(up) // 目标冲突未写入, 重新走 probe + 空 finish
1438
1578
  }
1439
1579
  if (r.status === 422 && r.json.error === 'checksum-mismatch') {
1440
- const err = new Error('文件校验不一致(SHA-256),已清除坏分片')
1580
+ const err = new Error(t('fs.checksumCleared'))
1441
1581
  err.checksum = true
1442
1582
  throw err
1443
1583
  }
1444
1584
  if (r.status !== 201) throw new Error(r.json.error || ('HTTP ' + r.status))
1445
1585
  if (r.json.sha256 && r.json.sha256 !== expected) {
1446
- const err = new Error('文件校验不一致(SHA-256)'); err.checksum = true
1586
+ const err = new Error(t('fs.checksumMismatch')); err.checksum = true
1447
1587
  throw err
1448
1588
  }
1449
1589
  hideFsProgress()
1450
1590
  setFsButtons(false)
1451
1591
  state.fs.upload = null
1452
- toast(`已上传 ${up.name}(SHA-256 已校验${wasResumed ? ' · 断点续传' : ''})`, 'ok')
1592
+ toast(t('fs.uploadDone', { name: up.name, resumed: wasResumed ? t('fs.resumedSuffix') : '' }), 'ok')
1453
1593
  loadFs()
1454
1594
  return
1455
1595
  }
@@ -1462,8 +1602,8 @@ async function runFsUpload(up) {
1462
1602
  if (e?.code === 'PAUSED') {
1463
1603
  up.paused = true
1464
1604
  setFsButtons(true, true)
1465
- setFsProgressText(`已暂停 · ${Math.round(up.offset / Math.max(1, up.size) * 100)}%`)
1466
- toast('已暂停,点「继续」接着传', 'ok')
1605
+ setFsProgressText(t('fs.pausedPct', { pct: Math.round(up.offset / Math.max(1, up.size) * 100) }))
1606
+ toast(t('fs.pausedToast'), 'ok')
1467
1607
  return
1468
1608
  }
1469
1609
  if (e?.checksum) {
@@ -1476,20 +1616,20 @@ async function runFsUpload(up) {
1476
1616
  })
1477
1617
  } catch {}
1478
1618
  setFsButtons(true, true)
1479
- setFsProgressText('校验失败 · 点「继续」重新上传')
1619
+ setFsProgressText(t('fs.checksumFailed'))
1480
1620
  toast(e.message, 'err')
1481
1621
  return
1482
1622
  }
1483
1623
  hideFsProgress()
1484
1624
  setFsButtons(false)
1485
- toast(`上传中断:${e.message}(重选同一文件可断点续传)`, 'err')
1625
+ toast(t('fs.uploadInterrupted', { msg: e.message }), 'err')
1486
1626
  }
1487
1627
  }
1488
1628
 
1489
1629
  function fsUp() {
1490
1630
  if (!state.fs.path || !state.fs.initial) return
1491
1631
  if (state.fs.path === state.fs.initial) {
1492
- toast('已在允许的根目录')
1632
+ toast(t('fs.alreadyRoot'))
1493
1633
  return
1494
1634
  }
1495
1635
  loadFs(fsParent(state.fs.path))
@@ -1508,7 +1648,7 @@ function bindFsPullRefresh() {
1508
1648
  const dy = e.touches[0].clientY - startY
1509
1649
  if (dy > 4 && window.scrollY <= 0) {
1510
1650
  pull.style.height = Math.min(64, dy / 2) + 'px'
1511
- pull.textContent = dy > 80 ? '松开刷新' : '下拉刷新'
1651
+ pull.textContent = dy > 80 ? t('fs.pullRelease') : t('fs.pullDown')
1512
1652
  }
1513
1653
  }, { passive: true })
1514
1654
  view.addEventListener('touchend', () => {
@@ -1516,7 +1656,7 @@ function bindFsPullRefresh() {
1516
1656
  const h = parseFloat(pull.style.height || '0')
1517
1657
  startY = null
1518
1658
  if (h >= 40) {
1519
- pull.textContent = '刷新中…'
1659
+ pull.textContent = t('fs.refreshing')
1520
1660
  loadFs(null, { silent: true })
1521
1661
  }
1522
1662
  pull.style.height = '0px'
@@ -1538,21 +1678,38 @@ async function submitGoalEdit() {
1538
1678
  const goal = state.goalEdit
1539
1679
  if (!goal) return
1540
1680
  const objective = $('goal-edit-text')?.value?.trim()
1541
- if (!objective) return toast('目标不能为空', 'err')
1542
- 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'))
1543
1683
  $('modal-goal').classList.add('hidden')
1544
- toast('目标已更新', 'ok')
1684
+ toast(t('goal.updated'), 'ok')
1545
1685
  scheduleRefresh()
1546
1686
  }
1547
1687
 
1548
1688
  /* ---------------- 检查更新 ---------------- */
1689
+ function parseVersion(v) {
1690
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v || '').trim())
1691
+ if (!m) return { core: [0, 0, 0], pre: null }
1692
+ return { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null }
1693
+ }
1549
1694
  function cmpVersion(a, b) {
1550
- const pa = String(a || '').split('.').map(Number)
1551
- const pb = String(b || '').split('.').map(Number)
1552
- for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
1553
- const d = (pa[i] || 0) - (pb[i] || 0)
1695
+ const pa = parseVersion(a), pb = parseVersion(b)
1696
+ for (let i = 0; i < 3; i++) {
1697
+ const d = pa.core[i] - pb.core[i]
1554
1698
  if (d) return d
1555
1699
  }
1700
+ // 无预发布后缀 = 正式版 > 任何 rc; 两个 rc 按段比较(数字段按数值, 字母段按字典序)
1701
+ if (!pa.pre && !pb.pre) return 0
1702
+ if (!pa.pre) return 1
1703
+ if (!pb.pre) return -1
1704
+ const sa = String(pa.pre).split('.'), sb = String(pb.pre).split('.')
1705
+ for (let i = 0; i < Math.max(sa.length, sb.length); i++) {
1706
+ const x = sa[i] ?? '', y = sb[i] ?? ''
1707
+ if (x === y) continue
1708
+ const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y)
1709
+ if (nx && ny) { const d = Number(x) - Number(y); if (d) return d }
1710
+ else if (nx !== ny) return nx ? -1 : 1 // 数字段 < 字母段(semver 规则)
1711
+ else { const d = x.localeCompare(y); if (d) return d }
1712
+ }
1556
1713
  return 0
1557
1714
  }
1558
1715
 
@@ -1561,36 +1718,36 @@ async function loadLocalVersion() {
1561
1718
  const res = await fetch('version.json?t=' + Date.now())
1562
1719
  if (res.ok) state.localVersion = (await res.json())?.version || ''
1563
1720
  } catch {}
1564
- $('update-desc').textContent = state.localVersion ? `当前版本 v${state.localVersion}` : '未获取到版本'
1721
+ $('update-desc').textContent = state.localVersion ? t('update.currentV', { version: state.localVersion }) : t('update.noVersion')
1565
1722
  }
1566
1723
 
1567
1724
  async function checkUpdate(silent) {
1568
1725
  const base = state.server
1569
1726
  if (!base) {
1570
- if (!silent) toast('请先设置服务器地址', 'err')
1571
- $('update-desc').textContent = state.localVersion ? `当前版本 v${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')
1572
1729
  return
1573
1730
  }
1574
- if (!silent) toast('正在检查更新…')
1731
+ if (!silent) toast(t('update.checking'))
1575
1732
  try {
1576
- const res = await fetch(base + '/update.json?t=' + Date.now())
1733
+ const res = await fetch(base + '/update.json?t=' + Date.now() + '&local=' + encodeURIComponent(state.localVersion))
1577
1734
  if (!res.ok) throw new Error('HTTP ' + res.status)
1578
1735
  const info = await res.json()
1579
1736
  if (info.version && cmpVersion(info.version, state.localVersion) > 0) {
1580
1737
  state.updateInfo = info
1581
- $('update-desc').textContent = `发现新版本 v${info.version}${info.notes ? ':' + info.notes : ''}`
1738
+ $('update-desc').textContent = t('update.found', { version: info.version, notes: info.notes ? ':' + info.notes : '' })
1582
1739
  $('btn-download-update').classList.remove('hidden')
1583
- if (!silent) toast(`发现新版本 v${info.version}`, 'ok')
1584
- else notify('发现新版本', 'v' + info.version + ' 可更新')
1740
+ if (!silent) toast(t('update.found', { version: info.version }), 'ok')
1741
+ else notify(t('update.foundTitle'), t('update.foundBody', { version: info.version }))
1585
1742
  } else {
1586
1743
  state.updateInfo = null
1587
- $('update-desc').textContent = state.localVersion ? `已是最新 v${state.localVersion}` : `最新版本 v${info.version || '?'}`
1744
+ $('update-desc').textContent = state.localVersion ? t('update.latestV', { version: state.localVersion }) : t('update.latestRemote', { version: info.version || '?' })
1588
1745
  $('btn-download-update').classList.add('hidden')
1589
- if (!silent) toast('已是最新版本', 'ok')
1746
+ if (!silent) toast(t('update.latestToast'), 'ok')
1590
1747
  }
1591
1748
  } catch (e) {
1592
- $('update-desc').textContent = '检查失败:' + (e.message || '网络错误')
1593
- if (!silent) toast('检查更新失败:' + e.message, 'err')
1749
+ $('update-desc').textContent = t('update.checkFailedDesc', { msg: e.message || t('fs.networkError') })
1750
+ if (!silent) toast(t('update.checkFailed', { msg: e.message }), 'err')
1594
1751
  }
1595
1752
  }
1596
1753
 
@@ -1606,14 +1763,14 @@ function downloadUpdate() {
1606
1763
  if (window.NativeUpdate?.downloadAndInstall) {
1607
1764
  try {
1608
1765
  window.NativeUpdate.downloadAndInstall(url)
1609
- toast('开始下载,完成后会弹出安装页', 'ok')
1766
+ toast(t('update.downloadStarted'), 'ok')
1610
1767
  } catch (e) {
1611
- toast('无法启动下载:' + (e?.message || ''), 'err')
1768
+ toast(t('update.downloadFailed', { msg: e?.message || '' }), 'err')
1612
1769
  }
1613
1770
  return
1614
1771
  }
1615
1772
  // 兜底: 旧版 App 没有原生桥时用浏览器下载
1616
- toast('当前版本不支持 App 内安装,已转浏览器下载', 'err')
1773
+ toast(t('update.installUnsupported'), 'err')
1617
1774
  }
1618
1775
  // 浏览器: 直接触发下载
1619
1776
  location.href = url
@@ -1630,7 +1787,7 @@ async function ensureNotify() {
1630
1787
  const p = await L.requestPermissions()
1631
1788
  return p?.display === 'granted'
1632
1789
  } catch (e) {
1633
- toast('通知权限申请失败:' + (e?.message || ''), 'err')
1790
+ toast(t('notify.permissionFailed', { msg: e?.message || '' }), 'err')
1634
1791
  return false
1635
1792
  }
1636
1793
  }
@@ -1674,7 +1831,7 @@ function showView(id) {
1674
1831
  function updateConn() {
1675
1832
  const ok = !!state.streamsOk?.mux
1676
1833
  const el = $('conn-badge')
1677
- el.textContent = ok ? '已连接' : '未连接'
1834
+ el.textContent = ok ? t('conn.on') : t('conn.off')
1678
1835
  el.className = 'conn-badge ' + (ok ? 'on' : 'off')
1679
1836
  }
1680
1837
 
@@ -1689,46 +1846,81 @@ function applyPairUrl(url) {
1689
1846
  try {
1690
1847
  const u = new URL(String(url).trim())
1691
1848
  if (u.protocol !== 'dshremote:' || u.hostname !== 'pair') return false
1692
- const t = (u.searchParams.get('token') || '').trim()
1849
+ const tok = (u.searchParams.get('token') || '').trim()
1693
1850
  const server = (u.searchParams.get('server') || '').trim().replace(/\/+$/, '')
1694
- if (!t || !/^https?:\/\//i.test(server)) return false
1695
- state.token = t
1696
- LS.set('token', t)
1851
+ if (!tok || !/^https?:\/\//i.test(server)) return false
1852
+ state.token = tok
1853
+ LS.set('token', tok)
1697
1854
  state.server = server
1698
1855
  if (!state.servers.includes(server)) state.servers.unshift(server)
1699
1856
  saveServers()
1700
1857
  renderServers()
1701
- $('token-desc').textContent = '已保存(扫码)'
1858
+ $('token-desc').textContent = t('token.savedScan')
1702
1859
  return true
1703
1860
  } catch {
1704
1861
  return false
1705
1862
  }
1706
1863
  }
1707
1864
 
1708
- /** App 内扫码: 调用原生 ML Kit, 扫到 dshremote://pair 后自动配对 */
1865
+ /** 拍照/相册得到的 dataUrl 画到 canvas jsQR 纯本地解码(不依赖任何谷歌服务) */
1866
+ async function decodeQrDataUrl(dataUrl) {
1867
+ const img = new Image()
1868
+ await new Promise((resolve, reject) => {
1869
+ img.onload = resolve
1870
+ img.onerror = () => reject(new Error(t('scan.imageLoadFailed')))
1871
+ img.src = dataUrl
1872
+ })
1873
+ const maxSide = 1600
1874
+ const scale = Math.min(1, maxSide / Math.max(img.naturalWidth || 1, img.naturalHeight || 1))
1875
+ const w = Math.max(1, Math.round((img.naturalWidth || 1) * scale))
1876
+ const h = Math.max(1, Math.round((img.naturalHeight || 1) * scale))
1877
+ const canvas = document.createElement('canvas')
1878
+ canvas.width = w
1879
+ canvas.height = h
1880
+ const ctx = canvas.getContext('2d', { willReadFrequently: true })
1881
+ if (!ctx) throw new Error(t('scan.decodeUnsupported'))
1882
+ ctx.drawImage(img, 0, 0, w, h)
1883
+ const imageData = ctx.getImageData(0, 0, w, h)
1884
+ const code = window.jsQR?.(imageData.data, w, h, { inversionAttempts: 'attemptBoth' })
1885
+ return code?.data || ''
1886
+ }
1887
+
1888
+ /** App 内扫码: 官方 Camera 拍照/相册 + jsQR 本地解码(无 Google ML Kit/GMS 依赖, 国内可用)。
1889
+ * 冗余路径 1: 系统相机扫 dshremote:// 二维码直接唤起 App(见 bindNativeLinks);
1890
+ * 冗余路径 2: 设置页手动粘贴令牌。 */
1709
1891
  async function scanPair() {
1710
1892
  if (!CAP?.isNativePlatform?.()) {
1711
- toast('浏览器请打开主机管理页,用手机相机扫码', 'err')
1893
+ toast(t('scan.browserHint'), 'err')
1712
1894
  return
1713
1895
  }
1714
- const scanner = CAP.Plugins?.BarcodeScanner
1715
- if (!scanner?.scan) { toast('当前 App 版本不支持扫码,请先更新 App', 'err'); return }
1896
+ const camera = CAP.Plugins?.Camera
1897
+ if (!camera?.getPhoto) { toast(t('scan.unsupported'), 'err'); return }
1716
1898
  try {
1717
- const supported = await scanner.isSupported?.()
1718
- if (supported && supported.supported === false) { toast('设备不支持扫码', 'err'); return }
1719
- const result = await scanner.scan({ formats: ['QR_CODE'], autoZoom: true })
1720
- const raw = result?.barcodes?.[0]?.rawValue || result?.barcodes?.[0]?.displayValue || ''
1721
- if (!raw) { toast('没有扫到内容', 'err'); return }
1899
+ const perm = await camera.requestPermissions?.({ permissions: ['camera'] })
1900
+ if (perm && perm.camera !== 'granted') { toast(t('scan.permissionDenied'), 'err'); return }
1901
+ const photo = await camera.getPhoto({
1902
+ resultType: 'dataUrl',
1903
+ source: 'PROMPT', // 拍照 / 从相册选择都行, 相册可扫截图
1904
+ quality: 85,
1905
+ correctOrientation: true,
1906
+ saveToGallery: false,
1907
+ promptLabelHeader: t('scan.promptHeader'),
1908
+ promptLabelPhoto: t('scan.promptPhoto'),
1909
+ promptLabelPicture: t('scan.promptGallery'),
1910
+ })
1911
+ if (!photo?.dataUrl) { toast(t('scan.noPhoto'), 'err'); return }
1912
+ const raw = await decodeQrDataUrl(photo.dataUrl)
1913
+ if (!raw) { toast(t('scan.noQr'), 'err'); return }
1722
1914
  if (applyPairUrl(raw)) {
1723
- toast('配对成功,正在连接', 'ok')
1915
+ toast(t('scan.paired'), 'ok')
1724
1916
  openStreams()
1725
1917
  refreshAll()
1726
1918
  } else {
1727
- toast('这不是 DSH Remote 的配对二维码', 'err')
1919
+ toast(t('scan.notPair'), 'err')
1728
1920
  }
1729
1921
  } catch (e) {
1730
1922
  const msg = String(e?.message || e || '')
1731
- toast(msg.includes('cancel') || msg.includes('Cancel') ? '已取消扫码' : '扫码失败:' + msg, 'err')
1923
+ toast(/cancel/i.test(msg) ? t('scan.cancelled') : t('scan.failed', { msg }), 'err')
1732
1924
  }
1733
1925
  }
1734
1926
 
@@ -1738,7 +1930,7 @@ function bindNativeLinks() {
1738
1930
  try {
1739
1931
  CAP.Plugins?.App?.addListener?.('appUrlOpen', (data) => {
1740
1932
  if (data?.url && applyPairUrl(data.url)) {
1741
- toast('已通过二维码配对', 'ok')
1933
+ toast(t('scan.pairedLink'), 'ok')
1742
1934
  openStreams()
1743
1935
  refreshAll()
1744
1936
  }
@@ -1759,11 +1951,30 @@ function initToken() {
1759
1951
  state.token = LS.get('token', '')
1760
1952
  }
1761
1953
  loadServers()
1762
- $('token-desc').textContent = state.token ? '已保存(本机)' : '未设置'
1763
- $('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' : '中文'
1764
1961
  }
1765
1962
 
1766
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
+ })
1767
1978
  renderServers()
1768
1979
  // 底部导航
1769
1980
  document.querySelectorAll('.nav-btn').forEach(b =>
@@ -1776,13 +1987,19 @@ function bindUi() {
1776
1987
  $('btn-back').addEventListener('click', closeSession)
1777
1988
  $('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
1778
1989
  $('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
1779
- $('btn-refresh').addEventListener('click', () => { toast('刷新中…'); refreshAll() })
1990
+ $('btn-refresh').addEventListener('click', () => { toast(t('common.refreshing')); openStreams(); refreshAll() })
1780
1991
  $('btn-admin').addEventListener('click', () => {
1781
1992
  location.href = state.server ? state.server.replace(/\/+$/, '') + '/admin' : 'admin'
1782
1993
  })
1783
1994
  $('btn-new-session').addEventListener('click', newSession)
1784
1995
  $('btn-cancel').addEventListener('click', cancelSession)
1785
1996
  $('btn-send').addEventListener('click', sendMessage)
1997
+ $('btn-plus').addEventListener('click', toggleComposerMenu)
1998
+ $('composer-menu').addEventListener('click', (e) => {
1999
+ const chip = e.target.closest('[data-cmd]')
2000
+ if (chip) { hideComposerMenu(); sendSessionText(chip.dataset.cmd) }
2001
+ })
2002
+ $('btn-model-refresh').addEventListener('click', loadSessionModels)
1786
2003
  const input = $('composer-input')
1787
2004
  input.addEventListener('input', () => autosize(input))
1788
2005
  input.addEventListener('keydown', (e) => {
@@ -1807,8 +2024,8 @@ function bindUi() {
1807
2024
  // 设置
1808
2025
  $('btn-scan-pair').addEventListener('click', scanPair)
1809
2026
  $('btn-change-token').addEventListener('click', () => {
1810
- const t = prompt('输入访问令牌(网关启动时打印的 token):', state.token)
1811
- if (t && t.trim()) { state.token = t.trim(); LS.set('token', t.trim()); $('token-desc').textContent = '已保存'; toast('已保存,正在重连', 'ok'); openStreams(); refreshAll() }
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() }
1812
2029
  })
1813
2030
  $('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
1814
2031
  $('btn-server-add').addEventListener('click', addServer)
@@ -1816,16 +2033,16 @@ function bindUi() {
1816
2033
  if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); addServer() }
1817
2034
  })
1818
2035
  $('btn-host-describe').addEventListener('click', async () => {
1819
- const v = await safeRpc('host.describe', {}, '探测失败')
2036
+ const v = await safeRpc('host.describe', {}, t('settings.probeFailed'))
1820
2037
  if (v) {
1821
2038
  state.hostInfo = v
1822
- $('host-desc').textContent = `DSH ${v.version} · ${v.cwd} · 附加会话 ${v.attachedSessions}`
2039
+ $('host-desc').textContent = t('settings.hostDesc', { version: v.version, cwd: v.cwd, n: v.attachedSessions })
1823
2040
  }
1824
2041
  })
1825
2042
  $('btn-check-update').addEventListener('click', () => checkUpdate(false))
1826
2043
  $('btn-download-update').addEventListener('click', downloadUpdate)
1827
2044
  $('btn-reset').addEventListener('click', () => {
1828
- if (!confirm('清除本地令牌、服务器与缓存?')) return
2045
+ if (!confirm(t('settings.confirmReset'))) return
1829
2046
  LS.del('token'); LS.del('notify'); LS.del('server')
1830
2047
  location.reload()
1831
2048
  })
@@ -1833,7 +2050,7 @@ function bindUi() {
1833
2050
  $('opt-notify').addEventListener('change', async (e) => {
1834
2051
  if (e.target.checked) {
1835
2052
  const ok = await ensureNotify()
1836
- if (!ok) { e.target.checked = false; return toast('通知权限未开启') }
2053
+ if (!ok) { e.target.checked = false; return toast(t('settings.notifyDenied')) }
1837
2054
  }
1838
2055
  LS.set('notify', e.target.checked ? '1' : '0')
1839
2056
  })
@@ -1841,12 +2058,12 @@ function bindUi() {
1841
2058
  $('opt-tools').addEventListener('change', (e) => {
1842
2059
  LS.set('showTools', e.target.checked ? '1' : '0')
1843
2060
  if (state.current) renderHistory(true)
1844
- toast(e.target.checked ? '已显示工具调用' : '已隐藏工具调用', 'ok')
2061
+ toast(e.target.checked ? t('settings.toolsShown') : t('settings.toolsHidden'), 'ok')
1845
2062
  })
1846
2063
 
1847
2064
  // 文件页
1848
2065
  $('fs-up').addEventListener('click', fsUp)
1849
- $('fs-refresh').addEventListener('click', () => { toast('刷新中…'); loadFs() })
2066
+ $('fs-refresh').addEventListener('click', () => { toast(t('common.refreshing')); loadFs() })
1850
2067
  $('fs-upload-btn').addEventListener('click', () => $('fs-file-input').click())
1851
2068
  $('fs-file-input').addEventListener('change', (e) => {
1852
2069
  const f = e.target.files?.[0]
@@ -1896,6 +2113,7 @@ function applyNativeInsets() {
1896
2113
  async function boot() {
1897
2114
  initToken()
1898
2115
  bindUi()
2116
+ renderLangBtn()
1899
2117
  bindNativeBack()
1900
2118
  bindNativeLinks()
1901
2119
  applyNativeInsets()
@@ -1903,14 +2121,14 @@ async function boot() {
1903
2121
  loadLocalVersion()
1904
2122
  if (!state.token) {
1905
2123
  showView('view-settings')
1906
- $('token-desc').textContent = '未设置——点「更换」粘贴网关启动时打印的 token'
2124
+ $('token-desc').textContent = t('token.notSetHint')
1907
2125
  } else {
1908
2126
  // 多服务器: 启动时静默测速一次, 选最快的连接(同源页面也参与比较)
1909
2127
  await selectFastestServer({ silent: true, reconnect: false })
1910
2128
  openStreams()
1911
2129
  await refreshAll()
1912
2130
  const host = await safeRpc('host.describe', {}, '')
1913
- if (host) { state.hostInfo = host; $('host-desc').textContent = `DSH ${host.version} · ${host.cwd} · 附加会话 ${host.attachedSessions}` }
2131
+ if (host) { state.hostInfo = host; $('host-desc').textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions }) }
1914
2132
  // 启动后自动检查一次更新(静默)
1915
2133
  setTimeout(() => checkUpdate(true), 4000)
1916
2134
  }