dsh-remote-plugin 0.5.7 → 0.5.8
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/README.en.md +1 -0
- package/README.md +1 -0
- package/apk/dsh-remote.apk +0 -0
- package/package.json +1 -1
- package/public/app.js +373 -41
- package/public/desktop/desktop.css +207 -0
- package/public/desktop/desktop.html +247 -0
- package/public/desktop/desktop.js +917 -0
- package/public/desktop/i18n.js +53 -0
- package/public/index.html +53 -0
- package/public/styles.css +28 -351
- package/public/theme-vars.css +351 -0
- package/public/update.json +3 -3
- package/public/version.json +1 -1
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
/* DSH Remote 桌面端 WebUI · 零依赖 · 只引用 --dsr-* 皮肤变量 */
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
const I18N = window.I18N
|
|
5
|
+
const t = (k, v) => I18N.t(k, v)
|
|
6
|
+
I18N.init(window.DESKTOP_STR)
|
|
7
|
+
|
|
8
|
+
const $ = (id) => document.getElementById(id)
|
|
9
|
+
const LS = {
|
|
10
|
+
get(k, d) { try { return localStorage.getItem(k) ?? d } catch { return d } },
|
|
11
|
+
set(k, v) { try { localStorage.setItem(k, v) } catch {} },
|
|
12
|
+
del(k) { try { localStorage.removeItem(k) } catch {} }
|
|
13
|
+
}
|
|
14
|
+
const CAP = window.Capacitor || null
|
|
15
|
+
|
|
16
|
+
/* ---------------- 皮肤 ---------------- */
|
|
17
|
+
const THEME_META = [
|
|
18
|
+
{ id: 'default', sw: ['#0B0E1A', '#151B33', '#5B8CFF'] },
|
|
19
|
+
{ id: 'dark', sw: ['#05348B', '#0D438F', '#F9A647'] },
|
|
20
|
+
{ id: 'light', sw: ['#EFEEEC', '#FAF8F5', '#E6BC7B'] },
|
|
21
|
+
{ id: 'neutral', sw: ['#DDD4B8', '#585818', '#832D15'] }
|
|
22
|
+
]
|
|
23
|
+
function themeGet() {
|
|
24
|
+
let v = LS.get('dshTheme', '')
|
|
25
|
+
if (!THEME_META.some(m => m.id === v)) v = ''
|
|
26
|
+
return v
|
|
27
|
+
}
|
|
28
|
+
function themeApply() {
|
|
29
|
+
const v = themeGet()
|
|
30
|
+
if (!v) document.documentElement.removeAttribute('data-theme')
|
|
31
|
+
else document.documentElement.setAttribute('data-theme', v)
|
|
32
|
+
const meta = THEME_META.find(m => m.id === v) || THEME_META[0]
|
|
33
|
+
const btn = $('btn-theme')
|
|
34
|
+
if (btn) btn.textContent = t('ds.theme.' + meta.id)
|
|
35
|
+
return meta.id || 'default'
|
|
36
|
+
}
|
|
37
|
+
function themeSet(id) { LS.set('dshTheme', id); themeApply() }
|
|
38
|
+
themeApply()
|
|
39
|
+
|
|
40
|
+
/* ---------------- 状态 ---------------- */
|
|
41
|
+
const state = {
|
|
42
|
+
token: LS.get('token', ''),
|
|
43
|
+
server: '',
|
|
44
|
+
servers: [],
|
|
45
|
+
groups: ['默认'],
|
|
46
|
+
activeGroup: '默认',
|
|
47
|
+
autoSelect: { '默认': true },
|
|
48
|
+
groupActive: { '默认': '' },
|
|
49
|
+
serverLatency: {},
|
|
50
|
+
selectingServer: false,
|
|
51
|
+
sessions: [],
|
|
52
|
+
byId: new Map(),
|
|
53
|
+
current: null,
|
|
54
|
+
history: { seqs: new Set(), visible: [], hasMore: false, loading: false, minSeq: Infinity },
|
|
55
|
+
approvals: [],
|
|
56
|
+
questions: [],
|
|
57
|
+
questionModal: null,
|
|
58
|
+
streamsOk: { mux: false, host: false },
|
|
59
|
+
errCount: 0,
|
|
60
|
+
fs: { path: null, initial: null, loaded: false },
|
|
61
|
+
view: 'sessions'
|
|
62
|
+
}
|
|
63
|
+
const streams = {}
|
|
64
|
+
|
|
65
|
+
function esc(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])) }
|
|
66
|
+
function short(id) { return '…' + String(id).slice(-8) }
|
|
67
|
+
function fmtTime(ts) {
|
|
68
|
+
if (!ts) return '—'
|
|
69
|
+
const d = new Date(ts)
|
|
70
|
+
const p = n => String(n).padStart(2, '0')
|
|
71
|
+
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
|
72
|
+
}
|
|
73
|
+
function fmtTokens(n) {
|
|
74
|
+
n = Number(n) || 0
|
|
75
|
+
if (n >= 1e6) return (n / 1e6).toFixed(n >= 1e7 ? 0 : 1) + 'M'
|
|
76
|
+
if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e5 ? 0 : 1) + 'K'
|
|
77
|
+
return String(Math.round(n))
|
|
78
|
+
}
|
|
79
|
+
function fmtCost(n) { return '¥' + (Number(n) || 0).toFixed(2) }
|
|
80
|
+
function fmtSize(n) {
|
|
81
|
+
n = Number(n) || 0
|
|
82
|
+
if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(2) + ' GB'
|
|
83
|
+
if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB'
|
|
84
|
+
if (n >= 1024) return (n / 1024).toFixed(1) + ' KB'
|
|
85
|
+
return n + ' B'
|
|
86
|
+
}
|
|
87
|
+
function toast(text, kind = '') {
|
|
88
|
+
const el = $('toast')
|
|
89
|
+
el.textContent = text
|
|
90
|
+
el.className = 'ds-toast ' + kind
|
|
91
|
+
clearTimeout(toast._t)
|
|
92
|
+
toast._t = setTimeout(() => el.classList.add('hidden'), 2600)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ---------------- API ---------------- */
|
|
96
|
+
function apiUrl(path) { return (state.server || '') + path }
|
|
97
|
+
async function rpc(method, payload = {}) {
|
|
98
|
+
const res = await fetch(apiUrl('/api/' + method), {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' },
|
|
101
|
+
body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
|
|
102
|
+
})
|
|
103
|
+
if (res.status === 401) throw new Error('AUTH')
|
|
104
|
+
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
105
|
+
const full = await res.json()
|
|
106
|
+
if (!full?.result) throw new Error('bad response')
|
|
107
|
+
if (!full.result.ok) throw new Error(full.result.error?.message || 'dsh error')
|
|
108
|
+
return full.result.value
|
|
109
|
+
}
|
|
110
|
+
async function respond(rpcId, value) {
|
|
111
|
+
const res = await fetch(apiUrl('/api/respond'), {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' },
|
|
114
|
+
body: JSON.stringify({ type: 'client-response', rpcId, result: { ok: true, value } })
|
|
115
|
+
})
|
|
116
|
+
if (res.status === 401) throw new Error('AUTH')
|
|
117
|
+
const receipt = await res.json()
|
|
118
|
+
return receipt?.accepted === true
|
|
119
|
+
}
|
|
120
|
+
async function safeRpc(method, payload, errText) {
|
|
121
|
+
try { return await rpc(method, payload) }
|
|
122
|
+
catch (e) {
|
|
123
|
+
if (e.message === 'AUTH') { toast(t('ds.toastAuth'), 'err'); return null }
|
|
124
|
+
toast(errText ? `${errText}:${e.message}` : e.message, 'err')
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function uuid() {
|
|
129
|
+
try { return crypto.randomUUID() } catch { return 'id-' + Date.now() + '-' + Math.random().toString(36).slice(2) }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* ---------------- 多服务端分组管理(与 App 共用 servers-v2) ---------------- */
|
|
133
|
+
function newServerId() { return 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6) }
|
|
134
|
+
function ensureGroup(name) {
|
|
135
|
+
if (!name) name = '默认'
|
|
136
|
+
if (!state.groups.includes(name)) state.groups.push(name)
|
|
137
|
+
if (!(name in state.autoSelect)) state.autoSelect[name] = true
|
|
138
|
+
if (!(name in state.groupActive)) state.groupActive[name] = ''
|
|
139
|
+
return name
|
|
140
|
+
}
|
|
141
|
+
function groupServers(g) { return state.servers.filter(s => s.group === g) }
|
|
142
|
+
function activeServers() { return groupServers(state.activeGroup) }
|
|
143
|
+
|
|
144
|
+
function migrateServersV1() {
|
|
145
|
+
if (LS.get('servers-v2', null) !== null) return
|
|
146
|
+
let arr = null
|
|
147
|
+
try { arr = JSON.parse(LS.get('servers', '')) } catch {}
|
|
148
|
+
if (!Array.isArray(arr)) {
|
|
149
|
+
const legacy = LS.get('server', '')
|
|
150
|
+
arr = legacy ? [legacy] : []
|
|
151
|
+
}
|
|
152
|
+
const urls = arr.map(s => String(s || '').trim().replace(/\/+$/, '')).filter(s => /^https?:\/\//i.test(s))
|
|
153
|
+
state.servers = urls.map((url, i) => ({ id: 's' + (i + 1), url, note: '', group: '默认' }))
|
|
154
|
+
state.groups = ['默认']; state.activeGroup = '默认'
|
|
155
|
+
state.autoSelect = { '默认': true }; state.groupActive = { '默认': '' }
|
|
156
|
+
const active = LS.get('activeServer', '')
|
|
157
|
+
if (active === 'origin') state.server = ''
|
|
158
|
+
else {
|
|
159
|
+
const hit = state.servers.find(s => s.url === active)
|
|
160
|
+
state.server = hit ? hit.url : (state.servers[0]?.url || '')
|
|
161
|
+
state.groupActive['默认'] = hit ? hit.id : (state.servers[0]?.id || '')
|
|
162
|
+
}
|
|
163
|
+
saveServers()
|
|
164
|
+
}
|
|
165
|
+
function loadServers() {
|
|
166
|
+
let data = null
|
|
167
|
+
try { data = JSON.parse(LS.get('servers-v2', '')) } catch {}
|
|
168
|
+
if (!data || !Array.isArray(data.servers)) { migrateServersV1(); return }
|
|
169
|
+
state.servers = data.servers.filter(s => s && typeof s.url === 'string').map(s => ({ id: s.id || newServerId(), url: s.url.replace(/\/+$/, ''), note: s.note || '', group: s.group || '默认' }))
|
|
170
|
+
state.groups = Array.isArray(data.groups) && data.groups.length ? data.groups : ['默认']
|
|
171
|
+
state.activeGroup = state.groups.includes(data.activeGroup) ? data.activeGroup : '默认'
|
|
172
|
+
state.autoSelect = data.autoSelect || {}
|
|
173
|
+
state.groupActive = data.groupActive || {}
|
|
174
|
+
ensureGroup('默认')
|
|
175
|
+
for (const s of state.servers) ensureGroup(s.group)
|
|
176
|
+
const manual = state.groupActive[state.activeGroup]
|
|
177
|
+
const manualSrv = manual ? state.servers.find(s => s.id === manual) : null
|
|
178
|
+
state.server = manualSrv ? manualSrv.url : (activeServers()[0]?.url || '')
|
|
179
|
+
}
|
|
180
|
+
function saveServers() {
|
|
181
|
+
LS.set('servers-v2', JSON.stringify({
|
|
182
|
+
servers: state.servers, groups: state.groups, activeGroup: state.activeGroup,
|
|
183
|
+
autoSelect: state.autoSelect, groupActive: state.groupActive,
|
|
184
|
+
}))
|
|
185
|
+
}
|
|
186
|
+
function serverCandidates() {
|
|
187
|
+
const list = activeServers().map(s => s.url)
|
|
188
|
+
if (location.origin && !list.includes(location.origin)) list.push(location.origin)
|
|
189
|
+
return list
|
|
190
|
+
}
|
|
191
|
+
async function pingServer(base) {
|
|
192
|
+
const u = String(base || '').replace(/\/+$/, '')
|
|
193
|
+
if (!u) return Infinity
|
|
194
|
+
const t0 = performance.now()
|
|
195
|
+
const ctrl = new AbortController()
|
|
196
|
+
const timer = setTimeout(() => ctrl.abort(), 3500)
|
|
197
|
+
try {
|
|
198
|
+
const res = await fetch(u + '/health?t=' + Date.now(), { signal: ctrl.signal, cache: 'no-store' })
|
|
199
|
+
return res.ok ? Math.round(performance.now() - t0) : Infinity
|
|
200
|
+
} catch { return Infinity } finally { clearTimeout(timer) }
|
|
201
|
+
}
|
|
202
|
+
async function selectFastestServer({ silent = false, reconnect = true } = {}) {
|
|
203
|
+
if (state.selectingServer) return null
|
|
204
|
+
state.selectingServer = true
|
|
205
|
+
try {
|
|
206
|
+
if (!silent) toast(t('ds.speedTesting'))
|
|
207
|
+
const candidates = serverCandidates()
|
|
208
|
+
let chosen = ''
|
|
209
|
+
let best = null
|
|
210
|
+
let ms = Infinity
|
|
211
|
+
if (state.autoSelect[state.activeGroup] !== false) {
|
|
212
|
+
for (const u of candidates) state.serverLatency[u] = await pingServer(u)
|
|
213
|
+
best = candidates.filter(u => Number.isFinite(state.serverLatency[u])).sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0] || null
|
|
214
|
+
chosen = best || (state.server || '')
|
|
215
|
+
ms = best ? state.serverLatency[best] : Infinity
|
|
216
|
+
} else {
|
|
217
|
+
const manual = state.groupActive[state.activeGroup]
|
|
218
|
+
const manualSrv = manual ? state.servers.find(s => s.id === manual) : null
|
|
219
|
+
chosen = manualSrv ? manualSrv.url : (activeServers()[0]?.url || '')
|
|
220
|
+
if (chosen) { state.serverLatency[chosen] = await pingServer(chosen); ms = state.serverLatency[chosen] }
|
|
221
|
+
}
|
|
222
|
+
renderServers()
|
|
223
|
+
if (chosen !== state.server) {
|
|
224
|
+
state.server = chosen
|
|
225
|
+
if (best) { const srv = state.servers.find(s => s.url === best); if (srv) state.groupActive[state.activeGroup] = srv.id }
|
|
226
|
+
saveServers()
|
|
227
|
+
if (!silent) {
|
|
228
|
+
if (chosen) toast(t('ds.speedSwitched', { url: chosen, ms: Number.isFinite(ms) ? ms : 0 }), 'ok')
|
|
229
|
+
}
|
|
230
|
+
if (reconnect && state.token) { openStreams(); refreshSessions() }
|
|
231
|
+
} else if (!silent) {
|
|
232
|
+
if (best) toast(t('ds.speedAlreadyBest', { url: chosen, ms: state.serverLatency[best] }), 'ok')
|
|
233
|
+
else if (chosen) toast(t('ds.speedManualUsing', { url: chosen, ms: Number.isFinite(ms) ? ms : '—' }), 'ok')
|
|
234
|
+
else toast(t('ds.speedAllDown'), 'err')
|
|
235
|
+
}
|
|
236
|
+
return chosen
|
|
237
|
+
} finally { state.selectingServer = false }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function serverTitle(s) { return s.note || s.url }
|
|
241
|
+
function renderGroupSelect() {
|
|
242
|
+
const label = $('group-select-label')
|
|
243
|
+
const menu = $('group-select-menu')
|
|
244
|
+
if (!label || !menu) return
|
|
245
|
+
label.textContent = state.activeGroup
|
|
246
|
+
menu.innerHTML = state.groups.map(g => `<button type="button" class="ds-group-option ${g === state.activeGroup ? 'current' : ''}" data-group-option="${esc(g)}">${esc(g)}${g === state.activeGroup ? ' ✓' : ''}</button>`).join('')
|
|
247
|
+
menu.querySelectorAll('[data-group-option]').forEach(b => b.addEventListener('click', () => {
|
|
248
|
+
closeGroupMenu()
|
|
249
|
+
if (b.dataset.groupOption !== state.activeGroup) switchGroup(b.dataset.groupOption)
|
|
250
|
+
}))
|
|
251
|
+
}
|
|
252
|
+
function toggleGroupMenu() { $('group-select-menu').classList.toggle('hidden') }
|
|
253
|
+
function closeGroupMenu() { $('group-select-menu').classList.add('hidden') }
|
|
254
|
+
|
|
255
|
+
function renderServers() {
|
|
256
|
+
const box = $('server-list')
|
|
257
|
+
if (!box) return
|
|
258
|
+
renderGroupSelect()
|
|
259
|
+
box.innerHTML = state.groups.map(g => {
|
|
260
|
+
const list = groupServers(g)
|
|
261
|
+
const auto = state.autoSelect[g] !== false
|
|
262
|
+
const activeManual = state.groupActive[g] || ''
|
|
263
|
+
return `<div class="ds-srv-group" data-group="${esc(g)}">
|
|
264
|
+
<div class="ds-srv-head">
|
|
265
|
+
<button class="ds-srv-name" data-group-name="${esc(g)}" title="${t('ds.groupsSwitchHint')}">${g === state.activeGroup ? '▾' : '▸'} ${esc(g)} <span class="ds-srv-count">${list.length}</span></button>
|
|
266
|
+
<button class="ds-mini" data-speed-group="${esc(g)}" title="${t('ds.speedTest')}">⚡</button>
|
|
267
|
+
<label class="ds-switch" title="${t('ds.groupsAutoSelect')}"><input type="checkbox" data-auto-group="${esc(g)}" ${auto ? 'checked' : ''}><span class="ds-slider"></span></label>
|
|
268
|
+
${g !== '默认' ? `<button class="ds-mini" data-del-group="${esc(g)}" title="${t('ds.groupsDelete')}">✕</button>` : ''}
|
|
269
|
+
</div>
|
|
270
|
+
<div class="ds-srv-body ${g === state.activeGroup ? '' : 'hidden'}">
|
|
271
|
+
${list.map(s => {
|
|
272
|
+
const ms = state.serverLatency[s.url]
|
|
273
|
+
let badge = `<span class="ds-server-badge">${t('ds.serversUntested')}</span>`
|
|
274
|
+
if (Number.isFinite(ms)) badge = `<span class="ds-server-badge ${s.url === state.server ? 'good' : ''}">${ms}ms${s.url === state.server ? t('ds.serversCurrent') : ''}</span>`
|
|
275
|
+
else if (ms !== undefined) badge = `<span class="ds-server-badge bad">${t('ds.serversUnreachable')}</span>`
|
|
276
|
+
const activeInGroup = auto ? s.url === state.server : s.id === activeManual
|
|
277
|
+
return `<div class="ds-server-row ${activeInGroup ? 'active' : ''}" data-use-server="${esc(s.id)}">
|
|
278
|
+
<span class="ds-server-main"><span class="ds-server-note">${esc(serverTitle(s))}</span>${s.note ? `<span class="ds-server-url">${esc(s.url)}</span>` : ''}</span>${badge}
|
|
279
|
+
<button class="ds-mini" data-edit-server="${esc(s.id)}" title="${t('ds.serversEdit')}">✎</button>
|
|
280
|
+
<button class="ds-mini" data-del-server="${esc(s.id)}" title="${t('ds.serversDelete')}">✕</button>
|
|
281
|
+
</div>`
|
|
282
|
+
}).join('') || `<div class="ds-empty">${t('ds.groupsNoServer')}</div>`}
|
|
283
|
+
</div>
|
|
284
|
+
</div>`
|
|
285
|
+
}).join('')
|
|
286
|
+
box.querySelectorAll('[data-group-name]').forEach(b => {
|
|
287
|
+
b.addEventListener('click', () => switchGroup(b.dataset.groupName))
|
|
288
|
+
b.addEventListener('dblclick', () => renameGroup(b.dataset.groupName))
|
|
289
|
+
})
|
|
290
|
+
box.querySelectorAll('[data-speed-group]').forEach(b => b.addEventListener('click', () => { state.activeGroup = b.dataset.speedGroup; saveServers(); selectFastestServer({ silent: false }) }))
|
|
291
|
+
box.querySelectorAll('[data-auto-group]').forEach(chk => chk.addEventListener('change', (e) => {
|
|
292
|
+
const g = e.target.dataset.autoGroup
|
|
293
|
+
state.autoSelect[g] = e.target.checked
|
|
294
|
+
saveServers()
|
|
295
|
+
if (g === state.activeGroup) selectFastestServer({ silent: false })
|
|
296
|
+
toast(t(e.target.checked ? 'ds.groupsAutoOn' : 'ds.groupsAutoOff', { group: g }), 'ok')
|
|
297
|
+
}))
|
|
298
|
+
box.querySelectorAll('[data-del-group]').forEach(b => b.addEventListener('click', () => deleteGroup(b.dataset.delGroup)))
|
|
299
|
+
box.querySelectorAll('[data-del-server]').forEach(b => b.addEventListener('click', (e) => { e.stopPropagation(); removeServer(b.dataset.delServer) }))
|
|
300
|
+
box.querySelectorAll('[data-edit-server]').forEach(b => b.addEventListener('click', (e) => { e.stopPropagation(); editServer(b.dataset.editServer) }))
|
|
301
|
+
box.querySelectorAll('[data-use-server]').forEach(row => row.addEventListener('click', (e) => {
|
|
302
|
+
if (e.target.closest('button')) return
|
|
303
|
+
const id = row.dataset.useServer
|
|
304
|
+
const s = state.servers.find(x => x.id === id)
|
|
305
|
+
if (!s) return
|
|
306
|
+
if (state.autoSelect[s.group] !== false) { editServer(id); return }
|
|
307
|
+
state.groupActive[s.group] = id
|
|
308
|
+
state.activeGroup = s.group
|
|
309
|
+
state.server = s.url
|
|
310
|
+
saveServers()
|
|
311
|
+
renderServers()
|
|
312
|
+
toast(t('ds.serversManualSelected', { url: serverTitle(s) }), 'ok')
|
|
313
|
+
if (state.token) { openStreams(); refreshSessions() }
|
|
314
|
+
}))
|
|
315
|
+
const cur = state.servers.find(s => s.url === state.server)
|
|
316
|
+
const curGroup = cur ? cur.group : state.activeGroup
|
|
317
|
+
const curLabel = cur ? (cur.note || cur.url) : (state.server || t('ds.origin'))
|
|
318
|
+
const curMs = state.serverLatency[state.server]
|
|
319
|
+
$('server-desc').textContent = t('ds.serversCurrentDesc', { group: curGroup, url: curLabel, ms: Number.isFinite(curMs) ? curMs + 'ms' : '—' })
|
|
320
|
+
updateConn()
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function addServer() {
|
|
324
|
+
const input = $('server-input')
|
|
325
|
+
let raw = (input?.value || '').trim().replace(/\/+$/, '')
|
|
326
|
+
if (!raw) return toast(t('ds.serversNeedAddress'), 'err')
|
|
327
|
+
try { const u = new URL(raw); if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad') }
|
|
328
|
+
catch { return toast(t('ds.serversBadProtocol'), 'err') }
|
|
329
|
+
if (state.servers.some(s => s.url === raw)) return toast(t('ds.serversDuplicate'), 'err')
|
|
330
|
+
const note = (prompt(t('ds.serversPromptNote')) || '').trim()
|
|
331
|
+
state.servers.push({ id: newServerId(), url: raw, note, group: state.activeGroup })
|
|
332
|
+
saveServers()
|
|
333
|
+
if (input) input.value = ''
|
|
334
|
+
renderServers()
|
|
335
|
+
toast(t('ds.serversAdded'), 'ok')
|
|
336
|
+
if (state.token) selectFastestServer({ silent: false })
|
|
337
|
+
}
|
|
338
|
+
function editServer(id) {
|
|
339
|
+
const s = state.servers.find(x => x.id === id)
|
|
340
|
+
if (!s) return
|
|
341
|
+
const raw = (prompt(t('ds.serversPromptEditUrl'), s.url) || '').trim().replace(/\/+$/, '')
|
|
342
|
+
if (!raw) return
|
|
343
|
+
try { const u = new URL(raw); if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad') }
|
|
344
|
+
catch { return toast(t('ds.serversBadProtocol'), 'err') }
|
|
345
|
+
if (state.servers.some(x => x.id !== id && x.url === raw)) return toast(t('ds.serversDuplicate'), 'err')
|
|
346
|
+
const note = prompt(t('ds.serversPromptEditNote', { url: raw }), s.note || '')
|
|
347
|
+
if (note === null) return
|
|
348
|
+
const group = prompt(t('ds.serversPromptEditGroup'), s.group || '默认')
|
|
349
|
+
if (group === null) return
|
|
350
|
+
const wasActive = state.server === s.url
|
|
351
|
+
s.url = raw; s.note = note.trim(); s.group = ensureGroup(group.trim() || '默认')
|
|
352
|
+
if (wasActive) state.server = raw
|
|
353
|
+
saveServers(); renderServers(); toast(t('ds.serversEdited'), 'ok')
|
|
354
|
+
if (wasActive && state.token) selectFastestServer({ silent: true })
|
|
355
|
+
}
|
|
356
|
+
function removeServer(id) {
|
|
357
|
+
const s = state.servers.find(x => x.id === id)
|
|
358
|
+
if (!s) return
|
|
359
|
+
state.servers = state.servers.filter(x => x.id !== id)
|
|
360
|
+
const wasActive = state.server === s.url
|
|
361
|
+
for (const g of state.groups) if (state.groupActive[g] === id) state.groupActive[g] = ''
|
|
362
|
+
saveServers(); renderServers()
|
|
363
|
+
if (wasActive) { toast(t('ds.serversRemovedActive')); selectFastestServer({ silent: true }) }
|
|
364
|
+
}
|
|
365
|
+
function switchGroup(name) {
|
|
366
|
+
if (!state.groups.includes(name)) return
|
|
367
|
+
state.activeGroup = name
|
|
368
|
+
saveServers(); renderServers()
|
|
369
|
+
toast(t('ds.groupsSwitched', { group: name }), 'ok')
|
|
370
|
+
selectFastestServer({ silent: false })
|
|
371
|
+
}
|
|
372
|
+
function addGroup() {
|
|
373
|
+
const name = (prompt(t('ds.groupsPromptAdd')) || '').trim()
|
|
374
|
+
if (!name) return
|
|
375
|
+
if (state.groups.includes(name)) return toast(t('ds.groupsDuplicate'), 'err')
|
|
376
|
+
ensureGroup(name); state.activeGroup = name
|
|
377
|
+
saveServers(); renderServers(); toast(t('ds.groupsAdded', { group: name }), 'ok')
|
|
378
|
+
}
|
|
379
|
+
function renameGroup(oldName) {
|
|
380
|
+
if (oldName === '默认') return
|
|
381
|
+
const name = (prompt(t('ds.groupsPromptRename', { group: oldName }), oldName) || '').trim()
|
|
382
|
+
if (!name || name === oldName) return
|
|
383
|
+
if (state.groups.includes(name)) return toast(t('ds.groupsDuplicate'), 'err')
|
|
384
|
+
const idx = state.groups.indexOf(oldName)
|
|
385
|
+
state.groups[idx] = name
|
|
386
|
+
for (const s of state.servers) if (s.group === oldName) s.group = name
|
|
387
|
+
if (state.activeGroup === oldName) state.activeGroup = name
|
|
388
|
+
state.autoSelect[name] = state.autoSelect[oldName] !== false
|
|
389
|
+
delete state.autoSelect[oldName]
|
|
390
|
+
state.groupActive[name] = state.groupActive[oldName] || ''
|
|
391
|
+
delete state.groupActive[oldName]
|
|
392
|
+
saveServers(); renderServers(); toast(t('ds.groupsRenamed', { group: name }), 'ok')
|
|
393
|
+
}
|
|
394
|
+
function deleteGroup(name) {
|
|
395
|
+
if (name === '默认') return toast(t('ds.groupsCannotDeleteDefault'), 'err')
|
|
396
|
+
if (!state.groups.includes(name)) return
|
|
397
|
+
if (!confirm(t('ds.groupsConfirmDelete', { group: name }))) return
|
|
398
|
+
state.groups = state.groups.filter(g => g !== name)
|
|
399
|
+
for (const s of state.servers) if (s.group === name) s.group = '默认'
|
|
400
|
+
delete state.autoSelect[name]; delete state.groupActive[name]
|
|
401
|
+
if (state.activeGroup === name) state.activeGroup = '默认'
|
|
402
|
+
saveServers(); renderServers(); toast(t('ds.groupsDeleted'), 'ok')
|
|
403
|
+
if (state.token) selectFastestServer({ silent: true })
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/* ---------------- 事件流 ---------------- */
|
|
407
|
+
function openStreams() {
|
|
408
|
+
if (!state.token) return
|
|
409
|
+
openStream('mux', onMuxFrame, true)
|
|
410
|
+
openStream('host', onHostFrame, false)
|
|
411
|
+
}
|
|
412
|
+
function openStream(kind, handler, refreshOnOpen) {
|
|
413
|
+
if (!state.token) return
|
|
414
|
+
let base
|
|
415
|
+
if (state.server) base = state.server.replace(/^http/, 'ws')
|
|
416
|
+
else { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; base = `${proto}//${location.host}` }
|
|
417
|
+
const ws = new WebSocket(`${base}/api/events.${kind}?token=${encodeURIComponent(state.token)}&client=web`)
|
|
418
|
+
try { streams[kind]?.close() } catch {}
|
|
419
|
+
streams[kind] = ws
|
|
420
|
+
ws.onopen = () => {
|
|
421
|
+
state.streamsOk[kind] = true
|
|
422
|
+
state.errCount = 0
|
|
423
|
+
updateConn()
|
|
424
|
+
if (kind === 'mux') { state.approvals = []; state.questions = []; renderNotifStack() }
|
|
425
|
+
if (refreshOnOpen) refreshSessions()
|
|
426
|
+
}
|
|
427
|
+
ws.onmessage = (msg) => {
|
|
428
|
+
state.streamsOk[kind] = true
|
|
429
|
+
updateConn()
|
|
430
|
+
try { handler(JSON.parse(msg.data)) } catch {}
|
|
431
|
+
}
|
|
432
|
+
ws.onclose = () => {
|
|
433
|
+
state.streamsOk[kind] = false
|
|
434
|
+
state.errCount++
|
|
435
|
+
updateConn()
|
|
436
|
+
if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
|
|
437
|
+
if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), 1200)
|
|
438
|
+
}
|
|
439
|
+
ws.onerror = () => { try { ws.close() } catch {} }
|
|
440
|
+
}
|
|
441
|
+
function onMuxFrame(full) {
|
|
442
|
+
const f = full.payload
|
|
443
|
+
if (!f) return
|
|
444
|
+
if (f.type === 'session/event') return onSessionEvent(f.sessionId, f.event)
|
|
445
|
+
if (f.type === 'approval/requested') {
|
|
446
|
+
state.approvals = state.approvals.filter(a => a.approvalId !== f.approvalId)
|
|
447
|
+
state.approvals.push({ ...f, rpcId: full.rpcId })
|
|
448
|
+
renderNotifStack()
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
if (f.type === 'approval/resolved') { state.approvals = state.approvals.filter(a => a.approvalId !== f.approvalId); renderNotifStack(); return }
|
|
452
|
+
if (f.type === 'question/requested') {
|
|
453
|
+
state.questions = state.questions.filter(q => q.rpcId !== full.rpcId)
|
|
454
|
+
state.questions.push({ ...f, rpcId: full.rpcId })
|
|
455
|
+
renderNotifStack()
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
if (f.type === 'question/resolved') { state.questions = state.questions.filter(q => q.rpcId !== f.questionRpcId); renderNotifStack(); return }
|
|
459
|
+
if (f.type === 'session/projection') { applyProjection(f.sessionId, f.key, f.value, f.seq); return }
|
|
460
|
+
if (f.type === 'stream/error') toast(f.error?.message || 'stream error', 'err')
|
|
461
|
+
}
|
|
462
|
+
function onHostFrame(full) {
|
|
463
|
+
const f = full.payload
|
|
464
|
+
if (!f) return
|
|
465
|
+
if (['host/session-added', 'host/session-removed', 'host/workspace-changed', 'host/workspace-removed', 'host/workspace-order-changed', 'host/archived-sessions-changed'].includes(f.type)) refreshSessions()
|
|
466
|
+
if (f.type === 'host/session-status') {
|
|
467
|
+
const s = state.byId.get(f.sessionId)
|
|
468
|
+
if (s) { s.running = f.running; if (state.current === f.sessionId) renderSessions() }
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
function applyProjection(sessionId, key, value, seq) {
|
|
472
|
+
const s = state.byId.get(sessionId)
|
|
473
|
+
if (s) {
|
|
474
|
+
s.projections = s.projections || { asOfSeq: 0, values: {} }
|
|
475
|
+
s.projections.values = s.projections.values || {}
|
|
476
|
+
s.projections.values[key] = value
|
|
477
|
+
s.projections.asOfSeq = Math.max(s.projections.asOfSeq || 0, seq || 0)
|
|
478
|
+
}
|
|
479
|
+
if (state.current === sessionId) renderSessions()
|
|
480
|
+
if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) refreshSessions()
|
|
481
|
+
}
|
|
482
|
+
function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
|
|
483
|
+
function titleOf(s) { return proj(s, 'title') || short(s.sessionId) }
|
|
484
|
+
function onSessionEvent(sessionId, event) {
|
|
485
|
+
if (state.current === sessionId && event) {
|
|
486
|
+
const h = state.history
|
|
487
|
+
const seq = event.seq
|
|
488
|
+
if (seq != null && !h.seqs.has(seq) && shouldShowEvent(event.type)) {
|
|
489
|
+
h.seqs.add(seq)
|
|
490
|
+
h.visible.push({ seq, event })
|
|
491
|
+
h.visible.sort((a, b) => a.seq - b.seq)
|
|
492
|
+
renderHistory()
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/* ---------------- 会话 ---------------- */
|
|
498
|
+
async function refreshSessions() {
|
|
499
|
+
const v = await safeRpc('session.list', {}, '')
|
|
500
|
+
if (!v) { renderSessions(); return }
|
|
501
|
+
state.sessions = v.items || []
|
|
502
|
+
state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
|
|
503
|
+
renderSessions()
|
|
504
|
+
}
|
|
505
|
+
function renderSessions() {
|
|
506
|
+
const items = [...state.sessions].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
|
507
|
+
const html = items.map(s => {
|
|
508
|
+
const title = titleOf(s)
|
|
509
|
+
return `<button class="ds-session-item ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
|
|
510
|
+
<span class="ds-session-title">${esc(title)}</span>
|
|
511
|
+
<span class="ds-session-meta"><span class="ds-session-dot ${s.running ? 'running' : ''}"></span>${fmtTime(s.updatedAt)}</span>
|
|
512
|
+
</button>`
|
|
513
|
+
}).join('') || `<div class="ds-empty">${t('ds.sessionsEmpty')}</div>`
|
|
514
|
+
$('session-list').innerHTML = html
|
|
515
|
+
$('mobile-session-list').innerHTML = html
|
|
516
|
+
document.querySelectorAll('[data-id]').forEach(b => b.addEventListener('click', () => openSession(b.dataset.id)))
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function openSession(id) {
|
|
520
|
+
state.current = id
|
|
521
|
+
state.history = { seqs: new Set(), visible: [], hasMore: false, loading: false, minSeq: Infinity }
|
|
522
|
+
showView('view-chat')
|
|
523
|
+
$('ds-title').textContent = titleOf(state.byId.get(id)) || t('ds.sessions')
|
|
524
|
+
$('history').innerHTML = `<div class="ds-empty">${t('ds.historyLoading')}</div>`
|
|
525
|
+
renderSessions()
|
|
526
|
+
await loadHistory()
|
|
527
|
+
}
|
|
528
|
+
function closeSession() {
|
|
529
|
+
state.current = null
|
|
530
|
+
state.history = { seqs: new Set(), visible: [], hasMore: false, loading: false, minSeq: Infinity }
|
|
531
|
+
showView('view-sessions')
|
|
532
|
+
}
|
|
533
|
+
async function loadHistory() {
|
|
534
|
+
const id = state.current
|
|
535
|
+
if (!id || state.history.loading) return
|
|
536
|
+
state.history.loading = true
|
|
537
|
+
let v
|
|
538
|
+
try { v = await rpc('session.history', { sessionId: id, maxMessages: 60 }) }
|
|
539
|
+
catch (e) {
|
|
540
|
+
state.history.loading = false
|
|
541
|
+
if (e.message === 'AUTH') return
|
|
542
|
+
$('history').innerHTML = `<div class="ds-empty">${e.message}</div>`
|
|
543
|
+
return
|
|
544
|
+
}
|
|
545
|
+
for (const entry of v.events || []) {
|
|
546
|
+
const ev = entry?.event
|
|
547
|
+
const seq = ev?.seq
|
|
548
|
+
if (seq == null || state.history.seqs.has(seq)) continue
|
|
549
|
+
if (!shouldShowEvent(ev.type)) continue
|
|
550
|
+
state.history.seqs.add(seq)
|
|
551
|
+
state.history.visible.push({ seq, event: ev })
|
|
552
|
+
}
|
|
553
|
+
state.history.visible.sort((a, b) => a.seq - b.seq)
|
|
554
|
+
state.history.hasMore = !!v.hasMore
|
|
555
|
+
state.history.loading = false
|
|
556
|
+
renderHistory()
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const INTERESTING_EVENTS = new Set([
|
|
560
|
+
'user/message', 'assistant/message', 'tool/call', 'tool/result',
|
|
561
|
+
'agent/status', 'checkpoint/created', 'compaction/complete', 'compaction/summary',
|
|
562
|
+
'goal/created', 'goal/updated', 'goal/completed', 'goal/cleared',
|
|
563
|
+
'todo/updated', 'plan/updated', 'question/asked', 'question/resolved',
|
|
564
|
+
'approval/asked', 'approval/resolved', 'session/title', 'title'
|
|
565
|
+
])
|
|
566
|
+
function shouldShowEvent(type) { return INTERESTING_EVENTS.has(type) }
|
|
567
|
+
function safeJson(v) { try { return JSON.stringify(v, null, 2) } catch { return String(v) } }
|
|
568
|
+
function blockHtml(b) {
|
|
569
|
+
if (!b) return ''
|
|
570
|
+
if (b.type === 'text') return `<span>${esc(b.text ?? '')}</span>`
|
|
571
|
+
if (b.type === 'reasoning') return `<span style="opacity:.75">${esc(b.text ?? '')}</span>`
|
|
572
|
+
if (b.type === 'tool-call') return `<div>🔧 ${esc(b.name || '')}</div>`
|
|
573
|
+
if (b.type === 'tool-result') return `<div>📦</div>`
|
|
574
|
+
if (b.type === 'image') return `<div>🖼</div>`
|
|
575
|
+
return ''
|
|
576
|
+
}
|
|
577
|
+
function eventHtml(entry) {
|
|
578
|
+
const ev = entry.event || {}
|
|
579
|
+
const data = ev.data || {}
|
|
580
|
+
const type = ev.type || 'event'
|
|
581
|
+
if (!shouldShowEvent(type)) return ''
|
|
582
|
+
if (type === 'user/message' || type === 'assistant/message') {
|
|
583
|
+
const msg = data.message || {}
|
|
584
|
+
const role = data.role || msg.role || (type.startsWith('user') ? 'user' : 'assistant')
|
|
585
|
+
const blocks = msg.content || data.content || []
|
|
586
|
+
const text = blocks.map(blockHtml).join('')
|
|
587
|
+
return `<div class="ds-msg ${esc(role)}"><div class="role">${esc(role === 'user' ? t('ds.role.me') : t('ds.role.dsh'))}</div>${text || '<span style="opacity:.6">…</span>'}</div>`
|
|
588
|
+
}
|
|
589
|
+
if (type === 'tool/call') {
|
|
590
|
+
const name = data.name || data.toolName || t('ds.toolDefault')
|
|
591
|
+
const step = (data.turn != null ? ` · turn ${data.turn}` : '') + (data.step != null ? `.${data.step}` : '')
|
|
592
|
+
return `<details class="ds-tool"><summary>🔧 ${esc(name)}${esc(step)}</summary><pre>${esc(safeJson(data.arguments ?? data.args ?? data.input ?? data))}</pre></details>`
|
|
593
|
+
}
|
|
594
|
+
if (type === 'tool/result') {
|
|
595
|
+
const callId = data.callId || data.message?.source?.callId || ''
|
|
596
|
+
const text = data.text || data.content || safeJson(data.message?.content ?? data)
|
|
597
|
+
return `<details class="ds-tool"><summary>📦 ${esc(callId)}</summary><pre>${esc(safeJson(text))}</pre></details>`
|
|
598
|
+
}
|
|
599
|
+
if (type === 'approval/asked') return `<div class="ds-tool">🔐 ${esc(t('ds.approvalTitle'))} · ${esc(data.toolName || '')}</div>`
|
|
600
|
+
if (type === 'question/asked') return `<div class="ds-tool">❓ ${esc(data.question || '')}</div>`
|
|
601
|
+
return `<div class="ds-tool">${esc(type)}</div>`
|
|
602
|
+
}
|
|
603
|
+
function renderHistory() {
|
|
604
|
+
const box = $('history')
|
|
605
|
+
const items = state.history.visible
|
|
606
|
+
box.innerHTML = items.map(eventHtml).join('') || `<div class="ds-empty">${t('ds.historyEmpty')}</div>`
|
|
607
|
+
box.scrollTop = box.scrollHeight
|
|
608
|
+
}
|
|
609
|
+
async function sendMessage() {
|
|
610
|
+
const input = $('composer')
|
|
611
|
+
const text = input.value.trim()
|
|
612
|
+
if (!text || !state.current) return
|
|
613
|
+
input.value = ''
|
|
614
|
+
const v = await safeRpc('session.prompt', { sessionId: state.current, text }, '')
|
|
615
|
+
if (v) toast(t('ds.toastSent'), 'ok')
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/* ---------------- 审批/提问通知卡片栈 ---------------- */
|
|
619
|
+
function serverLabel() {
|
|
620
|
+
const cur = state.servers.find(s => s.url === state.server)
|
|
621
|
+
return cur ? (cur.note || cur.url) : (state.server || location.host)
|
|
622
|
+
}
|
|
623
|
+
function renderNotifStack() {
|
|
624
|
+
const stack = $('notif-stack')
|
|
625
|
+
const items = [
|
|
626
|
+
...state.approvals.map(a => ({ kind: 'approval', a })),
|
|
627
|
+
...state.questions.map(q => ({ kind: 'question', q }))
|
|
628
|
+
]
|
|
629
|
+
stack.innerHTML = items.map(it => {
|
|
630
|
+
if (it.kind === 'approval') {
|
|
631
|
+
const a = it.a
|
|
632
|
+
const reason = a.reason || a.arguments ? safeJson(a.arguments ?? a.reason ?? '') : ''
|
|
633
|
+
return `<div class="ds-notif-card" data-approval="${esc(a.approvalId)}" tabindex="0">
|
|
634
|
+
<div class="ds-notif-head">🔐 ${t('ds.approvalTitle')} · ${esc(serverLabel())} · ${fmtTime(a.time || Date.now())}</div>
|
|
635
|
+
<div class="ds-notif-title">${esc(a.toolName || t('ds.toolDefault'))}</div>
|
|
636
|
+
<div class="ds-notif-body">${esc(reason.slice(0, 500))}</div>
|
|
637
|
+
<div class="ds-notif-actions">
|
|
638
|
+
<button class="ds-btn allow" data-approve="1">${t('ds.allow')}</button>
|
|
639
|
+
<button class="ds-btn reject" data-approve="0">${t('ds.reject')}</button>
|
|
640
|
+
<button class="ds-btn" data-ignore-approval>${t('ds.ignore')}</button>
|
|
641
|
+
</div>
|
|
642
|
+
</div>`
|
|
643
|
+
}
|
|
644
|
+
const q = it.q
|
|
645
|
+
const text = q.questions?.map(x => x.question).join(' / ') || ''
|
|
646
|
+
return `<div class="ds-notif-card question" data-question="${esc(q.rpcId)}" tabindex="0">
|
|
647
|
+
<div class="ds-notif-head">❓ ${t('ds.questionNotify')} · ${esc(serverLabel())} · ${fmtTime(q.time || Date.now())}</div>
|
|
648
|
+
<div class="ds-notif-title">${esc(text.slice(0, 120))}</div>
|
|
649
|
+
<div class="ds-notif-actions">
|
|
650
|
+
<button class="ds-btn" data-open-question>${t('ds.submit')}</button>
|
|
651
|
+
<button class="ds-btn" data-ignore-question>${t('ds.ignore')}</button>
|
|
652
|
+
</div>
|
|
653
|
+
</div>`
|
|
654
|
+
}).join('')
|
|
655
|
+
stack.querySelectorAll('[data-approve]').forEach(b => b.addEventListener('click', () => approveApproval(b.closest('[data-approval]')?.dataset.approval || '', b.dataset.approve === '1')))
|
|
656
|
+
stack.querySelectorAll('[data-ignore-approval]').forEach(b => b.addEventListener('click', () => { toast(t('ds.ignored'), 'ok') }))
|
|
657
|
+
stack.querySelectorAll('[data-open-question]').forEach(b => b.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === b.closest('[data-question]')?.dataset.question))))
|
|
658
|
+
stack.querySelectorAll('[data-ignore-question]').forEach(b => b.addEventListener('click', () => { toast(t('ds.ignored'), 'ok') }))
|
|
659
|
+
// Esc 忽略最上方卡片
|
|
660
|
+
stack.querySelectorAll('.ds-notif-card').forEach(card => card.addEventListener('keydown', (e) => {
|
|
661
|
+
if (e.key === 'Escape') toast(t('ds.ignored'), 'ok')
|
|
662
|
+
}))
|
|
663
|
+
}
|
|
664
|
+
async function approveApproval(id, allow) {
|
|
665
|
+
const a = state.approvals.find(x => x.approvalId === id)
|
|
666
|
+
if (!a) return
|
|
667
|
+
const ok = await respond(a.rpcId, { sessionId: a.sessionId, approvalId: a.approvalId, outcome: allow ? 'allowed-once' : 'rejected' })
|
|
668
|
+
toast(ok ? (allow ? t('ds.allowed') : t('ds.rejected')) : t('ds.stale'), ok ? 'ok' : 'err')
|
|
669
|
+
state.approvals = state.approvals.filter(x => x.approvalId !== id)
|
|
670
|
+
renderNotifStack()
|
|
671
|
+
}
|
|
672
|
+
function openQuestionModal(q) {
|
|
673
|
+
if (!q) return
|
|
674
|
+
state.questionModal = q
|
|
675
|
+
$('question-body').innerHTML = q.questions.map((item, i) => `
|
|
676
|
+
<div class="ds-q-item">
|
|
677
|
+
<div class="ds-q-text">${esc(item.header ? item.header + ':' : '')}${esc(item.question)}</div>
|
|
678
|
+
${(item.options || []).map((o, j) => `
|
|
679
|
+
<label class="ds-q-option"><input type="${item.multiSelect ? 'checkbox' : 'radio'}" name="q${i}" value="${esc(o.label)}"><span>${esc(o.label)}${o.description ? `<div class="muted">${esc(o.description)}</div>` : ''}</span></label>`).join('')}
|
|
680
|
+
<textarea rows="2" placeholder="${t('ds.questionCustom')}" data-qcustom="${i}"></textarea>
|
|
681
|
+
</div>`).join('')
|
|
682
|
+
$('modal-question').classList.remove('hidden')
|
|
683
|
+
}
|
|
684
|
+
async function submitQuestion() {
|
|
685
|
+
const q = state.questionModal
|
|
686
|
+
if (!q) return
|
|
687
|
+
const answers = q.questions.map((item, i) => {
|
|
688
|
+
const sel = [...$('question-body').querySelectorAll(`input[name="q${i}"]:checked`)].map(x => x.value)
|
|
689
|
+
const custom = $('question-body').querySelector(`[data-qcustom="${i}"]`)?.value?.trim()
|
|
690
|
+
const ans = { id: item.id, selected: sel }
|
|
691
|
+
if (custom) ans.custom = custom
|
|
692
|
+
if (!sel.length && !custom) return null
|
|
693
|
+
return ans
|
|
694
|
+
}).filter(Boolean)
|
|
695
|
+
if (!answers.length) return toast(t('ds.questionNeedAnswer'), 'err')
|
|
696
|
+
const ok = await respond(q.rpcId, { sessionId: q.sessionId, answer: { answers } })
|
|
697
|
+
if (ok) {
|
|
698
|
+
toast(t('ds.questionSubmitted'), 'ok')
|
|
699
|
+
$('modal-question').classList.add('hidden')
|
|
700
|
+
state.questions = state.questions.filter(x => x.rpcId !== q.rpcId)
|
|
701
|
+
renderNotifStack()
|
|
702
|
+
} else toast(t('ds.stale'), 'err')
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/* ---------------- 文件传输 ---------------- */
|
|
706
|
+
function fsApiUrl(sub, params = {}) {
|
|
707
|
+
const u = new URL(apiUrl('/fs' + sub), location.href)
|
|
708
|
+
for (const [k, v] of Object.entries(params)) {
|
|
709
|
+
if (v != null && v !== '') u.searchParams.set(k, v)
|
|
710
|
+
}
|
|
711
|
+
return u.href
|
|
712
|
+
}
|
|
713
|
+
function fsHeaders() {
|
|
714
|
+
return { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' }
|
|
715
|
+
}
|
|
716
|
+
function fsParent(p) {
|
|
717
|
+
if (!p) return null
|
|
718
|
+
const parts = String(p).split('/').filter(Boolean)
|
|
719
|
+
parts.pop()
|
|
720
|
+
return parts.length ? '/' + parts.join('/') : '/'
|
|
721
|
+
}
|
|
722
|
+
async function loadFs(dir, silent) {
|
|
723
|
+
if (!state.token) {
|
|
724
|
+
$('fs-path').textContent = t('ds.toastAuth')
|
|
725
|
+
$('fs-list').innerHTML = `<div class="ds-empty">${t('ds.toastAuth')}</div>`
|
|
726
|
+
return
|
|
727
|
+
}
|
|
728
|
+
const target = dir ?? state.fs.path ?? ''
|
|
729
|
+
if (!silent) {
|
|
730
|
+
$('fs-list').innerHTML = `<div class="ds-empty">${t('ds.loading')}</div>`
|
|
731
|
+
$('fs-path').textContent = target ? '…' + target.slice(-40) : t('ds.loading')
|
|
732
|
+
}
|
|
733
|
+
try {
|
|
734
|
+
const res = await fetch(fsApiUrl('/list', target ? { path: target } : {}), { headers: fsHeaders() })
|
|
735
|
+
if (res.status === 401) { toast(t('ds.toastAuth'), 'err'); return }
|
|
736
|
+
const data = await res.json().catch(() => ({}))
|
|
737
|
+
if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error || ('HTTP ' + res.status))
|
|
738
|
+
state.fs.path = data.path
|
|
739
|
+
if (!state.fs.initial) state.fs.initial = data.path
|
|
740
|
+
state.fs.loaded = true
|
|
741
|
+
$('fs-path').textContent = data.path
|
|
742
|
+
$('fs-list').innerHTML = (data.entries || []).map(e => `
|
|
743
|
+
<div class="ds-fs-row" data-fs-path="${esc(e.path)}" data-fs-dir="${e.type === 'dir' ? '1' : '0'}">
|
|
744
|
+
<span>${e.type === 'dir' ? '📁' : '📄'}</span>
|
|
745
|
+
<span class="ds-fs-name">${esc(e.name)}</span>
|
|
746
|
+
<span class="ds-fs-size">${e.type === 'dir' ? '' : fmtSize(e.size)}</span>
|
|
747
|
+
</div>`).join('') || `<div class="ds-empty">${t('ds.fsEmpty')}</div>`
|
|
748
|
+
$('fs-list').querySelectorAll('[data-fs-path]').forEach(row => row.addEventListener('click', () => {
|
|
749
|
+
if (row.dataset.fsDir === '1') loadFs(row.dataset.fsPath)
|
|
750
|
+
else window.open(fsApiUrl('/file', { path: row.dataset.fsPath, token: state.token }), '_blank')
|
|
751
|
+
}))
|
|
752
|
+
} catch (e) {
|
|
753
|
+
$('fs-path').textContent = target || '~'
|
|
754
|
+
$('fs-list').innerHTML = `<div class="ds-empty">${esc(e.message || t('ds.toastConnFailed'))}</div>`
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
function fsUp() {
|
|
758
|
+
if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) {
|
|
759
|
+
loadFs(fsParent(state.fs.path))
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/* ---------------- 统计 ---------------- */
|
|
764
|
+
function bucketTokens(b) { return (b.input || 0) + (b.cacheRead || 0) + (b.cacheWrite || 0) + (b.output || 0) }
|
|
765
|
+
let statsDrawerOpened = false
|
|
766
|
+
function toggleStatsDrawer() {
|
|
767
|
+
const drawer = $('stats-drawer')
|
|
768
|
+
if (!drawer) return
|
|
769
|
+
const willOpen = drawer.classList.contains('hidden')
|
|
770
|
+
drawer.classList.toggle('hidden')
|
|
771
|
+
drawer.setAttribute('aria-hidden', willOpen ? 'false' : 'true')
|
|
772
|
+
if (willOpen && !statsDrawerOpened) { statsDrawerOpened = true; loadStats() }
|
|
773
|
+
}
|
|
774
|
+
async function loadStats() {
|
|
775
|
+
if (!state.token) {
|
|
776
|
+
$('stats-cards').innerHTML = `<div class="ds-empty">${t('ds.statsGatewayDown')}</div>`
|
|
777
|
+
return
|
|
778
|
+
}
|
|
779
|
+
try {
|
|
780
|
+
const res = await fetch(apiUrl('/stats/summary?days=7'), { headers: { authorization: 'Bearer ' + state.token } })
|
|
781
|
+
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
782
|
+
const json = await res.json()
|
|
783
|
+
renderStats(json.days || [])
|
|
784
|
+
} catch {
|
|
785
|
+
$('stats-cards').innerHTML = `<div class="ds-empty">${t('ds.statsGatewayDown')}</div>`
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
function renderStats(days) {
|
|
789
|
+
if (!days.length) {
|
|
790
|
+
$('stats-cards').innerHTML = `<div class="ds-empty">${t('ds.statsEmpty')}</div>`
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
const today = days[days.length - 1]
|
|
794
|
+
const totalTokens = bucketTokens(today.total)
|
|
795
|
+
const peakCost = today.peak.cost || 0
|
|
796
|
+
const offCost = today.off.cost || 0
|
|
797
|
+
const totalCost = peakCost + offCost
|
|
798
|
+
const peakShare = totalCost > 0 ? Math.round(peakCost / totalCost * 100) : 0
|
|
799
|
+
$('stats-cards').innerHTML = `
|
|
800
|
+
<div class="ds-stat-card"><div class="v">${fmtTokens(totalTokens)}</div><div class="k">${t('ds.statsTodayTokens')}
|
|
801
|
+
<div class="ds-bucket-grid">
|
|
802
|
+
<div class="b"><span class="n">${t('ds.statsInput')}</span><span class="t">${fmtTokens(today.total.input)}</span></div>
|
|
803
|
+
<div class="b"><span class="n">${t('ds.statsCacheRead')}</span><span class="t">${fmtTokens(today.total.cacheRead)}</span></div>
|
|
804
|
+
<div class="b"><span class="n">${t('ds.statsCacheWrite')}</span><span class="t">${fmtTokens(today.total.cacheWrite)}</span></div>
|
|
805
|
+
<div class="b"><span class="n">${t('ds.statsOutput')}</span><span class="t">${fmtTokens(today.total.output)}</span></div>
|
|
806
|
+
</div></div></div>
|
|
807
|
+
<div class="ds-stat-card"><div class="v">${fmtCost(totalCost)}</div><div class="k">${t('ds.statsTodayCost')}<br>${t('ds.statsPeak')} ${fmtCost(peakCost)} / ${t('ds.statsOff')} ${fmtCost(offCost)}</div></div>
|
|
808
|
+
<div class="ds-stat-card"><div class="v">${peakShare}%</div><div class="k">${t('ds.statsPeakShare')}<br>${t('ds.statsDays', { n: days.length })}</div></div>`
|
|
809
|
+
$('stats-legend').innerHTML = `<span class="sw peak"></span>${t('ds.statsPeak')} <span class="sw off"></span>${t('ds.statsOff')}`
|
|
810
|
+
$('stats-note').textContent = t('ds.statsNote')
|
|
811
|
+
const maxCost = Math.max(...days.map(d => (d.total.cost || 0)), 0.0001)
|
|
812
|
+
$('stats-chart').innerHTML = days.map(d => {
|
|
813
|
+
const cost = d.total.cost || 0
|
|
814
|
+
const peakH = cost > 0 ? Math.round((d.peak.cost || 0) / cost * 100) : 0
|
|
815
|
+
const offH = cost > 0 ? Math.max(0, 100 - peakH) : 0
|
|
816
|
+
const totalH = cost > 0 ? Math.max(3, Math.round(cost / maxCost * 100)) : 0
|
|
817
|
+
return `<div class="ds-stats-bar" title="${d.date} · ${t('ds.statsPeak')} ${fmtCost(d.peak.cost)} · ${t('ds.statsOff')} ${fmtCost(d.off.cost)}">
|
|
818
|
+
<div class="bars" style="height:${totalH}%"><div class="seg peak" style="height:${peakH}%"></div><div class="seg off" style="height:${offH}%"></div></div>
|
|
819
|
+
<div class="val">${cost > 0 ? fmtCost(cost) : ''}</div>
|
|
820
|
+
<div class="lbl">${d.date.slice(5)}</div>
|
|
821
|
+
</div>`
|
|
822
|
+
}).join('')
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/* ---------------- 视图与连接状态 ---------------- */
|
|
826
|
+
function showView(id) {
|
|
827
|
+
state.view = id
|
|
828
|
+
for (const v of ['view-sessions', 'view-chat', 'view-files', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
|
|
829
|
+
document.querySelectorAll('.ds-nav-item').forEach(b => b.classList.toggle('active', b.dataset.view === id))
|
|
830
|
+
const titles = { 'view-sessions': 'ds.sessions', 'view-chat': 'ds.sessions', 'view-files': 'ds.files', 'view-settings': 'ds.settings' }
|
|
831
|
+
if (id === 'view-chat') { const s = state.byId.get(state.current); $('ds-title').textContent = s ? titleOf(s) : t('ds.sessions') }
|
|
832
|
+
else $('ds-title').textContent = t(titles[id])
|
|
833
|
+
if (id === 'view-files' && !state.fs.loaded) loadFs(null, true)
|
|
834
|
+
}
|
|
835
|
+
function updateConn() {
|
|
836
|
+
const el = $('conn-badge')
|
|
837
|
+
const any = Object.values(state.streamsOk).some(Boolean)
|
|
838
|
+
const all = state.streamsOk.mux && state.streamsOk.host
|
|
839
|
+
el.textContent = '●'
|
|
840
|
+
el.className = 'ds-conn ' + (all ? 'on' : any ? 'ing' : '')
|
|
841
|
+
el.title = all ? t('ds.connOn') : any ? t('ds.connIng') : t('ds.connOff')
|
|
842
|
+
const cur = state.servers.find(s => s.url === state.server)
|
|
843
|
+
const group = cur ? cur.group : state.activeGroup
|
|
844
|
+
const label = cur ? (cur.note || cur.url) : (state.server || t('ds.origin'))
|
|
845
|
+
$('server-badge').textContent = t('ds.currentServer', { group, url: label })
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/* ---------------- 初始化 ---------------- */
|
|
849
|
+
function bindUi() {
|
|
850
|
+
$('btn-new-session').addEventListener('click', async () => {
|
|
851
|
+
const v = await safeRpc('session.create', {}, '')
|
|
852
|
+
if (v?.sessionId) { await refreshSessions(); openSession(v.sessionId) }
|
|
853
|
+
})
|
|
854
|
+
$('btn-mobile-nav').addEventListener('click', () => {
|
|
855
|
+
const list = $('mobile-session-list')
|
|
856
|
+
list.style.display = list.style.display === 'none' ? 'flex' : 'none'
|
|
857
|
+
})
|
|
858
|
+
document.querySelectorAll('.ds-nav-item').forEach(b => b.addEventListener('click', () => showView(b.dataset.view)))
|
|
859
|
+
$('session-list').addEventListener('click', (e) => {
|
|
860
|
+
const item = e.target.closest('[data-id]')
|
|
861
|
+
if (item) openSession(item.dataset.id)
|
|
862
|
+
})
|
|
863
|
+
$('btn-send').addEventListener('click', sendMessage)
|
|
864
|
+
$('composer').addEventListener('keydown', (e) => {
|
|
865
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage() }
|
|
866
|
+
})
|
|
867
|
+
$('btn-stats-top').addEventListener('click', toggleStatsDrawer)
|
|
868
|
+
$('stats-drawer-close').addEventListener('click', toggleStatsDrawer)
|
|
869
|
+
|
|
870
|
+
$('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
|
|
871
|
+
$('btn-server-add').addEventListener('click', addServer)
|
|
872
|
+
$('btn-group-add').addEventListener('click', addGroup)
|
|
873
|
+
$('group-select-btn').addEventListener('click', (e) => { e.stopPropagation(); toggleGroupMenu() })
|
|
874
|
+
document.addEventListener('click', (e) => { if (!e.target.closest('#group-select')) closeGroupMenu() })
|
|
875
|
+
$('server-input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); addServer() } })
|
|
876
|
+
|
|
877
|
+
$('btn-copy-token').addEventListener('click', async () => {
|
|
878
|
+
try { await navigator.clipboard.writeText(state.token); toast(t('ds.toastCopied'), 'ok') }
|
|
879
|
+
catch { toast(t('ds.toastOpFailed'), 'err') }
|
|
880
|
+
})
|
|
881
|
+
$('btn-theme').addEventListener('click', () => {
|
|
882
|
+
const cur = themeGet() || 'default'
|
|
883
|
+
const idx = THEME_META.findIndex(m => m.id === cur)
|
|
884
|
+
themeSet(THEME_META[(idx + 1) % THEME_META.length].id)
|
|
885
|
+
})
|
|
886
|
+
$('btn-lang').addEventListener('click', () => {
|
|
887
|
+
I18N.setLang(I18N.lang === 'zh' ? 'en' : 'zh')
|
|
888
|
+
$('btn-lang').textContent = I18N.lang === 'zh' ? 'EN' : '中文'
|
|
889
|
+
renderServers(); renderSessions(); updateConn(); themeApply()
|
|
890
|
+
})
|
|
891
|
+
$('fs-up').addEventListener('click', fsUp)
|
|
892
|
+
$('fs-refresh').addEventListener('click', () => loadFs(state.fs.path || null))
|
|
893
|
+
$('btn-question-submit').addEventListener('click', submitQuestion)
|
|
894
|
+
$('btn-question-cancel').addEventListener('click', () => { $('modal-question').classList.add('hidden'); toast(t('ds.ignored'), 'ok') })
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async function start() {
|
|
898
|
+
loadServers()
|
|
899
|
+
renderServers()
|
|
900
|
+
showView('view-sessions')
|
|
901
|
+
const urlToken = new URLSearchParams(location.search).get('token')
|
|
902
|
+
if (urlToken) { state.token = urlToken; LS.set('token', urlToken); history.replaceState(null, '', location.pathname) }
|
|
903
|
+
if (!state.token) {
|
|
904
|
+
const input = prompt(t('ds.tokenTitle'))
|
|
905
|
+
if (input && input.trim()) { state.token = input.trim(); LS.set('token', state.token) }
|
|
906
|
+
}
|
|
907
|
+
$('token-desc').textContent = state.token ? '● ' + state.token.slice(0, 12) + '…' : t('ds.toastAuth')
|
|
908
|
+
bindUi()
|
|
909
|
+
updateConn()
|
|
910
|
+
if (state.token) {
|
|
911
|
+
if (state.servers.length) await selectFastestServer({ silent: true, reconnect: false })
|
|
912
|
+
openStreams()
|
|
913
|
+
refreshSessions()
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
start()
|