dsh-remote-plugin 0.7.0 → 0.7.2
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/client.js +12 -2
- package/gateway.cjs +81 -9
- package/index.mjs +10 -0
- package/insights.mjs +73 -0
- package/package.json +3 -2
- package/plugin-center.mjs +118 -21
- package/public/admin.html +3 -0
- package/public/admin.js +1 -0
- package/public/app.js +100 -174
- package/public/desktop/desktop.html +5 -0
- package/public/desktop/desktop.js +76 -8
- package/public/index.html +14 -64
- package/public/insights.css +14 -0
- package/public/insights.js +228 -0
- package/public/plugin-center.css +89 -1
- package/public/plugin-center.js +148 -39
- package/public/plugin.js +7 -1
- package/public/styles.css +0 -93
- package/public/update.json +12 -12
- package/public/version.json +1 -1
package/public/app.js
CHANGED
|
@@ -29,8 +29,13 @@ function clientIdHeaders() { return CLIENT_ID ? { 'x-dsh-remote-client-id': CLIE
|
|
|
29
29
|
|
|
30
30
|
/* 离线缓存: 会话列表 + 每会话聊天记录。只在网络失败时兜底展示, 不会替代线上数据。 */
|
|
31
31
|
const CACHE = {
|
|
32
|
-
sessions
|
|
33
|
-
history
|
|
32
|
+
get sessions() { return connectionCacheKey('sessions') },
|
|
33
|
+
get history() { return connectionCacheKey('history') }
|
|
34
|
+
}
|
|
35
|
+
function connectionCacheKey(kind) {
|
|
36
|
+
const hash = new SHA256()
|
|
37
|
+
hash.update(new TextEncoder().encode(JSON.stringify([state.server || location.origin, state.token])))
|
|
38
|
+
return kind + 'CacheV2:' + hash.hex()
|
|
34
39
|
}
|
|
35
40
|
function cacheRead(key, d = null) {
|
|
36
41
|
try { return JSON.parse(LS.get(key, '')) || d } catch { return d }
|
|
@@ -112,6 +117,38 @@ const state = {
|
|
|
112
117
|
subagentExpandedSession: ''
|
|
113
118
|
}
|
|
114
119
|
|
|
120
|
+
// Increment on every identity transition, including A -> B -> A and token edits.
|
|
121
|
+
let connectionGeneration = 0, sessionsRequest = 0, filesRequest = 0, workbenchRequest = 0
|
|
122
|
+
for (const key of ['server', 'token']) {
|
|
123
|
+
let value = state[key]
|
|
124
|
+
Object.defineProperty(state, key, {
|
|
125
|
+
enumerable: true,
|
|
126
|
+
get: () => value,
|
|
127
|
+
set(next) {
|
|
128
|
+
if (next === value) return
|
|
129
|
+
value = next; connectionGeneration++
|
|
130
|
+
state.sessions = []; state.byId.clear(); state.pendingProjections.clear()
|
|
131
|
+
state.current = null; state.history = emptyHistory()
|
|
132
|
+
state.fs.path = null; state.fs.initial = null; state.fs.loaded = false; state.fs.roots = []; state.fs.rootIndex = 0
|
|
133
|
+
state.wb = null; state.wbProjects = []; state.wbArchived = []; state.workspaceFilter = ''; state.fs.workspaceId = ''; clearTimeout(scheduleHistoryCacheSave._t)
|
|
134
|
+
window.DshPluginCenter?.close()
|
|
135
|
+
window.DshInsights?.close()
|
|
136
|
+
if (typeof document !== 'undefined') {
|
|
137
|
+
document.getElementById('modal-workspace')?.classList.add('hidden')
|
|
138
|
+
const generation = connectionGeneration
|
|
139
|
+
queueMicrotask(() => {
|
|
140
|
+
if (generation === connectionGeneration && document.getElementById('view-plugins')?.classList.contains('hidden') === false) openPluginPage()
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
function captureConnection() {
|
|
147
|
+
const generation = connectionGeneration, server = state.server, token = state.token
|
|
148
|
+
return { server, token, valid: () => generation === connectionGeneration && server === state.server && token === state.token }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
115
152
|
const $ = (id) => document.getElementById(id)
|
|
116
153
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) =>
|
|
117
154
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
|
@@ -437,160 +474,6 @@ function bucketTokens(b) {
|
|
|
437
474
|
return (b.input || 0) + (b.cacheRead || 0) + (b.cacheWrite || 0) + (b.output || 0)
|
|
438
475
|
}
|
|
439
476
|
|
|
440
|
-
/* ---------------- Token 统计页 ---------------- */
|
|
441
|
-
let statsChartMode = LS.get('statsChartModeV1', 'token') === 'cost' ? 'cost' : 'token'
|
|
442
|
-
|
|
443
|
-
function applyStatsChartMode() {
|
|
444
|
-
document.querySelectorAll('[data-stats-panel]').forEach(panel => {
|
|
445
|
-
const active = panel.dataset.statsPanel === statsChartMode
|
|
446
|
-
panel.classList.toggle('hidden', !active)
|
|
447
|
-
panel.setAttribute('aria-hidden', active ? 'false' : 'true')
|
|
448
|
-
})
|
|
449
|
-
document.querySelectorAll('[data-stats-mode]').forEach(button => {
|
|
450
|
-
const active = button.dataset.statsMode === statsChartMode
|
|
451
|
-
button.classList.toggle('active', active)
|
|
452
|
-
button.setAttribute('aria-pressed', active ? 'true' : 'false')
|
|
453
|
-
})
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
function setStatsChartMode(mode) {
|
|
457
|
-
if (mode !== 'token' && mode !== 'cost') return
|
|
458
|
-
statsChartMode = mode
|
|
459
|
-
LS.set('statsChartModeV1', mode)
|
|
460
|
-
applyStatsChartMode()
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
async function loadStats() {
|
|
464
|
-
const cards = $('stats-cards')
|
|
465
|
-
const chart = $('stats-chart')
|
|
466
|
-
const tokenChart = $('stats-token-chart')
|
|
467
|
-
const note = $('stats-note')
|
|
468
|
-
if (!state.token) {
|
|
469
|
-
cards.innerHTML = ''
|
|
470
|
-
chart.innerHTML = `<div class="stats-empty">${t('statsPage.gatewayDown')}</div>`
|
|
471
|
-
tokenChart.innerHTML = `<div class="stats-empty">${t('statsPage.gatewayDown')}</div>`
|
|
472
|
-
note.textContent = ''
|
|
473
|
-
$('stats-sub').textContent = ''
|
|
474
|
-
$('stats-sub').textContent = ''
|
|
475
|
-
$('stats-legend').innerHTML = ''
|
|
476
|
-
$('stats-token-legend').innerHTML = ''
|
|
477
|
-
return
|
|
478
|
-
}
|
|
479
|
-
try {
|
|
480
|
-
const res = await fetch(apiUrl('/stats/summary?days=7'), {
|
|
481
|
-
headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }
|
|
482
|
-
})
|
|
483
|
-
if (res.status === 401) { authFailure(); return }
|
|
484
|
-
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
485
|
-
const json = await res.json()
|
|
486
|
-
renderStats(json.days || [])
|
|
487
|
-
} catch (e) {
|
|
488
|
-
cards.innerHTML = ''
|
|
489
|
-
chart.innerHTML = `<div class="stats-empty">${t('statsPage.gatewayDown')}</div>`
|
|
490
|
-
tokenChart.innerHTML = `<div class="stats-empty">${t('statsPage.gatewayDown')}</div>`
|
|
491
|
-
note.textContent = ''
|
|
492
|
-
$('stats-sub').textContent = ''
|
|
493
|
-
$('stats-sub').textContent = ''
|
|
494
|
-
$('stats-legend').innerHTML = ''
|
|
495
|
-
$('stats-token-legend').innerHTML = ''
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
function renderStats(days) {
|
|
500
|
-
const cards = $('stats-cards')
|
|
501
|
-
const chart = $('stats-chart')
|
|
502
|
-
const tokenChart = $('stats-token-chart')
|
|
503
|
-
const note = $('stats-note')
|
|
504
|
-
if (!days.length) {
|
|
505
|
-
cards.innerHTML = ''
|
|
506
|
-
chart.innerHTML = `<div class="stats-empty">${t('statsPage.empty')}</div>`
|
|
507
|
-
tokenChart.innerHTML = `<div class="stats-empty">${t('statsPage.empty')}</div>`
|
|
508
|
-
note.textContent = t('statsPage.note')
|
|
509
|
-
$('stats-sub').textContent = ''
|
|
510
|
-
$('stats-legend').innerHTML = ''
|
|
511
|
-
$('stats-token-legend').innerHTML = ''
|
|
512
|
-
return
|
|
513
|
-
}
|
|
514
|
-
const today = days[days.length - 1]
|
|
515
|
-
const totalTokens = bucketTokens(today.total)
|
|
516
|
-
const peakCost = today.peak.cost || 0
|
|
517
|
-
const offCost = today.off.cost || 0
|
|
518
|
-
const totalCost = peakCost + offCost
|
|
519
|
-
const peakShare = totalCost > 0 ? Math.round(peakCost / totalCost * 100) : 0
|
|
520
|
-
const todayLabel = fmtStatsDate(today.date)
|
|
521
|
-
const todayFullLabel = fmtStatsDate(today.date, true)
|
|
522
|
-
cards.innerHTML = `
|
|
523
|
-
<article class="scard scard-primary">
|
|
524
|
-
<div class="scard-head">
|
|
525
|
-
<div class="scard-label"><span class="scard-icon token" aria-hidden="true">↗</span><span>${t('statsPage.todayTokens')}</span></div>
|
|
526
|
-
<span class="scard-badge">${esc(t('statsPage.today'))} · ${esc(todayLabel)}</span>
|
|
527
|
-
</div>
|
|
528
|
-
<div class="scard-value">${fmtTokens(totalTokens)}<span class="scard-unit">${esc(t('statsPage.tokenUnit'))}</span></div>
|
|
529
|
-
<div class="scard-helper">${esc(t('statsPage.breakdown'))}</div>
|
|
530
|
-
<div class="bucket-grid">
|
|
531
|
-
<div class="b"><span class="bucket-dot input" aria-hidden="true"></span><span class="n">${t('statsPage.input')}</span><strong class="t">${fmtTokens(today.total.input)}</strong></div>
|
|
532
|
-
<div class="b"><span class="bucket-dot cache-read" aria-hidden="true"></span><span class="n">${t('statsPage.cacheRead')}</span><strong class="t">${fmtTokens(today.total.cacheRead)}</strong></div>
|
|
533
|
-
<div class="b"><span class="bucket-dot cache-write" aria-hidden="true"></span><span class="n">${t('statsPage.cacheWrite')}</span><strong class="t">${fmtTokens(today.total.cacheWrite)}</strong></div>
|
|
534
|
-
<div class="b"><span class="bucket-dot output" aria-hidden="true"></span><span class="n">${t('statsPage.output')}</span><strong class="t">${fmtTokens(today.total.output)}</strong></div>
|
|
535
|
-
</div>
|
|
536
|
-
</article>
|
|
537
|
-
<article class="scard scard-cost">
|
|
538
|
-
<div class="scard-head">
|
|
539
|
-
<div class="scard-label"><span class="scard-icon cost" aria-hidden="true">¥</span><span>${t('statsPage.todayCost')}</span></div>
|
|
540
|
-
<span class="scard-badge">${esc(t('statsPage.estimated'))}</span>
|
|
541
|
-
</div>
|
|
542
|
-
<div class="scard-value">${fmtCost(totalCost)}</div>
|
|
543
|
-
<div class="stats-split">
|
|
544
|
-
<div class="stats-split-item"><span><i class="split-dot peak" aria-hidden="true"></i>${t('statsPage.peak')}</span><strong>${fmtCost(peakCost)}</strong></div>
|
|
545
|
-
<div class="stats-split-item"><span><i class="split-dot off" aria-hidden="true"></i>${t('statsPage.off')}</span><strong>${fmtCost(offCost)}</strong></div>
|
|
546
|
-
</div>
|
|
547
|
-
</article>
|
|
548
|
-
<article class="scard scard-share">
|
|
549
|
-
<div class="scard-head">
|
|
550
|
-
<div class="scard-label"><span class="scard-icon share" aria-hidden="true">%</span><span>${t('statsPage.peakShare')}</span></div>
|
|
551
|
-
</div>
|
|
552
|
-
<div class="share-layout">
|
|
553
|
-
<div class="share-ring" style="--share:${peakShare}%" role="img" aria-label="${esc(t('statsPage.peakShareLabel', { n: peakShare }))}"><span>${peakShare}%</span></div>
|
|
554
|
-
<div class="share-copy"><strong>${t('statsPage.peak')}</strong><span>${t('statsPage.days', { n: days.length })}</span><small>${esc(todayFullLabel)}</small></div>
|
|
555
|
-
</div>
|
|
556
|
-
</article>`
|
|
557
|
-
$('stats-sub').textContent = todayFullLabel
|
|
558
|
-
note.textContent = t('statsPage.note')
|
|
559
|
-
$('stats-legend').innerHTML = `<span class="lg"><span class="sw peak"></span>${t('statsPage.peak')}</span><span class="lg"><span class="sw off"></span>${t('statsPage.off')}</span>`
|
|
560
|
-
$('stats-token-legend').innerHTML = `<span class="lg"><span class="sw token"></span>${t('statsPage.tokenTotal')}</span>`
|
|
561
|
-
chart.setAttribute('aria-label', t('statsPage.costChartLabel'))
|
|
562
|
-
tokenChart.setAttribute('aria-label', t('statsPage.tokenChartLabel'))
|
|
563
|
-
const maxCost = Math.max(...days.map(d => (d.total.cost || 0)), 0.0001)
|
|
564
|
-
chart.innerHTML = days.map(d => {
|
|
565
|
-
const cost = d.total.cost || 0
|
|
566
|
-
// 峰/谷按该日实际费用占比堆叠, 柱总高按当日费用相对窗口最大值; 不再加最小高度, 保证占比真实
|
|
567
|
-
const peakH = cost > 0 ? Math.round((d.peak.cost || 0) / cost * 100) : 0
|
|
568
|
-
const offH = cost > 0 ? Math.max(0, 100 - peakH) : 0
|
|
569
|
-
const totalH = cost > 0 ? Math.max(3, Math.round(cost / maxCost * 100)) : 0
|
|
570
|
-
const label = fmtStatsDate(d.date)
|
|
571
|
-
const fullLabel = `${fmtStatsDate(d.date, true)} · ${t('statsPage.peak')} ${fmtCost(d.peak.cost)} · ${t('statsPage.off')} ${fmtCost(d.off.cost)} · ${t('statsPage.tokenTotal')} ${fmtTokens(bucketTokens(d.total))}`
|
|
572
|
-
return `<div class="stats-bar" role="listitem" title="${esc(fullLabel)}" aria-label="${esc(fullLabel)}">
|
|
573
|
-
<div class="bars" style="height:${totalH}%">
|
|
574
|
-
<div class="seg peak" style="height:${peakH}%"></div>
|
|
575
|
-
<div class="seg off" style="height:${offH}%"></div>
|
|
576
|
-
</div>
|
|
577
|
-
<div class="val">${cost > 0 ? fmtCost(cost) : ''}</div>
|
|
578
|
-
<div class="lbl">${label}</div>
|
|
579
|
-
</div>`
|
|
580
|
-
}).join('')
|
|
581
|
-
const maxTokens = Math.max(...days.map(d => bucketTokens(d.total)), 1)
|
|
582
|
-
tokenChart.innerHTML = days.map(d => {
|
|
583
|
-
const tokens = bucketTokens(d.total)
|
|
584
|
-
const totalH = tokens > 0 ? Math.max(3, Math.round(tokens / maxTokens * 100)) : 0
|
|
585
|
-
const label = fmtStatsDate(d.date)
|
|
586
|
-
const tip = `${fmtStatsDate(d.date, true)} · ${t('statsPage.tokenTotal')} ${fmtTokens(tokens)} · ${t('statsPage.input')} ${fmtTokens(d.total.input)} · ${t('statsPage.output')} ${fmtTokens(d.total.output)}`
|
|
587
|
-
return `<div class="stats-bar" role="listitem" title="${esc(tip)}" aria-label="${esc(tip)}">
|
|
588
|
-
<div class="bars token" style="height:${totalH}%"></div>
|
|
589
|
-
<div class="val">${tokens > 0 ? fmtTokens(tokens) : ''}</div>
|
|
590
|
-
<div class="lbl">${label}</div>
|
|
591
|
-
</div>`
|
|
592
|
-
}).join('')
|
|
593
|
-
}
|
|
594
477
|
async function rpc(method, payload = {}, timeoutMs = 45000) {
|
|
595
478
|
const opts = {
|
|
596
479
|
method: 'POST',
|
|
@@ -630,8 +513,10 @@ async function respond(rpcId, value) {
|
|
|
630
513
|
}
|
|
631
514
|
|
|
632
515
|
async function safeRpc(method, payload, errText) {
|
|
516
|
+
const connection = captureConnection()
|
|
633
517
|
try { return await rpc(method, payload) }
|
|
634
518
|
catch (e) {
|
|
519
|
+
if (!connection.valid()) return null
|
|
635
520
|
if (e.message === 'AUTH') authFailure()
|
|
636
521
|
else toast(errText ? `${errText}:${e.message}` : e.message, 'err')
|
|
637
522
|
return null
|
|
@@ -1743,7 +1628,9 @@ async function refreshAll() {
|
|
|
1743
1628
|
}
|
|
1744
1629
|
|
|
1745
1630
|
async function refreshSessions() {
|
|
1631
|
+
const connection = captureConnection(), request = ++sessionsRequest
|
|
1746
1632
|
const v = await safeRpc('session.list', {}, t('err.sessionList'))
|
|
1633
|
+
if (!connection.valid() || request !== sessionsRequest) return
|
|
1747
1634
|
if (!v) {
|
|
1748
1635
|
// 网关不可达: 用上次成功的会话列表兜底, 用户仍能打开历史缓存
|
|
1749
1636
|
if (!state.sessions.length) {
|
|
@@ -1783,6 +1670,10 @@ function removeLocalSessionRecord(sessionId) {
|
|
|
1783
1670
|
}
|
|
1784
1671
|
|
|
1785
1672
|
function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
|
|
1673
|
+
function openInsights(tab = 'context') {
|
|
1674
|
+
window.DshInsights?.open({ tab, connection: captureConnection(), url: apiUrl,
|
|
1675
|
+
getSession: () => state.byId.get(state.current), getTitle: () => sessionTitleValue(state.byId.get(state.current)) })
|
|
1676
|
+
}
|
|
1786
1677
|
function hydrateSessionProjections(sessionId, projections) {
|
|
1787
1678
|
const s = state.byId.get(sessionId)
|
|
1788
1679
|
if (!s || !projections || typeof projections !== 'object') return
|
|
@@ -2046,21 +1937,28 @@ function renderWorkspaceNavigation() {
|
|
|
2046
1937
|
return items
|
|
2047
1938
|
}
|
|
2048
1939
|
async function refreshWorkbench() {
|
|
1940
|
+
const connection = captureConnection(), request = ++workbenchRequest
|
|
1941
|
+
const valid = () => connection.valid() && request === workbenchRequest
|
|
2049
1942
|
if (!state.token) return
|
|
2050
1943
|
try {
|
|
2051
1944
|
const res = await fetch(apiUrl('/workbench'), {
|
|
2052
1945
|
headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }
|
|
2053
1946
|
})
|
|
1947
|
+
if (!valid()) return
|
|
2054
1948
|
if (res.ok) {
|
|
2055
1949
|
const value = await res.json().catch(() => null)
|
|
1950
|
+
if (!valid()) return
|
|
2056
1951
|
if (value && typeof value.bound === 'boolean') state.wb = value
|
|
2057
1952
|
}
|
|
2058
1953
|
} catch {}
|
|
1954
|
+
if (!valid()) return
|
|
2059
1955
|
try {
|
|
2060
1956
|
const value = await rpc('workspace.list', {})
|
|
1957
|
+
if (!valid()) return
|
|
2061
1958
|
state.wbProjects = Array.isArray(value?.items) ? value.items : []
|
|
2062
1959
|
state.wbArchived = Array.isArray(value?.archivedSessionIds) ? value.archivedSessionIds : []
|
|
2063
1960
|
} catch {
|
|
1961
|
+
if (!valid()) return
|
|
2064
1962
|
state.wbProjects = []
|
|
2065
1963
|
state.wbArchived = []
|
|
2066
1964
|
}
|
|
@@ -2068,8 +1966,10 @@ async function refreshWorkbench() {
|
|
|
2068
1966
|
if (state.wb?.bound && state.wb.path) {
|
|
2069
1967
|
try {
|
|
2070
1968
|
const listRes = await fetch(fsApiUrl('/list', { path: state.wb.path }), { headers: fsHeaders() })
|
|
1969
|
+
if (!valid()) return
|
|
2071
1970
|
if (listRes.ok) {
|
|
2072
1971
|
const listData = await listRes.json().catch(() => ({}))
|
|
1972
|
+
if (!valid()) return
|
|
2073
1973
|
if (Array.isArray(listData.entries)) {
|
|
2074
1974
|
const diskDirs = new Set(listData.entries.filter(e => e.type === 'dir').map(e => wbPathKey(wbJoin(state.wb.path, e.name))))
|
|
2075
1975
|
// 工作台只管理自己根目录下的项目;DSH 中位于其他路径的工作区必须保留,
|
|
@@ -2080,14 +1980,18 @@ async function refreshWorkbench() {
|
|
|
2080
1980
|
if (entry.type !== 'dir') continue
|
|
2081
1981
|
const projectPath = wbJoin(state.wb.path, entry.name)
|
|
2082
1982
|
if (have.has(wbPathKey(projectPath))) continue
|
|
1983
|
+
if (!valid()) return
|
|
2083
1984
|
try {
|
|
2084
1985
|
const created = await rpc('workspace.create', { path: projectPath })
|
|
1986
|
+
if (!valid()) return
|
|
2085
1987
|
if (created?.workspace) { state.wbProjects.push(created.workspace); have.add(wbPathKey(projectPath)) }
|
|
2086
1988
|
} catch {}
|
|
1989
|
+
if (!valid()) return
|
|
2087
1990
|
}
|
|
2088
1991
|
}
|
|
2089
1992
|
}
|
|
2090
1993
|
} catch {}
|
|
1994
|
+
if (!valid()) return
|
|
2091
1995
|
}
|
|
2092
1996
|
renderWorkspaceNavigation()
|
|
2093
1997
|
renderWorkbench()
|
|
@@ -2551,7 +2455,8 @@ function trimVisible() {
|
|
|
2551
2455
|
/* 聊天记录本地缓存: 每会话最多 250 条, 全局最多 10 个会话 */
|
|
2552
2456
|
function scheduleHistoryCacheSave() {
|
|
2553
2457
|
clearTimeout(scheduleHistoryCacheSave._t)
|
|
2554
|
-
|
|
2458
|
+
const connection = captureConnection()
|
|
2459
|
+
scheduleHistoryCacheSave._t = setTimeout(() => { if (connection.valid()) saveHistoryCache() }, 400)
|
|
2555
2460
|
}
|
|
2556
2461
|
|
|
2557
2462
|
function saveHistoryCache() {
|
|
@@ -2595,6 +2500,7 @@ function restoreCachedHistory() {
|
|
|
2595
2500
|
}
|
|
2596
2501
|
|
|
2597
2502
|
async function loadHistory(reset) {
|
|
2503
|
+
const connection = captureConnection()
|
|
2598
2504
|
const id = state.current
|
|
2599
2505
|
if (!id || state.history.loading) return
|
|
2600
2506
|
const history = state.history
|
|
@@ -2610,7 +2516,7 @@ async function loadHistory(reset) {
|
|
|
2610
2516
|
try {
|
|
2611
2517
|
v = await rpc('session.history', payload)
|
|
2612
2518
|
} catch (e) {
|
|
2613
|
-
if (state.current !== id || state.history !== history) return
|
|
2519
|
+
if (!connection.valid() || state.current !== id || state.history !== history) return
|
|
2614
2520
|
history.loading = false
|
|
2615
2521
|
if (e.message === 'AUTH') { authFailure(); return }
|
|
2616
2522
|
if (restoreCachedHistory()) {
|
|
@@ -2631,7 +2537,7 @@ async function loadHistory(reset) {
|
|
|
2631
2537
|
return
|
|
2632
2538
|
}
|
|
2633
2539
|
|
|
2634
|
-
if (state.current !== id || state.history !== history) return
|
|
2540
|
+
if (!connection.valid() || state.current !== id || state.history !== history) return
|
|
2635
2541
|
const liveReasoning = (history.reasoningVersion || 0) !== reasoningVersion ? new Map(history.partialReasoning) : null
|
|
2636
2542
|
hydrateSessionProjections(id, v.projections)
|
|
2637
2543
|
history.loaded = true
|
|
@@ -2934,6 +2840,7 @@ function statsHtml(s) {
|
|
|
2934
2840
|
|
|
2935
2841
|
let sessionCardsRenderGeneration = 0
|
|
2936
2842
|
async function renderSessionCards() {
|
|
2843
|
+
window.DshInsights?.update()
|
|
2937
2844
|
const renderGeneration = ++sessionCardsRenderGeneration
|
|
2938
2845
|
const sessionId = state.current
|
|
2939
2846
|
const s = state.byId.get(sessionId)
|
|
@@ -4134,8 +4041,10 @@ function openWorkspaceFiles(workspaceId) {
|
|
|
4134
4041
|
}
|
|
4135
4042
|
|
|
4136
4043
|
async function openWorkspaceModal() {
|
|
4044
|
+
const connection = captureConnection()
|
|
4137
4045
|
if (!state.token) { toast(t('fs.noTokenToast'), 'err'); showView('view-settings'); return }
|
|
4138
4046
|
if (!state.fs.path) await loadFs(null, { silent: true })
|
|
4047
|
+
if (!connection.valid()) return
|
|
4139
4048
|
$('workspace-parent-path').textContent = state.fs.path || '~'
|
|
4140
4049
|
$('workspace-name').value = ''
|
|
4141
4050
|
$('modal-workspace').classList.remove('hidden')
|
|
@@ -4146,27 +4055,34 @@ async function createWorkspace() {
|
|
|
4146
4055
|
if (createWorkspace.busy) return
|
|
4147
4056
|
const name = $('workspace-name').value.trim()
|
|
4148
4057
|
if (!name) { toast(t('workspace.nameRequired'), 'err'); $('workspace-name').focus(); return }
|
|
4058
|
+
const connection = captureConnection()
|
|
4149
4059
|
createWorkspace.busy = true
|
|
4150
4060
|
const parent = state.fs.path || ''
|
|
4151
4061
|
const button = $('workspace-create')
|
|
4152
4062
|
button.disabled = true
|
|
4153
4063
|
try {
|
|
4154
4064
|
const res = await fetch(fsApiUrl('/mkdir', { path: parent, name }), { method: 'POST', headers: fsHeaders() })
|
|
4065
|
+
if (!connection.valid()) return
|
|
4155
4066
|
if (res.status === 401) { fsAuthError(401); return }
|
|
4156
4067
|
const data = await res.json().catch(() => ({}))
|
|
4068
|
+
if (!connection.valid()) return
|
|
4157
4069
|
if (!res.ok) {
|
|
4158
4070
|
const msg = data.error === 'exists' ? t('workspace.exists') : data.error === 'bad-name' ? t('workspace.invalidName') : data.error || ('HTTP ' + res.status)
|
|
4159
4071
|
throw new Error(msg)
|
|
4160
4072
|
}
|
|
4161
4073
|
closeWorkspaceModal()
|
|
4162
4074
|
await loadFs(parent || null, { silent: true })
|
|
4075
|
+
if (!connection.valid()) return
|
|
4163
4076
|
let workspace = null
|
|
4164
4077
|
try {
|
|
4165
4078
|
const created = await rpc('workspace.create', { path: data.path })
|
|
4079
|
+
if (!connection.valid()) return
|
|
4166
4080
|
workspace = created?.workspace || null
|
|
4167
4081
|
} catch {}
|
|
4082
|
+
if (!connection.valid()) return
|
|
4168
4083
|
const sessionPayload = workspace?.workspaceId ? { workspaceId: workspace.workspaceId } : { cwd: data.path }
|
|
4169
4084
|
const v = await safeRpc('session.create', sessionPayload, t('home.createFailed'))
|
|
4085
|
+
if (!connection.valid()) return
|
|
4170
4086
|
if (workspace?.workspaceId) {
|
|
4171
4087
|
state.workspaceFilter = workspace.workspaceId
|
|
4172
4088
|
state.fs.workspaceId = workspace.workspaceId
|
|
@@ -4174,6 +4090,7 @@ async function createWorkspace() {
|
|
|
4174
4090
|
LS.set('fsWorkspaceIdV1', workspace.workspaceId)
|
|
4175
4091
|
}
|
|
4176
4092
|
await refreshSessions()
|
|
4093
|
+
if (!connection.valid()) return
|
|
4177
4094
|
if (v?.sessionId) {
|
|
4178
4095
|
toast(t('workspace.created'), 'ok')
|
|
4179
4096
|
openSession(v.sessionId)
|
|
@@ -4181,6 +4098,7 @@ async function createWorkspace() {
|
|
|
4181
4098
|
toast(t('workspace.createdNoSession'), 'ok')
|
|
4182
4099
|
}
|
|
4183
4100
|
} catch (e) {
|
|
4101
|
+
if (!connection.valid()) return
|
|
4184
4102
|
if (e.message === 'AUTH') fsAuthError(401)
|
|
4185
4103
|
else toast(t('workspace.createFailed', { msg: e.message || t('fs.networkError') }), 'err')
|
|
4186
4104
|
} finally {
|
|
@@ -4202,6 +4120,8 @@ function fsAuthError(status) {
|
|
|
4202
4120
|
}
|
|
4203
4121
|
|
|
4204
4122
|
async function loadFs(dir, { silent = false, resetRoot = false } = {}) {
|
|
4123
|
+
const connection = captureConnection(), request = ++filesRequest
|
|
4124
|
+
const valid = () => connection.valid() && request === filesRequest
|
|
4205
4125
|
if (!state.token) {
|
|
4206
4126
|
$('fs-path').textContent = t('fs.noToken')
|
|
4207
4127
|
$('fs-list').innerHTML = '<div class="empty">' + t('fs.goSettings') + '</div>'
|
|
@@ -4215,8 +4135,10 @@ async function loadFs(dir, { silent = false, resetRoot = false } = {}) {
|
|
|
4215
4135
|
}
|
|
4216
4136
|
try {
|
|
4217
4137
|
const res = await fetch(fsApiUrl('/list', target ? { path: target } : {}), { headers: fsHeaders() })
|
|
4138
|
+
if (!valid()) return
|
|
4218
4139
|
if (res.status === 401) { fsAuthError(401); return }
|
|
4219
4140
|
const data = await res.json().catch(() => ({}))
|
|
4141
|
+
if (!valid()) return
|
|
4220
4142
|
if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error === 'not-found' ? t('fs.notFound') : data.error === 'forbidden' ? t('fs.forbidden') : data.error || ('HTTP ' + res.status))
|
|
4221
4143
|
state.fs.path = data.path
|
|
4222
4144
|
if (!state.fs.initial) state.fs.initial = data.path
|
|
@@ -4230,6 +4152,7 @@ async function loadFs(dir, { silent = false, resetRoot = false } = {}) {
|
|
|
4230
4152
|
renderWorkspaceNavigation()
|
|
4231
4153
|
renderFs(data)
|
|
4232
4154
|
} catch (e) {
|
|
4155
|
+
if (!valid()) return
|
|
4233
4156
|
if (e.message === 'AUTH') return
|
|
4234
4157
|
$('fs-path').textContent = target || '~'
|
|
4235
4158
|
$('fs-list').innerHTML = `<div class="empty">${esc(t('fs.loadFailed', { msg: e.message || t('fs.networkError') }))}</div>`
|
|
@@ -6158,7 +6081,7 @@ async function openModelConfigDocument() {
|
|
|
6158
6081
|
/* ---------------- 视图切换 ---------------- */
|
|
6159
6082
|
function showView(id) {
|
|
6160
6083
|
if (id !== 'view-session' && document.body.classList.contains('in-session') && state.current) void archiveEmptySessionOnLeave(state.current)
|
|
6161
|
-
for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-
|
|
6084
|
+
for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-plugins', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
|
|
6162
6085
|
// 离开会话页必须清掉 in-session, 否则其他页面顶栏被 body 样式隐藏
|
|
6163
6086
|
document.body.classList.toggle('in-session', id === 'view-session')
|
|
6164
6087
|
document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === id))
|
|
@@ -6170,7 +6093,8 @@ function showView(id) {
|
|
|
6170
6093
|
const workspace = workspaceById(state.fs.workspaceId)
|
|
6171
6094
|
loadFs(workspace?.path || null, { silent: true, resetRoot: true })
|
|
6172
6095
|
}
|
|
6173
|
-
if (id === 'view-
|
|
6096
|
+
if (id === 'view-plugins') openPluginPage()
|
|
6097
|
+
else window.DshPluginCenter?.close()
|
|
6174
6098
|
if (id === 'view-settings') showSettingsHome()
|
|
6175
6099
|
}
|
|
6176
6100
|
|
|
@@ -6263,6 +6187,7 @@ function setComposerFullscreen(on) {
|
|
|
6263
6187
|
const wrap = $('composer-wrap')
|
|
6264
6188
|
if (!wrap) return
|
|
6265
6189
|
$('btn-stats')?.classList.toggle('hidden', !!on)
|
|
6190
|
+
$('btn-insights')?.classList.toggle('hidden', !!on)
|
|
6266
6191
|
$('btn-fs-send')?.classList.toggle('hidden', !on)
|
|
6267
6192
|
if (on) {
|
|
6268
6193
|
$('composer-image-menu')?.classList.add('hidden')
|
|
@@ -6923,9 +6848,6 @@ function bindUi() {
|
|
|
6923
6848
|
$('notes-pages').addEventListener('scroll', updateNotesPage)
|
|
6924
6849
|
$('modal-notes').addEventListener('click', (e) => { if (e.target === $('modal-notes')) closeNotesModal() })
|
|
6925
6850
|
renderServers()
|
|
6926
|
-
document.querySelectorAll('[data-stats-mode]').forEach(button =>
|
|
6927
|
-
button.addEventListener('click', () => setStatsChartMode(button.dataset.statsMode)))
|
|
6928
|
-
applyStatsChartMode()
|
|
6929
6851
|
// 底部导航
|
|
6930
6852
|
document.querySelectorAll('.nav-btn').forEach(b =>
|
|
6931
6853
|
b.addEventListener('click', () => showView(b.dataset.view)))
|
|
@@ -7001,6 +6923,7 @@ function bindUi() {
|
|
|
7001
6923
|
})
|
|
7002
6924
|
$('btn-back').addEventListener('click', () => { void closeSession() })
|
|
7003
6925
|
$('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
|
|
6926
|
+
$('btn-insights').addEventListener('click', () => openInsights())
|
|
7004
6927
|
$('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
|
|
7005
6928
|
$('btn-refresh').addEventListener('click', () => { toast(t('common.refreshing')); openStreams(); refreshAll() })
|
|
7006
6929
|
$('overview-refresh').addEventListener('click', () => {
|
|
@@ -7448,13 +7371,16 @@ async function boot() {
|
|
|
7448
7371
|
|
|
7449
7372
|
document.addEventListener('DOMContentLoaded', boot)
|
|
7450
7373
|
|
|
7451
|
-
|
|
7452
|
-
|
|
7453
|
-
|
|
7454
|
-
|
|
7455
|
-
|
|
7456
|
-
|
|
7457
|
-
|
|
7458
|
-
|
|
7459
|
-
|
|
7460
|
-
|
|
7374
|
+
function openPluginPage() {
|
|
7375
|
+
const connection = captureConnection()
|
|
7376
|
+
if (!connection.token) {
|
|
7377
|
+
$('plugin-page').textContent = '请先在设置中连接 DSH 主机。'
|
|
7378
|
+
return
|
|
7379
|
+
}
|
|
7380
|
+
window.DshPluginCenter.mount({
|
|
7381
|
+
url: path => connection.server + path,
|
|
7382
|
+
headers: { authorization: 'Bearer ' + connection.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() },
|
|
7383
|
+
valid: connection.valid,
|
|
7384
|
+
}, $('plugin-page'))
|
|
7385
|
+
}
|
|
7386
|
+
document.getElementById('btn-plugin-center')?.addEventListener('click', () => showView('view-plugins'))
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
<link rel="stylesheet" href="../theme-vars.css">
|
|
9
9
|
<link rel="stylesheet" href="desktop.css">
|
|
10
10
|
<link rel="stylesheet" href="../plugin-center.css">
|
|
11
|
+
<link rel="stylesheet" href="../insights.css">
|
|
11
12
|
</head>
|
|
12
13
|
<body>
|
|
13
14
|
<div class="ds-app" id="ds-app">
|
|
@@ -107,6 +108,7 @@
|
|
|
107
108
|
<button id="btn-rename-session" class="ds-icon-btn hidden" data-i18n-title="ds.sessionRename" data-i18n-aria="ds.sessionRename">✎</button>
|
|
108
109
|
<button id="btn-archive-session" class="ds-btn hidden" data-i18n="ds.sessionArchive">归档</button>
|
|
109
110
|
<div class="ds-top-right">
|
|
111
|
+
<button id="btn-insights" class="ds-icon-btn hidden" data-i18n-title="ds.insights" data-i18n-aria="ds.insights"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="12" r="8"/><path d="M12 4v8l6 5M12 12H4"/></svg></button>
|
|
110
112
|
<span class="ds-conn" id="conn-badge" title="">●</span>
|
|
111
113
|
<span class="ds-server" id="server-badge"></span>
|
|
112
114
|
<button id="btn-donate" class="ds-icon-btn" data-i18n-aria="ds.donate" data-i18n-title="ds.donate"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8h11v6a4 4 0 0 1-4 4H10a4 4 0 0 1-4-4Z"/><path d="M17 10h1.5a2.5 2.5 0 0 1 0 5H17M9 4c0 1 1 1.2 1 2M13 4c0 1 1 1.2 1 2M5 20h13"/></svg></button>
|
|
@@ -498,6 +500,7 @@
|
|
|
498
500
|
'ds.gateway': '网关', 'ds.dshUpstream': 'DSH 上游', 'ds.mux': '实时 mux', 'ds.host': '实时 host', 'ds.online': '在线', 'ds.offlineShort': '离线',
|
|
499
501
|
'ds.attention': '需要你处理', 'ds.runtime': '运行指标', 'ds.recentActivity': '近期活动', 'ds.dshVersion': 'DSH 版本', 'ds.gatewayStatus': '网关状态', 'ds.activeSessions': '活跃会话', 'ds.connectionMode': '连接模式', 'ds.liveWs': '实时 WS', 'ds.poll': '降级轮询', 'ds.running': '运行中', 'ds.noReason': '无说明', 'ds.noActivity': '暂无后台活动', 'ds.noSessions': '暂无会话', 'ds.refreshOverview': '刷新总览', 'ds.pendingCount': '{n} 项待处理', 'ds.activeCount': '{n} 个活跃会话', 'ds.nothingPending': '暂无事项', 'ds.action.openSession': '打开最近会话', 'ds.action.newSession': '新建会话', 'ds.action.attention': '处理待办', 'ds.action.connect': '去设置连接', 'ds.action.refresh': '重新检查连接',
|
|
500
502
|
'ds.sortLabel': '会话排序', 'ds.sortTime': '按时间', 'ds.sortWorkspace': '按工作区', 'ds.workspace': '工作区', 'ds.workspaceUnknown': '未指定工作区', 'ds.wbOrderSaved': '顺序已保存到当前设备',
|
|
503
|
+
'ds.insights': '上下文与费用',
|
|
501
504
|
'ds.files': '文件传输', 'ds.settings': '设置', 'ds.stats': '统计', 'ds.menu': '导航',
|
|
502
505
|
'ds.send': '发送', 'ds.composerPlaceholder': '输入消息,Enter 发送…', 'ds.composerRunning': '运行中…', 'ds.presets': '预设', 'ds.sessionRename': '重命名会话', 'ds.sessionRenameTitle': '重命名会话', 'ds.sessionRenamePlaceholder': '输入新的会话名称', 'ds.sessionRenameConfirm': '保存', 'ds.sessionRenameFailed': '重命名失败', 'ds.sessionRenameEmpty': '会话名称不能为空', 'ds.sessionRenamed': '会话名称已更新', 'ds.sessionArchive': '归档', 'ds.sessionArchiveConfirm': '归档这个会话?归档后可在“显示已归档”中打开。', 'ds.sessionArchived': '会话已归档', 'ds.sessionStop': '停止本轮', 'ds.sessionStopConfirm': '停止当前回合?排队中的消息不会被删除。', 'ds.sessionStopFailed': '停止失败', 'ds.sessionStopRequested': '已请求停止本轮', 'ds.sessionRecovering': '恢复会话中…', 'ds.sessionReady': '会话已恢复', 'ds.sessionRecoveryFailed': '会话恢复失败',
|
|
503
506
|
'ds.commands': '指令',
|
|
@@ -603,6 +606,7 @@
|
|
|
603
606
|
'ds.gateway': 'Gateway', 'ds.dshUpstream': 'DSH upstream', 'ds.mux': 'Live mux', 'ds.host': 'Live host', 'ds.online': 'Online', 'ds.offlineShort': 'Offline',
|
|
604
607
|
'ds.attention': 'Needs attention', 'ds.runtime': 'Runtime metrics', 'ds.recentActivity': 'Recent activity', 'ds.dshVersion': 'DSH version', 'ds.gatewayStatus': 'Gateway status', 'ds.activeSessions': 'Active sessions', 'ds.connectionMode': 'Connection mode', 'ds.liveWs': 'Live WS', 'ds.poll': 'Degraded polling', 'ds.running': 'Running', 'ds.noReason': 'No reason given', 'ds.noActivity': 'No background activity', 'ds.noSessions': 'No sessions yet', 'ds.refreshOverview': 'Refresh overview', 'ds.pendingCount': '{n} pending', 'ds.activeCount': '{n} active sessions', 'ds.nothingPending': 'Nothing pending', 'ds.action.openSession': 'Open recent session', 'ds.action.newSession': 'New session', 'ds.action.attention': 'Review pending', 'ds.action.connect': 'Set up connection', 'ds.action.refresh': 'Check connection',
|
|
605
608
|
'ds.sortLabel': 'Session sort', 'ds.sortTime': 'By time', 'ds.sortWorkspace': 'By workspace', 'ds.workspace': 'Workspace', 'ds.workspaceUnknown': 'No workspace', 'ds.wbOrderSaved': 'Order saved on this device',
|
|
609
|
+
'ds.insights': 'Context & costs',
|
|
606
610
|
'ds.files': 'Files', 'ds.settings': 'Settings', 'ds.stats': 'Stats', 'ds.menu': 'Menu',
|
|
607
611
|
'ds.send': 'Send', 'ds.composerPlaceholder': 'Type a message, Enter to send…', 'ds.composerRunning': 'Running…', 'ds.presets': 'Presets', 'ds.sessionRename': 'Rename session', 'ds.sessionRenameTitle': 'Rename session', 'ds.sessionRenamePlaceholder': 'Enter a new session name', 'ds.sessionRenameConfirm': 'Save', 'ds.sessionRenameFailed': 'Rename failed', 'ds.sessionRenameEmpty': 'Session name cannot be empty', 'ds.sessionRenamed': 'Session name updated', 'ds.sessionArchive': 'Archive', 'ds.sessionArchiveConfirm': 'Archive this session? You can open it from “Show archived”.', 'ds.sessionArchived': 'Session archived', 'ds.sessionStop': 'Stop turn', 'ds.sessionStopConfirm': 'Stop the current turn? Queued messages will be kept.', 'ds.sessionStopFailed': 'Stop failed', 'ds.sessionStopRequested': 'Stop requested', 'ds.sessionRecovering': 'Restoring session…', 'ds.sessionReady': 'Session ready', 'ds.sessionRecoveryFailed': 'Session restore failed',
|
|
608
612
|
'ds.commands': 'Commands',
|
|
@@ -712,6 +716,7 @@
|
|
|
712
716
|
<script src="../motion.js"></script>
|
|
713
717
|
<script type="module" src="../morphicons-init.js"></script>
|
|
714
718
|
<script src="../plugin-center.js"></script>
|
|
719
|
+
<script src="../insights.js"></script>
|
|
715
720
|
<script src="desktop.js"></script>
|
|
716
721
|
</body>
|
|
717
722
|
</html>
|