dsh-remote-plugin 0.5.9 → 0.6.1

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
@@ -57,6 +57,8 @@ const state = {
57
57
  jobs: {}, // sessionId -> jobs
58
58
  history: emptyHistory(),
59
59
  errCount: 0,
60
+ streamMode: 'ws', // 'ws' | 'poll'
61
+ pollSeq: { mux: 0, host: 0 },
60
62
  refreshTimer: null,
61
63
  fs: { path: null, initial: null, loaded: false, upload: null },
62
64
  models: { loaded: false, loading: false, groups: [], current: null, failures: [] }
@@ -267,11 +269,15 @@ function renderStats(days) {
267
269
  }).join('')
268
270
  }
269
271
  async function rpc(method, payload = {}) {
270
- const res = await fetch(apiUrl('/api/' + method), {
272
+ const opts = {
271
273
  method: 'POST',
272
274
  headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
273
275
  body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
274
- })
276
+ }
277
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
278
+ opts.signal = AbortSignal.timeout(20000)
279
+ }
280
+ const res = await fetch(apiUrl('/api/' + method), opts)
275
281
  if (res.status === 401) throw new Error('AUTH')
276
282
  if (!res.ok) throw new Error('HTTP ' + res.status)
277
283
  const full = await res.json()
@@ -449,6 +455,7 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
449
455
  if (srv) state.groupActive[state.activeGroup] = srv.id
450
456
  }
451
457
  saveServers()
