dsh-remote-plugin 0.6.13 → 0.6.15
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 +261 -27
- package/package.json +1 -1
- package/public/announcements.json +25 -0
- package/public/app.js +570 -62
- package/public/desktop/desktop.css +42 -7
- package/public/desktop/desktop.html +30 -6
- package/public/desktop/desktop.js +395 -46
- package/public/index.html +57 -20
- package/public/md.js +71 -1
- package/public/morphicons-init.js +41 -0
- package/public/motion.js +469 -0
- package/public/plugin.html +4 -1
- package/public/plugin.js +1 -0
- package/public/styles.css +47 -6
- package/public/transcribe-core.js +74 -0
- package/public/update.json +12 -12
- package/public/vendor/gsap/NOTICE.md +10 -0
- package/public/vendor/gsap/gsap.min.js +11 -0
- package/public/vendor/morphicons/LICENSE +21 -0
- package/public/vendor/morphicons/README.md +10 -0
- package/public/vendor/morphicons/controller-CXZuwJ_M.js +152 -0
- package/public/vendor/morphicons/dom.js +206 -0
- package/public/vendor/morphicons/element.js +261 -0
- package/public/vendor/morphicons/normalize-CYnN3Npw.js +540 -0
- package/public/vendor/morphicons/spring-CFHloqPP.js +623 -0
- 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 = {
|
|
@@ -62,6 +64,9 @@ const state = {
|
|
|
62
64
|
workspaceFilter: LS.get('workspaceFilterV1', ''),
|
|
63
65
|
byId: new Map(),
|
|
64
66
|
current: null, // 当前打开的 sessionId
|
|
67
|
+
sessionRecovery: { status: 'idle', error: '' },
|
|
68
|
+
pendingProjections: new Map(),
|
|
69
|
+
lastStreamResyncAt: 0,
|
|
65
70
|
hostInfo: null,
|
|
66
71
|
localVersion: '',
|
|
67
72
|
updateInfo: null,
|
|
@@ -71,6 +76,8 @@ const state = {
|
|
|
71
76
|
approvals: [], // 待处理审批
|
|
72
77
|
questions: [], // 待处理提问
|
|
73
78
|
queues: {}, // sessionId -> queue items
|
|
79
|
+
queueSteering: {}, // sessionId:itemId -> pending steer request
|
|
80
|
+
sessionTurnTimes: {}, // sessionId -> 本轮开始/结束时间,避免中间事件推动排序
|
|
74
81
|
jobs: {}, // sessionId -> jobs
|
|
75
82
|
history: emptyHistory(),
|
|
76
83
|
errCount: 0,
|
|
@@ -88,7 +95,8 @@ const state = {
|
|
|
88
95
|
wbProjects: [],
|
|
89
96
|
wbArchived: [],
|
|
90
97
|
wbOpen: false,
|
|
91
|
-
wbOpenProjects: {}
|
|
98
|
+
wbOpenProjects: {},
|
|
99
|
+
subagentExpandedSession: ''
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
const $ = (id) => document.getElementById(id)
|
|
@@ -363,6 +371,7 @@ async function getWsTicket() {
|
|
|
363
371
|
headers: {
|
|
364
372
|
authorization: 'Bearer ' + token,
|
|
365
373
|
'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web',
|
|
374
|
+
...clientIdHeaders(),
|
|
366
375
|
}
|
|
367
376
|
})
|
|
368
377
|
if (!res.ok) throw new Error('ws ticket HTTP ' + res.status)
|
|
@@ -414,7 +423,7 @@ async function loadStats() {
|
|
|
414
423
|
}
|
|
415
424
|
try {
|
|
416
425
|
const res = await fetch(apiUrl('/stats/summary?days=7'), {
|
|
417
|
-
headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
|
|
426
|
+
headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }
|
|
418
427
|
})
|
|
419
428
|
if (res.status === 401) { authFailure(); return }
|
|
420
429
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
@@ -480,7 +489,7 @@ function renderStats(days) {
|
|
|
480
489
|
async function rpc(method, payload = {}, timeoutMs = 45000) {
|
|
481
490
|
const opts = {
|
|
482
491
|
method: 'POST',
|
|
483
|
-
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
|
|
492
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() },
|
|
484
493
|
body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
|
|
485
494
|
}
|
|
486
495
|
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
@@ -501,7 +510,7 @@ async function rpc(method, payload = {}, timeoutMs = 45000) {
|
|
|
501
510
|
async function respond(rpcId, value) {
|
|
502
511
|
const opts = {
|
|
503
512
|
method: 'POST',
|
|
504
|
-
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
|
|
513
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() },
|
|
505
514
|
body: JSON.stringify({ type: 'client-response', rpcId, result: { ok: true, value } })
|
|
506
515
|
}
|
|
507
516
|
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
@@ -1076,6 +1085,7 @@ function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
|
|
|
1076
1085
|
renderPending()
|
|
1077
1086
|
}
|
|
1078
1087
|
if (refreshOnOpen) refreshAll()
|
|
1088
|
+
if (allStreamsOpen()) resyncAfterStreamOpen()
|
|
1079
1089
|
}
|
|
1080
1090
|
ws.onmessage = (msg) => {
|
|
1081
1091
|
if (!streamIsCurrent(kind, ws, generation)) return
|
|
@@ -1162,9 +1172,9 @@ async function pollKind(kind) {
|
|
|
1162
1172
|
const since = state.pollSeq[kind] || 0
|
|
1163
1173
|
let res
|
|
1164
1174
|
try {
|
|
1165
|
-
const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(
|
|
1166
|
-
const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
|
|
1167
|
-
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 })
|
|
1175
|
+
const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(30000) : undefined
|
|
1176
|
+
const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }
|
|
1177
|
+
res = signal ? await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}&wait=25000`), { signal, headers }) : await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}&wait=25000`), { headers })
|
|
1168
1178
|
} catch { return }
|
|
1169
1179
|
if (res.status === 401) { authFailure(); return }
|
|
1170
1180
|
if (!res.ok) return
|
|
@@ -1182,6 +1192,7 @@ async function pollKind(kind) {
|
|
|
1182
1192
|
renderPending()
|
|
1183
1193
|
}
|
|
1184
1194
|
scheduleRefresh()
|
|
1195
|
+
if (state.current) void resyncCurrentSession()
|
|
1185
1196
|
}
|
|
1186
1197
|
for (const item of data.events) {
|
|
1187
1198
|
if (item.seq > (state.pollSeq[kind] || 0)) {
|
|
@@ -1295,7 +1306,10 @@ function onHostFrame(full) {
|
|
|
1295
1306
|
function onSessionEvent(sessionId, event) {
|
|
1296
1307
|
if (!event) return
|
|
1297
1308
|
const s = state.byId.get(sessionId)
|
|
1298
|
-
if (
|
|
1309
|
+
if (event.type === 'turn/start' || event.type === 'turn/end') {
|
|
1310
|
+
noteSessionTurnTime(sessionId, event)
|
|
1311
|
+
renderSessions()
|
|
1312
|
+
}
|
|
1299
1313
|
if (event.type === 'agent/status') {
|
|
1300
1314
|
if (s) { s.running = !!event.data?.running; s.blank = false; if (s.running) s.error = false }
|
|
1301
1315
|
if (state.current === sessionId) { updateCancelBtn(); renderSessionSub(); updateSessionStatus() }
|
|
@@ -1337,24 +1351,73 @@ async function refreshSessions() {
|
|
|
1337
1351
|
}
|
|
1338
1352
|
state.sessions = v.items || []
|
|
1339
1353
|
state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
|
|
1354
|
+
applyPendingProjections()
|
|
1340
1355
|
cacheWrite(CACHE.sessions, state.sessions.slice(0, 80))
|
|
1341
1356
|
renderSessions()
|
|
1342
1357
|
refreshWorkbench()
|
|
1343
1358
|
}
|
|
1344
1359
|
|
|
1345
1360
|
function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
|
|
1361
|
+
function hydrateSessionProjections(sessionId, projections) {
|
|
1362
|
+
const s = state.byId.get(sessionId)
|
|
1363
|
+
if (!s || !projections || typeof projections !== 'object') return
|
|
1364
|
+
const incomingSeq = Number(projections.asOfSeq) || 0
|
|
1365
|
+
const current = s.projections || { asOfSeq: 0, values: {} }
|
|
1366
|
+
const currentSeq = Number(current.asOfSeq) || 0
|
|
1367
|
+
if (incomingSeq < currentSeq) return
|
|
1368
|
+
s.projections = {
|
|
1369
|
+
asOfSeq: Math.max(currentSeq, incomingSeq),
|
|
1370
|
+
values: { ...(current.values || {}), ...(projections.values || {}) }
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
function applyPendingProjections() {
|
|
1374
|
+
for (const [sessionId, projections] of state.pendingProjections) {
|
|
1375
|
+
if (!state.byId.has(sessionId)) continue
|
|
1376
|
+
hydrateSessionProjections(sessionId, projections)
|
|
1377
|
+
state.pendingProjections.delete(sessionId)
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1346
1380
|
function applyProjection(sessionId, key, value, seq) {
|
|
1347
1381
|
const s = state.byId.get(sessionId)
|
|
1348
|
-
if (s) {
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1382
|
+
if (!s) {
|
|
1383
|
+
const pending = state.pendingProjections.get(sessionId) || { asOfSeq: 0, values: {} }
|
|
1384
|
+
pending.values[key] = value
|
|
1385
|
+
pending.asOfSeq = Math.max(pending.asOfSeq || 0, seq || 0)
|
|
1386
|
+
state.pendingProjections.set(sessionId, pending)
|
|
1387
|
+
return
|
|
1353
1388
|
}
|
|
1389
|
+
const currentSeq = Number(s.projections?.asOfSeq) || 0
|
|
1390
|
+
if (seq && seq < currentSeq) return
|
|
1391
|
+
s.projections = s.projections || { asOfSeq: 0, values: {} }
|
|
1392
|
+
s.projections.values = s.projections.values || {}
|
|
1393
|
+
s.projections.values[key] = value
|
|
1394
|
+
s.projections.asOfSeq = Math.max(currentSeq, seq || 0)
|
|
1354
1395
|
if (state.current === sessionId) { renderSessionTitle(); renderSessionCards() }
|
|
1355
1396
|
if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) scheduleRefresh()
|
|
1356
1397
|
else renderSessions()
|
|
1357
1398
|
}
|
|
1399
|
+
function setSessionRecovery(status, error = '') {
|
|
1400
|
+
state.sessionRecovery = { status, error: String(error || '') }
|
|
1401
|
+
if (state.current) { renderSessionSub(); updateSessionStatus() }
|
|
1402
|
+
}
|
|
1403
|
+
function recoveryLabel() {
|
|
1404
|
+
const status = state.sessionRecovery.status
|
|
1405
|
+
if (status === 'loading' || status === 'resuming') return t('session.recovering')
|
|
1406
|
+
if (status === 'cached') return t('session.recoveryCached')
|
|
1407
|
+
if (status === 'error') return t('session.recoveryFailed')
|
|
1408
|
+
return ''
|
|
1409
|
+
}
|
|
1410
|
+
function resyncCurrentSession() {
|
|
1411
|
+
if (!state.current) return Promise.resolve()
|
|
1412
|
+
return loadHistory(true).then(() => {
|
|
1413
|
+
if (state.current) { renderSessionCards(); renderSessionSub(); updateCancelBtn(); updateSessionStatus() }
|
|
1414
|
+
})
|
|
1415
|
+
}
|
|
1416
|
+
function resyncAfterStreamOpen() {
|
|
1417
|
+
if (!state.current || Date.now() - state.lastStreamResyncAt < 1200) return
|
|
1418
|
+
state.lastStreamResyncAt = Date.now()
|
|
1419
|
+
void refreshAll().then(() => resyncCurrentSession())
|
|
1420
|
+
}
|
|
1358
1421
|
function titleOf(s) { return proj(s, 'title') || (s?.sessionId ? short(s.sessionId) : t('session.unknown')) }
|
|
1359
1422
|
function short(id) { return '…' + String(id).slice(-8) }
|
|
1360
1423
|
function isTopLevelSession(session) {
|
|
@@ -1406,6 +1469,63 @@ const WORKSPACE_UNGROUPED = '__ungrouped__'
|
|
|
1406
1469
|
function workspaceItems() {
|
|
1407
1470
|
return (state.wbProjects || []).filter(w => w && typeof w.workspaceId === 'string' && w.workspaceId && typeof w.path === 'string' && w.path)
|
|
1408
1471
|
}
|
|
1472
|
+
const WORKBENCH_ORDER_CACHE_KEY = 'workbenchOrderV1'
|
|
1473
|
+
function workbenchOrderScope() { return String(state.server || location.origin || 'default') }
|
|
1474
|
+
function workbenchOrderStore() {
|
|
1475
|
+
const value = cacheRead(WORKBENCH_ORDER_CACHE_KEY, {})
|
|
1476
|
+
if (!value || typeof value !== 'object') return { scopes: {} }
|
|
1477
|
+
if (!value.scopes || typeof value.scopes !== 'object') value.scopes = {}
|
|
1478
|
+
return value
|
|
1479
|
+
}
|
|
1480
|
+
function workbenchOrderScopeValue() {
|
|
1481
|
+
const store = workbenchOrderStore()
|
|
1482
|
+
const key = workbenchOrderScope()
|
|
1483
|
+
if (!store.scopes[key] || typeof store.scopes[key] !== 'object') store.scopes[key] = {}
|
|
1484
|
+
return { store, value: store.scopes[key] }
|
|
1485
|
+
}
|
|
1486
|
+
function orderedItems(items, ids, getId) {
|
|
1487
|
+
const source = Array.isArray(items) ? items : []
|
|
1488
|
+
const byId = new Map(source.map(item => [String(getId(item)), item]))
|
|
1489
|
+
const result = []
|
|
1490
|
+
const used = new Set()
|
|
1491
|
+
for (const id of Array.isArray(ids) ? ids : []) {
|
|
1492
|
+
const key = String(id)
|
|
1493
|
+
const item = byId.get(key)
|
|
1494
|
+
if (item && !used.has(key)) { result.push(item); used.add(key) }
|
|
1495
|
+
}
|
|
1496
|
+
for (const item of source) {
|
|
1497
|
+
const key = String(getId(item))
|
|
1498
|
+
if (!used.has(key)) { result.push(item); used.add(key) }
|
|
1499
|
+
}
|
|
1500
|
+
return result
|
|
1501
|
+
}
|
|
1502
|
+
function orderedWorkspaceItems(items) {
|
|
1503
|
+
const { value } = workbenchOrderScopeValue()
|
|
1504
|
+
return orderedItems(items, value.workspaceIds, item => item.workspaceId)
|
|
1505
|
+
}
|
|
1506
|
+
function orderedWorkspaceSessions(workspaceId, items) {
|
|
1507
|
+
const { value } = workbenchOrderScopeValue()
|
|
1508
|
+
return orderedItems(items, value.sessionIds?.[String(workspaceId)], item => item.sessionId)
|
|
1509
|
+
}
|
|
1510
|
+
function saveWorkbenchOrder(mutator) {
|
|
1511
|
+
const { store, value } = workbenchOrderScopeValue()
|
|
1512
|
+
mutator(value)
|
|
1513
|
+
cacheWrite(WORKBENCH_ORDER_CACHE_KEY, store)
|
|
1514
|
+
}
|
|
1515
|
+
function commitWorkspaceOrder(order) {
|
|
1516
|
+
saveWorkbenchOrder(value => { value.workspaceIds = order.map(String) })
|
|
1517
|
+
renderWorkbench()
|
|
1518
|
+
toast(t('wb.orderSaved'), 'ok')
|
|
1519
|
+
}
|
|
1520
|
+
function commitWorkspaceSessionOrder(workspaceId, order) {
|
|
1521
|
+
if (!workspaceId) return
|
|
1522
|
+
saveWorkbenchOrder(value => {
|
|
1523
|
+
value.sessionIds ||= {}
|
|
1524
|
+
value.sessionIds[String(workspaceId)] = order.map(String)
|
|
1525
|
+
})
|
|
1526
|
+
renderWorkbench()
|
|
1527
|
+
toast(t('wb.orderSaved'), 'ok')
|
|
1528
|
+
}
|
|
1409
1529
|
function workspaceById(workspaceId) {
|
|
1410
1530
|
return workspaceItems().find(w => w.workspaceId === workspaceId) || null
|
|
1411
1531
|
}
|
|
@@ -1469,7 +1589,7 @@ async function refreshWorkbench() {
|
|
|
1469
1589
|
if (!state.token) return
|
|
1470
1590
|
try {
|
|
1471
1591
|
const res = await fetch(apiUrl('/workbench'), {
|
|
1472
|
-
headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
|
|
1592
|
+
headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }
|
|
1473
1593
|
})
|
|
1474
1594
|
if (res.ok) {
|
|
1475
1595
|
const value = await res.json().catch(() => null)
|
|
@@ -1532,32 +1652,48 @@ function renderWorkbench() {
|
|
|
1532
1652
|
toggle.setAttribute('aria-expanded', state.wbOpen ? 'true' : 'false')
|
|
1533
1653
|
panel.classList.toggle('hidden', !state.wbOpen)
|
|
1534
1654
|
if (!state.wbOpen) { panel.innerHTML = ''; return }
|
|
1535
|
-
const projects = state.wbProjects.filter(w => wbStrictInside(w.path, workbenchRoot()))
|
|
1655
|
+
const projects = orderedWorkspaceItems(state.wbProjects.filter(w => wbStrictInside(w.path, workbenchRoot())))
|
|
1536
1656
|
if (!projects.length) {
|
|
1537
1657
|
panel.innerHTML = `<div class="wb-empty">${esc(t('wb.noProjects'))}</div>`
|
|
1538
1658
|
return
|
|
1539
1659
|
}
|
|
1540
1660
|
const archivedSet = new Set(state.wbArchived || [])
|
|
1541
|
-
|
|
1661
|
+
const projectHtml = projects.map(w => {
|
|
1542
1662
|
const id = String(w.workspaceId || '')
|
|
1543
1663
|
const open = !!state.wbOpenProjects[id]
|
|
1544
|
-
const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isTopLevelSession).filter(s => !archivedSet.has(s.sessionId))
|
|
1664
|
+
const sessions = orderedWorkspaceSessions(id, (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isTopLevelSession).filter(s => !archivedSet.has(s.sessionId)))
|
|
1545
1665
|
const body = open ? `<div class="wb-sessions">${sessions.length ? sessions.map(s => `
|
|
1546
1666
|
<div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
|
|
1547
|
-
<button class="wb-session" type="button" data-wb-session="${esc(s.sessionId)}">
|
|
1667
|
+
<button class="wb-session" type="button" data-wb-session="${esc(s.sessionId)}" data-motion-key="${esc(s.sessionId)}">
|
|
1668
|
+
<span class="wb-session-drag-handle" data-reorder-handle aria-hidden="true">⠿</span>
|
|
1548
1669
|
<span class="wb-session-title">${esc(titleOf(s))}</span>
|
|
1549
|
-
<span class="wb-session-meta">${s.running ? esc(t('sessions.running')) : esc(fmtTime(s
|
|
1670
|
+
<span class="wb-session-meta">${s.running ? esc(t('sessions.running')) : esc(fmtTime(sessionSortTime(s)))}</span>
|
|
1550
1671
|
</button>
|
|
1551
1672
|
<button type="button" class="sc-archive-btn" data-archive-session="${esc(s.sessionId)}">${esc(t('session.archive'))}</button>
|
|
1552
1673
|
</div>`).join('') : `<div class="wb-empty">${esc(t('wb.noSessions'))}</div>`}</div>` : ''
|
|
1553
|
-
return `<div class="wb-project ${open ? 'open' : ''}" data-wb-project="${esc(id)}">
|
|
1674
|
+
return `<div class="wb-project ${open ? 'open' : ''}" data-wb-project="${esc(id)}" data-motion-key="${esc(id)}">
|
|
1554
1675
|
<div class="wb-project-head">
|
|
1676
|
+
<span class="wb-drag-handle" data-reorder-handle aria-hidden="true">⠿</span>
|
|
1555
1677
|
<span class="wb-chevron" aria-hidden="true">${open ? '▾' : '▸'}</span>
|
|
1556
1678
|
<span class="wb-project-title">${esc(w.title || wbBaseName(w.path) || w.path)}</span>
|
|
1557
1679
|
<button class="mini-btn wb-new" type="button" data-wb-new="${esc(id)}">${esc(t('wb.newSession'))}</button>
|
|
1558
1680
|
</div>${body}
|
|
1559
1681
|
</div>`
|
|
1560
1682
|
}).join('')
|
|
1683
|
+
if (window.DshMotion?.relayout) {
|
|
1684
|
+
window.DshMotion.relayout(panel, '.wb-project', () => { panel.innerHTML = projectHtml })
|
|
1685
|
+
} else panel.innerHTML = projectHtml
|
|
1686
|
+
window.DshMotion?.list(panel, '.wb-session')
|
|
1687
|
+
window.DshMotion?.bindLongPressReorder(panel, '.wb-project', {
|
|
1688
|
+
handleSelector: '.wb-project-head',
|
|
1689
|
+
excludeSelector: '[data-wb-new]',
|
|
1690
|
+
onCommit: ({ order }) => commitWorkspaceOrder(order)
|
|
1691
|
+
})
|
|
1692
|
+
window.DshMotion?.bindLongPressReorder(panel, '.session-swipe', {
|
|
1693
|
+
groupSelector: '.wb-project',
|
|
1694
|
+
handleSelector: '.wb-session',
|
|
1695
|
+
onCommit: ({ item, order }) => commitWorkspaceSessionOrder(item.closest('[data-wb-project]')?.dataset.wbProject, order)
|
|
1696
|
+
})
|
|
1561
1697
|
}
|
|
1562
1698
|
|
|
1563
1699
|
function sessionCwd(s) { return typeof s?.cwd === 'string' ? s.cwd.trim() : '' }
|
|
@@ -1576,6 +1712,25 @@ function workspaceDisplayName(label) {
|
|
|
1576
1712
|
const parts = clean.split(/[\\/]/).filter(Boolean)
|
|
1577
1713
|
return parts[parts.length - 1] || value
|
|
1578
1714
|
}
|
|
1715
|
+
function sessionSortTime(s) {
|
|
1716
|
+
return Math.max(Number(state.sessionTurnTimes[s?.sessionId]) || 0, Number(s?.updatedAt) || 0, Number(s?.createdAt) || 0)
|
|
1717
|
+
}
|
|
1718
|
+
function noteSessionTurnTime(sessionId, eventOrTime) {
|
|
1719
|
+
const raw = typeof eventOrTime === 'object' ? eventOrTime?.time : eventOrTime
|
|
1720
|
+
const time = Number(raw) > 0 ? Number(raw) : Date.now()
|
|
1721
|
+
if (!sessionId || !Number.isFinite(time)) return
|
|
1722
|
+
state.sessionTurnTimes[sessionId] = Math.max(Number(state.sessionTurnTimes[sessionId]) || 0, time)
|
|
1723
|
+
}
|
|
1724
|
+
function sessionWorkspaceOrderKey(s) {
|
|
1725
|
+
const workspace = workspaceForSession(s)
|
|
1726
|
+
return String(workspace?.workspaceId || 'path:' + (sessionWorkspaceLabel(s) || WORKSPACE_UNGROUPED))
|
|
1727
|
+
}
|
|
1728
|
+
function commitWorkspaceGroupOrder(order) {
|
|
1729
|
+
saveWorkbenchOrder(value => { value.workspaceIds = order.map(String) })
|
|
1730
|
+
renderSessions()
|
|
1731
|
+
renderWorkbench()
|
|
1732
|
+
toast(t('wb.orderSaved'), 'ok')
|
|
1733
|
+
}
|
|
1579
1734
|
function sortedSessions() {
|
|
1580
1735
|
const items = topLevelSessions()
|
|
1581
1736
|
if (state.sessionSort === 'workspace') {
|
|
@@ -1583,10 +1738,10 @@ function sortedSessions() {
|
|
|
1583
1738
|
const aw = sessionCwd(a) || '\uffff'
|
|
1584
1739
|
const bw = sessionCwd(b) || '\uffff'
|
|
1585
1740
|
const byWorkspace = aw.localeCompare(bw, undefined, { numeric: true, sensitivity: 'base' })
|
|
1586
|
-
return byWorkspace || ((b
|
|
1741
|
+
return byWorkspace || (sessionSortTime(b) - sessionSortTime(a))
|
|
1587
1742
|
})
|
|
1588
1743
|
}
|
|
1589
|
-
return items.sort((a, b) => (b
|
|
1744
|
+
return items.sort((a, b) => sessionSortTime(b) - sessionSortTime(a))
|
|
1590
1745
|
}
|
|
1591
1746
|
function renderSessions() {
|
|
1592
1747
|
const list = $('session-list')
|
|
@@ -1600,16 +1755,9 @@ function renderSessions() {
|
|
|
1600
1755
|
const archived = visible.filter(s => archivedSet.has(s.sessionId))
|
|
1601
1756
|
const main = visible.filter(s => !archivedSet.has(s.sessionId))
|
|
1602
1757
|
const showArchived = LS.get('showArchivedV1', '0') === '1'
|
|
1603
|
-
const
|
|
1604
|
-
let lastWorkspace = null
|
|
1605
|
-
const rows = []
|
|
1606
|
-
for (const s of items) {
|
|
1758
|
+
const renderSession = s => {
|
|
1607
1759
|
const workspace = sessionWorkspaceLabel(s)
|
|
1608
1760
|
const workspaceTitle = sessionWorkspaceName(s)
|
|
1609
|
-
if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
|
|
1610
|
-
rows.push(`<div class="session-group-label" title="${esc(workspace)}"><span class="session-group-icon" aria-hidden="true">⌂</span><span class="session-group-name">${esc(workspaceTitle)}</span></div>`)
|
|
1611
|
-
lastWorkspace = workspace
|
|
1612
|
-
}
|
|
1613
1761
|
const title = titleOf(s)
|
|
1614
1762
|
const goal = goalOf(s)
|
|
1615
1763
|
const pending = (state.approvals.some(a => a.sessionId === s.sessionId) || state.questions.some(q => q.sessionId === s.sessionId)) ? 'pending' : ''
|
|
@@ -1620,12 +1768,12 @@ function renderSessions() {
|
|
|
1620
1768
|
const badge = goal ? `<span class="sc-badge ${goal.phase === 'active' ? 'goal-active' : ''}">${esc(t('sessions.goalBadge', { phase: goal.phase || '?' }))}</span>` : ''
|
|
1621
1769
|
const queueBadge = queueN ? `<span class="sc-badge">${esc(t('sessions.queueBadge', { n: queueN }))}</span>` : ''
|
|
1622
1770
|
const archiveButton = archivedSet.has(s.sessionId) ? '' : `<button type="button" class="sc-archive-btn" data-archive-session="${esc(s.sessionId)}">${esc(t('session.archive'))}</button>`
|
|
1623
|
-
|
|
1771
|
+
return `<div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
|
|
1624
1772
|
<div class="session-card ${state.current === s.sessionId ? 'current' : ''}">
|
|
1625
1773
|
<div class="sc-title">${esc(title)}</div>
|
|
1626
1774
|
<div class="sc-meta">
|
|
1627
1775
|
<span class="sc-dot ${dots.join(' ')}"></span>
|
|
1628
|
-
<span>${fmtTime(s
|
|
1776
|
+
<span>${fmtTime(sessionSortTime(s))}</span>
|
|
1629
1777
|
${s.running ? '<span>' + t('sessions.running') + '</span>' : ''}
|
|
1630
1778
|
${badge}${queueBadge}
|
|
1631
1779
|
</div>
|
|
@@ -1633,13 +1781,42 @@ function renderSessions() {
|
|
|
1633
1781
|
<span class="sc-arrow">›</span>
|
|
1634
1782
|
</div>
|
|
1635
1783
|
${archiveButton}
|
|
1636
|
-
</div>`
|
|
1784
|
+
</div>`
|
|
1785
|
+
}
|
|
1786
|
+
const renderItems = (items) => {
|
|
1787
|
+
if (state.sessionSort !== 'workspace') return items.map(renderSession).join('')
|
|
1788
|
+
const groups = []
|
|
1789
|
+
const byKey = new Map()
|
|
1790
|
+
for (const session of items) {
|
|
1791
|
+
const key = sessionWorkspaceOrderKey(session)
|
|
1792
|
+
let group = byKey.get(key)
|
|
1793
|
+
if (!group) {
|
|
1794
|
+
group = { key, label: sessionWorkspaceName(session), path: sessionWorkspaceLabel(session), items: [] }
|
|
1795
|
+
byKey.set(key, group)
|
|
1796
|
+
groups.push(group)
|
|
1797
|
+
}
|
|
1798
|
+
group.items.push(session)
|
|
1637
1799
|
}
|
|
1638
|
-
|
|
1800
|
+
const { value } = workbenchOrderScopeValue()
|
|
1801
|
+
return orderedItems(groups, value.workspaceIds, group => group.key).map(group => `<div class="session-workspace-group" data-workspace-group="${esc(group.key)}" data-motion-key="${esc(group.key)}">
|
|
1802
|
+
<div class="session-group-label" data-reorder-handle title="${esc(group.path)}"><span class="session-group-drag-handle" aria-hidden="true">⠿</span><span class="session-group-icon" aria-hidden="true">⌂</span><span class="session-group-name">${esc(group.label)}</span></div>
|
|
1803
|
+
${orderedWorkspaceSessions(group.key, group.items).map(renderSession).join('')}
|
|
1804
|
+
</div>`).join('')
|
|
1639
1805
|
}
|
|
1640
1806
|
const divider = archived.length ? `<button class="archived-toggle" type="button" data-archived-toggle>${esc(showArchived ? t('wb.archivedShown') : t('wb.archivedHidden'))}</button>` : ''
|
|
1641
1807
|
const rows = renderItems(main) + divider + (showArchived ? renderItems(archived) : '')
|
|
1642
|
-
list.innerHTML = rows || `<div class="empty">${esc(t('home.empty'))}</div>`
|
|
1808
|
+
const renderList = () => { list.innerHTML = rows || `<div class="empty">${esc(t('home.empty'))}</div>` }
|
|
1809
|
+
if (window.DshMotion?.relayout) window.DshMotion.relayout(list, '.session-swipe', renderList)
|
|
1810
|
+
else renderList()
|
|
1811
|
+
window.DshMotion?.bindLongPressReorder(list, '.session-workspace-group', {
|
|
1812
|
+
handleSelector: '.session-group-label',
|
|
1813
|
+
onCommit: ({ order }) => commitWorkspaceGroupOrder(order)
|
|
1814
|
+
})
|
|
1815
|
+
window.DshMotion?.bindLongPressReorder(list, '.session-swipe', {
|
|
1816
|
+
groupSelector: '.session-workspace-group',
|
|
1817
|
+
handleSelector: '.session-card',
|
|
1818
|
+
onCommit: ({ item, order }) => commitWorkspaceSessionOrder(item.closest('[data-workspace-group]')?.dataset.workspaceGroup, order)
|
|
1819
|
+
})
|
|
1643
1820
|
list.classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
|
|
1644
1821
|
const sort = $('session-sort')
|
|
1645
1822
|
if (sort) { sort.value = state.sessionSort; syncCustomSelect(sort) }
|
|
@@ -1656,12 +1833,16 @@ function renderSessions() {
|
|
|
1656
1833
|
/* ---------------- 会话详情 ---------------- */
|
|
1657
1834
|
async function openSession(id) {
|
|
1658
1835
|
state.current = id
|
|
1836
|
+
setSessionRecovery('loading')
|
|
1659
1837
|
state.history = emptyHistory()
|
|
1660
1838
|
document.body.classList.add('in-session')
|
|
1661
1839
|
showView('view-session')
|
|
1840
|
+
$('btn-rename-session').classList.remove('hidden')
|
|
1841
|
+
$('btn-archive-session').classList.toggle('hidden', (state.wbArchived || []).includes(id))
|
|
1662
1842
|
$('session-cards').innerHTML = ''
|
|
1663
1843
|
renderSessionTitle(); renderSessionSub(); updateCancelBtn(); updateSessionStatus()
|
|
1664
1844
|
$('history').innerHTML = '<div class="empty">' + t('history.loading') + '</div>'
|
|
1845
|
+
renderQueue()
|
|
1665
1846
|
restoreCachedHistory()
|
|
1666
1847
|
await loadHistory(true)
|
|
1667
1848
|
renderSessionCards()
|
|
@@ -1672,6 +1853,9 @@ function closeSession() {
|
|
|
1672
1853
|
setComposerFullscreen(false)
|
|
1673
1854
|
clearComposerImages()
|
|
1674
1855
|
state.current = null
|
|
1856
|
+
setSessionRecovery('idle')
|
|
1857
|
+
$('btn-rename-session').classList.add('hidden')
|
|
1858
|
+
$('btn-archive-session').classList.add('hidden')
|
|
1675
1859
|
state.history = emptyHistory()
|
|
1676
1860
|
document.body.classList.remove('in-session')
|
|
1677
1861
|
hideComposerMenu()
|
|
@@ -1686,7 +1870,7 @@ function bindNativeBack() {
|
|
|
1686
1870
|
if ($('composer-wrap')?.classList.contains('fs')) { setComposerFullscreen(false); return }
|
|
1687
1871
|
if (customSelectCurrent) { closeCustomSelect(); return }
|
|
1688
1872
|
const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
|
|
1689
|
-
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 openModal.classList.add('hidden'); return } // 先关弹窗
|
|
1873
|
+
if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else if (openModal.id === 'modal-archive') closeArchiveConfirm(); else if (openModal.id === 'modal-rename') closeRenameSession(); else if (openModal.id === 'modal-app-version-warning') closeAppVersionWarning(false); else if (openModal.id === 'modal-scan-live') closeLiveScan(''); else openModal.classList.add('hidden'); return } // 先关弹窗
|
|
1690
1874
|
if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
|
|
1691
1875
|
if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
|
|
1692
1876
|
if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
|
|
@@ -1709,6 +1893,8 @@ function renderSessionSub() {
|
|
|
1709
1893
|
if (s.cwd) parts.push(s.cwd)
|
|
1710
1894
|
if (s.running) parts.push(t('session.running'))
|
|
1711
1895
|
else if (s.error) parts.push(t('session.interrupted'))
|
|
1896
|
+
const recovery = recoveryLabel()
|
|
1897
|
+
if (recovery) parts.push(recovery)
|
|
1712
1898
|
$('session-sub').textContent = parts.join(' · ')
|
|
1713
1899
|
}
|
|
1714
1900
|
|
|
@@ -1717,6 +1903,8 @@ function updateSessionStatus() {
|
|
|
1717
1903
|
const s = state.byId.get(state.current)
|
|
1718
1904
|
const head = $('session-head')
|
|
1719
1905
|
if (!head) return
|
|
1906
|
+
const composerStatus = $('composer-status')
|
|
1907
|
+
if (composerStatus) composerStatus.classList.toggle('hidden', !s?.running)
|
|
1720
1908
|
head.classList.remove('running', 'interrupted')
|
|
1721
1909
|
const queued = (state.queues[state.current] || []).some(i => i.placement !== 'context')
|
|
1722
1910
|
if (s?.running || queued) head.classList.add('running')
|
|
@@ -1846,6 +2034,7 @@ function restoreCachedHistory() {
|
|
|
1846
2034
|
if (!id) return false
|
|
1847
2035
|
const cached = readHistoryCache()[id]
|
|
1848
2036
|
if (!cached?.events?.length) return false
|
|
2037
|
+
if (cached.title) hydrateSessionProjections(id, { values: { title: cached.title }, asOfSeq: 0 })
|
|
1849
2038
|
const h = emptyHistory()
|
|
1850
2039
|
for (const e of cached.events) {
|
|
1851
2040
|
if (e?.seq == null) continue
|
|
@@ -1863,6 +2052,7 @@ async function loadHistory(reset) {
|
|
|
1863
2052
|
const id = state.current
|
|
1864
2053
|
if (!id || state.history.loading) return
|
|
1865
2054
|
state.history.loading = true
|
|
2055
|
+
if (reset) setSessionRecovery('loading')
|
|
1866
2056
|
const moreBtn = $('history-more')
|
|
1867
2057
|
if (moreBtn) moreBtn.classList.add('hidden')
|
|
1868
2058
|
const payload = { sessionId: id, maxMessages: 60 }
|
|
@@ -1875,10 +2065,12 @@ async function loadHistory(reset) {
|
|
|
1875
2065
|
state.history.loading = false
|
|
1876
2066
|
if (e.message === 'AUTH') { authFailure(); return }
|
|
1877
2067
|
if (restoreCachedHistory()) {
|
|
2068
|
+
setSessionRecovery('cached', e.message)
|
|
1878
2069
|
toast(t('history.cacheFallback'), 'ok')
|
|
1879
2070
|
return
|
|
1880
2071
|
}
|
|
1881
2072
|
const msg = e.message || t('err.dshError')
|
|
2073
|
+
setSessionRecovery('error', msg)
|
|
1882
2074
|
const box = $('history')
|
|
1883
2075
|
if (box && (reset || !state.history.visible.length)) {
|
|
1884
2076
|
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>`
|
|
@@ -1890,15 +2082,17 @@ async function loadHistory(reset) {
|
|
|
1890
2082
|
return
|
|
1891
2083
|
}
|
|
1892
2084
|
|
|
2085
|
+
hydrateSessionProjections(id, v.projections)
|
|
1893
2086
|
const incoming = v.events || []
|
|
1894
2087
|
let added = 0
|
|
1895
2088
|
if (reset) state.history.partialReasoning.clear()
|
|
1896
2089
|
for (const entry of incoming) {
|
|
1897
2090
|
const ev = entry?.event
|
|
1898
2091
|
const seq = ev?.seq
|
|
2092
|
+
if (ev?.type === 'turn/start' || ev?.type === 'turn/end') noteSessionTurnTime(id, ev)
|
|
1899
2093
|
applyReasoningStreamEvent(ev)
|
|
1900
2094
|
if (seq == null || state.history.seqs.has(seq)) continue
|
|
1901
|
-
if (!shouldShowEvent(ev.type)) continue
|
|
2095
|
+
if (!shouldShowEvent(ev.type, ev)) continue // chunk 与非用户上下文不保留
|
|
1902
2096
|
state.history.seqs.add(seq)
|
|
1903
2097
|
state.history.visible.push({ seq, event: ev, view: entry.view })
|
|
1904
2098
|
added++
|
|
@@ -1910,6 +2104,8 @@ async function loadHistory(reset) {
|
|
|
1910
2104
|
trimVisible()
|
|
1911
2105
|
state.history.hasMore = !!v.hasMore
|
|
1912
2106
|
state.history.loading = false
|
|
2107
|
+
setSessionRecovery('ready')
|
|
2108
|
+
renderSessionTitle(); renderSessionSub(); renderSessionCards()
|
|
1913
2109
|
try {
|
|
1914
2110
|
if (reset) renderHistory(true)
|
|
1915
2111
|
else if (added) renderHistory(false, 'keep')
|
|
@@ -1929,7 +2125,7 @@ function insertLiveEvent(event) {
|
|
|
1929
2125
|
return
|
|
1930
2126
|
}
|
|
1931
2127
|
const seq = event?.seq
|
|
1932
|
-
if (seq == null || h.seqs.has(seq) || !shouldShowEvent(event.type)) {
|
|
2128
|
+
if (seq == null || h.seqs.has(seq) || !shouldShowEvent(event.type, event)) {
|
|
1933
2129
|
if (reasoningChanged) scheduleReasoningRender()
|
|
1934
2130
|
return
|
|
1935
2131
|
}
|
|
@@ -2069,9 +2265,24 @@ const INTERESTING_EVENTS = new Set([
|
|
|
2069
2265
|
'approval/asked', 'approval/resolved',
|
|
2070
2266
|
'session/title', 'title'
|
|
2071
2267
|
])
|
|
2072
|
-
function
|
|
2073
|
-
|
|
2074
|
-
return
|
|
2268
|
+
function messageSource(data) {
|
|
2269
|
+
const source = data?.source ?? data?.message?.source
|
|
2270
|
+
return source && typeof source === 'object' ? source : null
|
|
2271
|
+
}
|
|
2272
|
+
function isHumanUserMessage(event) {
|
|
2273
|
+
if (event?.type !== 'user/message') return false
|
|
2274
|
+
const source = messageSource(event.data || {})
|
|
2275
|
+
// Older DSH events may not carry source metadata; keep those visible for compatibility.
|
|
2276
|
+
return !source || source.kind === 'user'
|
|
2277
|
+
}
|
|
2278
|
+
function shouldShowEvent(type, event) {
|
|
2279
|
+
if (!INTERESTING_EVENTS.has(type)) return false
|
|
2280
|
+
if (type === 'user/message' && !isHumanUserMessage(event)) {
|
|
2281
|
+
const data = event?.data || {}
|
|
2282
|
+
const blocks = data.message?.content || data.content || []
|
|
2283
|
+
return systemReminderText(blocks).length > 0
|
|
2284
|
+
}
|
|
2285
|
+
return true
|
|
2075
2286
|
}
|
|
2076
2287
|
function systemReminderText(blocks) {
|
|
2077
2288
|
if (!Array.isArray(blocks)) return ''
|
|
@@ -2085,7 +2296,7 @@ function eventHtml(entry, ctx = {}) {
|
|
|
2085
2296
|
const ev = entry.event || {}
|
|
2086
2297
|
const data = ev.data || {}
|
|
2087
2298
|
const type = ev.type || 'event'
|
|
2088
|
-
if (!shouldShowEvent(type)) return ''
|
|
2299
|
+
if (!shouldShowEvent(type, ev)) return ''
|
|
2089
2300
|
let inner = ''
|
|
2090
2301
|
|
|
2091
2302
|
if (type === 'user/message' || type === 'assistant/message') {
|
|
@@ -2095,6 +2306,8 @@ function eventHtml(entry, ctx = {}) {
|
|
|
2095
2306
|
const sysText = type === 'user/message' ? systemReminderText(blocks) : ''
|
|
2096
2307
|
if (sysText) {
|
|
2097
2308
|
inner = `<details class="event event-detail" data-seq="${seq}"><summary>${esc(t('event.systemReminder'))}</summary><pre>${esc(truncate(sysText, 4000))}</pre></details>`
|
|
2309
|
+
} else if (type === 'user/message' && !isHumanUserMessage(ev)) {
|
|
2310
|
+
return ''
|
|
2098
2311
|
} else {
|
|
2099
2312
|
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>`
|
|
2100
2313
|
}
|
|
@@ -2202,13 +2415,24 @@ async function renderSessionCards() {
|
|
|
2202
2415
|
const sub = await safeRpc('subagent.list', { parentSessionId: sessionId })
|
|
2203
2416
|
if (renderGeneration !== sessionCardsRenderGeneration || state.current !== sessionId) return
|
|
2204
2417
|
if (sub?.entries?.length) {
|
|
2418
|
+
const expanded = state.subagentExpandedSession === sessionId
|
|
2419
|
+
const toggleLabel = expanded ? t('subagent.collapse') : t('subagent.expand')
|
|
2205
2420
|
const rows = sub.entries.map(e => {
|
|
2206
2421
|
if (e.kind === 'diagnostic') return `<div class="card-row"><span class="k">${t('subagent.diagnostic')}</span><span class="v">${esc(e.reason)}</span></div>`
|
|
2207
2422
|
const label = e.label || short(e.id)
|
|
2208
2423
|
const running = e.activity === 'running'
|
|
2209
2424
|
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>`
|
|
2210
2425
|
}).join('')
|
|
2211
|
-
|
|
2426
|
+
const subagentClosedIcon = 'M7 10l5 5 5-5'
|
|
2427
|
+
const subagentOpenIcon = 'M7 14l5-5 5 5'
|
|
2428
|
+
const subagentIcon = expanded ? subagentOpenIcon : subagentClosedIcon
|
|
2429
|
+
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"><morph-icon data-morph-state="${expanded ? 'open' : 'closed'}" data-morph-closed="${subagentClosedIcon}" data-morph-open="${subagentOpenIcon}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${subagentIcon}"/></svg></morph-icon></span></button><div class="subagent-list${expanded ? '' : ' hidden'}">${rows}</div></div>`)
|
|
2430
|
+
box.querySelector('[data-subagent-toggle]')?.addEventListener('click', () => {
|
|
2431
|
+
const icon = box.querySelector('[data-subagent-toggle] morph-icon')
|
|
2432
|
+
if (icon) icon.setAttribute('data-morph-state', expanded ? 'closed' : 'open')
|
|
2433
|
+
state.subagentExpandedSession = expanded ? '' : sessionId
|
|
2434
|
+
setTimeout(() => renderSessionCards(), 240)
|
|
2435
|
+
})
|
|
2212
2436
|
box.querySelectorAll('[data-sub-interrupt]').forEach(btn =>
|
|
2213
2437
|
btn.addEventListener('click', () => interruptSubagent(btn.dataset.subInterrupt)))
|
|
2214
2438
|
}
|
|
@@ -2261,7 +2485,7 @@ async function runSlashCommand(text) {
|
|
|
2261
2485
|
: undefined
|
|
2262
2486
|
const res = await fetch(apiUrl('/remote/api/command'), {
|
|
2263
2487
|
method: 'POST',
|
|
2264
|
-
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
|
|
2488
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() },
|
|
2265
2489
|
body: JSON.stringify({ sessionId: state.current, line: clean }),
|
|
2266
2490
|
...(signal ? { signal } : {})
|
|
2267
2491
|
})
|
|
@@ -2383,15 +2607,24 @@ async function sendSessionContent(text, images) {
|
|
|
2383
2607
|
try {
|
|
2384
2608
|
const content = [...await encodeComposerImagesFor(images)]
|
|
2385
2609
|
if (clean) content.push({ type: 'text', text: clean })
|
|
2610
|
+
setSessionRecovery('resuming')
|
|
2386
2611
|
const v = await safeRpc('session.prompt', {
|
|
2387
2612
|
sessionId: state.current,
|
|
2388
2613
|
mode: 'queue',
|
|
2389
2614
|
content
|
|
2390
2615
|
}, t('send.failed'))
|
|
2391
|
-
if (v?.accepted) {
|
|
2616
|
+
if (v?.accepted) {
|
|
2617
|
+
setSessionRecovery('ready')
|
|
2618
|
+
noteSessionTurnTime(state.current, Date.now())
|
|
2619
|
+
renderSessions()
|
|
2620
|
+
toast(images.length ? t('send.imageSent') : (clean.startsWith('/') ? t('send.commandSent') : t('send.sent')), 'ok')
|
|
2621
|
+
return true
|
|
2622
|
+
}
|
|
2392
2623
|
if (v?.command?.text) { toast(t('send.commandExecuted'), 'ok'); return true }
|
|
2624
|
+
setSessionRecovery('error')
|
|
2393
2625
|
return false
|
|
2394
2626
|
} catch (e) {
|
|
2627
|
+
setSessionRecovery('error', e?.message)
|
|
2395
2628
|
toast(t('composer.imageReadFailed', { msg: e?.message || e }), 'err')
|
|
2396
2629
|
return false
|
|
2397
2630
|
} finally {
|
|
@@ -2553,7 +2786,41 @@ async function cancelSession() {
|
|
|
2553
2786
|
if (!state.current) return
|
|
2554
2787
|
if (!confirm(t('session.confirmStop'))) return
|
|
2555
2788
|
const v = await safeRpc('session.cancel', { sessionId: state.current }, t('session.stopFailed'))
|
|
2556
|
-
if (v?.accepted) toast(t('session.stopRequested'), 'ok')
|
|
2789
|
+
if (v?.accepted) { setSessionRecovery('ready'); toast(t('session.stopRequested'), 'ok') }
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
let renamePendingSessionId = null
|
|
2793
|
+
function renameSession(sessionId = state.current) {
|
|
2794
|
+
const session = state.byId.get(sessionId)
|
|
2795
|
+
if (!session) return
|
|
2796
|
+
renamePendingSessionId = sessionId
|
|
2797
|
+
$('rename-session-input').value = titleOf(session)
|
|
2798
|
+
$('modal-rename').classList.remove('hidden')
|
|
2799
|
+
setTimeout(() => { $('rename-session-input').focus(); $('rename-session-input').select() }, 40)
|
|
2800
|
+
}
|
|
2801
|
+
function closeRenameSession() {
|
|
2802
|
+
renamePendingSessionId = null
|
|
2803
|
+
$('modal-rename').classList.add('hidden')
|
|
2804
|
+
}
|
|
2805
|
+
async function confirmRenameSession() {
|
|
2806
|
+
const sessionId = renamePendingSessionId
|
|
2807
|
+
if (!sessionId) return
|
|
2808
|
+
const title = $('rename-session-input').value.trim()
|
|
2809
|
+
if (!title) return toast(t('session.renameEmpty'), 'err')
|
|
2810
|
+
const button = $('rename-confirm')
|
|
2811
|
+
button.disabled = true
|
|
2812
|
+
setSessionRecovery('resuming')
|
|
2813
|
+
try {
|
|
2814
|
+
const value = await safeRpc('session.rename', { sessionId, title }, t('session.renameFailed'))
|
|
2815
|
+
if (value == null) { setSessionRecovery('error'); return }
|
|
2816
|
+
if (value.title) applyProjection(sessionId, 'title', value.title, value.seq)
|
|
2817
|
+
closeRenameSession()
|
|
2818
|
+
setSessionRecovery('ready')
|
|
2819
|
+
toast(t('session.renamed'), 'ok')
|
|
2820
|
+
await refreshSessions()
|
|
2821
|
+
} finally {
|
|
2822
|
+
button.disabled = false
|
|
2823
|
+
}
|
|
2557
2824
|
}
|
|
2558
2825
|
|
|
2559
2826
|
async function newSession() {
|
|
@@ -2623,6 +2890,7 @@ async function confirmArchiveSession() {
|
|
|
2623
2890
|
closeArchiveConfirm()
|
|
2624
2891
|
toast(t('session.archived'), 'ok')
|
|
2625
2892
|
await refreshSessions()
|
|
2893
|
+
if (state.current === sessionId) closeSession()
|
|
2626
2894
|
} finally {
|
|
2627
2895
|
button.disabled = false
|
|
2628
2896
|
}
|
|
@@ -2735,7 +3003,7 @@ function renderOverview() {
|
|
|
2735
3003
|
|
|
2736
3004
|
const topSessions = topLevelSessions()
|
|
2737
3005
|
const running = topSessions.filter(s => s.running).length
|
|
2738
|
-
const sessions = topSessions.sort((a, b) => Number(b.running) - Number(a.running) || (
|
|
3006
|
+
const sessions = topSessions.sort((a, b) => Number(b.running) - Number(a.running) || (sessionSortTime(b) - sessionSortTime(a))).slice(0, 4)
|
|
2739
3007
|
const primary = $('overview-primary-action')
|
|
2740
3008
|
if (primary) {
|
|
2741
3009
|
let action = 'new'
|
|
@@ -2766,7 +3034,7 @@ function renderOverview() {
|
|
|
2766
3034
|
$('overview-connection-mode').textContent = state.token ? t(state.streamMode === 'poll' ? 'overview.poll' : 'overview.ws') : '—'
|
|
2767
3035
|
$('overview-active-count').textContent = running ? t('overview.activeCount', { n: running }) : ''
|
|
2768
3036
|
$('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)}">
|
|
2769
|
-
<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
|
|
3037
|
+
<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>
|
|
2770
3038
|
</button>`).join('') : `<div class="overview-empty">${t('overview.noSession')}</div>`
|
|
2771
3039
|
$('overview-session-list').querySelectorAll('[data-overview-session]').forEach(btn => btn.addEventListener('click', () => openSession(btn.dataset.overviewSession)))
|
|
2772
3040
|
}
|
|
@@ -2862,11 +3130,46 @@ async function submitQuestion() {
|
|
|
2862
3130
|
}
|
|
2863
3131
|
|
|
2864
3132
|
/* ---------------- 后台任务 ---------------- */
|
|
3133
|
+
function queuePreview(item) {
|
|
3134
|
+
const blocks = item?.message?.content || item?.content || []
|
|
3135
|
+
const text = Array.isArray(blocks)
|
|
3136
|
+
? blocks.filter(block => block?.type === 'text').map(block => String(block.text || '')).join(' ').trim()
|
|
3137
|
+
: ''
|
|
3138
|
+
return text || (Array.isArray(blocks) && blocks.some(block => block?.type === 'image') ? t('queue.image') : '…')
|
|
3139
|
+
}
|
|
3140
|
+
async function steerQueueItem(itemId) {
|
|
3141
|
+
const sessionId = state.current
|
|
3142
|
+
const key = `${sessionId}:${itemId}`
|
|
3143
|
+
const s = state.byId.get(sessionId)
|
|
3144
|
+
if (!sessionId || !s?.running || state.queueSteering[key]) return
|
|
3145
|
+
state.queueSteering[key] = true
|
|
3146
|
+
renderQueue()
|
|
3147
|
+
try {
|
|
3148
|
+
const v = await safeRpc('session.updateQueue', { sessionId, itemId, action: { kind: 'steer' } }, t('queue.steerFailed', { msg: '' }).replace(/:$/, '').replace(/: $/, ''))
|
|
3149
|
+
if (v?.accepted) toast(t('queue.steerSubmitted'), 'ok')
|
|
3150
|
+
} finally {
|
|
3151
|
+
delete state.queueSteering[key]
|
|
3152
|
+
renderQueue()
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
2865
3155
|
function renderQueue() {
|
|
2866
3156
|
const s = state.byId.get(state.current)
|
|
2867
3157
|
if (!s) return
|
|
2868
|
-
const items = state.queues[state.current] || []
|
|
3158
|
+
const items = (state.queues[state.current] || []).filter(item => item?.placement === 'queued')
|
|
3159
|
+
const box = $('queue-dock')
|
|
3160
|
+
if (box) {
|
|
3161
|
+
box.classList.toggle('hidden', !items.length)
|
|
3162
|
+
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 => {
|
|
3163
|
+
const key = `${state.current}:${item.id}`
|
|
3164
|
+
const busy = !!state.queueSteering[key]
|
|
3165
|
+
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>`
|
|
3166
|
+
}).join('')}</div>` : ''
|
|
3167
|
+
box.querySelectorAll('[data-queue-steer]').forEach(button => {
|
|
3168
|
+
button.addEventListener('click', () => steerQueueItem(button.dataset.queueSteer))
|
|
3169
|
+
})
|
|
3170
|
+
}
|
|
2869
3171
|
updateCancelBtn()
|
|
3172
|
+
updateSessionStatus()
|
|
2870
3173
|
// 队列数量在会话列表已显示; 详情页不重复大 UI
|
|
2871
3174
|
$('history-hint').textContent = items.length ? t('history.queueAndCount', { q: items.length, n: state.history.visible.length }) : t('history.countOnly', { n: state.history.visible.length })
|
|
2872
3175
|
renderSessions()
|
|
@@ -2889,7 +3192,8 @@ function renderJobs() {
|
|
|
2889
3192
|
function fsHeaders() {
|
|
2890
3193
|
return {
|
|
2891
3194
|
authorization: 'Bearer ' + state.token,
|
|
2892
|
-
'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web'
|
|
3195
|
+
'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web',
|
|
3196
|
+
...clientIdHeaders()
|
|
2893
3197
|
}
|
|
2894
3198
|
}
|
|
2895
3199
|
|
|
@@ -3259,6 +3563,9 @@ async function runFsUpload(up) {
|
|
|
3259
3563
|
xhr.open('POST', fsApiUrl('/upload', params))
|
|
3260
3564
|
xhr.setRequestHeader('authorization', 'Bearer ' + state.token)
|
|
3261
3565
|
xhr.setRequestHeader('x-dsh-remote-client', CAP?.isNativePlatform?.() ? 'app' : 'web')
|
|
3566
|
+
if (CLIENT_ID) xhr.setRequestHeader('x-dsh-remote-client-id', CLIENT_ID)
|
|
3567
|
+
if (params.offset != null) xhr.setRequestHeader('Upload-Offset', String(params.offset))
|
|
3568
|
+
if (params.size != null) xhr.setRequestHeader('Upload-Length', String(params.size))
|
|
3262
3569
|
xhr.upload.onprogress = (e) => {
|
|
3263
3570
|
if (e.lengthComputable) {
|
|
3264
3571
|
const loaded = up.offset + Math.min(e.loaded, e.total)
|
|
@@ -3283,7 +3590,7 @@ async function runFsUpload(up) {
|
|
|
3283
3590
|
})
|
|
3284
3591
|
|
|
3285
3592
|
const probe = async () => {
|
|
3286
|
-
const res = await fetch(fsApiUrl('/upload-probe', { path: up.path, name: up.name, session: up.session }), { headers: fsHeaders() })
|
|
3593
|
+
const res = await fetch(fsApiUrl('/upload-probe', { path: up.path, name: up.name, session: up.session, size: String(up.size) }), { headers: fsHeaders() })
|
|
3287
3594
|
if (res.status === 401) { fsAuthError(401); return null }
|
|
3288
3595
|
const json = await res.json().catch(() => ({}))
|
|
3289
3596
|
if (json.ok) up.offset = json.partialSize || 0
|
|
@@ -3315,7 +3622,7 @@ async function runFsUpload(up) {
|
|
|
3315
3622
|
}
|
|
3316
3623
|
hasher.update(chunkBytes)
|
|
3317
3624
|
const isLast = end >= up.size
|
|
3318
|
-
const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset) }
|
|
3625
|
+
const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset), size: String(up.size) }
|
|
3319
3626
|
if (isLast) { params.finish = '1'; params.sha256 = hasher.hex() }
|
|
3320
3627
|
if (overwrite) params.overwrite = '1'
|
|
3321
3628
|
const r = await uploadChunk(params, blob)
|
|
@@ -3357,7 +3664,7 @@ async function runFsUpload(up) {
|
|
|
3357
3664
|
// 发一个空 finish 块完成收尾, 同时带上全量 SHA-256 校验
|
|
3358
3665
|
if (up.offset >= up.size) {
|
|
3359
3666
|
const expected = hasher.hex()
|
|
3360
|
-
const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset), finish: '1', sha256: expected }
|
|
3667
|
+
const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset), size: String(up.size), finish: '1', sha256: expected }
|
|
3361
3668
|
if (overwrite) params.overwrite = '1'
|
|
3362
3669
|
const r = await uploadChunk(params, new Blob([]))
|
|
3363
3670
|
if (r.status === 401) { fsAuthError(401); return }
|
|
@@ -4177,9 +4484,9 @@ function saveBgConfig(enabled) {
|
|
|
4177
4484
|
const b = bgBridge()
|
|
4178
4485
|
if (!b?.saveBackgroundConfig) return false
|
|
4179
4486
|
const base = bgBase()
|
|
4180
|
-
const intervalMin = parseFloat($('bg-interval')?.value || '
|
|
4487
|
+
const intervalMin = parseFloat($('bg-interval')?.value || '0.5') || 0.5
|
|
4181
4488
|
const notifyTaskDone = $('opt-task-done')?.checked !== false
|
|
4182
|
-
b.saveBackgroundConfig(JSON.stringify({ enabled, intervalMin, base, token: state.token || '', notifyTaskDone }))
|
|
4489
|
+
b.saveBackgroundConfig(JSON.stringify({ enabled, intervalMin, base, token: state.token || '', clientId: CLIENT_ID || '', notifyTaskDone }))
|
|
4183
4490
|
if (enabled) $('bg-auth-status')?.classList.add('hidden')
|
|
4184
4491
|
return true
|
|
4185
4492
|
}
|
|
@@ -4310,6 +4617,7 @@ function showView(id) {
|
|
|
4310
4617
|
// 离开会话页必须清掉 in-session, 否则其他页面顶栏被 body 样式隐藏
|
|
4311
4618
|
document.body.classList.toggle('in-session', id === 'view-session')
|
|
4312
4619
|
document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === id))
|
|
4620
|
+
window.DshMotion?.view($(id))
|
|
4313
4621
|
window.scrollTo(0, 0)
|
|
4314
4622
|
if (id === 'view-files' && !state.fs.loaded) {
|
|
4315
4623
|
const workspace = workspaceById(state.fs.workspaceId)
|
|
@@ -4398,8 +4706,7 @@ function updateComposerFullscreenButton() {
|
|
|
4398
4706
|
const shouldShow = active || input.scrollHeight > 120
|
|
4399
4707
|
button.classList.toggle('hidden', !shouldShow)
|
|
4400
4708
|
$('composer-input-wrap')?.classList.toggle('has-fs-btn', shouldShow)
|
|
4401
|
-
$('fs-ico
|
|
4402
|
-
$('fs-ico-collapse')?.classList.toggle('hidden', !active)
|
|
4709
|
+
$('fs-ico')?.setAttribute('data-morph-state', active ? 'open' : 'closed')
|
|
4403
4710
|
button.title = t(active ? 'composer.exitFullscreen' : 'composer.fullscreen')
|
|
4404
4711
|
button.setAttribute('aria-label', t(active ? 'composer.exitFullscreen' : 'composer.fullscreen'))
|
|
4405
4712
|
}
|
|
@@ -4505,14 +4812,207 @@ async function decodeQrDataUrl(dataUrl) {
|
|
|
4505
4812
|
return code?.data || ''
|
|
4506
4813
|
}
|
|
4507
4814
|
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4815
|
+
let liveScanStream = null
|
|
4816
|
+
let liveScanTimer = null
|
|
4817
|
+
let liveScanResolve = null
|
|
4818
|
+
let liveScanCanvas = null
|
|
4819
|
+
let liveScanContext = null
|
|
4820
|
+
let liveScanCancelled = false
|
|
4821
|
+
let liveScanDetector = null
|
|
4822
|
+
let liveScanBusy = false
|
|
4823
|
+
let liveScanWorker = null
|
|
4824
|
+
let liveScanWorkerUrl = ''
|
|
4825
|
+
let liveScanWorkerResolve = null
|
|
4826
|
+
|
|
4827
|
+
function stopLiveScanWorker() {
|
|
4828
|
+
liveScanWorkerResolve?.('')
|
|
4829
|
+
liveScanWorkerResolve = null
|
|
4830
|
+
liveScanWorker?.terminate()
|
|
4831
|
+
liveScanWorker = null
|
|
4832
|
+
if (liveScanWorkerUrl) URL.revokeObjectURL(liveScanWorkerUrl)
|
|
4833
|
+
liveScanWorkerUrl = ''
|
|
4834
|
+
}
|
|
4835
|
+
|
|
4836
|
+
function startLiveScanWorker() {
|
|
4837
|
+
if (!window.Worker || !window.Blob || !window.URL?.createObjectURL) return
|
|
4838
|
+
try {
|
|
4839
|
+
const sourceUrl = new URL('jsqr.min.js', document.baseURI).href
|
|
4840
|
+
const workerSource = `
|
|
4841
|
+
let loaded = false
|
|
4842
|
+
self.onmessage = event => {
|
|
4843
|
+
try {
|
|
4844
|
+
if (!loaded) {
|
|
4845
|
+
self.importScripts(${JSON.stringify(sourceUrl)})
|
|
4846
|
+
loaded = true
|
|
4847
|
+
}
|
|
4848
|
+
const { data, width, height } = event.data || {}
|
|
4849
|
+
const code = self.jsQR?.(data, width, height, { inversionAttempts: 'attemptBoth' })
|
|
4850
|
+
self.postMessage({ raw: code?.data || '' })
|
|
4851
|
+
} catch (error) {
|
|
4852
|
+
self.postMessage({ raw: '', error: String(error?.message || error) })
|
|
4853
|
+
}
|
|
4854
|
+
}
|
|
4855
|
+
`
|
|
4856
|
+
liveScanWorkerUrl = URL.createObjectURL(new Blob([workerSource], { type: 'application/javascript' }))
|
|
4857
|
+
liveScanWorker = new Worker(liveScanWorkerUrl)
|
|
4858
|
+
liveScanWorker.onmessage = event => {
|
|
4859
|
+
const resolve = liveScanWorkerResolve
|
|
4860
|
+
liveScanWorkerResolve = null
|
|
4861
|
+
resolve?.(event.data?.raw || '')
|
|
4862
|
+
}
|
|
4863
|
+
liveScanWorker.onerror = () => {
|
|
4864
|
+
const resolve = liveScanWorkerResolve
|
|
4865
|
+
liveScanWorkerResolve = null
|
|
4866
|
+
resolve?.('')
|
|
4867
|
+
stopLiveScanWorker()
|
|
4868
|
+
}
|
|
4869
|
+
} catch {
|
|
4870
|
+
stopLiveScanWorker()
|
|
4871
|
+
}
|
|
4872
|
+
}
|
|
4873
|
+
|
|
4874
|
+
function closeLiveScan(result = '') {
|
|
4875
|
+
liveScanCancelled = true
|
|
4876
|
+
if (liveScanTimer) clearTimeout(liveScanTimer)
|
|
4877
|
+
liveScanTimer = null
|
|
4878
|
+
if (liveScanStream) liveScanStream.getTracks().forEach(track => track.stop())
|
|
4879
|
+
liveScanStream = null
|
|
4880
|
+
liveScanDetector = null
|
|
4881
|
+
stopLiveScanWorker()
|
|
4882
|
+
const video = $('scan-live-video')
|
|
4883
|
+
if (video) video.srcObject = null
|
|
4884
|
+
$('modal-scan-live')?.classList.add('hidden')
|
|
4885
|
+
const resolve = liveScanResolve
|
|
4886
|
+
liveScanResolve = null
|
|
4887
|
+
resolve?.(result)
|
|
4888
|
+
}
|
|
4889
|
+
|
|
4890
|
+
async function scanLiveFrame() {
|
|
4891
|
+
if (!liveScanResolve) return
|
|
4892
|
+
const video = $('scan-live-video')
|
|
4893
|
+
if (!video || video.readyState < 2 || !video.videoWidth || !liveScanContext) {
|
|
4894
|
+
liveScanTimer = setTimeout(scanLiveFrame, 180)
|
|
4895
|
+
return
|
|
4896
|
+
}
|
|
4897
|
+
if (liveScanBusy) return
|
|
4898
|
+
liveScanBusy = true
|
|
4899
|
+
try {
|
|
4900
|
+
let raw = ''
|
|
4901
|
+
if (liveScanDetector) {
|
|
4902
|
+
const codes = await liveScanDetector.detect(video)
|
|
4903
|
+
raw = codes?.[0]?.rawValue || ''
|
|
4904
|
+
} else {
|
|
4905
|
+
// 只解码取景框中央区域,避免在低端手机上对整幅高分辨率画面反复二值化。
|
|
4906
|
+
const side = Math.floor(Math.min(video.videoWidth, video.videoHeight) * 0.64)
|
|
4907
|
+
const sx = Math.floor((video.videoWidth - side) / 2)
|
|
4908
|
+
const sy = Math.floor((video.videoHeight - side) / 2)
|
|
4909
|
+
const maxSide = 480
|
|
4910
|
+
const scale = Math.min(1, maxSide / Math.max(1, side))
|
|
4911
|
+
const w = Math.max(1, Math.round(side * scale))
|
|
4912
|
+
const h = w
|
|
4913
|
+
if (liveScanCanvas.width !== w || liveScanCanvas.height !== h) {
|
|
4914
|
+
liveScanCanvas.width = w
|
|
4915
|
+
liveScanCanvas.height = h
|
|
4916
|
+
}
|
|
4917
|
+
liveScanContext.drawImage(video, sx, sy, side, side, 0, 0, w, h)
|
|
4918
|
+
const imageData = liveScanContext.getImageData(0, 0, w, h)
|
|
4919
|
+
if (liveScanWorker) {
|
|
4920
|
+
raw = await new Promise(resolve => {
|
|
4921
|
+
liveScanWorkerResolve = resolve
|
|
4922
|
+
liveScanWorker.postMessage({ data: imageData.data, width: w, height: h }, [imageData.data.buffer])
|
|
4923
|
+
})
|
|
4924
|
+
} else {
|
|
4925
|
+
raw = window.jsQR?.(imageData.data, w, h, { inversionAttempts: 'attemptBoth' })?.data || ''
|
|
4926
|
+
}
|
|
4927
|
+
}
|
|
4928
|
+
if (raw && liveScanResolve) return closeLiveScan(raw)
|
|
4929
|
+
} catch {
|
|
4930
|
+
// 摄像头帧在切后台或权限切换时可能暂时不可读,下一帧继续即可。
|
|
4931
|
+
} finally {
|
|
4932
|
+
liveScanBusy = false
|
|
4933
|
+
}
|
|
4934
|
+
if (liveScanResolve) liveScanTimer = setTimeout(scanLiveFrame, 180)
|
|
4935
|
+
}
|
|
4936
|
+
|
|
4937
|
+
/** 打开持续取帧的本地摄像头扫码;返回 undefined 表示当前 WebView 不支持实时摄像头。 */
|
|
4938
|
+
async function scanPairLive() {
|
|
4939
|
+
if (!navigator.mediaDevices?.getUserMedia) return undefined
|
|
4940
|
+
const video = $('scan-live-video')
|
|
4941
|
+
const modal = $('modal-scan-live')
|
|
4942
|
+
if (!video || !modal || !window.jsQR) return undefined
|
|
4943
|
+
const camera = CAP.Plugins?.Camera
|
|
4944
|
+
const perm = await camera?.requestPermissions?.({ permissions: ['camera'] })
|
|
4945
|
+
if (perm && perm.camera !== 'granted') throw new Error(t('scan.permissionDenied'))
|
|
4946
|
+
liveScanCancelled = false
|
|
4947
|
+
modal.classList.remove('hidden')
|
|
4948
|
+
$('scan-live-status').textContent = t('scan.liveStarting')
|
|
4949
|
+
try {
|
|
4950
|
+
liveScanStream = await navigator.mediaDevices.getUserMedia({
|
|
4951
|
+
audio: false,
|
|
4952
|
+
video: {
|
|
4953
|
+
facingMode: { ideal: 'environment' },
|
|
4954
|
+
width: { ideal: 960, max: 1280 },
|
|
4955
|
+
height: { ideal: 720, max: 1280 },
|
|
4956
|
+
frameRate: { ideal: 24, max: 30 }
|
|
4957
|
+
}
|
|
4958
|
+
})
|
|
4959
|
+
if (liveScanCancelled) {
|
|
4960
|
+
liveScanStream.getTracks().forEach(track => track.stop())
|
|
4961
|
+
liveScanStream = null
|
|
4962
|
+
return ''
|
|
4963
|
+
}
|
|
4964
|
+
video.srcObject = liveScanStream
|
|
4965
|
+
await video.play()
|
|
4966
|
+
$('scan-live-status').textContent = t('scan.liveHint')
|
|
4967
|
+
try {
|
|
4968
|
+
if (window.BarcodeDetector) {
|
|
4969
|
+
const formats = await window.BarcodeDetector.getSupportedFormats?.()
|
|
4970
|
+
if (!formats || formats.includes('qr_code')) liveScanDetector = new window.BarcodeDetector({ formats: ['qr_code'] })
|
|
4971
|
+
}
|
|
4972
|
+
} catch { liveScanDetector = null }
|
|
4973
|
+
liveScanCanvas = document.createElement('canvas')
|
|
4974
|
+
liveScanContext = liveScanCanvas.getContext('2d', { willReadFrequently: true })
|
|
4975
|
+
if (!liveScanContext) throw new Error(t('scan.decodeUnsupported'))
|
|
4976
|
+
startLiveScanWorker()
|
|
4977
|
+
return await new Promise(resolve => {
|
|
4978
|
+
liveScanResolve = resolve
|
|
4979
|
+
scanLiveFrame()
|
|
4980
|
+
})
|
|
4981
|
+
} catch (e) {
|
|
4982
|
+
closeLiveScan('')
|
|
4983
|
+
throw e
|
|
4984
|
+
} finally {
|
|
4985
|
+
liveScanCanvas = null
|
|
4986
|
+
liveScanContext = null
|
|
4987
|
+
liveScanDetector = null
|
|
4988
|
+
liveScanBusy = false
|
|
4989
|
+
}
|
|
4990
|
+
}
|
|
4991
|
+
|
|
4992
|
+
/** App 内扫码优先使用实时摄像头取帧;不支持时回退到官方 Camera 拍照/相册 + jsQR。 */
|
|
4511
4993
|
async function scanPair(source) {
|
|
4512
4994
|
if (!CAP?.isNativePlatform?.()) {
|
|
4513
4995
|
toast(t('scan.browserHint'), 'err')
|
|
4514
4996
|
return
|
|
4515
4997
|
}
|
|
4998
|
+
if (source === 'CAMERA') {
|
|
4999
|
+
try {
|
|
5000
|
+
const liveRaw = await scanPairLive()
|
|
5001
|
+
if (liveRaw !== undefined) {
|
|
5002
|
+
if (!liveRaw) return toast(t('scan.cancelled'), 'ok')
|
|
5003
|
+
if (applyPairUrl(liveRaw)) {
|
|
5004
|
+
toast(t('scan.paired'), 'ok')
|
|
5005
|
+
openStreams()
|
|
5006
|
+
refreshAll()
|
|
5007
|
+
} else toast(t('scan.notPair'), 'err')
|
|
5008
|
+
return
|
|
5009
|
+
}
|
|
5010
|
+
} catch (e) {
|
|
5011
|
+
const msg = String(e?.message || e || '')
|
|
5012
|
+
toast(/cancel/i.test(msg) ? t('scan.cancelled') : t('scan.failed', { msg }), 'err')
|
|
5013
|
+
return
|
|
5014
|
+
}
|
|
5015
|
+
}
|
|
4516
5016
|
const camera = CAP.Plugins?.Camera
|
|
4517
5017
|
if (!camera?.getPhoto) { toast(t('scan.unsupported'), 'err'); return }
|
|
4518
5018
|
try {
|
|
@@ -4995,6 +5495,12 @@ function bindUi() {
|
|
|
4995
5495
|
})
|
|
4996
5496
|
$('modal-file-preview').addEventListener('click', (e) => { if (e.target === $('modal-file-preview')) closeFsPreview() })
|
|
4997
5497
|
$('btn-cancel').addEventListener('click', cancelSession)
|
|
5498
|
+
$('btn-rename-session').addEventListener('click', () => renameSession())
|
|
5499
|
+
$('btn-archive-session').addEventListener('click', () => archiveSession(state.current))
|
|
5500
|
+
$('rename-cancel').addEventListener('click', closeRenameSession)
|
|
5501
|
+
$('rename-confirm').addEventListener('click', confirmRenameSession)
|
|
5502
|
+
$('rename-session-input').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.isComposing) confirmRenameSession() })
|
|
5503
|
+
$('modal-rename').addEventListener('click', e => { if (e.target === $('modal-rename')) closeRenameSession() })
|
|
4998
5504
|
$('btn-send').addEventListener('click', sendMessage)
|
|
4999
5505
|
$('btn-fs-send').addEventListener('click', sendMessage)
|
|
5000
5506
|
$('btn-plus').addEventListener('click', toggleComposerMenu)
|
|
@@ -5121,6 +5627,8 @@ function bindUi() {
|
|
|
5121
5627
|
})
|
|
5122
5628
|
$('btn-scan-camera').addEventListener('click', () => scanPair('CAMERA'))
|
|
5123
5629
|
$('btn-scan-gallery').addEventListener('click', () => scanPair('PHOTOS'))
|
|
5630
|
+
$('scan-live-cancel')?.addEventListener('click', () => closeLiveScan(''))
|
|
5631
|
+
$('modal-scan-live')?.addEventListener('click', e => { if (e.target === $('modal-scan-live')) closeLiveScan('') })
|
|
5124
5632
|
$('btn-change-token').addEventListener('click', () => {
|
|
5125
5633
|
const input = prompt(t('token.prompt'), state.token)
|
|
5126
5634
|
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() }
|