dsh-remote-plugin 0.6.3 → 0.6.5

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
@@ -275,7 +275,7 @@ async function rpc(method, payload = {}) {
275
275
  body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
276
276
  }
277
277
  if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
278
- opts.signal = AbortSignal.timeout(20000)
278
+ opts.signal = AbortSignal.timeout(45000)
279
279
  }
280
280
  const res = await fetch(apiUrl('/api/' + method), opts)
281
281
  if (res.status === 401) throw new Error('AUTH')
@@ -709,16 +709,54 @@ const streams = {}
709
709
  state.streamsOk = { mux: false, host: false }
710
710
  let pollTimer = null
711
711
  let wsRetryTimer = null
712
+ let connTickTimer = null
713
+ let reconnectInfo = null
714
+
715
+ function clearStreamTimers(ws) {
716
+ if (!ws) return
717
+ clearInterval(ws._hbTimer)
718
+ clearInterval(ws._staleTimer)
719
+ ws._hbTimer = null
720
+ ws._staleTimer = null
721
+ }
722
+
723
+ function clearConnTick() {
724
+ clearInterval(connTickTimer)
725
+ connTickTimer = null
726
+ }
727
+
728
+ function startConnTick() {
729
+ if (connTickTimer) return
730
+ connTickTimer = setInterval(() => {
731
+ updateConn()
732
+ if (!reconnectInfo || state.streamMode === 'poll' || !navigator.onLine ||
733
+ (streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN)) {
734
+ clearConnTick()
735
+ }
736
+ }, 1000)
737
+ }
738
+
739
+ function setReconnect(delay) {
740
+ reconnectInfo = { at: Date.now() + delay }
741
+ startConnTick()
742
+ updateConn()
743
+ }
744
+
745
+ function clearReconnect() {
746
+ reconnectInfo = null
747
+ clearConnTick()
748
+ }
712
749
 
713
750
  function openStreams() {
714
751
  if (!state.token) return
715
752
  if (state.streamMode === 'poll') stopPolling()
716
753
  state.streamMode = 'ws'
754
+ clearReconnect()
717
755
  openStream('mux', onMuxFrame, true)
718
756
  openStream('host', onHostFrame, false)
719
757
  }
720
758
 
721
- function openStream(kind, handler, refreshOnOpen) {
759
+ function openStream(kind, handler, refreshOnOpen, isRestore) {
722
760
  if (!state.token) return
723
761
  let base
724
762
  if (state.server) {
@@ -731,11 +769,28 @@ function openStream(kind, handler, refreshOnOpen) {
731
769
  const ws = new WebSocket(`${base}/api/events.${kind}?token=${encodeURIComponent(state.token)}&client=${clientMark}`)
732
770
  try { streams[kind]?.close() } catch {}
733
771
  streams[kind] = ws
772
+ ws._attempt = 0
773
+ ws._lastMsgAt = 0
774
+ ws._isRestore = !!isRestore
734
775
  ws.onopen = () => {
735
776
  state.streamsOk[kind] = true
736
777
  state.errCount = 0
778
+ ws._attempt = 0
779
+ ws._lastMsgAt = Date.now()
780
+ clearStreamTimers(ws)
781
+ // 应用层心跳: 25s 发纯文本 ping, 防 NAT/WiFi 切换后的 WS 半开假活
782
+ ws._hbTimer = setInterval(() => {
783
+ try { if (ws.readyState === WebSocket.OPEN) ws.send('ping') } catch {}
784
+ }, 25000)
785
+ // 60s 没有任何消息(含 pong/业务帧)就主动断开, 触发指数退避重连
786
+ ws._staleTimer = setInterval(() => {
787
+ if (ws.readyState === WebSocket.OPEN && Date.now() - (ws._lastMsgAt || 0) > 60000) {
788
+ try { ws.close() } catch {}
789
+ }
790
+ }, 10000)
737
791
  // 重连成功:切回 WS 并停止轮询
738
792
  if (state.streamMode === 'poll') { stopPolling(); state.streamMode = 'ws' }
793
+ if (streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN) clearReconnect()
739
794
  updateConn()
740
795
  // mux 每次(重)连接都会重放“仍待处理”的审批/提问基线:
741
796
  // 先清空旧列表, 避免“桌面已自定义回答, 手机漏收 question/resolved”后永久残留。
@@ -747,6 +802,7 @@ function openStream(kind, handler, refreshOnOpen) {
747
802
  if (refreshOnOpen) refreshAll()
748
803
  }
749
804
  ws.onmessage = (msg) => {
805
+ ws._lastMsgAt = Date.now()
750
806
  state.streamsOk[kind] = true
751
807
  state.errCount = 0
752
808
  updateConn()
@@ -756,16 +812,34 @@ function openStream(kind, handler, refreshOnOpen) {
756
812
  } catch {}
757
813
  }
758
814
  ws.onclose = () => {
815
+ clearStreamTimers(ws)
759
816
  state.streamsOk[kind] = false
760
817
  state.errCount++
761
818
  updateConn()
762
- if (state.streamMode === 'poll') return
819
+ if (!navigator.onLine) { clearReconnect(); return }
820
+ if (state.streamMode === 'poll') {
821
+ // 降级轮询期间: 恢复尝试失败也用退避, 避免 30s 固定间隔内空转
822
+ if (ws._isRestore && streams[kind] === ws) {
823
+ const attempt = ws._attempt || 0
824
+ ws._attempt = attempt + 1
825
+ const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
826
+ const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
827
+ setTimeout(() => openStream(kind, handler, refreshOnOpen, true), delay)
828
+ }
829
+ return
830
+ }
763
831
  // 连续失败 3 次 -> 降级为轮询
764
832
  if (state.errCount >= 3) { enterPollMode(); return }
765
833
  // 多服务器: 连续掉线若干次就重测速, 自动换到当前可达的最快地址
766
834
  if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
767
- // 无条件重连; 页面被挂起时定时器暂停, visibilitychange 会再触发一次
768
- if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), 1200)
835
+ // 指数退避 + 20% 抖动: min(1200 * 2^attempt, 30000)
836
+ const attempt = ws._attempt || 0
837
+ ws._attempt = attempt + 1
838
+ const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
839
+ const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
840
+ setReconnect(delay)
841
+ // 页面被挂起时定时器暂停, visibilitychange 会再触发一次
842
+ if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), delay)
769
843
  }
770
844
  ws.onerror = () => { try { ws.close() } catch {} }
771
845
  }
