dsh-remote-plugin 0.6.12 → 0.6.14
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 +353 -21
- package/index.mjs +30 -5
- package/package.json +1 -1
- package/public/admin.html +157 -8
- package/public/admin.js +264 -5
- package/public/announcements.json +52 -0
- package/public/app.js +515 -48
- package/public/desktop/desktop.css +25 -0
- package/public/desktop/desktop.html +12 -6
- package/public/desktop/desktop.js +225 -36
- package/public/index.html +61 -16
- package/public/md.js +71 -1
- package/public/styles.css +50 -0
- package/public/transcribe-core.js +74 -0
- package/public/update.json +12 -12
- package/public/version.json +1 -1
package/public/app.js
CHANGED
|
@@ -13,14 +13,16 @@ const LS = {
|
|
|
13
13
|
}
|
|
14
14
|
const CLIENT_ID = (() => {
|
|
15
15
|
try {
|
|
16
|
-
|
|
16
|
+
const key = 'dshRemoteClientIdV2'
|
|
17
|
+
let id = localStorage.getItem(key)
|
|
17
18
|
if (!id) {
|
|
18
19
|
id = (globalThis.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`)
|
|
19
|
-
|
|
20
|
+
localStorage.setItem(key, id)
|
|
20
21
|
}
|
|
21
22
|
return id
|
|
22
23
|
} catch { return '' }
|
|
23
24
|
})()
|
|
25
|
+
function clientIdHeaders() { return CLIENT_ID ? { 'x-dsh-remote-client-id': CLIENT_ID } : {} }
|
|
24
26
|
|
|
25
27
|
/* 离线缓存: 会话列表 + 每会话聊天记录。只在网络失败时兜底展示, 不会替代线上数据。 */
|
|
26
28
|
const CACHE = {
|
|
@@ -55,6 +57,7 @@ const state = {
|
|
|
55
57
|
autoSelect: { '默认': true }, // 组内自动测速选优 / 手动指定
|
|
56
58
|
groupActive: { '默认': '' }, // 每组当前生效的 server id(手动模式)
|
|
57
59
|
serverLatency: {}, // url -> 最近一次 /health 测速毫秒数
|
|
60
|
+
gatewayHealth: {}, // url -> /health 协议版本与能力声明
|
|
58
61
|
selectingServer: false, // 防重入: 测速/切换中
|
|
59
62
|
sessions: [],
|
|
60
63
|
sessionSort: LS.get('sessionSort', 'time') === 'workspace' ? 'workspace' : 'time',
|
|
@@ -64,11 +67,14 @@ const state = {
|
|
|
64
67
|
hostInfo: null,
|
|
65
68
|
localVersion: '',
|
|
66
69
|
updateInfo: null,
|
|
70
|
+
warnedGatewayVersions: new Set(),
|
|
67
71
|
announcement: null,
|
|
68
72
|
announcements: [],
|
|
69
73
|
approvals: [], // 待处理审批
|
|
70
74
|
questions: [], // 待处理提问
|
|
71
75
|
queues: {}, // sessionId -> queue items
|
|
76
|
+
queueSteering: {}, // sessionId:itemId -> pending steer request
|
|
77
|
+
sessionTurnTimes: {}, // sessionId -> 本轮开始/结束时间,避免中间事件推动排序
|
|
72
78
|
jobs: {}, // sessionId -> jobs
|
|
73
79
|
history: emptyHistory(),
|
|
74
80
|
errCount: 0,
|
|
@@ -86,7 +92,8 @@ const state = {
|
|
|
86
92
|
wbProjects: [],
|
|
87
93
|
wbArchived: [],
|
|
88
94
|
wbOpen: false,
|
|
89
|
-
wbOpenProjects: {}
|
|
95
|
+
wbOpenProjects: {},
|
|
96
|
+
subagentExpandedSession: ''
|
|
90
97
|
}
|
|
91
98
|
|
|
92
99
|
const $ = (id) => document.getElementById(id)
|
|
@@ -355,11 +362,13 @@ async function getWsTicket() {
|
|
|
355
362
|
const token = state.token
|
|
356
363
|
const server = state.server
|
|
357
364
|
wsTicketPromise = (async () => {
|
|
365
|
+
if (activeGatewayCapability('wsTicket') === false) throw new Error('ws ticket unsupported')
|
|
358
366
|
const res = await fetch(apiUrl('/api/ws-ticket'), {
|
|
359
367
|
method: 'POST',
|
|
360
368
|
headers: {
|
|
361
369
|
authorization: 'Bearer ' + token,
|
|
362
370
|
'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web',
|
|
371
|
+
...clientIdHeaders(),
|
|
363
372
|
}
|
|
364
373
|
})
|
|
365
374
|
if (!res.ok) throw new Error('ws ticket HTTP ' + res.status)
|
|
@@ -411,7 +420,7 @@ async function loadStats() {
|
|
|
411
420
|
}
|
|
412
421
|
try {
|
|
413
422
|
const res = await fetch(apiUrl('/stats/summary?days=7'), {
|
|
414
|
-
headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
|
|
423
|
+
headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }
|
|
415
424
|
})
|
|
416
425
|
if (res.status === 401) { authFailure(); return }
|
|
417
426
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
@@ -477,7 +486,7 @@ function renderStats(days) {
|
|
|
477
486
|
async function rpc(method, payload = {}, timeoutMs = 45000) {
|
|
478
487
|
const opts = {
|
|
479
488
|
method: 'POST',
|
|
480
|
-
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
|
|
489
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() },
|
|
481
490
|
body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
|
|
482
491
|
}
|
|
483
492
|
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
@@ -498,7 +507,7 @@ async function rpc(method, payload = {}, timeoutMs = 45000) {
|
|
|
498
507
|
async function respond(rpcId, value) {
|
|
499
508
|
const opts = {
|
|
500
509
|
method: 'POST',
|
|
501
|
-
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
|
|
510
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() },
|
|
502
511
|
body: JSON.stringify({ type: 'client-response', rpcId, result: { ok: true, value } })
|
|
503
512
|
}
|
|
504
513
|
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
@@ -621,7 +630,10 @@ async function pingServer(base) {
|
|
|
621
630
|
const timer = setTimeout(() => ctrl.abort(), 3500)
|
|
622
631
|
try {
|
|
623
632
|
const res = await fetch(u + '/health?t=' + Date.now(), { signal: ctrl.signal, cache: 'no-store' })
|
|
624
|
-
|
|
633
|
+
if (!res.ok) return Infinity
|
|
634
|
+
const health = await res.json().catch(() => null)
|
|
635
|
+
if (health && typeof health === 'object') state.gatewayHealth[u] = health
|
|
636
|
+
return Math.round(performance.now() - t0)
|
|
625
637
|
} catch {
|
|
626
638
|
return Infinity
|
|
627
639
|
} finally {
|
|
@@ -629,6 +641,13 @@ async function pingServer(base) {
|
|
|
629
641
|
}
|
|
630
642
|
}
|
|
631
643
|
|
|
644
|
+
function activeGatewayCapability(name) {
|
|
645
|
+
const key = String(state.server || location.origin || '').replace(/\/+$/, '')
|
|
646
|
+
const capabilities = state.gatewayHealth[key]?.capabilities
|
|
647
|
+
if (!capabilities || !Object.prototype.hasOwnProperty.call(capabilities, name)) return null
|
|
648
|
+
return Number(capabilities[name]) > 0
|
|
649
|
+
}
|
|
650
|
+
|
|
632
651
|
async function selectFastestServer({ silent = false, reconnect = true } = {}) {
|
|
633
652
|
if (state.selectingServer) return null
|
|
634
653
|
state.selectingServer = true
|
|
@@ -677,6 +696,7 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
|
|
|
677
696
|
else if (chosen) toast(t('speed.manualUsing', { url: chosen, ms: Number.isFinite(ms) ? ms : '—' }), 'ok')
|
|
678
697
|
else toast(t('speed.allDown'), 'err')
|
|
679
698
|
}
|
|
699
|
+
await maybeWarnAppBehindGateway()
|
|
680
700
|
return chosen
|
|
681
701
|
} finally {
|
|
682
702
|
state.selectingServer = false
|
|
@@ -1149,7 +1169,7 @@ async function pollKind(kind) {
|
|
|
1149
1169
|
let res
|
|
1150
1170
|
try {
|
|
1151
1171
|
const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(5000) : undefined
|
|
1152
|
-
const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
|
|
1172
|
+
const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }
|
|
1153
1173
|
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 })
|
|
1154
1174
|
} catch { return }
|
|
1155
1175
|
if (res.status === 401) { authFailure(); return }
|
|
@@ -1281,7 +1301,10 @@ function onHostFrame(full) {
|
|
|
1281
1301
|
function onSessionEvent(sessionId, event) {
|
|
1282
1302
|
if (!event) return
|
|
1283
1303
|
const s = state.byId.get(sessionId)
|
|
1284
|
-
if (
|
|
1304
|
+
if (event.type === 'turn/start' || event.type === 'turn/end') {
|
|
1305
|
+
noteSessionTurnTime(sessionId, event)
|
|
1306
|
+
renderSessions()
|
|
1307
|
+
}
|
|
1285
1308
|
if (event.type === 'agent/status') {
|
|
1286
1309
|
if (s) { s.running = !!event.data?.running; s.blank = false; if (s.running) s.error = false }
|
|
1287
1310
|
if (state.current === sessionId) { updateCancelBtn(); renderSessionSub(); updateSessionStatus() }
|
|
@@ -1343,6 +1366,10 @@ function applyProjection(sessionId, key, value, seq) {
|
|
|
1343
1366
|
}
|
|
1344
1367
|
function titleOf(s) { return proj(s, 'title') || (s?.sessionId ? short(s.sessionId) : t('session.unknown')) }
|
|
1345
1368
|
function short(id) { return '…' + String(id).slice(-8) }
|
|
1369
|
+
function isTopLevelSession(session) {
|
|
1370
|
+
return !!session && !session.parentSessionId && session.origin !== 'subagent'
|
|
1371
|
+
}
|
|
1372
|
+
function topLevelSessions() { return state.sessions.filter(isTopLevelSession) }
|
|
1346
1373
|
const GOAL_TERMINAL_PHASES = new Set(['complete', 'cleared'])
|
|
1347
1374
|
function isGoalTerminal(goal) {
|
|
1348
1375
|
return !!goal && GOAL_TERMINAL_PHASES.has(goal.phase)
|
|
@@ -1451,7 +1478,7 @@ async function refreshWorkbench() {
|
|
|
1451
1478
|
if (!state.token) return
|
|
1452
1479
|
try {
|
|
1453
1480
|
const res = await fetch(apiUrl('/workbench'), {
|
|
1454
|
-
headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
|
|
1481
|
+
headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }
|
|
1455
1482
|
})
|
|
1456
1483
|
if (res.ok) {
|
|
1457
1484
|
const value = await res.json().catch(() => null)
|
|
@@ -1523,12 +1550,12 @@ function renderWorkbench() {
|
|
|
1523
1550
|
panel.innerHTML = projects.map(w => {
|
|
1524
1551
|
const id = String(w.workspaceId || '')
|
|
1525
1552
|
const open = !!state.wbOpenProjects[id]
|
|
1526
|
-
const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(
|
|
1553
|
+
const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isTopLevelSession).filter(s => !archivedSet.has(s.sessionId))
|
|
1527
1554
|
const body = open ? `<div class="wb-sessions">${sessions.length ? sessions.map(s => `
|
|
1528
1555
|
<div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
|
|
1529
1556
|
<button class="wb-session" type="button" data-wb-session="${esc(s.sessionId)}">
|
|
1530
1557
|
<span class="wb-session-title">${esc(titleOf(s))}</span>
|
|
1531
|
-
<span class="wb-session-meta">${s.running ? esc(t('sessions.running')) : esc(fmtTime(s
|
|
1558
|
+
<span class="wb-session-meta">${s.running ? esc(t('sessions.running')) : esc(fmtTime(sessionSortTime(s)))}</span>
|
|
1532
1559
|
</button>
|
|
1533
1560
|
<button type="button" class="sc-archive-btn" data-archive-session="${esc(s.sessionId)}">${esc(t('session.archive'))}</button>
|
|
1534
1561
|
</div>`).join('') : `<div class="wb-empty">${esc(t('wb.noSessions'))}</div>`}</div>` : ''
|
|
@@ -1558,17 +1585,26 @@ function workspaceDisplayName(label) {
|
|
|
1558
1585
|
const parts = clean.split(/[\\/]/).filter(Boolean)
|
|
1559
1586
|
return parts[parts.length - 1] || value
|
|
1560
1587
|
}
|
|
1588
|
+
function sessionSortTime(s) {
|
|
1589
|
+
return Math.max(Number(state.sessionTurnTimes[s?.sessionId]) || 0, Number(s?.updatedAt) || 0, Number(s?.createdAt) || 0)
|
|
1590
|
+
}
|
|
1591
|
+
function noteSessionTurnTime(sessionId, eventOrTime) {
|
|
1592
|
+
const raw = typeof eventOrTime === 'object' ? eventOrTime?.time : eventOrTime
|
|
1593
|
+
const time = Number(raw) > 0 ? Number(raw) : Date.now()
|
|
1594
|
+
if (!sessionId || !Number.isFinite(time)) return
|
|
1595
|
+
state.sessionTurnTimes[sessionId] = Math.max(Number(state.sessionTurnTimes[sessionId]) || 0, time)
|
|
1596
|
+
}
|
|
1561
1597
|
function sortedSessions() {
|
|
1562
|
-
const items =
|
|
1598
|
+
const items = topLevelSessions()
|
|
1563
1599
|
if (state.sessionSort === 'workspace') {
|
|
1564
1600
|
return items.sort((a, b) => {
|
|
1565
1601
|
const aw = sessionCwd(a) || '\uffff'
|
|
1566
1602
|
const bw = sessionCwd(b) || '\uffff'
|
|
1567
1603
|
const byWorkspace = aw.localeCompare(bw, undefined, { numeric: true, sensitivity: 'base' })
|
|
1568
|
-
return byWorkspace || ((b
|
|
1604
|
+
return byWorkspace || (sessionSortTime(b) - sessionSortTime(a))
|
|
1569
1605
|
})
|
|
1570
1606
|
}
|
|
1571
|
-
return items.sort((a, b) => (b
|
|
1607
|
+
return items.sort((a, b) => sessionSortTime(b) - sessionSortTime(a))
|
|
1572
1608
|
}
|
|
1573
1609
|
function renderSessions() {
|
|
1574
1610
|
const list = $('session-list')
|
|
@@ -1607,7 +1643,7 @@ function renderSessions() {
|
|
|
1607
1643
|
<div class="sc-title">${esc(title)}</div>
|
|
1608
1644
|
<div class="sc-meta">
|
|
1609
1645
|
<span class="sc-dot ${dots.join(' ')}"></span>
|
|
1610
|
-
<span>${fmtTime(s
|
|
1646
|
+
<span>${fmtTime(sessionSortTime(s))}</span>
|
|
1611
1647
|
${s.running ? '<span>' + t('sessions.running') + '</span>' : ''}
|
|
1612
1648
|
${badge}${queueBadge}
|
|
1613
1649
|
</div>
|
|
@@ -1626,7 +1662,7 @@ function renderSessions() {
|
|
|
1626
1662
|
const sort = $('session-sort')
|
|
1627
1663
|
if (sort) { sort.value = state.sessionSort; syncCustomSelect(sort) }
|
|
1628
1664
|
$('home-empty').classList.toggle('hidden', visible.length > 0)
|
|
1629
|
-
const running =
|
|
1665
|
+
const running = topLevelSessions().filter(s => s.running).length
|
|
1630
1666
|
const pending = state.approvals.length + state.questions.length
|
|
1631
1667
|
$('stat-strip').innerHTML = `
|
|
1632
1668
|
<div class="stat running"><div class="v">${running}</div><div class="k">${t('sessions.statRunning')}</div></div>
|
|
@@ -1644,6 +1680,7 @@ async function openSession(id) {
|
|
|
1644
1680
|
$('session-cards').innerHTML = ''
|
|
1645
1681
|
renderSessionTitle(); renderSessionSub(); updateCancelBtn(); updateSessionStatus()
|
|
1646
1682
|
$('history').innerHTML = '<div class="empty">' + t('history.loading') + '</div>'
|
|
1683
|
+
renderQueue()
|
|
1647
1684
|
restoreCachedHistory()
|
|
1648
1685
|
await loadHistory(true)
|
|
1649
1686
|
renderSessionCards()
|
|
@@ -1668,7 +1705,7 @@ function bindNativeBack() {
|
|
|
1668
1705
|
if ($('composer-wrap')?.classList.contains('fs')) { setComposerFullscreen(false); return }
|
|
1669
1706
|
if (customSelectCurrent) { closeCustomSelect(); return }
|
|
1670
1707
|
const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
|
|
1671
|
-
if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else if (openModal.id === 'modal-archive') closeArchiveConfirm(); else openModal.classList.add('hidden'); return } // 先关弹窗
|
|
1708
|
+
if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else if (openModal.id === 'modal-archive') closeArchiveConfirm(); else if (openModal.id === 'modal-app-version-warning') closeAppVersionWarning(false); else if (openModal.id === 'modal-scan-live') closeLiveScan(''); else openModal.classList.add('hidden'); return } // 先关弹窗
|
|
1672
1709
|
if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
|
|
1673
1710
|
if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
|
|
1674
1711
|
if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
|
|
@@ -1699,6 +1736,8 @@ function updateSessionStatus() {
|
|
|
1699
1736
|
const s = state.byId.get(state.current)
|
|
1700
1737
|
const head = $('session-head')
|
|
1701
1738
|
if (!head) return
|
|
1739
|
+
const composerStatus = $('composer-status')
|
|
1740
|
+
if (composerStatus) composerStatus.classList.toggle('hidden', !s?.running)
|
|
1702
1741
|
head.classList.remove('running', 'interrupted')
|
|
1703
1742
|
const queued = (state.queues[state.current] || []).some(i => i.placement !== 'context')
|
|
1704
1743
|
if (s?.running || queued) head.classList.add('running')
|
|
@@ -1716,8 +1755,76 @@ const HISTORY_MAX_VISIBLE = 5000 // 已加载的可显示事件上限(消息/
|
|
|
1716
1755
|
function emptyHistory() {
|
|
1717
1756
|
return {
|
|
1718
1757
|
visible: [], seqs: new Set(), minSeq: Infinity,
|
|
1719
|
-
hasMore: false, loading: false, renderStart: 0, renderEnd: 0
|
|
1758
|
+
hasMore: false, loading: false, renderStart: 0, renderEnd: 0,
|
|
1759
|
+
partialReasoning: new Map()
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
function reasoningStreamKey(data, index) {
|
|
1764
|
+
return `${data?.turn ?? '?'}:${data?.step ?? '?'}:${index ?? '?'}`
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
/**
|
|
1768
|
+
* DSH 的实时思考以 assistant/chunk 下发,历史尾页可能把增量压成
|
|
1769
|
+
* reasoning-chunks。最终 assistant/message 到达后再由正式消息接管展示。
|
|
1770
|
+
*/
|
|
1771
|
+
function applyReasoningStreamEvent(event) {
|
|
1772
|
+
const h = state.history
|
|
1773
|
+
const data = event?.data || {}
|
|
1774
|
+
let changed = false
|
|
1775
|
+
if (event?.type === 'assistant/chunk') {
|
|
1776
|
+
const chunk = data.chunk || {}
|
|
1777
|
+
const key = reasoningStreamKey(data, chunk.index)
|
|
1778
|
+
if (chunk.type === 'block-start' && chunk.blockType === 'reasoning') {
|
|
1779
|
+
h.partialReasoning.set(key, { turn: data.turn, step: data.step, index: chunk.index, text: '' })
|
|
1780
|
+
changed = true
|
|
1781
|
+
} else if (chunk.type === 'reasoning-delta') {
|
|
1782
|
+
const item = h.partialReasoning.get(key) || { turn: data.turn, step: data.step, index: chunk.index, text: '' }
|
|
1783
|
+
item.text += String(chunk.text || '')
|
|
1784
|
+
h.partialReasoning.set(key, item)
|
|
1785
|
+
changed = true
|
|
1786
|
+
} else if (chunk.type === 'block-end' && chunk.block?.type === 'reasoning') {
|
|
1787
|
+
h.partialReasoning.set(key, {
|
|
1788
|
+
turn: data.turn, step: data.step, index: chunk.index,
|
|
1789
|
+
text: String(chunk.block.text ?? chunk.block.content ?? '')
|
|
1790
|
+
})
|
|
1791
|
+
changed = true
|
|
1792
|
+
}
|
|
1793
|
+
} else if (event?.type === 'reasoning-chunks') {
|
|
1794
|
+
const key = reasoningStreamKey(data, data.index)
|
|
1795
|
+
const item = h.partialReasoning.get(key) || { turn: data.turn, step: data.step, index: data.index, text: '' }
|
|
1796
|
+
item.text += Array.isArray(data.texts) ? data.texts.join('') : String(data.text || '')
|
|
1797
|
+
h.partialReasoning.set(key, item)
|
|
1798
|
+
changed = true
|
|
1799
|
+
} else if (event?.type === 'assistant/message') {
|
|
1800
|
+
for (const [key, item] of h.partialReasoning) {
|
|
1801
|
+
if (item.turn === data.turn && item.step === data.step) {
|
|
1802
|
+
h.partialReasoning.delete(key)
|
|
1803
|
+
changed = true
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1720
1806
|
}
|
|
1807
|
+
return changed
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
let reasoningRenderTimer = null
|
|
1811
|
+
function scheduleReasoningRender() {
|
|
1812
|
+
if (reasoningRenderTimer) return
|
|
1813
|
+
reasoningRenderTimer = setTimeout(() => {
|
|
1814
|
+
reasoningRenderTimer = null
|
|
1815
|
+
if (!state.current) return
|
|
1816
|
+
const box = $('history')
|
|
1817
|
+
const nearBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 240
|
|
1818
|
+
renderHistory(false, nearBottom ? 'bottom' : 'fixed')
|
|
1819
|
+
}, 80)
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
function partialReasoningHtml() {
|
|
1823
|
+
return [...state.history.partialReasoning.values()]
|
|
1824
|
+
.filter(item => item.text)
|
|
1825
|
+
.sort((a, b) => (a.turn ?? 0) - (b.turn ?? 0) || (a.step ?? 0) - (b.step ?? 0) || (a.index ?? 0) - (b.index ?? 0))
|
|
1826
|
+
.map(item => `<div class="msg assistant reasoning-live"><div class="role">${esc(t('role.dsh'))}</div><details class="tool" open><summary>${esc(t('block.thinkingLive'))}</summary><div class="tool-text">${esc(truncate(item.text, 12000))}</div></details></div>`)
|
|
1827
|
+
.join('')
|
|
1721
1828
|
}
|
|
1722
1829
|
|
|
1723
1830
|
function trimVisible() {
|
|
@@ -1806,11 +1913,14 @@ async function loadHistory(reset) {
|
|
|
1806
1913
|
|
|
1807
1914
|
const incoming = v.events || []
|
|
1808
1915
|
let added = 0
|
|
1916
|
+
if (reset) state.history.partialReasoning.clear()
|
|
1809
1917
|
for (const entry of incoming) {
|
|
1810
1918
|
const ev = entry?.event
|
|
1811
1919
|
const seq = ev?.seq
|
|
1920
|
+
if (ev?.type === 'turn/start' || ev?.type === 'turn/end') noteSessionTurnTime(id, ev)
|
|
1921
|
+
applyReasoningStreamEvent(ev)
|
|
1812
1922
|
if (seq == null || state.history.seqs.has(seq)) continue
|
|
1813
|
-
if (!shouldShowEvent(ev.type)) continue
|
|
1923
|
+
if (!shouldShowEvent(ev.type, ev)) continue // chunk 与非用户上下文不保留
|
|
1814
1924
|
state.history.seqs.add(seq)
|
|
1815
1925
|
state.history.visible.push({ seq, event: ev, view: entry.view })
|
|
1816
1926
|
added++
|
|
@@ -1835,8 +1945,16 @@ async function loadHistory(reset) {
|
|
|
1835
1945
|
|
|
1836
1946
|
function insertLiveEvent(event) {
|
|
1837
1947
|
const h = state.history
|
|
1948
|
+
const reasoningChanged = applyReasoningStreamEvent(event)
|
|
1949
|
+
if (event?.type === 'assistant/chunk' || event?.type === 'reasoning-chunks') {
|
|
1950
|
+
if (reasoningChanged) scheduleReasoningRender()
|
|
1951
|
+
return
|
|
1952
|
+
}
|
|
1838
1953
|
const seq = event?.seq
|
|
1839
|
-
if (seq == null || h.seqs.has(seq) || !shouldShowEvent(event.type))
|
|
1954
|
+
if (seq == null || h.seqs.has(seq) || !shouldShowEvent(event.type, event)) {
|
|
1955
|
+
if (reasoningChanged) scheduleReasoningRender()
|
|
1956
|
+
return
|
|
1957
|
+
}
|
|
1840
1958
|
h.seqs.add(seq)
|
|
1841
1959
|
h.visible.push({ seq, event })
|
|
1842
1960
|
h.visible.sort((a, b) => a.seq - b.seq)
|
|
@@ -1870,7 +1988,8 @@ function renderHistory(reset, mode = 'bottom') {
|
|
|
1870
1988
|
const h = state.history
|
|
1871
1989
|
const filtered = filteredEntries()
|
|
1872
1990
|
const len = filtered.length
|
|
1873
|
-
|
|
1991
|
+
const reasoningHtml = partialReasoningHtml()
|
|
1992
|
+
if (!len && !reasoningHtml) {
|
|
1874
1993
|
box.innerHTML = '<div class="empty">' + t('history.empty') + '</div>'
|
|
1875
1994
|
h.renderStart = 0; h.renderEnd = 0
|
|
1876
1995
|
updateRail()
|
|
@@ -1891,7 +2010,7 @@ function renderHistory(reset, mode = 'bottom') {
|
|
|
1891
2010
|
const d = e.event.data || {}
|
|
1892
2011
|
if (d.callId && d.name) toolNames.set(d.callId, d.name)
|
|
1893
2012
|
}
|
|
1894
|
-
box.innerHTML = filtered.slice(start, end).map(e => eventHtml(e, { toolNames })).join('')
|
|
2013
|
+
box.innerHTML = filtered.slice(start, end).map(e => eventHtml(e, { toolNames })).join('') + reasoningHtml
|
|
1895
2014
|
if (reset || mode === 'bottom') box.scrollTop = box.scrollHeight
|
|
1896
2015
|
else if (mode === 'keep') box.scrollTop = Math.max(0, oldTop + (box.scrollHeight - oldH))
|
|
1897
2016
|
else if (mode === 'fixed') box.scrollTop = oldTop
|
|
@@ -1972,9 +2091,24 @@ const INTERESTING_EVENTS = new Set([
|
|
|
1972
2091
|
'approval/asked', 'approval/resolved',
|
|
1973
2092
|
'session/title', 'title'
|
|
1974
2093
|
])
|
|
1975
|
-
function
|
|
1976
|
-
|
|
1977
|
-
return
|
|
2094
|
+
function messageSource(data) {
|
|
2095
|
+
const source = data?.source ?? data?.message?.source
|
|
2096
|
+
return source && typeof source === 'object' ? source : null
|
|
2097
|
+
}
|
|
2098
|
+
function isHumanUserMessage(event) {
|
|
2099
|
+
if (event?.type !== 'user/message') return false
|
|
2100
|
+
const source = messageSource(event.data || {})
|
|
2101
|
+
// Older DSH events may not carry source metadata; keep those visible for compatibility.
|
|
2102
|
+
return !source || source.kind === 'user'
|
|
2103
|
+
}
|
|
2104
|
+
function shouldShowEvent(type, event) {
|
|
2105
|
+
if (!INTERESTING_EVENTS.has(type)) return false
|
|
2106
|
+
if (type === 'user/message' && !isHumanUserMessage(event)) {
|
|
2107
|
+
const data = event?.data || {}
|
|
2108
|
+
const blocks = data.message?.content || data.content || []
|
|
2109
|
+
return systemReminderText(blocks).length > 0
|
|
2110
|
+
}
|
|
2111
|
+
return true
|
|
1978
2112
|
}
|
|
1979
2113
|
function systemReminderText(blocks) {
|
|
1980
2114
|
if (!Array.isArray(blocks)) return ''
|
|
@@ -1988,7 +2122,7 @@ function eventHtml(entry, ctx = {}) {
|
|
|
1988
2122
|
const ev = entry.event || {}
|
|
1989
2123
|
const data = ev.data || {}
|
|
1990
2124
|
const type = ev.type || 'event'
|
|
1991
|
-
if (!shouldShowEvent(type)) return ''
|
|
2125
|
+
if (!shouldShowEvent(type, ev)) return ''
|
|
1992
2126
|
let inner = ''
|
|
1993
2127
|
|
|
1994
2128
|
if (type === 'user/message' || type === 'assistant/message') {
|
|
@@ -1998,6 +2132,8 @@ function eventHtml(entry, ctx = {}) {
|
|
|
1998
2132
|
const sysText = type === 'user/message' ? systemReminderText(blocks) : ''
|
|
1999
2133
|
if (sysText) {
|
|
2000
2134
|
inner = `<details class="event event-detail" data-seq="${seq}"><summary>${esc(t('event.systemReminder'))}</summary><pre>${esc(truncate(sysText, 4000))}</pre></details>`
|
|
2135
|
+
} else if (type === 'user/message' && !isHumanUserMessage(ev)) {
|
|
2136
|
+
return ''
|
|
2001
2137
|
} else {
|
|
2002
2138
|
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>`
|
|
2003
2139
|
}
|
|
@@ -2105,13 +2241,19 @@ async function renderSessionCards() {
|
|
|
2105
2241
|
const sub = await safeRpc('subagent.list', { parentSessionId: sessionId })
|
|
2106
2242
|
if (renderGeneration !== sessionCardsRenderGeneration || state.current !== sessionId) return
|
|
2107
2243
|
if (sub?.entries?.length) {
|
|
2244
|
+
const expanded = state.subagentExpandedSession === sessionId
|
|
2245
|
+
const toggleLabel = expanded ? t('subagent.collapse') : t('subagent.expand')
|
|
2108
2246
|
const rows = sub.entries.map(e => {
|
|
2109
2247
|
if (e.kind === 'diagnostic') return `<div class="card-row"><span class="k">${t('subagent.diagnostic')}</span><span class="v">${esc(e.reason)}</span></div>`
|
|
2110
2248
|
const label = e.label || short(e.id)
|
|
2111
2249
|
const running = e.activity === 'running'
|
|
2112
2250
|
return `<div class="card-row"><span class="k">${running ? '▶ ' : ''}${esc(label)}</span><span class="v">${esc(e.mode)} ${running ? t('subagent.running') : ''}${e.mode === 'continuable' && running ? ` <button class="mini-btn" data-sub-interrupt="${esc(e.id)}">${t('subagent.interrupt')}</button>` : ''}</span></div>`
|
|
2113
2251
|
}).join('')
|
|
2114
|
-
box.insertAdjacentHTML('beforeend', `<div class="card"><
|
|
2252
|
+
box.insertAdjacentHTML('beforeend', `<div class="card subagent-card"><button type="button" class="subagent-toggle" data-subagent-toggle aria-expanded="${expanded}" aria-label="${esc(toggleLabel)}" title="${esc(toggleLabel)}"><span class="card-title">${esc(t('subagent.count', { n: sub.entries.length }))}</span><span class="subagent-toggle-icon" aria-hidden="true">${expanded ? '⌃' : '⌄'}</span></button><div class="subagent-list${expanded ? '' : ' hidden'}">${rows}</div></div>`)
|
|
2253
|
+
box.querySelector('[data-subagent-toggle]')?.addEventListener('click', () => {
|
|
2254
|
+
state.subagentExpandedSession = expanded ? '' : sessionId
|
|
2255
|
+
renderSessionCards()
|
|
2256
|
+
})
|
|
2115
2257
|
box.querySelectorAll('[data-sub-interrupt]').forEach(btn =>
|
|
2116
2258
|
btn.addEventListener('click', () => interruptSubagent(btn.dataset.subInterrupt)))
|
|
2117
2259
|
}
|
|
@@ -2164,7 +2306,7 @@ async function runSlashCommand(text) {
|
|
|
2164
2306
|
: undefined
|
|
2165
2307
|
const res = await fetch(apiUrl('/remote/api/command'), {
|
|
2166
2308
|
method: 'POST',
|
|
2167
|
-
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
|
|
2309
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() },
|
|
2168
2310
|
body: JSON.stringify({ sessionId: state.current, line: clean }),
|
|
2169
2311
|
...(signal ? { signal } : {})
|
|
2170
2312
|
})
|
|
@@ -2291,7 +2433,12 @@ async function sendSessionContent(text, images) {
|
|
|
2291
2433
|
mode: 'queue',
|
|
2292
2434
|
content
|
|
2293
2435
|
}, t('send.failed'))
|
|
2294
|
-
if (v?.accepted) {
|
|
2436
|
+
if (v?.accepted) {
|
|
2437
|
+
noteSessionTurnTime(state.current, Date.now())
|
|
2438
|
+
renderSessions()
|
|
2439
|
+
toast(images.length ? t('send.imageSent') : (clean.startsWith('/') ? t('send.commandSent') : t('send.sent')), 'ok')
|
|
2440
|
+
return true
|
|
2441
|
+
}
|
|
2295
2442
|
if (v?.command?.text) { toast(t('send.commandExecuted'), 'ok'); return true }
|
|
2296
2443
|
return false
|
|
2297
2444
|
} catch (e) {
|
|
@@ -2393,16 +2540,37 @@ function renderEffortMenu() {
|
|
|
2393
2540
|
const cur = state.models.current
|
|
2394
2541
|
const provider = (state.models.groups || []).find(g => g.id === cur?.provider)
|
|
2395
2542
|
const model = (provider?.models || []).find(m => m.id === cur?.model)
|
|
2396
|
-
const efforts = model
|
|
2397
|
-
group.classList.toggle('hidden', !efforts.length)
|
|
2543
|
+
const { efforts, defaultEffort, custom } = reasoningEffortOptions(model)
|
|
2544
|
+
group.classList.toggle('hidden', !cur || !efforts.length)
|
|
2398
2545
|
box.innerHTML = efforts.map(e => {
|
|
2399
|
-
const isCur = cur?.reasoningEffort === e.id || (!cur?.reasoningEffort && e.id ===
|
|
2546
|
+
const isCur = cur?.reasoningEffort === e.id || (!cur?.reasoningEffort && e.id === defaultEffort)
|
|
2400
2547
|
return `<button class="menu-chip ${isCur ? 'current' : ''}" data-effort="${esc(e.id)}" title="${esc(e.description || '')}">${esc(e.name || e.id)}</button>`
|
|
2401
|
-
}).join('')
|
|
2548
|
+
}).join('') + (custom ? `<span class="effort-hint">${esc(t('models.effortCustomHint'))}</span>` : '')
|
|
2402
2549
|
box.querySelectorAll('[data-effort]').forEach(btn =>
|
|
2403
2550
|
btn.addEventListener('click', () => selectSessionEffort(btn.dataset.effort)))
|
|
2404
2551
|
}
|
|
2405
2552
|
|
|
2553
|
+
function reasoningEffortOptions(model) {
|
|
2554
|
+
const raw = Array.isArray(model?.reasoning?.efforts) && model.reasoning.efforts.length
|
|
2555
|
+
? model.reasoning.efforts
|
|
2556
|
+
: (Array.isArray(model?.reasoningEfforts) && model.reasoningEfforts.length ? model.reasoningEfforts : null)
|
|
2557
|
+
const names = {
|
|
2558
|
+
low: t('models.effortLow'), high: t('models.effortHigh'), max: t('models.effortMax'), off: t('models.effortOff')
|
|
2559
|
+
}
|
|
2560
|
+
if (raw) {
|
|
2561
|
+
return {
|
|
2562
|
+
efforts: raw.map(e => typeof e === 'string' ? { id: e, name: names[e] || e } : e),
|
|
2563
|
+
defaultEffort: model?.reasoning?.defaultEffort,
|
|
2564
|
+
custom: !model?.reasoning?.efforts
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
return {
|
|
2568
|
+
efforts: ['low', 'high', 'max'].map(id => ({ id, name: names[id] })),
|
|
2569
|
+
defaultEffort: undefined,
|
|
2570
|
+
custom: true
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2406
2574
|
async function selectSessionEffort(effortId) {
|
|
2407
2575
|
const cur = state.models.current
|
|
2408
2576
|
if (!state.current || !cur) return
|
|
@@ -2615,8 +2783,9 @@ function renderOverview() {
|
|
|
2615
2783
|
btn.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === btn.dataset.overviewQuestion)))
|
|
2616
2784
|
})
|
|
2617
2785
|
|
|
2618
|
-
const
|
|
2619
|
-
const
|
|
2786
|
+
const topSessions = topLevelSessions()
|
|
2787
|
+
const running = topSessions.filter(s => s.running).length
|
|
2788
|
+
const sessions = topSessions.sort((a, b) => Number(b.running) - Number(a.running) || (sessionSortTime(b) - sessionSortTime(a))).slice(0, 4)
|
|
2620
2789
|
const primary = $('overview-primary-action')
|
|
2621
2790
|
if (primary) {
|
|
2622
2791
|
let action = 'new'
|
|
@@ -2647,7 +2816,7 @@ function renderOverview() {
|
|
|
2647
2816
|
$('overview-connection-mode').textContent = state.token ? t(state.streamMode === 'poll' ? 'overview.poll' : 'overview.ws') : '—'
|
|
2648
2817
|
$('overview-active-count').textContent = running ? t('overview.activeCount', { n: running }) : ''
|
|
2649
2818
|
$('overview-session-list').innerHTML = sessions.length ? sessions.map(s => `<button type="button" class="overview-session-item ${s.running ? 'running' : ''}" data-overview-session="${esc(s.sessionId)}">
|
|
2650
|
-
<span class="overview-item-mark">${s.running ? '●' : '○'}</span><span class="overview-item-copy"><span class="overview-item-title">${esc(titleOf(s))}</span><span class="overview-item-desc">${s.running ? esc(t('sessions.running')) + ' · ' : ''}${esc(fmtTime(s
|
|
2819
|
+
<span class="overview-item-mark">${s.running ? '●' : '○'}</span><span class="overview-item-copy"><span class="overview-item-title">${esc(titleOf(s))}</span><span class="overview-item-desc">${s.running ? esc(t('sessions.running')) + ' · ' : ''}${esc(fmtTime(sessionSortTime(s)))}</span></span><span class="overview-item-arrow">›</span>
|
|
2651
2820
|
</button>`).join('') : `<div class="overview-empty">${t('overview.noSession')}</div>`
|
|
2652
2821
|
$('overview-session-list').querySelectorAll('[data-overview-session]').forEach(btn => btn.addEventListener('click', () => openSession(btn.dataset.overviewSession)))
|
|
2653
2822
|
}
|
|
@@ -2743,11 +2912,46 @@ async function submitQuestion() {
|
|
|
2743
2912
|
}
|
|
2744
2913
|
|
|
2745
2914
|
/* ---------------- 后台任务 ---------------- */
|
|
2915
|
+
function queuePreview(item) {
|
|
2916
|
+
const blocks = item?.message?.content || item?.content || []
|
|
2917
|
+
const text = Array.isArray(blocks)
|
|
2918
|
+
? blocks.filter(block => block?.type === 'text').map(block => String(block.text || '')).join(' ').trim()
|
|
2919
|
+
: ''
|
|
2920
|
+
return text || (Array.isArray(blocks) && blocks.some(block => block?.type === 'image') ? t('queue.image') : '…')
|
|
2921
|
+
}
|
|
2922
|
+
async function steerQueueItem(itemId) {
|
|
2923
|
+
const sessionId = state.current
|
|
2924
|
+
const key = `${sessionId}:${itemId}`
|
|
2925
|
+
const s = state.byId.get(sessionId)
|
|
2926
|
+
if (!sessionId || !s?.running || state.queueSteering[key]) return
|
|
2927
|
+
state.queueSteering[key] = true
|
|
2928
|
+
renderQueue()
|
|
2929
|
+
try {
|
|
2930
|
+
const v = await safeRpc('session.updateQueue', { sessionId, itemId, action: { kind: 'steer' } }, t('queue.steerFailed', { msg: '' }).replace(/:$/, '').replace(/: $/, ''))
|
|
2931
|
+
if (v?.accepted) toast(t('queue.steerSubmitted'), 'ok')
|
|
2932
|
+
} finally {
|
|
2933
|
+
delete state.queueSteering[key]
|
|
2934
|
+
renderQueue()
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2746
2937
|
function renderQueue() {
|
|
2747
2938
|
const s = state.byId.get(state.current)
|
|
2748
2939
|
if (!s) return
|
|
2749
|
-
const items = state.queues[state.current] || []
|
|
2940
|
+
const items = (state.queues[state.current] || []).filter(item => item?.placement === 'queued')
|
|
2941
|
+
const box = $('queue-dock')
|
|
2942
|
+
if (box) {
|
|
2943
|
+
box.classList.toggle('hidden', !items.length)
|
|
2944
|
+
box.innerHTML = items.length ? `<div class="queue-dock-head"><span>⌁</span><span>${esc(t('queue.title'))} · ${items.length}</span></div><div class="queue-dock-list">${items.map(item => {
|
|
2945
|
+
const key = `${state.current}:${item.id}`
|
|
2946
|
+
const busy = !!state.queueSteering[key]
|
|
2947
|
+
return `<div class="queue-dock-item"><span class="queue-dock-preview" title="${esc(queuePreview(item))}">${esc(queuePreview(item))}</span><button type="button" class="mini-btn queue-dock-action" data-queue-steer="${esc(item.id)}" title="${esc(s.running ? t('queue.steer') : t('queue.steerUnavailable'))}" ${s.running && !busy ? '' : 'disabled'}>${busy ? '…' : esc(t('queue.steer'))}</button></div>`
|
|
2948
|
+
}).join('')}</div>` : ''
|
|
2949
|
+
box.querySelectorAll('[data-queue-steer]').forEach(button => {
|
|
2950
|
+
button.addEventListener('click', () => steerQueueItem(button.dataset.queueSteer))
|
|
2951
|
+
})
|
|
2952
|
+
}
|
|
2750
2953
|
updateCancelBtn()
|
|
2954
|
+
updateSessionStatus()
|
|
2751
2955
|
// 队列数量在会话列表已显示; 详情页不重复大 UI
|
|
2752
2956
|
$('history-hint').textContent = items.length ? t('history.queueAndCount', { q: items.length, n: state.history.visible.length }) : t('history.countOnly', { n: state.history.visible.length })
|
|
2753
2957
|
renderSessions()
|
|
@@ -2770,7 +2974,8 @@ function renderJobs() {
|
|
|
2770
2974
|
function fsHeaders() {
|
|
2771
2975
|
return {
|
|
2772
2976
|
authorization: 'Bearer ' + state.token,
|
|
2773
|
-
'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web'
|
|
2977
|
+
'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web',
|
|
2978
|
+
...clientIdHeaders()
|
|
2774
2979
|
}
|
|
2775
2980
|
}
|
|
2776
2981
|
|
|
@@ -3140,6 +3345,7 @@ async function runFsUpload(up) {
|
|
|
3140
3345
|
xhr.open('POST', fsApiUrl('/upload', params))
|
|
3141
3346
|
xhr.setRequestHeader('authorization', 'Bearer ' + state.token)
|
|
3142
3347
|
xhr.setRequestHeader('x-dsh-remote-client', CAP?.isNativePlatform?.() ? 'app' : 'web')
|
|
3348
|
+
if (CLIENT_ID) xhr.setRequestHeader('x-dsh-remote-client-id', CLIENT_ID)
|
|
3143
3349
|
xhr.upload.onprogress = (e) => {
|
|
3144
3350
|
if (e.lengthComputable) {
|
|
3145
3351
|
const loaded = up.offset + Math.min(e.loaded, e.total)
|
|
@@ -3385,6 +3591,54 @@ function cmpVersion(a, b) {
|
|
|
3385
3591
|
return 0
|
|
3386
3592
|
}
|
|
3387
3593
|
|
|
3594
|
+
function isComparableVersion(value) {
|
|
3595
|
+
return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(String(value || '').trim())
|
|
3596
|
+
}
|
|
3597
|
+
|
|
3598
|
+
function isAppBehindGateway(appVersion, gatewayVersion) {
|
|
3599
|
+
return isComparableVersion(appVersion) && isComparableVersion(gatewayVersion) && cmpVersion(gatewayVersion, appVersion) > 0
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
function isAppVersionWarningOpen() {
|
|
3603
|
+
const modal = $('modal-app-version-warning')
|
|
3604
|
+
return !!modal && !modal.classList.contains('hidden')
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3607
|
+
async function maybeWarnAppBehindGateway({ probe = false } = {}) {
|
|
3608
|
+
if (!CAP?.isNativePlatform?.() || !state.localVersion) return false
|
|
3609
|
+
const base = String(state.server || '').replace(/\/+$/, '')
|
|
3610
|
+
if (!base) return false
|
|
3611
|
+
let health = state.gatewayHealth[base]
|
|
3612
|
+
if (!health && probe) {
|
|
3613
|
+
await pingServer(base)
|
|
3614
|
+
health = state.gatewayHealth[base]
|
|
3615
|
+
}
|
|
3616
|
+
const gatewayVersion = String(health?.version || '').trim()
|
|
3617
|
+
if (!isAppBehindGateway(state.localVersion, gatewayVersion)) return false
|
|
3618
|
+
const warningKey = `${state.localVersion}\u0000${gatewayVersion}`
|
|
3619
|
+
if (state.warnedGatewayVersions.has(warningKey)) return false
|
|
3620
|
+
const anotherModal = [...document.querySelectorAll('.modal')]
|
|
3621
|
+
.some(modal => modal.id !== 'modal-app-version-warning' && !modal.classList.contains('hidden'))
|
|
3622
|
+
if (anotherModal) return false
|
|
3623
|
+
state.warnedGatewayVersions.add(warningKey)
|
|
3624
|
+
$('app-version-current').textContent = 'v' + state.localVersion
|
|
3625
|
+
$('app-version-gateway').textContent = 'v' + gatewayVersion
|
|
3626
|
+
$('modal-app-version-warning').classList.remove('hidden')
|
|
3627
|
+
setTimeout(() => $('app-version-update')?.focus(), 50)
|
|
3628
|
+
return true
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3631
|
+
function closeAppVersionWarning(checkNow = false) {
|
|
3632
|
+
$('modal-app-version-warning').classList.add('hidden')
|
|
3633
|
+
if (checkNow) {
|
|
3634
|
+
showView('view-settings')
|
|
3635
|
+
showSettingsPage('about')
|
|
3636
|
+
void checkUpdate(false)
|
|
3637
|
+
} else {
|
|
3638
|
+
scheduleStartupNotices(150)
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3641
|
+
|
|
3388
3642
|
function resetUpdateExpand() {
|
|
3389
3643
|
const desc = $('update-desc')
|
|
3390
3644
|
if (desc) desc.classList.remove('expanded')
|
|
@@ -3672,7 +3926,7 @@ function closeAnnouncement(markSeen) {
|
|
|
3672
3926
|
}
|
|
3673
3927
|
async function fetchAnnouncements() {
|
|
3674
3928
|
const base = updateBase()
|
|
3675
|
-
if (!base || !state.localVersion) return false
|
|
3929
|
+
if (!base || !state.localVersion || isAppVersionWarningOpen()) return false
|
|
3676
3930
|
try {
|
|
3677
3931
|
const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(8000) : undefined
|
|
3678
3932
|
const url = base + '/announcements.json?t=' + Date.now()
|
|
@@ -3713,6 +3967,16 @@ function startAnnouncementPolling() {
|
|
|
3713
3967
|
window.addEventListener('online', () => { void checkAnnouncements() })
|
|
3714
3968
|
}
|
|
3715
3969
|
|
|
3970
|
+
let startupNoticeTimer = null
|
|
3971
|
+
function scheduleStartupNotices(delay = 4000) {
|
|
3972
|
+
clearTimeout(startupNoticeTimer)
|
|
3973
|
+
startupNoticeTimer = setTimeout(async () => {
|
|
3974
|
+
if (isAppVersionWarningOpen()) return
|
|
3975
|
+
const shown = await checkAnnouncements()
|
|
3976
|
+
if (!shown && state.token && !isAppVersionWarningOpen()) checkUpdate(true)
|
|
3977
|
+
}, delay)
|
|
3978
|
+
}
|
|
3979
|
+
|
|
3716
3980
|
/* ---------------- 更新内容弹窗 ---------------- */
|
|
3717
3981
|
const NOTES_KEY = 'seenNotesVersion'
|
|
3718
3982
|
let notesVersion = ''
|
|
@@ -4002,7 +4266,7 @@ function saveBgConfig(enabled) {
|
|
|
4002
4266
|
const base = bgBase()
|
|
4003
4267
|
const intervalMin = parseFloat($('bg-interval')?.value || '1') || 1
|
|
4004
4268
|
const notifyTaskDone = $('opt-task-done')?.checked !== false
|
|
4005
|
-
b.saveBackgroundConfig(JSON.stringify({ enabled, intervalMin, base, token: state.token || '', notifyTaskDone }))
|
|
4269
|
+
b.saveBackgroundConfig(JSON.stringify({ enabled, intervalMin, base, token: state.token || '', clientId: CLIENT_ID || '', notifyTaskDone }))
|
|
4006
4270
|
if (enabled) $('bg-auth-status')?.classList.add('hidden')
|
|
4007
4271
|
return true
|
|
4008
4272
|
}
|
|
@@ -4328,14 +4592,207 @@ async function decodeQrDataUrl(dataUrl) {
|
|
|
4328
4592
|
return code?.data || ''
|
|
4329
4593
|
}
|
|
4330
4594
|
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4595
|
+
let liveScanStream = null
|
|
4596
|
+
let liveScanTimer = null
|
|
4597
|
+
let liveScanResolve = null
|
|
4598
|
+
let liveScanCanvas = null
|
|
4599
|
+
let liveScanContext = null
|
|
4600
|
+
let liveScanCancelled = false
|
|
4601
|
+
let liveScanDetector = null
|
|
4602
|
+
let liveScanBusy = false
|
|
4603
|
+
let liveScanWorker = null
|
|
4604
|
+
let liveScanWorkerUrl = ''
|
|
4605
|
+
let liveScanWorkerResolve = null
|
|
4606
|
+
|
|
4607
|
+
function stopLiveScanWorker() {
|
|
4608
|
+
liveScanWorkerResolve?.('')
|
|
4609
|
+
liveScanWorkerResolve = null
|
|
4610
|
+
liveScanWorker?.terminate()
|
|
4611
|
+
liveScanWorker = null
|
|
4612
|
+
if (liveScanWorkerUrl) URL.revokeObjectURL(liveScanWorkerUrl)
|
|
4613
|
+
liveScanWorkerUrl = ''
|
|
4614
|
+
}
|
|
4615
|
+
|
|
4616
|
+
function startLiveScanWorker() {
|
|
4617
|
+
if (!window.Worker || !window.Blob || !window.URL?.createObjectURL) return
|
|
4618
|
+
try {
|
|
4619
|
+
const sourceUrl = new URL('jsqr.min.js', document.baseURI).href
|
|
4620
|
+
const workerSource = `
|
|
4621
|
+
let loaded = false
|
|
4622
|
+
self.onmessage = event => {
|
|
4623
|
+
try {
|
|
4624
|
+
if (!loaded) {
|
|
4625
|
+
self.importScripts(${JSON.stringify(sourceUrl)})
|
|
4626
|
+
loaded = true
|
|
4627
|
+
}
|
|
4628
|
+
const { data, width, height } = event.data || {}
|
|
4629
|
+
const code = self.jsQR?.(data, width, height, { inversionAttempts: 'attemptBoth' })
|
|
4630
|
+
self.postMessage({ raw: code?.data || '' })
|
|
4631
|
+
} catch (error) {
|
|
4632
|
+
self.postMessage({ raw: '', error: String(error?.message || error) })
|
|
4633
|
+
}
|
|
4634
|
+
}
|
|
4635
|
+
`
|
|
4636
|
+
liveScanWorkerUrl = URL.createObjectURL(new Blob([workerSource], { type: 'application/javascript' }))
|
|
4637
|
+
liveScanWorker = new Worker(liveScanWorkerUrl)
|
|
4638
|
+
liveScanWorker.onmessage = event => {
|
|
4639
|
+
const resolve = liveScanWorkerResolve
|
|
4640
|
+
liveScanWorkerResolve = null
|
|
4641
|
+
resolve?.(event.data?.raw || '')
|
|
4642
|
+
}
|
|
4643
|
+
liveScanWorker.onerror = () => {
|
|
4644
|
+
const resolve = liveScanWorkerResolve
|
|
4645
|
+
liveScanWorkerResolve = null
|
|
4646
|
+
resolve?.('')
|
|
4647
|
+
stopLiveScanWorker()
|
|
4648
|
+
}
|
|
4649
|
+
} catch {
|
|
4650
|
+
stopLiveScanWorker()
|
|
4651
|
+
}
|
|
4652
|
+
}
|
|
4653
|
+
|
|
4654
|
+
function closeLiveScan(result = '') {
|
|
4655
|
+
liveScanCancelled = true
|
|
4656
|
+
if (liveScanTimer) clearTimeout(liveScanTimer)
|
|
4657
|
+
liveScanTimer = null
|
|
4658
|
+
if (liveScanStream) liveScanStream.getTracks().forEach(track => track.stop())
|
|
4659
|
+
liveScanStream = null
|
|
4660
|
+
liveScanDetector = null
|
|
4661
|
+
stopLiveScanWorker()
|
|
4662
|
+
const video = $('scan-live-video')
|
|
4663
|
+
if (video) video.srcObject = null
|
|
4664
|
+
$('modal-scan-live')?.classList.add('hidden')
|
|
4665
|
+
const resolve = liveScanResolve
|
|
4666
|
+
liveScanResolve = null
|
|
4667
|
+
resolve?.(result)
|
|
4668
|
+
}
|
|
4669
|
+
|
|
4670
|
+
async function scanLiveFrame() {
|
|
4671
|
+
if (!liveScanResolve) return
|
|
4672
|
+
const video = $('scan-live-video')
|
|
4673
|
+
if (!video || video.readyState < 2 || !video.videoWidth || !liveScanContext) {
|
|
4674
|
+
liveScanTimer = setTimeout(scanLiveFrame, 180)
|
|
4675
|
+
return
|
|
4676
|
+
}
|
|
4677
|
+
if (liveScanBusy) return
|
|
4678
|
+
liveScanBusy = true
|
|
4679
|
+
try {
|
|
4680
|
+
let raw = ''
|
|
4681
|
+
if (liveScanDetector) {
|
|
4682
|
+
const codes = await liveScanDetector.detect(video)
|
|
4683
|
+
raw = codes?.[0]?.rawValue || ''
|
|
4684
|
+
} else {
|
|
4685
|
+
// 只解码取景框中央区域,避免在低端手机上对整幅高分辨率画面反复二值化。
|
|
4686
|
+
const side = Math.floor(Math.min(video.videoWidth, video.videoHeight) * 0.64)
|
|
4687
|
+
const sx = Math.floor((video.videoWidth - side) / 2)
|
|
4688
|
+
const sy = Math.floor((video.videoHeight - side) / 2)
|
|
4689
|
+
const maxSide = 480
|
|
4690
|
+
const scale = Math.min(1, maxSide / Math.max(1, side))
|
|
4691
|
+
const w = Math.max(1, Math.round(side * scale))
|
|
4692
|
+
const h = w
|
|
4693
|
+
if (liveScanCanvas.width !== w || liveScanCanvas.height !== h) {
|
|
4694
|
+
liveScanCanvas.width = w
|
|
4695
|
+
liveScanCanvas.height = h
|
|
4696
|
+
}
|
|
4697
|
+
liveScanContext.drawImage(video, sx, sy, side, side, 0, 0, w, h)
|
|
4698
|
+
const imageData = liveScanContext.getImageData(0, 0, w, h)
|
|
4699
|
+
if (liveScanWorker) {
|
|
4700
|
+
raw = await new Promise(resolve => {
|
|
4701
|
+
liveScanWorkerResolve = resolve
|
|
4702
|
+
liveScanWorker.postMessage({ data: imageData.data, width: w, height: h }, [imageData.data.buffer])
|
|
4703
|
+
})
|
|
4704
|
+
} else {
|
|
4705
|
+
raw = window.jsQR?.(imageData.data, w, h, { inversionAttempts: 'attemptBoth' })?.data || ''
|
|
4706
|
+
}
|
|
4707
|
+
}
|
|
4708
|
+
if (raw && liveScanResolve) return closeLiveScan(raw)
|
|
4709
|
+
} catch {
|
|
4710
|
+
// 摄像头帧在切后台或权限切换时可能暂时不可读,下一帧继续即可。
|
|
4711
|
+
} finally {
|
|
4712
|
+
liveScanBusy = false
|
|
4713
|
+
}
|
|
4714
|
+
if (liveScanResolve) liveScanTimer = setTimeout(scanLiveFrame, 180)
|
|
4715
|
+
}
|
|
4716
|
+
|
|
4717
|
+
/** 打开持续取帧的本地摄像头扫码;返回 undefined 表示当前 WebView 不支持实时摄像头。 */
|
|
4718
|
+
async function scanPairLive() {
|
|
4719
|
+
if (!navigator.mediaDevices?.getUserMedia) return undefined
|
|
4720
|
+
const video = $('scan-live-video')
|
|
4721
|
+
const modal = $('modal-scan-live')
|
|
4722
|
+
if (!video || !modal || !window.jsQR) return undefined
|
|
4723
|
+
const camera = CAP.Plugins?.Camera
|
|
4724
|
+
const perm = await camera?.requestPermissions?.({ permissions: ['camera'] })
|
|
4725
|
+
if (perm && perm.camera !== 'granted') throw new Error(t('scan.permissionDenied'))
|
|
4726
|
+
liveScanCancelled = false
|
|
4727
|
+
modal.classList.remove('hidden')
|
|
4728
|
+
$('scan-live-status').textContent = t('scan.liveStarting')
|
|
4729
|
+
try {
|
|
4730
|
+
liveScanStream = await navigator.mediaDevices.getUserMedia({
|
|
4731
|
+
audio: false,
|
|
4732
|
+
video: {
|
|
4733
|
+
facingMode: { ideal: 'environment' },
|
|
4734
|
+
width: { ideal: 960, max: 1280 },
|
|
4735
|
+
height: { ideal: 720, max: 1280 },
|
|
4736
|
+
frameRate: { ideal: 24, max: 30 }
|
|
4737
|
+
}
|
|
4738
|
+
})
|
|
4739
|
+
if (liveScanCancelled) {
|
|
4740
|
+
liveScanStream.getTracks().forEach(track => track.stop())
|
|
4741
|
+
liveScanStream = null
|
|
4742
|
+
return ''
|
|
4743
|
+
}
|
|
4744
|
+
video.srcObject = liveScanStream
|
|
4745
|
+
await video.play()
|
|
4746
|
+
$('scan-live-status').textContent = t('scan.liveHint')
|
|
4747
|
+
try {
|
|
4748
|
+
if (window.BarcodeDetector) {
|
|
4749
|
+
const formats = await window.BarcodeDetector.getSupportedFormats?.()
|
|
4750
|
+
if (!formats || formats.includes('qr_code')) liveScanDetector = new window.BarcodeDetector({ formats: ['qr_code'] })
|
|
4751
|
+
}
|
|
4752
|
+
} catch { liveScanDetector = null }
|
|
4753
|
+
liveScanCanvas = document.createElement('canvas')
|
|
4754
|
+
liveScanContext = liveScanCanvas.getContext('2d', { willReadFrequently: true })
|
|
4755
|
+
if (!liveScanContext) throw new Error(t('scan.decodeUnsupported'))
|
|
4756
|
+
startLiveScanWorker()
|
|
4757
|
+
return await new Promise(resolve => {
|
|
4758
|
+
liveScanResolve = resolve
|
|
4759
|
+
scanLiveFrame()
|
|
4760
|
+
})
|
|
4761
|
+
} catch (e) {
|
|
4762
|
+
closeLiveScan('')
|
|
4763
|
+
throw e
|
|
4764
|
+
} finally {
|
|
4765
|
+
liveScanCanvas = null
|
|
4766
|
+
liveScanContext = null
|
|
4767
|
+
liveScanDetector = null
|
|
4768
|
+
liveScanBusy = false
|
|
4769
|
+
}
|
|
4770
|
+
}
|
|
4771
|
+
|
|
4772
|
+
/** App 内扫码优先使用实时摄像头取帧;不支持时回退到官方 Camera 拍照/相册 + jsQR。 */
|
|
4334
4773
|
async function scanPair(source) {
|
|
4335
4774
|
if (!CAP?.isNativePlatform?.()) {
|
|
4336
4775
|
toast(t('scan.browserHint'), 'err')
|
|
4337
4776
|
return
|
|
4338
4777
|
}
|
|
4778
|
+
if (source === 'CAMERA') {
|
|
4779
|
+
try {
|
|
4780
|
+
const liveRaw = await scanPairLive()
|
|
4781
|
+
if (liveRaw !== undefined) {
|
|
4782
|
+
if (!liveRaw) return toast(t('scan.cancelled'), 'ok')
|
|
4783
|
+
if (applyPairUrl(liveRaw)) {
|
|
4784
|
+
toast(t('scan.paired'), 'ok')
|
|
4785
|
+
openStreams()
|
|
4786
|
+
refreshAll()
|
|
4787
|
+
} else toast(t('scan.notPair'), 'err')
|
|
4788
|
+
return
|
|
4789
|
+
}
|
|
4790
|
+
} catch (e) {
|
|
4791
|
+
const msg = String(e?.message || e || '')
|
|
4792
|
+
toast(/cancel/i.test(msg) ? t('scan.cancelled') : t('scan.failed', { msg }), 'err')
|
|
4793
|
+
return
|
|
4794
|
+
}
|
|
4795
|
+
}
|
|
4339
4796
|
const camera = CAP.Plugins?.Camera
|
|
4340
4797
|
if (!camera?.getPhoto) { toast(t('scan.unsupported'), 'err'); return }
|
|
4341
4798
|
try {
|
|
@@ -4523,6 +4980,10 @@ function renderDshControlStatus(value) {
|
|
|
4523
4980
|
|
|
4524
4981
|
async function loadDshControl() {
|
|
4525
4982
|
if (!state.token || !$('dsh-control-desc')) return
|
|
4983
|
+
if (activeGatewayCapability('dshLifecycle') === false) {
|
|
4984
|
+
renderDshControlStatus({ supported: false, message: t('settings.dshUnsupported') })
|
|
4985
|
+
return
|
|
4986
|
+
}
|
|
4526
4987
|
try {
|
|
4527
4988
|
const res = await fetch(adminApiUrl('/admin/api/dsh'), { headers: { authorization: 'Bearer ' + state.token }, cache: 'no-store' })
|
|
4528
4989
|
if (res.status === 401) return authFailure()
|
|
@@ -4775,6 +5236,7 @@ function bindUi() {
|
|
|
4775
5236
|
document.addEventListener('keydown', (e) => {
|
|
4776
5237
|
if (e.key === 'Escape' && !$('feedback-sheet').classList.contains('hidden')) { closeFeedbackSheet(); $('btn-feedback').focus() }
|
|
4777
5238
|
else if (e.key === 'Escape' && !$('modal-feedback-success').classList.contains('hidden')) closeFeedbackSuccess()
|
|
5239
|
+
else if (e.key === 'Escape' && isAppVersionWarningOpen()) closeAppVersionWarning(false)
|
|
4778
5240
|
})
|
|
4779
5241
|
$('btn-new-session').addEventListener('click', newSession)
|
|
4780
5242
|
$('session-workspace-filter').addEventListener('change', (e) => {
|
|
@@ -4939,6 +5401,8 @@ function bindUi() {
|
|
|
4939
5401
|
})
|
|
4940
5402
|
$('btn-scan-camera').addEventListener('click', () => scanPair('CAMERA'))
|
|
4941
5403
|
$('btn-scan-gallery').addEventListener('click', () => scanPair('PHOTOS'))
|
|
5404
|
+
$('scan-live-cancel')?.addEventListener('click', () => closeLiveScan(''))
|
|
5405
|
+
$('modal-scan-live')?.addEventListener('click', e => { if (e.target === $('modal-scan-live')) closeLiveScan('') })
|
|
4942
5406
|
$('btn-change-token').addEventListener('click', () => {
|
|
4943
5407
|
const input = prompt(t('token.prompt'), state.token)
|
|
4944
5408
|
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() }
|
|
@@ -4965,6 +5429,11 @@ function bindUi() {
|
|
|
4965
5429
|
$('btn-check-update').addEventListener('click', () => checkUpdate(false))
|
|
4966
5430
|
$('btn-download-update').addEventListener('click', downloadUpdate)
|
|
4967
5431
|
$('btn-update-expand').addEventListener('click', toggleUpdateExpand)
|
|
5432
|
+
$('app-version-later').addEventListener('click', () => closeAppVersionWarning(false))
|
|
5433
|
+
$('app-version-update').addEventListener('click', () => closeAppVersionWarning(true))
|
|
5434
|
+
$('modal-app-version-warning').addEventListener('click', (e) => {
|
|
5435
|
+
if (e.target === $('modal-app-version-warning')) closeAppVersionWarning(false)
|
|
5436
|
+
})
|
|
4968
5437
|
$('btn-reset').addEventListener('click', () => {
|
|
4969
5438
|
if (!confirm(t('settings.confirmReset'))) return
|
|
4970
5439
|
LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction'); LS.del(ANNOUNCEMENTS_KEY); LS.del(ANNOUNCEMENT_HISTORY_KEY); LS.del(ANNOUNCEMENT_VOTES_KEY)
|
|
@@ -5119,6 +5588,7 @@ async function boot() {
|
|
|
5119
5588
|
} else {
|
|
5120
5589
|
// 多服务器: 启动时静默测速一次, 选最快的连接(同源页面也参与比较)
|
|
5121
5590
|
await selectFastestServer({ silent: true, reconnect: false })
|
|
5591
|
+
await maybeWarnAppBehindGateway({ probe: true })
|
|
5122
5592
|
openStreams()
|
|
5123
5593
|
await refreshAll()
|
|
5124
5594
|
const host = await safeRpc('host.describe', {}, '')
|
|
@@ -5128,10 +5598,7 @@ async function boot() {
|
|
|
5128
5598
|
// 网关从中央 HTTPS 公告源读取并在不可达时回退内置文件。前台每 30 秒检查,
|
|
5129
5599
|
// 回到前台或网络恢复时立即补查;公告优先,避免启动时两个弹窗重叠。
|
|
5130
5600
|
startAnnouncementPolling()
|
|
5131
|
-
|
|
5132
|
-
const shown = await checkAnnouncements()
|
|
5133
|
-
if (!shown && state.token) checkUpdate(true)
|
|
5134
|
-
}, 4000)
|
|
5601
|
+
scheduleStartupNotices()
|
|
5135
5602
|
renderPending()
|
|
5136
5603
|
}
|
|
5137
5604
|
|