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/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +36 -3
- package/index.mjs +102 -10
- package/package.json +1 -1
- package/public/admin.html +32 -4
- package/public/admin.js +69 -1
- package/public/app.js +178 -54
- package/public/desktop/desktop.css +10 -0
- package/public/desktop/desktop.html +3 -0
- package/public/desktop/desktop.js +133 -9
- package/public/index.html +17 -3
- package/public/md.js +115 -0
- package/public/styles.css +7 -0
- package/public/update.json +12 -4
- package/public/version.json +1 -1
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(
|
|
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 (
|
|
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
|
-
//
|
|
768
|
-
|
|
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())
|
|
1184
|
-
|
|
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>${
|
|
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
|
-
/* ---------------- 峰谷计费提醒(
|
|
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
|
|
2661
|
-
if (!
|
|
2769
|
+
const b = bgBridge()
|
|
2770
|
+
if (!b?.startPeakReminder) return false
|
|
2662
2771
|
try {
|
|
2663
|
-
|
|
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
|
|
2678
|
-
if (!
|
|
2779
|
+
const b = bgBridge()
|
|
2780
|
+
if (!b?.stopPeakReminder) return false
|
|
2679
2781
|
try {
|
|
2680
|
-
|
|
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') + ' · ' +
|
|
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 =
|
|
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
|
-
|
|
2796
|
-
if (
|
|
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: '
|
|
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-
|
|
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 (
|
|
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
|
-
|
|
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 `<
|
|
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', {
|
|
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 =
|
|
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 =
|
|
1612
|
+
$('server-badge').textContent = serverText
|
|
1489
1613
|
}
|
|
1490
1614
|
|
|
1491
1615
|
/* ---------------- 初始化 ---------------- */
|