@@ -838,8 +912,8 @@ async function pollKind(kind) {
838
912
  function tryRestoreWs() {
839
913
  if (state.streamMode !== 'poll' || !state.token) return
840
914
  // 轮询继续跑,等 WS 真正 onopen 后再切回,避免重连窗口丢事件
841
- openStream('mux', onMuxFrame, true)
842
- openStream('host', onHostFrame, false)
915
+ openStream('mux', onMuxFrame, true, true)
916
+ openStream('host', onHostFrame, false, true)
843
917
  }
844
918
 
845
919
  /* 回前台恢复: 强制重排修复 MIUI WebView 后台切回时 sticky 顶栏不绘制的问题 */
@@ -876,6 +950,23 @@ setInterval(() => {
876
950
  setInterval(() => {
877
951
  if (document.visibilityState === 'visible' && state.servers.length) selectFastestServer({ silent: true })
878
952
  }, 300000)
953
+
954
+ /* 网络感知: 离线立刻关 WS + 显示离线, 在线立即重连 */
955
+ window.addEventListener('offline', () => {
956
+ clearReconnect()
957
+ try { streams.mux?.close() } catch {}
958
+ try { streams.host?.close() } catch {}
959
+ if (state.streamMode === 'poll') stopPolling()
960
+ updateConn()
961
+ })
962
+ window.addEventListener('online', () => {
963
+ if (!state.token) { updateConn(); return }
964
+ state.errCount = 0
965
+ clearReconnect()
966
+ openStreams()
967
+ updateConn()
968
+ })
969
+
879
970
  function onMuxFrame(full) {
880
971
  const f = full.payload
881
972
  if (!f) return
@@ -1180,8 +1271,19 @@ async function loadHistory(reset) {
1180
1271
  } catch (e) {
1181
1272
  state.history.loading = false
1182
1273
  if (e.message === 'AUTH') { authFailure(); return }
1183
- if (restoreCachedHistory()) toast(t('history.cacheFallback'), 'ok')
1184
- else toast(t('history.loadFailed', { msg: e.message }), 'err')
1274
+ if (restoreCachedHistory()) {
1275
+ toast(t('history.cacheFallback'), 'ok')
1276
+ return
1277
+ }
1278
+ const msg = e.message || t('err.dshError')
1279
+ const box = $('history')
1280
+ if (box && (reset || !state.history.visible.length)) {
1281
+ box.innerHTML = `<div class="empty"><div>${esc(t('history.loadFailed', { msg }))}</div><button type="button" class="mini-btn" id="btn-history-retry" style="margin-top:10px">${esc(t('history.retry'))}</button></div>`
1282
+ const retry = $('btn-history-retry')
1283
+ if (retry) retry.addEventListener('click', () => loadHistory(true))
1284
+ } else {
1285
+ toast(t('history.loadFailed', { msg }), 'err')
1286
+ }
1185
1287
  return
1186
1288
  }
1187
1289
 
@@ -1404,7 +1506,7 @@ function blockHtml(b) {
1404
1506
  if (!b || typeof b !== 'object') return `<p>${esc(String(b))}</p>`
1405
1507
  if ((b.type === 'tool-call' || b.type === 'tool-result') && LS.get('showTools', '1') === '0') return ''
1406
1508
  switch (b.type) {
1407
- case 'text': return `<div>${renderMarkdown(b.text ?? '')}</div>`
1509
+ case 'text': return `<div class="md">${window.mdToHtml ? window.mdToHtml(b.text ?? '') : esc(b.text ?? '')}</div>`
1408
1510
  case 'image': return `<img alt="${t('block.image')}" src="data:${esc(b.mediaType || 'image/png')};base64,${esc(b.data || '')}">`
1409
1511
  case 'thinking':
1410
1512
  case 'reasoning':
@@ -1418,20 +1520,6 @@ function blockHtml(b) {
1418
1520
  }
1419
1521
  }
1420
1522
 
1421
- function renderMarkdown(text) {
1422
- const parts = String(text ?? '').split(/```/)
1423
- let out = ''
1424
- for (let i = 0; i < parts.length; i++) {
1425
- if (i % 2 === 1) out += `<pre>${esc(parts[i])}</pre>`
1426
- else out += esc(parts[i])
1427
- .replace(/`([^`]+)`/g, '<code>$1</code>')
1428
- .replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
1429
- .replace(/(?:^|\n)(#{1,4})\s+([^\n]+)/g, (m, h, t) => `\n<b>${t}</b>`)
1430
- .replace(/\n/g, '<br>')
1431
- }
1432
- return out
1433
- }
1434
-
1435
1523
  function safeJson(v) {
1436
1524
  try { return typeof v === 'string' ? v : JSON.stringify(v, null, 2) }
1437
1525
  catch { return String(v) }
@@ -2544,6 +2632,33 @@ function notify(title, body) {
2544
2632
  } catch {}
2545
2633
  }
2546
2634
 
2635
+ async function sendTestNotification() {
2636
+ if (!CAP?.isNativePlatform?.()) {
2637
+ toast(t('settings.testNotifyUnavailable'), 'err')
2638
+ return
2639
+ }
2640
+ const L = CAP.Plugins?.LocalNotifications
2641
+ if (!L?.schedule) {
2642
+ toast(t('settings.testNotifyUnavailable'), 'err')
2643
+ return
2644
+ }
2645
+ const ok = await ensureNotify()
2646
+ if (!ok) { toast(t('settings.notifyDenied'), 'err'); return }
2647
+ try {
2648
+ await L.schedule({
2649
+ notifications: [{
2650
+ id: 8899,
2651
+ title: 'DSH Remote',
2652
+ body: '测试通知 · Test',
2653
+ schedule: { at: new Date(Date.now() + 3000) }
2654
+ }]
2655
+ })
2656
+ toast(t('settings.testNotifySent'), 'ok')
2657
+ } catch (e) {
2658
+ toast(t('settings.testNotifyFailed', { msg: e?.message || '' }), 'err')
2659
+ }
2660
+ }
2661
+
2547
2662
  /* ---------------- 后台轮询(Android 前台服务) ---------------- */
2548
2663
  function bgBridge() { return window.NativeBackground }
2549
2664
  function bgBase() { return (state.server || location.origin || '').replace(/\/+$/, '') }
@@ -2646,38 +2761,25 @@ function deletePreset(id) {
2646
2761
  toast(t('presets.deleted'), 'ok')
2647
2762
  }
2648
2763
 
2649
- /* ---------------- 峰谷计费提醒(每天 9/12/14/18 点本地通知) ---------------- */
2650
- const PEAK_REMIND_NOTIFS = [
2651
- { id: 8801, hour: 9, periodKey: 'peak0912', enterKey: 'enterPeak' },
2652
- { id: 8802, hour: 12, periodKey: 'off1214', enterKey: 'enterOff' },
2653
- { id: 8803, hour: 14, periodKey: 'peak1418', enterKey: 'enterPeak' },
2654
- { id: 8804, hour: 18, periodKey: 'off1809', enterKey: 'enterOff' },
2655
- ]
2764
+ /* ---------------- 峰谷计费提醒(前台服务进程内定时, 绕开 MIUI 后台限制) ---------------- */
2656
2765
  function peakRemindOn() { return LS.get('peakRemind', '0') === '1' }
2657
2766
 
2658
2767
  async function schedulePeakReminders() {
2659
2768
  if (!CAP?.isNativePlatform?.()) return false
2660
- const L = CAP.Plugins?.LocalNotifications
2661
- if (!L?.schedule) return false
2769
+ const b = bgBridge()
2770
+ if (!b?.startPeakReminder) return false
2662
2771
  try {
2663
- await L.schedule({
2664
- notifications: PEAK_REMIND_NOTIFS.map(n => ({
2665
- id: n.id,
2666
- title: 'DSH Remote',
2667
- body: `${t('peakRemind.' + n.enterKey)} · ${t('peakRemind.' + n.periodKey)}`,
2668
- schedule: { every: 'day', on: { hour: n.hour, minute: 0 } },
2669
- }))
2670
- })
2772
+ b.startPeakReminder()
2671
2773
  return true
2672
2774
  } catch { return false }
2673
2775
  }
2674
2776
 
2675
2777
  async function cancelPeakReminders() {
2676
2778
  if (!CAP?.isNativePlatform?.()) return false
2677
- const L = CAP.Plugins?.LocalNotifications
2678
- if (!L?.cancel) return false
2779
+ const b = bgBridge()
2780
+ if (!b?.stopPeakReminder) return false
2679
2781
  try {
2680
- await L.cancel({ notifications: PEAK_REMIND_NOTIFS.map(n => ({ id: n.id })) })
2782
+ b.stopPeakReminder()
2681
2783
  return true
2682
2784
  } catch { return false }
2683
2785
  }
@@ -2716,16 +2818,36 @@ function updateConn() {
2716
2818
  const ms = state.serverLatency[state.server]
2717
2819
  const curGroup = cur ? cur.group : state.activeGroup
2718
2820
  const curLabel = cur ? (cur.note || cur.url) : (state.server || t('speed.origin'))
2821
+ const titleBase = t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
2822
+ if (!navigator.onLine) {
2823
+ el.textContent = t('conn.offline')
2824
+ el.className = 'topbar-btn conn-badge off'
2825
+ el.title = titleBase
2826
+ return
2827
+ }
2719
2828
  if (state.streamMode === 'poll') {
2720
2829
  el.textContent = t('conn.poll')
2721
2830
  el.className = 'topbar-btn conn-badge off'
2722
- el.title = t('conn.pollTitle') + ' · ' + t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
2831
+ el.title = t('conn.pollTitle') + ' · ' + titleBase
2723
2832
  return
2724
2833
  }
2725
2834
  const ok = !!state.streamsOk?.mux
2835
+ if (!ok && reconnectInfo) {
2836
+ const remain = Math.max(0, Math.ceil((reconnectInfo.at - Date.now()) / 1000))
2837
+ el.textContent = remain > 0 ? t('conn.reconnectIn', { n: remain }) : t('conn.reconnecting')
2838
+ el.className = 'topbar-btn conn-badge off'
2839
+ el.title = t('conn.reconnecting') + ' · ' + titleBase
2840
+ return
2841
+ }
2842
+ if (!ok && state.errCount > 0) {
2843
+ el.textContent = t('conn.failed')
2844
+ el.className = 'topbar-btn conn-badge off'
2845
+ el.title = titleBase
2846
+ return
2847
+ }
2726
2848
  el.textContent = ok ? t('conn.on') : t('conn.off')
2727
2849
  el.className = 'topbar-btn conn-badge ' + (ok ? 'on' : 'off')
2728
- el.title = t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
2850
+ el.title = titleBase
2729
2851
  }
2730
2852
 
2731
2853
  function autosize(el) {
@@ -2784,7 +2906,7 @@ async function decodeQrDataUrl(dataUrl) {
2784
2906
  /** App 内扫码: 官方 Camera 拍照/相册 + jsQR 本地解码(无 Google ML Kit/GMS 依赖, 国内可用)。
2785
2907
  * 冗余路径 1: 系统相机扫 dshremote:// 二维码直接唤起 App(见 bindNativeLinks);
2786
2908
  * 冗余路径 2: 设置页手动粘贴令牌。 */
2787
- async function scanPair() {
2909
+ async function scanPair(source) {
2788
2910
  if (!CAP?.isNativePlatform?.()) {
2789
2911
  toast(t('scan.browserHint'), 'err')
2790
2912
  return
@@ -2792,17 +2914,17 @@ async function scanPair() {
2792
2914
  const camera = CAP.Plugins?.Camera
2793
2915
  if (!camera?.getPhoto) { toast(t('scan.unsupported'), 'err'); return }
2794
2916
  try {
2795
- const perm = await camera.requestPermissions?.({ permissions: ['camera'] })
2796
- if (perm && perm.camera !== 'granted') { toast(t('scan.permissionDenied'), 'err'); return }
2917
+ // 显式指定来源绕过 PROMPT: 小米/HyperOS PROMPT 选择器会错乱(选拍照开相册/选相册开相机)
2918
+ if (source === 'CAMERA') {
2919
+ const perm = await camera.requestPermissions?.({ permissions: ['camera'] })
2920
+ if (perm && perm.camera !== 'granted') { toast(t('scan.permissionDenied'), 'err'); return }
2921
+ }
2797
2922
  const photo = await camera.getPhoto({
2798
2923
  resultType: 'dataUrl',
2799
- source: 'PROMPT', // 拍照 / 从相册选择都行, 相册可扫截图
2924
+ source: source === 'PHOTOS' ? 'PHOTOS' : 'CAMERA',
2800
2925
  quality: 85,
2801
2926
  correctOrientation: true,
2802
2927
  saveToGallery: false,
2803
- promptLabelHeader: t('scan.promptHeader'),
2804
- promptLabelPhoto: t('scan.promptPhoto'),
2805
- promptLabelPicture: t('scan.promptGallery'),
2806
2928
  })
2807
2929
  if (!photo?.dataUrl) { toast(t('scan.noPhoto'), 'err'); return }
2808
2930
  const raw = await decodeQrDataUrl(photo.dataUrl)
@@ -3020,7 +3142,8 @@ function bindUi() {
3020
3142
  if (group) { showSettingsPage(group.dataset.settingsGroup); return }
3021
3143
  if (e.target.closest('[data-settings-back]')) { showSettingsHome(); return }
3022
3144
  })
3023
- $('btn-scan-pair').addEventListener('click', scanPair)
3145
+ $('btn-scan-camera').addEventListener('click', () => scanPair('CAMERA'))
3146
+ $('btn-scan-gallery').addEventListener('click', () => scanPair('PHOTOS'))
3024
3147
  $('btn-change-token').addEventListener('click', () => {
3025
3148
  const input = prompt(t('token.prompt'), state.token)
3026
3149
  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() }
@@ -3076,6 +3199,7 @@ function bindUi() {
3076
3199
  }
3077
3200
  LS.set('peakRemind', e.target.checked ? '1' : '0')
3078
3201
  })
3202
+ $('btn-test-notify').addEventListener('click', sendTestNotification)
3079
3203
  // 已开启则启动时重新调度, 防止系统清理后丢失
3080
3204
  if (peakRemindOn() && CAP?.isNativePlatform?.()) schedulePeakReminders()
3081
3205
  applyBgConfigFromNative()
@@ -103,6 +103,16 @@ a.ds-btn { text-decoration: none; }
103
103
  .ds-msg.user { align-self: flex-end; background: var(--dsr-accent-soft); border: 1px solid var(--dsr-accent-line); color: var(--dsr-accent-strong); }
104
104
  .ds-msg.assistant { align-self: flex-start; background: var(--dsr-panel); border: 1px solid var(--dsr-line); color: var(--dsr-text); }
105
105
  .ds-msg .role { font-size: 10px; color: var(--dsr-muted); margin-bottom: 3px; }
106
+ .ds-msg p { margin: 4px 0; }
107
+ .ds-msg p:first-child { margin-top: 0; } .ds-msg p:last-child { margin-bottom: 0; }
108
+ .ds-msg ul, .ds-msg ol { margin: 4px 0; padding-left: 20px; }
109
+ .ds-msg li { margin: 2px 0; }
110
+ .ds-msg h1, .ds-msg h2, .ds-msg h3 { margin: 8px 0 4px; line-height: 1.3; font-weight: 700; }
111
+ .ds-msg h1 { font-size: 1.3em; } .ds-msg h2 { font-size: 1.18em; } .ds-msg h3 { font-size: 1.05em; }
112
+ .ds-msg blockquote { margin: 6px 0; padding: 2px 10px; border-left: 3px solid var(--dsr-accent-2); color: var(--dsr-muted); }
113
+ .ds-msg a { color: inherit; text-decoration: underline; word-break: break-all; }
114
+ .ds-msg code { font-family: ui-monospace, monospace; font-size: .9em; background: var(--dsr-accent-soft); padding: 0 4px; border-radius: 4px; }
115
+ .ds-msg pre { margin: 6px 0; background: var(--dsr-code-bg); border-radius: 8px; padding: 8px 10px; overflow-x: auto; font-size: 12px; max-height: 220px; overflow-y: auto; }
106
116
  .ds-tool { align-self: flex-start; max-width: min(78%, 680px); width: fit-content; box-sizing: border-box; background: var(--dsr-bg-2); border: 1px solid var(--dsr-line); border-radius: 10px; padding: 8px 11px; font-size: 12px; }
107
117
  .ds-tool summary { cursor: pointer; color: var(--dsr-muted); }
108
118
  .ds-tool pre { margin: 6px 0 0; white-space: pre-wrap; word-break: break-all; font-size: 11px; color: var(--dsr-text); }
@@ -354,6 +354,7 @@
354
354
  'ds.feedbackRateLimited': '提交太频繁,请 5 分钟后再试', 'ds.feedbackSubmitFailed': '提交失败:{msg}', 'ds.feedbackNetworkError': '网络错误',
355
355
  'ds.questionTitle': '回答问题', 'ds.ignore': '忽略', 'ds.submit': '提交',
356
356
  'ds.connOn': '已连接', 'ds.connOff': '未连接', 'ds.connIng': '连接中',
357
+ 'ds.connReconnecting': '重连中…', 'ds.connReconnectIn': '重连中 {n}s', 'ds.connOffline': '离线', 'ds.connFailed': '连接失败',
357
358
  'ds.connPollTitle': '当前网络不支持实时推送,已降级为轮询(延迟数秒)',
358
359
  'ds.currentServer': '{group} · {url}', 'ds.origin': '当前页面',
359
360
  'ds.toastSent': '已发送', 'ds.toastCopied': '令牌已复制', 'ds.toastAuth': '令牌无效',
@@ -448,6 +449,7 @@
448
449
  'ds.feedbackRateLimited': 'Too frequent, try again in 5 minutes', 'ds.feedbackSubmitFailed': 'Submit failed: {msg}', 'ds.feedbackNetworkError': 'Network error',
449
450
  'ds.questionTitle': 'Answer question', 'ds.ignore': 'Ignore', 'ds.submit': 'Submit',
450
451
  'ds.connOn': 'Connected', 'ds.connOff': 'Offline', 'ds.connIng': 'Connecting',
452
+ 'ds.connReconnecting': 'Reconnecting…', 'ds.connReconnectIn': 'Reconnecting {n}s', 'ds.connOffline': 'Offline', 'ds.connFailed': 'Connection failed',
451
453
  'ds.connPollTitle': 'Realtime push is unavailable on this network; degraded to polling (a few seconds delay)',
452
454
  'ds.currentServer': '{group} · {url}', 'ds.origin': 'this page',
453
455
  'ds.toastSent': 'Sent', 'ds.toastCopied': 'Token copied', 'ds.toastAuth': 'Invalid token',
@@ -511,6 +513,7 @@
511
513
  }
512
514
  </script>
513
515
  <script src="i18n.js"></script>
516
+ <script src="../md.js"></script>
514
517
  <script src="desktop.js"></script>
515
518
  </body>
516
519
  </html>
@@ -66,6 +66,8 @@ const state = {
66
66
  const streams = {}
67
67
  let pollTimer = null
68
68
  let wsRetryTimer = null
69
+ let connTickTimer = null
70
+ let reconnectInfo = null
69
71
 
70
72
  function esc(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])) }
71
73
  function short(id) { return '…' + String(id).slice(-8) }
@@ -803,14 +805,50 @@ function deleteGroup(name) {
803
805
  }
804
806
 
805
807
  /* ---------------- 事件流 (WebSocket + 轮询降级) ---------------- */
808
+ function clearStreamTimers(ws) {
809
+ if (!ws) return
810
+ clearInterval(ws._hbTimer)
811
+ clearInterval(ws._staleTimer)
812
+ ws._hbTimer = null
813
+ ws._staleTimer = null
814
+ }
815
+
816
+ function clearConnTick() {
817
+ clearInterval(connTickTimer)
818
+ connTickTimer = null
819
+ }
820
+
821
+ function startConnTick() {
822
+ if (connTickTimer) return
823
+ connTickTimer = setInterval(() => {
824
+ updateConn()
825
+ if (!reconnectInfo || state.streamMode === 'poll' || !navigator.onLine ||
826
+ (streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN)) {
827
+ clearConnTick()
828
+ }
829
+ }, 1000)
830
+ }
831
+
832
+ function setReconnect(delay) {
833
+ reconnectInfo = { at: Date.now() + delay }
834
+ startConnTick()
835
+ updateConn()
836
+ }
837
+
838
+ function clearReconnect() {
839
+ reconnectInfo = null
840
+ clearConnTick()
841
+ }
842
+
806
843
  function openStreams() {
807
844
  if (!state.token) return
808
845
  if (state.streamMode === 'poll') stopPolling()
809
846
  state.streamMode = 'ws'
847
+ clearReconnect()
810
848
  openStream('mux', onMuxFrame, true)
811
849
  openStream('host', onHostFrame, false)
812
850
  }
813
- function openStream(kind, handler, refreshOnOpen) {
851
+ function openStream(kind, handler, refreshOnOpen, isRestore) {
814
852
  if (!state.token) return
815
853
  let base
816
854
  if (state.server) base = state.server.replace(/^http/, 'ws')
@@ -818,28 +856,64 @@ function openStream(kind, handler, refreshOnOpen) {
818
856
  const ws = new WebSocket(`${base}/api/events.${kind}?token=${encodeURIComponent(state.token)}&client=web`)
819
857
  try { streams[kind]?.close() } catch {}
820
858
  streams[kind] = ws
859
+ ws._attempt = 0
860
+ ws._lastMsgAt = 0
861
+ ws._isRestore = !!isRestore
821
862
  ws.onopen = () => {
822
863
  state.streamsOk[kind] = true
823
864
  state.errCount = 0
865
+ ws._attempt = 0
866
+ ws._lastMsgAt = Date.now()
867
+ clearStreamTimers(ws)
868
+ // 应用层心跳: 25s 发纯文本 ping, 防 NAT/WiFi 切换后的 WS 半开假活
869
+ ws._hbTimer = setInterval(() => {
870
+ try { if (ws.readyState === WebSocket.OPEN) ws.send('ping') } catch {}
871
+ }, 25000)
872
+ // 60s 没有任何消息(含 pong/业务帧)就主动断开, 触发指数退避重连
873
+ ws._staleTimer = setInterval(() => {
874
+ if (ws.readyState === WebSocket.OPEN && Date.now() - (ws._lastMsgAt || 0) > 60000) {
875
+ try { ws.close() } catch {}
876
+ }
877
+ }, 10000)
824
878
  if (state.streamMode === 'poll') { stopPolling(); state.streamMode = 'ws' }
879
+ if (streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN) clearReconnect()
825
880
  updateConn()
826
881
  if (kind === 'mux') { state.approvals = []; state.questions = []; renderNotifStack() }
827
882
  if (refreshOnOpen) refreshSessions()
828
883
  }
829
884
  ws.onmessage = (msg) => {
885
+ ws._lastMsgAt = Date.now()
830
886
  state.streamsOk[kind] = true
831
887
  state.errCount = 0
832
888
  updateConn()
833
889
  try { handler(JSON.parse(msg.data)) } catch {}
834
890
  }
835
891
  ws.onclose = () => {
892
+ clearStreamTimers(ws)
836
893
  state.streamsOk[kind] = false
837
894
  state.errCount++
838
895
  updateConn()
839
- if (state.streamMode === 'poll') return
896
+ if (!navigator.onLine) { clearReconnect(); return }
897
+ if (state.streamMode === 'poll') {
898
+ // 降级轮询期间: 恢复尝试失败也用退避, 避免 30s 固定间隔内空转
899
+ if (ws._isRestore && streams[kind] === ws) {
900
+ const attempt = ws._attempt || 0
901
+ ws._attempt = attempt + 1
902
+ const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
903
+ const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
904
+ setTimeout(() => openStream(kind, handler, refreshOnOpen, true), delay)
905
+ }
906
+ return
907
+ }
840
908
  if (state.errCount >= 3) { enterPollMode(); return }
841
909
  if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
842
- if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), 1200)
910
+ // 指数退避 + 20% 抖动: min(1200 * 2^attempt, 30000)
911
+ const attempt = ws._attempt || 0
912
+ ws._attempt = attempt + 1
913
+ const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
914
+ const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
915
+ setReconnect(delay)
916
+ if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), delay)
843
917
  }
844
918
  ws.onerror = () => { try { ws.close() } catch {} }
845
919
  }
@@ -911,9 +985,32 @@ async function pollKind(kind) {
911
985
  function tryRestoreWs() {
912
986
  if (state.streamMode !== 'poll' || !state.token) return
913
987
  // 轮询继续跑,等 WS 真正 onopen 后再切回,避免重连窗口丢事件
914
- openStream('mux', onMuxFrame, true)
915
- openStream('host', onHostFrame, false)
988
+ openStream('mux', onMuxFrame, true, true)
989
+ openStream('host', onHostFrame, false, true)
916
990
  }
991
+
992
+ /* 网络感知: 离线立刻关 WS + 显示离线, 在线立即重连 */
993
+ window.addEventListener('offline', () => {
994
+ clearReconnect()
995
+ try { streams.mux?.close() } catch {}
996
+ try { streams.host?.close() } catch {}
997
+ if (state.streamMode === 'poll') stopPolling()
998
+ updateConn()
999
+ })
1000
+ window.addEventListener('online', () => {
1001
+ if (!state.token) { updateConn(); return }
1002
+ state.errCount = 0
1003
+ clearReconnect()
1004
+ openStreams()
1005
+ updateConn()
1006
+ })
1007
+ document.addEventListener('visibilitychange', () => {
1008
+ if (document.visibilityState === 'visible' && state.token &&
1009
+ (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN)) {
1010
+ openStreams()
1011
+ }
1012
+ })
1013
+
917
1014
  function onMuxFrame(full) {
918
1015
  const f = full.payload
919
1016
  if (!f) return
@@ -1060,7 +1157,7 @@ function shouldShowEvent(type) { return INTERESTING_EVENTS.has(type) }
1060
1157
  function safeJson(v) { try { return JSON.stringify(v, null, 2) } catch { return String(v) } }
1061
1158
  function blockHtml(b) {
1062
1159
  if (!b) return ''
1063
- if (b.type === 'text') return `<span>${esc(b.text ?? '')}</span>`
1160
+ if (b.type === 'text') return `<div class="md">${window.mdToHtml ? window.mdToHtml(b.text ?? '') : esc(b.text ?? '')}</div>`
1064
1161
  if (b.type === 'reasoning') return `<span style="opacity:.75">${esc(b.text ?? '')}</span>`
1065
1162
  if (b.type === 'tool-call') return `<div>🔧 ${esc(b.name || '')}</div>`
1066
1163
  if (b.type === 'tool-result') return `<div>📦</div>`
@@ -1230,7 +1327,11 @@ async function sendMessage() {
1230
1327
  if (!text || !state.current) return
1231
1328
  if (await runSlashCommand(text)) { input.value = ''; return }
1232
1329
  input.value = ''
1233
- const v = await safeRpc('session.prompt', { sessionId: state.current, text }, '')
1330
+ const v = await safeRpc('session.prompt', {
1331
+ sessionId: state.current,
1332
+ mode: 'queue',
1333
+ content: [{ type: 'text', text }]
1334
+ }, '')
1234
1335
  if (v) toast(t('ds.toastSent'), 'ok')
1235
1336
  }
1236
1337
 
@@ -1473,19 +1574,42 @@ function updateConn() {
1473
1574
  const cur = state.servers.find(s => s.url === state.server)
1474
1575
  const group = cur ? cur.group : state.activeGroup
1475
1576
  const label = cur ? (cur.note || cur.url) : (state.server || t('ds.origin'))
1577
+ const serverText = t('ds.currentServer', { group, url: label })
1578
+ if (!navigator.onLine) {
1579
+ el.textContent = t('ds.connOffline')
1580
+ el.className = 'ds-conn off'
1581
+ el.title = serverText
1582
+ $('server-badge').textContent = serverText
1583
+ return
1584
+ }
1476
1585
  if (state.streamMode === 'poll') {
1477
1586
  el.textContent = '●'
1478
1587
  el.className = 'ds-conn off'
1479
1588
  el.title = t('ds.connPollTitle')
1480
- $('server-badge').textContent = t('ds.currentServer', { group, url: label })
1589
+ $('server-badge').textContent = serverText
1481
1590
  return
1482
1591
  }
1483
1592
  const any = Object.values(state.streamsOk).some(Boolean)
1484
1593
  const all = state.streamsOk.mux && state.streamsOk.host
1594
+ if (!all && reconnectInfo) {
1595
+ const remain = Math.max(0, Math.ceil((reconnectInfo.at - Date.now()) / 1000))
1596
+ el.textContent = remain > 0 ? t('ds.connReconnectIn', { n: remain }) : t('ds.connReconnecting')
1597
+ el.className = 'ds-conn ing'
1598
+ el.title = t('ds.connReconnecting') + ' · ' + serverText
1599
+ $('server-badge').textContent = serverText
1600
+ return
1601
+ }
1602
+ if (!all && state.errCount > 0 && !any) {
1603
+ el.textContent = t('ds.connFailed')
1604
+ el.className = 'ds-conn off'
1605
+ el.title = serverText
1606
+ $('server-badge').textContent = serverText
1607
+ return
1608
+ }
1485
1609
  el.textContent = '●'
1486
1610
  el.className = 'ds-conn ' + (all ? 'on' : any ? 'ing' : '')
1487
1611
  el.title = all ? t('ds.connOn') : any ? t('ds.connIng') : t('ds.connOff')
1488
- $('server-badge').textContent = t('ds.currentServer', { group, url: label })
1612
+ $('server-badge').textContent = serverText
1489
1613
  }
1490
1614
 
1491
1615
  /* ---------------- 初始化 ---------------- */