458
+ syncBgConfig()
452
459
  if (!silent) {
453
460
  if (chosen) toast(t('speed.switched', { url: chosen, ms: Number.isFinite(ms) ? ms : 0 }), 'ok')
454
461
  else toast(t('speed.switchedOrigin'), 'ok')
@@ -697,12 +704,16 @@ function deleteGroup(name) {
697
704
  if (state.token) selectFastestServer({ silent: true })
698
705
  }
699
706
 
700
- /* ---------------- 事件流 (WebSocket) ---------------- */
707
+ /* ---------------- 事件流 (WebSocket + 轮询降级) ---------------- */
701
708
  const streams = {}
702
709
  state.streamsOk = { mux: false, host: false }
710
+ let pollTimer = null
711
+ let wsRetryTimer = null
703
712
 
704
713
  function openStreams() {
705
714
  if (!state.token) return
715
+ if (state.streamMode === 'poll') stopPolling()
716
+ state.streamMode = 'ws'
706
717
  openStream('mux', onMuxFrame, true)
707
718
  openStream('host', onHostFrame, false)
708
719
  }
@@ -723,6 +734,8 @@ function openStream(kind, handler, refreshOnOpen) {
723
734
  ws.onopen = () => {
724
735
  state.streamsOk[kind] = true
725
736
  state.errCount = 0
737
+ // 重连成功:切回 WS 并停止轮询
738
+ if (state.streamMode === 'poll') { stopPolling(); state.streamMode = 'ws' }
726
739
  updateConn()
727
740
  // mux 每次(重)连接都会重放“仍待处理”的审批/提问基线:
728
741
  // 先清空旧列表, 避免“桌面已自定义回答, 手机漏收 question/resolved”后永久残留。
@@ -746,7 +759,9 @@ function openStream(kind, handler, refreshOnOpen) {
746
759
  state.streamsOk[kind] = false
747
760
  state.errCount++
748
761
  updateConn()
749
- if (state.errCount === 3) toast(t('conn.reconnecting'), 'err')
762
+ if (state.streamMode === 'poll') return
763
+ // 连续失败 3 次 -> 降级为轮询
764
+ if (state.errCount >= 3) { enterPollMode(); return }
750
765
  // 多服务器: 连续掉线若干次就重测速, 自动换到当前可达的最快地址
751
766
  if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
752
767
  // 无条件重连; 页面被挂起时定时器暂停, visibilitychange 会再触发一次
@@ -755,6 +770,78 @@ function openStream(kind, handler, refreshOnOpen) {
755
770
  ws.onerror = () => { try { ws.close() } catch {} }
756
771
  }
757
772
 
773
+ /* ---------------- 轮询降级模式 ---------------- */
774
+ function enterPollMode() {
775
+ if (state.streamMode === 'poll') return
776
+ state.streamMode = 'poll'
777
+ state.pollSeq = { mux: 0, host: 0 }
778
+ state.streamsOk = { mux: false, host: false }
779
+ try { streams.mux?.close() } catch {}
780
+ try { streams.host?.close() } catch {}
781
+ streams.mux = null
782
+ streams.host = null
783
+ refreshAll()
784
+ startPolling()
785
+ updateConn()
786
+ }
787
+
788
+ function stopPolling() {
789
+ clearInterval(pollTimer)
790
+ pollTimer = null
791
+ clearTimeout(wsRetryTimer)
792
+ wsRetryTimer = null
793
+ }
794
+
795
+ function startPolling() {
796
+ stopPolling()
797
+ pollTimer = setInterval(pollOnce, 4000)
798
+ wsRetryTimer = setInterval(tryRestoreWs, 30000)
799
+ pollOnce()
800
+ }
801
+
802
+ let pollInFlight = false
803
+ async function pollOnce() {
804
+ if (state.streamMode !== 'poll' || pollInFlight) return
805
+ pollInFlight = true
806
+ try {
807
+ await Promise.all([pollKind('mux'), pollKind('host')])
808
+ } finally {
809
+ pollInFlight = false
810
+ }
811
+ }
812
+
813
+ async function pollKind(kind) {
814
+ if (state.streamMode !== 'poll') return
815
+ const since = state.pollSeq[kind] || 0
816
+ let res
817
+ try {
818
+ const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(5000) : undefined
819
+ const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
820
+ res = signal ? await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}`), { signal, headers }) : await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}`), { headers })
821
+ } catch { return }
822
+ if (res.status === 401) { authFailure(); return }
823
+ if (!res.ok) return
824
+ let data
825
+ try { data = await res.json() } catch { return }
826
+ if (!data || !Array.isArray(data.events)) return
827
+ // 网关重启后 seq 会重置:落后就从头拉当前缓冲
828
+ if (typeof data.latestSeq === 'number' && data.latestSeq < since) state.pollSeq[kind] = 0
829
+ for (const item of data.events) {
830
+ if (item.seq > (state.pollSeq[kind] || 0)) {
831
+ state.pollSeq[kind] = item.seq
832
+ if (kind === 'mux') onMuxFrame(item.event)
833
+ else onHostFrame(item.event)
834
+ }
835
+ }
836
+ }
837
+
838
+ function tryRestoreWs() {
839
+ if (state.streamMode !== 'poll' || !state.token) return
840
+ // 轮询继续跑,等 WS 真正 onopen 后再切回,避免重连窗口丢事件
841
+ openStream('mux', onMuxFrame, true)
842
+ openStream('host', onHostFrame, false)
843
+ }
844
+
758
845
  /* 回前台恢复: 强制重排修复 MIUI WebView 后台切回时 sticky 顶栏不绘制的问题 */
759
846
  function onResume() {
760
847
  if (document.visibilityState !== 'visible') return
@@ -894,6 +981,10 @@ function applyProjection(sessionId, key, value, seq) {
894
981
  else renderSessions()
895
982
  }function titleOf(s) { return proj(s, 'title') || short(s.sessionId) }
896
983
  function short(id) { return '…' + String(id).slice(-8) }
984
+ const GOAL_TERMINAL_PHASES = new Set(['complete', 'cleared'])
985
+ function isGoalTerminal(goal) {
986
+ return !!goal && GOAL_TERMINAL_PHASES.has(goal.phase)
987
+ }
897
988
  function goalOf(s) {
898
989
  const p = proj(s, 'goal')
899
990
  if (!p) return null
@@ -946,8 +1037,10 @@ async function openSession(id) {
946
1037
  state.history = emptyHistory()
947
1038
  document.body.classList.add('in-session')
948
1039
  showView('view-session')
1040
+ $('session-cards').innerHTML = ''
949
1041
  renderSessionTitle(); renderSessionSub(); updateCancelBtn(); updateSessionStatus()
950
1042
  $('history').innerHTML = '<div class="empty">' + t('history.loading') + '</div>'
1043
+ restoreCachedHistory()
951
1044
  await loadHistory(true)
952
1045
  renderSessionCards()
953
1046
  refreshSessions()
@@ -967,7 +1060,7 @@ function bindNativeBack() {
967
1060
  try {
968
1061
  CAP.Plugins?.App?.addListener?.('backButton', () => {
969
1062
  const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
970
- if (openModal) { openModal.classList.add('hidden'); return } // 先关弹窗
1063
+ if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else openModal.classList.add('hidden'); return } // 先关弹窗
971
1064
  if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
972
1065
  if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
973
1066
  if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
@@ -1110,8 +1203,12 @@ async function loadHistory(reset) {
1110
1203
  trimVisible()
1111
1204
  state.history.hasMore = !!v.hasMore
1112
1205
  state.history.loading = false
1113
- if (reset) renderHistory(true)
1114
- else if (added) renderHistory(false, 'keep')
1206
+ try {
1207
+ if (reset) renderHistory(true)
1208
+ else if (added) renderHistory(false, 'keep')
1209
+ } catch (e) {
1210
+ console.error('renderHistory failed', e)
1211
+ }
1115
1212
  if (moreBtn) moreBtn.classList.toggle('hidden', !state.history.hasMore)
1116
1213
  $('history-hint').textContent = state.history.visible.length ? t('history.count', { n: state.history.visible.length }) : ''
1117
1214
  scheduleHistoryCacheSave()
@@ -1256,6 +1353,13 @@ function shouldShowEvent(type) {
1256
1353
  if (INTERESTING_EVENTS.has(type)) return true
1257
1354
  return false
1258
1355
  }
1356
+ function systemReminderText(blocks) {
1357
+ if (!Array.isArray(blocks)) return ''
1358
+ return blocks
1359
+ .filter(b => b && typeof b === 'object' && b.type === 'text' && String(b.text ?? '').trimStart().startsWith('<system-reminder>'))
1360
+ .map(b => String(b.text ?? ''))
1361
+ .join('\n')
1362
+ }
1259
1363
  function eventHtml(entry, ctx = {}) {
1260
1364
  const seq = entry.seq
1261
1365
  const ev = entry.event || {}
@@ -1268,7 +1372,12 @@ function eventHtml(entry, ctx = {}) {
1268
1372
  const msg = data.message || {}
1269
1373
  const role = data.role || msg.role || (type.startsWith('user') ? 'user' : 'assistant')
1270
1374
  const blocks = msg.content || data.content || []
1271
- 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>`
1375
+ const sysText = type === 'user/message' ? systemReminderText(blocks) : ''
1376
+ if (sysText) {
1377
+ inner = `<details class="event" data-seq="${seq}"><summary>${esc(t('event.systemReminder'))}</summary><pre>${esc(truncate(sysText, 400))}</pre></details>`
1378
+ } else {
1379
+ 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>`
1380
+ }
1272
1381
  } else if (type === 'tool/call') {
1273
1382
  const name = data.name || data.toolName || t('tool.default')
1274
1383
  const step = (data.turn != null ? ` · turn ${data.turn}` : '') + (data.step != null ? `.${data.step}` : '')
@@ -1354,12 +1463,13 @@ async function renderSessionCards() {
1354
1463
  const box = $('session-cards')
1355
1464
  const statsBox = $('stats-body')
1356
1465
  if (!s) { box.innerHTML = ''; if (statsBox) statsBox.innerHTML = ''; return }
1357
- if (statsBox) statsBox.innerHTML = statsHtml(s)
1466
+ if (statsBox) { try { statsBox.innerHTML = statsHtml(s) } catch {} }
1467
+ box.innerHTML = ''
1358
1468
  const goal = goalOf(s)
1359
1469
  const todos = proj(s, 'todos')
1360
1470
  let html = ''
1361
1471
 
1362
- if (goal) {
1472
+ if (goal && !isGoalTerminal(goal)) {
1363
1473
  html += `<div class="card"><div class="card-title">${t('goal.title')}</div>
1364
1474
  <div class="goal-obj">${esc(goal.objective || '')}</div>
1365
1475
  <div class="goal-phase">phase: ${esc(goal.phase || '?')} · revision ${goal.revision ?? '?'}</div>
@@ -1394,6 +1504,16 @@ async function renderSessionCards() {
1394
1504
  }
1395
1505
  }
1396
1506
 
1507
+ function setGoalPhaseLocal(phase) {
1508
+ const s = state.byId.get(state.current)
1509
+ const p = s && proj(s, 'goal')
1510
+ const goal = p && typeof p === 'object' && p.goal && typeof p.goal === 'object' ? p.goal : p
1511
+ if (!goal) return
1512
+ goal.phase = phase
1513
+ renderSessions()
1514
+ renderSessionCards()
1515
+ }
1516
+
1397
1517
  async function goalAction(kind) {
1398
1518
  const s = state.byId.get(state.current)
1399
1519
  const goal = goalOf(s)
@@ -1406,6 +1526,8 @@ async function goalAction(kind) {
1406
1526
  if (kind === 'clear' && !confirm(t('goal.confirmClear'))) return
1407
1527
  if (kind === 'complete' && !confirm(t('goal.confirmComplete'))) return
1408
1528
  await safeRpc(method, { sessionId: state.current, ref }, t('goal.actionFailed'))
1529
+ if (kind === 'complete') setGoalPhaseLocal('complete')
1530
+ if (kind === 'clear') setGoalPhaseLocal('cleared')
1409
1531
  toast(t('goal.actionSubmitted'), 'ok')
1410
1532
  scheduleRefresh()
1411
1533
  }
@@ -1418,9 +1540,34 @@ async function interruptSubagent(childId) {
1418
1540
  }
1419
1541
 
1420
1542
  /* ---------------- 发送 / 取消 / 快捷菜单 ---------------- */
1543
+ async function runSlashCommand(text) {
1544
+ const clean = String(text || '').trim()
1545
+ if (!clean.startsWith('/') || !state.current) return false
1546
+ try {
1547
+ const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
1548
+ ? AbortSignal.timeout(20000)
1549
+ : undefined
1550
+ const res = await fetch(apiUrl('/remote/api/command'), {
1551
+ method: 'POST',
1552
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
1553
+ body: JSON.stringify({ sessionId: state.current, line: clean }),
1554
+ ...(signal ? { signal } : {})
1555
+ })
1556
+ if (res.status === 401) { authFailure(); return true }
1557
+ if (!res.ok) return false
1558
+ const data = await res.json().catch(() => null)
1559
+ if (data?.ok === false) { toast(data.message || t('send.failed'), 'err'); return true }
1560
+ if (data?.ok && data.executed === true) { toast(t('send.commandExecuted'), 'ok'); return true }
1561
+ } catch (e) {
1562
+ console.error('slash command bridge failed', e)
1563
+ }
1564
+ return false
1565
+ }
1566
+
1421
1567
  async function sendSessionText(text) {
1422
1568
  const clean = String(text || '').trim()
1423
1569
  if (!clean || !state.current) return false
1570
+ if (await runSlashCommand(clean)) return true
1424
1571
  $('btn-send').disabled = true
1425
1572
  const v = await safeRpc('session.prompt', {
1426
1573
  sessionId: state.current,
@@ -2158,6 +2305,54 @@ async function loadLocalVersion() {
2158
2305
  $('update-desc').textContent = state.localVersion ? t('update.currentV', { version: state.localVersion }) : t('update.noVersion')
2159
2306
  }
2160
2307
 
2308
+ /* ---------------- 更新内容弹窗 ---------------- */
2309
+ const NOTES_KEY = 'seenNotesVersion'
2310
+ let notesVersion = ''
2311
+ let notesPages = []
2312
+ let notesPage = 0
2313
+ function splitNotes(notes) {
2314
+ return String(notes || '').split(/[;;]/).map(s => s.trim()).filter(Boolean)
2315
+ }
2316
+ function renderNotesPages(items) {
2317
+ const box = $('notes-pages')
2318
+ if (!box) return
2319
+ const pages = []
2320
+ for (let i = 0; i < items.length; i += 3) pages.push(items.slice(i, i + 3))
2321
+ notesPages = pages
2322
+ notesPage = 0
2323
+ box.innerHTML = pages.map(page => `<div class="notes-page" style="flex:0 0 100%;scroll-snap-align:start;box-sizing:border-box;min-width:0;">${page.map(item => `<div class="notes-item" style="padding:6px 0;line-height:1.5;">${esc(item)}</div>`).join('')}</div>`).join('')
2324
+ box.scrollLeft = 0
2325
+ updateNotesPage()
2326
+ }
2327
+ function updateNotesPage() {
2328
+ const box = $('notes-pages')
2329
+ const pageEl = $('notes-page')
2330
+ if (!box || !pageEl) return
2331
+ const total = notesPages.length || 1
2332
+ const idx = Math.min(Math.max(0, Math.round(box.scrollLeft / Math.max(1, box.clientWidth))), total - 1)
2333
+ notesPage = idx
2334
+ pageEl.textContent = t('notes.page', { current: idx + 1, total })
2335
+ }
2336
+ function scrollNotes(dir) {
2337
+ const box = $('notes-pages')
2338
+ if (box) box.scrollBy({ left: dir * box.clientWidth, behavior: 'smooth' })
2339
+ }
2340
+ function openNotesModal(info) {
2341
+ if (!info?.version || String(info.version).includes('-rc')) return
2342
+ if (LS.get(NOTES_KEY) === info.version) return
2343
+ const items = splitNotes(info.notes)
2344
+ if (!items.length) return
2345
+ notesVersion = info.version
2346
+ const vEl = $('notes-version')
2347
+ if (vEl) vEl.textContent = 'v' + info.version
2348
+ renderNotesPages(items)
2349
+ $('modal-notes').classList.remove('hidden')
2350
+ }
2351
+ function closeNotesModal() {
2352
+ $('modal-notes').classList.add('hidden')
2353
+ if (notesVersion) { LS.set(NOTES_KEY, notesVersion); notesVersion = '' }
2354
+ }
2355
+
2161
2356
  async function checkUpdate(silent) {
2162
2357
  if (!state.localVersion) {
2163
2358
  $('update-desc').textContent = t('update.noVersion')
@@ -2177,6 +2372,7 @@ async function checkUpdate(silent) {
2177
2372
  const res = await fetch(base + '/update.json?t=' + Date.now() + '&local=' + encodeURIComponent(state.localVersion))
2178
2373
  if (!res.ok) throw new Error('HTTP ' + res.status)
2179
2374
  const info = await res.json()
2375
+ openNotesModal(info)
2180
2376
  if (info.version && cmpVersion(info.version, state.localVersion) > 0) {
2181
2377
  state.updateInfo = info
2182
2378
  const hasNotes = !!(info.notes && String(info.notes).trim())
@@ -2201,13 +2397,64 @@ async function checkUpdate(silent) {
2201
2397
  }
2202
2398
  }
2203
2399
 
2204
- function downloadUpdate() {
2400
+ async function sha256Hex(buffer) {
2401
+ const digest = await crypto.subtle.digest('SHA-256', buffer)
2402
+ return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('')
2403
+ }
2404
+
2405
+ /**
2406
+ * 下载 APK 并用 update.json 的 sha256 校验。
2407
+ * 返回 { ok, skipped } 或 { ok:false, status | corrupted | network }。
2408
+ * 老产物没有 sha256 时跳过校验;crypto.subtle 不可用也跳过(不阻塞老 WebView)。
2409
+ */
2410
+ async function verifyUpdateApk(info, url) {
2411
+ const expected = String(info.sha256 || '').trim().toLowerCase()
2412
+ if (!expected || !/^[0-9a-f]{64}$/.test(expected)) return { ok: true, skipped: true }
2413
+ let res
2414
+ try {
2415
+ const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(120000) : undefined
2416
+ res = signal ? await fetch(url, { signal }) : await fetch(url)
2417
+ } catch (err) {
2418
+ return { ok: false, network: true, msg: err?.message || '' }
2419
+ }
2420
+ if (!res.ok) return { ok: false, status: res.status }
2421
+ let buf
2422
+ try {
2423
+ buf = await res.arrayBuffer()
2424
+ } catch (err) {
2425
+ return { ok: false, network: true, msg: err?.message || '' }
2426
+ }
2427
+ let actual
2428
+ try {
2429
+ actual = await sha256Hex(buf)
2430
+ } catch {
2431
+ return { ok: true, skipped: true }
2432
+ }
2433
+ if (actual.toLowerCase() !== expected) return { ok: false, corrupted: true }
2434
+ return { ok: true, skipped: false }
2435
+ }
2436
+
2437
+ async function downloadUpdate() {
2205
2438
  const info = state.updateInfo
2206
2439
  if (!info) return
2207
2440
  const base = state.server || ''
2208
2441
  let url
2209
2442
  try { url = new URL(info.apkUrl || 'dsh-remote.apk', base + '/').href }
2210
2443
  catch { url = base + '/' + (info.apkUrl || 'dsh-remote.apk') }
2444
+
2445
+ // 先下载校验再交给原生/浏览器安装;校验失败不进入安装
2446
+ const verify = await verifyUpdateApk(info, url)
2447
+ if (!verify.ok) {
2448
+ if (verify.corrupted) {
2449
+ toast(t('update.corrupted'), 'err')
2450
+ } else if (verify.status) {
2451
+ toast(t('update.serverFileMissing'), 'err')
2452
+ } else {
2453
+ toast(t('update.downloadFailed', { msg: verify.msg || t('fs.networkError') }), 'err')
2454
+ }
2455
+ return
2456
+ }
2457
+
2211
2458
  if (CAP?.isNativePlatform?.()) {
2212
2459
  // Android WebView 原生桥(不依赖 Capacitor 插件路由)
2213
2460
  if (window.NativeUpdate?.downloadAndInstall) {
@@ -2268,6 +2515,108 @@ function notify(title, body) {
2268
2515
  } catch {}
2269
2516
  }
2270
2517
 
2518
+ /* ---------------- 后台轮询(Android 前台服务) ---------------- */
2519
+ function bgBridge() { return window.NativeBackground }
2520
+ function bgBase() { return (state.server || location.origin || '').replace(/\/+$/, '') }
2521
+ function applyBgConfigFromNative() {
2522
+ const b = bgBridge()
2523
+ if (!b?.getBackgroundConfig) return
2524
+ try {
2525
+ const cfg = JSON.parse(b.getBackgroundConfig() || '{}')
2526
+ $('opt-bg-poll').checked = !!cfg.enabled
2527
+ const v = String(cfg.intervalMin ?? 1)
2528
+ const opts = Array.from($('bg-interval')?.options || [])
2529
+ if (opts.some(o => o.value === v)) $('bg-interval').value = v
2530
+ if ($('opt-task-done')) $('opt-task-done').checked = cfg.notifyTaskDone !== false
2531
+ $('bg-auth-status')?.classList.toggle('hidden', !cfg.loginExpired)
2532
+ } catch {}
2533
+ }
2534
+ function saveBgConfig(enabled) {
2535
+ const b = bgBridge()
2536
+ if (!b?.saveBackgroundConfig) return false
2537
+ const base = bgBase()
2538
+ const intervalMin = parseFloat($('bg-interval')?.value || '1') || 1
2539
+ const notifyTaskDone = $('opt-task-done')?.checked !== false
2540
+ b.saveBackgroundConfig(JSON.stringify({ enabled, intervalMin, base, token: state.token || '', notifyTaskDone }))
2541
+ if (enabled) $('bg-auth-status')?.classList.add('hidden')
2542
+ return true
2543
+ }
2544
+ function syncBgConfig() {
2545
+ if ($('opt-bg-poll')?.checked && state.token) saveBgConfig(true)
2546
+ }
2547
+
2548
+ /* ---------------- 预设提示词 ---------------- */
2549
+ const PRESETS_KEY = 'dshPromptPresets'
2550
+ const PRESET_NAME_MAX = 20
2551
+ const PRESET_TEXT_MAX = 2000
2552
+ const PRESET_LIMIT = 20
2553
+ function readPresets() {
2554
+ try {
2555
+ const v = JSON.parse(LS.get(PRESETS_KEY, '[]') || '[]')
2556
+ return Array.isArray(v) ? v.filter(p => p && typeof p.id === 'string' && typeof p.name === 'string' && typeof p.text === 'string') : []
2557
+ } catch { return [] }
2558
+ }
2559
+ function writePresets(list) {
2560
+ LS.set(PRESETS_KEY, JSON.stringify(list))
2561
+ renderPresets()
2562
+ renderPresetMenu()
2563
+ }
2564
+ function renderPresets() {
2565
+ const box = $('preset-list')
2566
+ if (!box) return
2567
+ const list = readPresets()
2568
+ if (!list.length) {
2569
+ box.innerHTML = `<div class="server-empty">${esc(t('presets.empty'))}</div>`
2570
+ return
2571
+ }
2572
+ box.innerHTML = list.map(p => `<div class="server-row">
2573
+ <div class="server-main"><div class="server-note">${esc(p.name)}</div><div class="server-url">${esc((p.text || '').slice(0, 60))}</div></div>
2574
+ <button class="mini-btn" data-preset-edit="${esc(p.id)}">${t('presets.edit')}</button>
2575
+ <button class="mini-btn" data-preset-del="${esc(p.id)}">${t('presets.delete')}</button>
2576
+ </div>`).join('')
2577
+ box.querySelectorAll('[data-preset-edit]').forEach(b => b.addEventListener('click', () => editPreset(b.dataset.presetEdit)))
2578
+ box.querySelectorAll('[data-preset-del]').forEach(b => b.addEventListener('click', () => deletePreset(b.dataset.presetDel)))
2579
+ }
2580
+ function renderPresetMenu() {
2581
+ const group = $('preset-menu-group')
2582
+ const listBox = $('preset-menu-list')
2583
+ if (!group || !listBox) return
2584
+ const list = readPresets()
2585
+ group.classList.toggle('hidden', !list.length)
2586
+ listBox.innerHTML = list.map(p => `<button class="menu-chip" data-preset="${esc(p.id)}">${esc(p.name)}</button>`).join('')
2587
+ }
2588
+ function promptPreset(id) {
2589
+ const list = readPresets()
2590
+ const existing = id ? list.find(p => p.id === id) : null
2591
+ const name = prompt(t('presets.namePrompt'), existing?.name || '')
2592
+ if (name == null) return
2593
+ const text = prompt(t('presets.textPrompt'), existing?.text || '')
2594
+ if (text == null) return
2595
+ const n = (name || '').trim()
2596
+ if (!n) return toast(t('presets.nameEmpty'), 'err')
2597
+ if (n.length > PRESET_NAME_MAX) return toast(t('presets.nameTooLong'), 'err')
2598
+ if (text.length > PRESET_TEXT_MAX) return toast(t('presets.textTooLong'), 'err')
2599
+ if (existing) {
2600
+ existing.name = n
2601
+ existing.text = text
2602
+ } else {
2603
+ if (list.length >= PRESET_LIMIT) return toast(t('presets.limit'), 'err')
2604
+ list.push({ id: 'p' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), name: n, text })
2605
+ }
2606
+ writePresets(list)
2607
+ toast(existing ? t('presets.saved') : t('presets.added'), 'ok')
2608
+ }
2609
+ function addPreset() { promptPreset(null) }
2610
+ function editPreset(id) { promptPreset(id) }
2611
+ function deletePreset(id) {
2612
+ const list = readPresets()
2613
+ const p = list.find(x => x.id === id)
2614
+ if (!p) return
2615
+ if (!confirm(t('presets.confirmDelete', { name: p.name }))) return
2616
+ writePresets(list.filter(x => x.id !== id))
2617
+ toast(t('presets.deleted'), 'ok')
2618
+ }
2619
+
2271
2620
  /* ---------------- 峰谷计费提醒(每天 9/12/14/18 点本地通知) ---------------- */
2272
2621
  const PEAK_REMIND_NOTIFS = [
2273
2622
  { id: 8801, hour: 9, periodKey: 'peak0912', enterKey: 'enterPeak' },
@@ -2313,17 +2662,40 @@ function showView(id) {
2313
2662
  window.scrollTo(0, 0)
2314
2663
  if (id === 'view-files' && !state.fs.loaded) loadFs(null, { silent: true })
2315
2664
  if (id === 'view-stats') loadStats()
2665
+ if (id === 'view-settings') showSettingsHome()
2666
+ }
2667
+
2668
+ const SETTINGS_GROUPS = ['general', 'servers', 'notify', 'theme', 'about']
2669
+ function showSettingsHome() {
2670
+ const home = $('settings-home')
2671
+ if (!home) return
2672
+ home.classList.remove('hidden')
2673
+ for (const name of SETTINGS_GROUPS) $('settings-page-' + name)?.classList.add('hidden')
2674
+ window.scrollTo(0, 0)
2675
+ }
2676
+ function showSettingsPage(name) {
2677
+ const home = $('settings-home')
2678
+ if (!home || !SETTINGS_GROUPS.includes(name)) return
2679
+ home.classList.add('hidden')
2680
+ for (const g of SETTINGS_GROUPS) $('settings-page-' + g)?.classList.toggle('hidden', g !== name)
2681
+ window.scrollTo(0, 0)
2316
2682
  }
2317
2683
 
2318
2684
  function updateConn() {
2319
- const ok = !!state.streamsOk?.mux
2320
2685
  const el = $('conn-badge')
2321
- el.textContent = ok ? t('conn.on') : t('conn.off')
2322
- el.className = 'topbar-btn conn-badge ' + (ok ? 'on' : 'off')
2323
2686
  const cur = state.servers.find(s => s.url === state.server)
2324
2687
  const ms = state.serverLatency[state.server]
2325
2688
  const curGroup = cur ? cur.group : state.activeGroup
2326
2689
  const curLabel = cur ? (cur.note || cur.url) : (state.server || t('speed.origin'))
2690
+ if (state.streamMode === 'poll') {
2691
+ el.textContent = t('conn.poll')
2692
+ el.className = 'topbar-btn conn-badge off'
2693
+ el.title = t('conn.pollTitle') + ' · ' + t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
2694
+ return
2695
+ }
2696
+ const ok = !!state.streamsOk?.mux
2697
+ el.textContent = ok ? t('conn.on') : t('conn.off')
2698
+ el.className = 'topbar-btn conn-badge ' + (ok ? 'on' : 'off')
2327
2699
  el.title = t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
2328
2700
  }
2329
2701
 
@@ -2350,6 +2722,7 @@ function applyPairUrl(url) {
2350
2722
  saveServers()
2351
2723
  renderServers()
2352
2724
  $('token-desc').textContent = t('token.savedScan')
2725
+ syncBgConfig()
2353
2726
  return true
2354
2727
  } catch {
2355
2728
  return false
@@ -2490,6 +2863,11 @@ function openThemePanel() {
2490
2863
  $('modal-theme').classList.remove('hidden')
2491
2864
  }
2492
2865
 
2866
+ function openDonateModal() {
2867
+ const m = $('modal-donate')
2868
+ if (m) m.classList.remove('hidden')
2869
+ }
2870
+
2493
2871
  function bindUi() {
2494
2872
  renderLangBtn()
2495
2873
  renderThemeBtn()
@@ -2510,6 +2888,17 @@ function bindUi() {
2510
2888
  })
2511
2889
  $('btn-theme').addEventListener('click', openThemePanel)
2512
2890
  $('theme-close').addEventListener('click', () => $('modal-theme').classList.add('hidden'))
2891
+ $('btn-donate').addEventListener('click', openDonateModal)
2892
+ $('donate-close').addEventListener('click', () => $('modal-donate').classList.add('hidden'))
2893
+ document.addEventListener('click', (e) => {
2894
+ if (e.target.closest('[data-donate-open]')) openDonateModal()
2895
+ })
2896
+ // 更新内容弹窗
2897
+ $('notes-close').addEventListener('click', closeNotesModal)
2898
+ $('notes-prev').addEventListener('click', () => scrollNotes(-1))
2899
+ $('notes-next').addEventListener('click', () => scrollNotes(1))
2900
+ $('notes-pages').addEventListener('scroll', updateNotesPage)
2901
+ $('modal-notes').addEventListener('click', (e) => { if (e.target === $('modal-notes')) closeNotesModal() })
2513
2902
  renderServers()
2514
2903
  // 底部导航
2515
2904
  document.querySelectorAll('.nav-btn').forEach(b =>
@@ -2552,9 +2941,27 @@ function bindUi() {
2552
2941
  $('btn-cancel').addEventListener('click', cancelSession)
2553
2942
  $('btn-send').addEventListener('click', sendMessage)
2554
2943
  $('btn-plus').addEventListener('click', toggleComposerMenu)
2555
- $('composer-menu').addEventListener('click', (e) => {
2944
+ $('composer-menu').addEventListener('click', async (e) => {
2556
2945
  const chip = e.target.closest('[data-cmd]')
2557
- if (chip) { hideComposerMenu(); sendSessionText(chip.dataset.cmd) }
2946
+ if (chip) {
2947
+ const input = $('composer-input')
2948
+ input.value = chip.dataset.cmd + ' '
2949
+ input.focus()
2950
+ autosize(input)
2951
+ hideComposerMenu()
2952
+ return
2953
+ }
2954
+ const preset = e.target.closest('[data-preset]')
2955
+ if (preset) {
2956
+ const found = readPresets().find(x => x.id === preset.dataset.preset)
2957
+ if (found) {
2958
+ const input = $('composer-input')
2959
+ input.value = found.text
2960
+ input.focus()
2961
+ autosize(input)
2962
+ }
2963
+ hideComposerMenu()
2964
+ }
2558
2965
  })
2559
2966
  $('btn-model-refresh').addEventListener('click', loadSessionModels)
2560
2967
  const input = $('composer-input')
@@ -2579,10 +2986,15 @@ function bindUi() {
2579
2986
  $('goal-close').addEventListener('click', () => $('modal-goal').classList.add('hidden'))
2580
2987
  $('goal-edit').addEventListener('click', submitGoalEdit)
2581
2988
  // 设置
2989
+ $('view-settings').addEventListener('click', (e) => {
2990
+ const group = e.target.closest('[data-settings-group]')
2991
+ if (group) { showSettingsPage(group.dataset.settingsGroup); return }
2992
+ if (e.target.closest('[data-settings-back]')) { showSettingsHome(); return }
2993
+ })
2582
2994
  $('btn-scan-pair').addEventListener('click', scanPair)
2583
2995
  $('btn-change-token').addEventListener('click', () => {
2584
2996
  const input = prompt(t('token.prompt'), state.token)
2585
- 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() }
2997
+ 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(); syncBgConfig() }
2586
2998
  })
2587
2999
  $('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
2588
3000
  $('btn-server-add').addEventListener('click', addServer)
@@ -2607,6 +3019,7 @@ function bindUi() {
2607
3019
  $('btn-reset').addEventListener('click', () => {
2608
3020
  if (!confirm(t('settings.confirmReset'))) return
2609
3021
  LS.del('token'); LS.del('notify'); LS.del('server')
3022
+ if (bgBridge()?.saveBackgroundConfig) saveBgConfig(false)
2610
3023
  location.reload()
2611
3024
  })
2612
3025
  $('opt-notify').checked = LS.get('notify', '0') === '1'
@@ -2636,6 +3049,35 @@ function bindUi() {
2636
3049
  })
2637
3050
  // 已开启则启动时重新调度, 防止系统清理后丢失
2638
3051
  if (peakRemindOn() && CAP?.isNativePlatform?.()) schedulePeakReminders()
3052
+ applyBgConfigFromNative()
3053
+ $('opt-bg-poll').addEventListener('change', async (e) => {
3054
+ if (e.target.checked) {
3055
+ if (!CAP?.isNativePlatform?.() || !bgBridge()?.saveBackgroundConfig) {
3056
+ e.target.checked = false
3057
+ return toast(t('settings.bgNativeOnly'), 'err')
3058
+ }
3059
+ if (!state.token) {
3060
+ e.target.checked = false
3061
+ return toast(t('settings.bgNeedToken'), 'err')
3062
+ }
3063
+ const ok = await ensureNotify()
3064
+ if (!ok) { e.target.checked = false; return toast(t('settings.notifyDenied')) }
3065
+ saveBgConfig(true)
3066
+ toast(t('settings.bgOn'), 'ok')
3067
+ } else {
3068
+ saveBgConfig(false)
3069
+ toast(t('settings.bgOff'), 'ok')
3070
+ }
3071
+ })
3072
+ $('bg-interval').addEventListener('change', () => {
3073
+ if ($('opt-bg-poll')?.checked) saveBgConfig(true)
3074
+ })
3075
+ $('opt-task-done')?.addEventListener('change', () => {
3076
+ if (bgBridge()?.saveBackgroundConfig) saveBgConfig($('opt-bg-poll')?.checked)
3077
+ })
3078
+ renderPresets()
3079
+ renderPresetMenu()
3080
+ $('btn-preset-add').addEventListener('click', addPreset)
2639
3081
  $('opt-tools').checked = LS.get('showTools', '1') !== '0'
2640
3082
  $('opt-tools').addEventListener('change', (e) => {
2641
3083
  LS.set('showTools', e.target.checked ? '1' : '0')