dsh-remote-plugin 0.5.7 → 0.5.9

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.
@@ -0,0 +1,1027 @@
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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
+ /* ---------------- 反馈 ---------------- */
96
+ const FEEDBACK_LINKS = {
97
+ githubIssues: 'https://github.com/Blank-not-black/dsh-Remote/issues',
98
+ giteeIssues: 'https://gitee.com/Blankneverfails/dsh-Remote/issues',
99
+ bili: 'https://space.bilibili.com/419009275/dynamic',
100
+ repo: 'https://github.com/Blank-not-black/dsh-Remote'
101
+ }
102
+ async function copyText(text) {
103
+ try { await navigator.clipboard.writeText(text); return true } catch {}
104
+ try {
105
+ const ta = document.createElement('textarea')
106
+ ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'
107
+ document.body.appendChild(ta); ta.focus(); ta.select()
108
+ const ok = document.execCommand('copy')
109
+ ta.remove(); return ok
110
+ } catch { return false }
111
+ }
112
+ function openFeedbackMenu() {
113
+ $('feedback-menu').classList.remove('hidden')
114
+ $('btn-feedback').setAttribute('aria-expanded', 'true')
115
+ const first = $('feedback-menu').querySelector('[role="menuitem"]')
116
+ if (first) first.focus()
117
+ }
118
+ function closeFeedbackMenu() {
119
+ $('feedback-menu').classList.add('hidden')
120
+ $('btn-feedback').setAttribute('aria-expanded', 'false')
121
+ }
122
+ function toggleFeedbackMenu() {
123
+ $('feedback-menu').classList.contains('hidden') ? openFeedbackMenu() : closeFeedbackMenu()
124
+ }
125
+ function openFeedbackModal() {
126
+ document.querySelectorAll('#fb-chips .ds-fb-chip').forEach(b => b.classList.toggle('current', b.dataset.fbType === 'bug'))
127
+ $('fb-msg').value = ''
128
+ $('fb-contact').value = ''
129
+ $('modal-feedback').classList.remove('hidden')
130
+ setTimeout(() => $('fb-msg').focus(), 50)
131
+ }
132
+ function closeFeedbackModal() { $('modal-feedback').classList.add('hidden') }
133
+ async function submitFeedback() {
134
+ const type = document.querySelector('#fb-chips .ds-fb-chip.current')?.dataset.fbType || 'bug'
135
+ const message = $('fb-msg').value.trim()
136
+ const contact = $('fb-contact').value.trim()
137
+ if (!message) { toast(t('ds.feedbackEmpty'), 'err'); return }
138
+ if (message.length > 2000) { toast(t('ds.feedbackTooLong'), 'err'); return }
139
+ const btn = $('fb-submit')
140
+ btn.disabled = true
141
+ try {
142
+ const res = await fetch(apiUrl('/feedback'), {
143
+ method: 'POST',
144
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token },
145
+ body: JSON.stringify({ type, message, contact, appVersion: '' })
146
+ })
147
+ let json = {}
148
+ try { json = await res.json() } catch {}
149
+ if (res.ok && json.ok) { toast(t('ds.feedbackSubmitted'), 'ok'); closeFeedbackModal() }
150
+ else if (res.status === 429) { toast(t('ds.feedbackRateLimited'), 'err') }
151
+ else { toast(t('ds.feedbackSubmitFailed', { msg: json.error || res.status }), 'err') }
152
+ } catch {
153
+ toast(t('ds.feedbackSubmitFailed', { msg: t('ds.feedbackNetworkError') }), 'err')
154
+ } finally {
155
+ btn.disabled = false
156
+ }
157
+ }
158
+ function showTip(text, anchorRect) {
159
+ const tip = $('ds-tip')
160
+ if (!tip) return
161
+ tip.textContent = text
162
+ tip.classList.remove('hidden')
163
+ const margin = 8
164
+ const tw = tip.offsetWidth
165
+ const th = tip.offsetHeight
166
+ let left = anchorRect.left + anchorRect.width / 2 - tw / 2
167
+ left = Math.max(margin, Math.min(left, window.innerWidth - tw - margin))
168
+ let top = anchorRect.top - th - 10
169
+ if (top < margin) top = anchorRect.bottom + 10
170
+ tip.style.left = left + 'px'
171
+ tip.style.top = top + 'px'
172
+ }
173
+ function hideTip() { const tip = $('ds-tip'); if (tip) tip.classList.add('hidden') }
174
+
175
+ /* ---------------- API ---------------- */
176
+ function apiUrl(path) { return (state.server || '') + path }
177
+ async function rpc(method, payload = {}) {
178
+ const res = await fetch(apiUrl('/api/' + method), {
179
+ method: 'POST',
180
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' },
181
+ body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
182
+ })
183
+ if (res.status === 401) throw new Error('AUTH')
184
+ if (!res.ok) throw new Error('HTTP ' + res.status)
185
+ const full = await res.json()
186
+ if (!full?.result) throw new Error('bad response')
187
+ if (!full.result.ok) throw new Error(full.result.error?.message || 'dsh error')
188
+ return full.result.value
189
+ }
190
+ async function respond(rpcId, value) {
191
+ const res = await fetch(apiUrl('/api/respond'), {
192
+ method: 'POST',
193
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' },
194
+ body: JSON.stringify({ type: 'client-response', rpcId, result: { ok: true, value } })
195
+ })
196
+ if (res.status === 401) throw new Error('AUTH')
197
+ const receipt = await res.json()
198
+ return receipt?.accepted === true
199
+ }
200
+ async function safeRpc(method, payload, errText) {
201
+ try { return await rpc(method, payload) }
202
+ catch (e) {
203
+ if (e.message === 'AUTH') { toast(t('ds.toastAuth'), 'err'); return null }
204
+ toast(errText ? `${errText}:${e.message}` : e.message, 'err')
205
+ return null
206
+ }
207
+ }
208
+ function uuid() {
209
+ try { return crypto.randomUUID() } catch { return 'id-' + Date.now() + '-' + Math.random().toString(36).slice(2) }
210
+ }
211
+
212
+ /* ---------------- 多服务端分组管理(与 App 共用 servers-v2) ---------------- */
213
+ function newServerId() { return 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6) }
214
+ function ensureGroup(name) {
215
+ if (!name) name = '默认'
216
+ if (!state.groups.includes(name)) state.groups.push(name)
217
+ if (!(name in state.autoSelect)) state.autoSelect[name] = true
218
+ if (!(name in state.groupActive)) state.groupActive[name] = ''
219
+ return name
220
+ }
221
+ function groupServers(g) { return state.servers.filter(s => s.group === g) }
222
+ function activeServers() { return groupServers(state.activeGroup) }
223
+
224
+ function migrateServersV1() {
225
+ if (LS.get('servers-v2', null) !== null) return
226
+ let arr = null
227
+ try { arr = JSON.parse(LS.get('servers', '')) } catch {}
228
+ if (!Array.isArray(arr)) {
229
+ const legacy = LS.get('server', '')
230
+ arr = legacy ? [legacy] : []
231
+ }
232
+ const urls = arr.map(s => String(s || '').trim().replace(/\/+$/, '')).filter(s => /^https?:\/\//i.test(s))
233
+ state.servers = urls.map((url, i) => ({ id: 's' + (i + 1), url, note: '', group: '默认' }))
234
+ state.groups = ['默认']; state.activeGroup = '默认'
235
+ state.autoSelect = { '默认': true }; state.groupActive = { '默认': '' }
236
+ const active = LS.get('activeServer', '')
237
+ if (active === 'origin') state.server = ''
238
+ else {
239
+ const hit = state.servers.find(s => s.url === active)
240
+ state.server = hit ? hit.url : (state.servers[0]?.url || '')
241
+ state.groupActive['默认'] = hit ? hit.id : (state.servers[0]?.id || '')
242
+ }
243
+ saveServers()
244
+ }
245
+ function loadServers() {
246
+ let data = null
247
+ try { data = JSON.parse(LS.get('servers-v2', '')) } catch {}
248
+ if (!data || !Array.isArray(data.servers)) { migrateServersV1(); return }
249
+ 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 || '默认' }))
250
+ state.groups = Array.isArray(data.groups) && data.groups.length ? data.groups : ['默认']
251
+ state.activeGroup = state.groups.includes(data.activeGroup) ? data.activeGroup : '默认'
252
+ state.autoSelect = data.autoSelect || {}
253
+ state.groupActive = data.groupActive || {}
254
+ ensureGroup('默认')
255
+ for (const s of state.servers) ensureGroup(s.group)
256
+ const manual = state.groupActive[state.activeGroup]
257
+ const manualSrv = manual ? state.servers.find(s => s.id === manual) : null
258
+ state.server = manualSrv ? manualSrv.url : (activeServers()[0]?.url || '')
259
+ }
260
+ function saveServers() {
261
+ LS.set('servers-v2', JSON.stringify({
262
+ servers: state.servers, groups: state.groups, activeGroup: state.activeGroup,
263
+ autoSelect: state.autoSelect, groupActive: state.groupActive,
264
+ }))
265
+ }
266
+ function serverCandidates() {
267
+ const list = activeServers().map(s => s.url)
268
+ if (location.origin && !list.includes(location.origin)) list.push(location.origin)
269
+ return list
270
+ }
271
+ async function pingServer(base) {
272
+ const u = String(base || '').replace(/\/+$/, '')
273
+ if (!u) return Infinity
274
+ const t0 = performance.now()
275
+ const ctrl = new AbortController()
276
+ const timer = setTimeout(() => ctrl.abort(), 3500)
277
+ try {
278
+ const res = await fetch(u + '/health?t=' + Date.now(), { signal: ctrl.signal, cache: 'no-store' })
279
+ return res.ok ? Math.round(performance.now() - t0) : Infinity
280
+ } catch { return Infinity } finally { clearTimeout(timer) }
281
+ }
282
+ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
283
+ if (state.selectingServer) return null
284
+ state.selectingServer = true
285
+ try {
286
+ if (!silent) toast(t('ds.speedTesting'))
287
+ const candidates = serverCandidates()
288
+ let chosen = ''
289
+ let best = null
290
+ let ms = Infinity
291
+ if (state.autoSelect[state.activeGroup] !== false) {
292
+ for (const u of candidates) state.serverLatency[u] = await pingServer(u)
293
+ best = candidates.filter(u => Number.isFinite(state.serverLatency[u])).sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0] || null
294
+ chosen = best || (state.server || '')
295
+ ms = best ? state.serverLatency[best] : Infinity
296
+ } else {
297
+ const manual = state.groupActive[state.activeGroup]
298
+ const manualSrv = manual ? state.servers.find(s => s.id === manual) : null
299
+ chosen = manualSrv ? manualSrv.url : (activeServers()[0]?.url || '')
300
+ if (chosen) { state.serverLatency[chosen] = await pingServer(chosen); ms = state.serverLatency[chosen] }
301
+ }
302
+ renderServers()
303
+ if (chosen !== state.server) {
304
+ state.server = chosen
305
+ if (best) { const srv = state.servers.find(s => s.url === best); if (srv) state.groupActive[state.activeGroup] = srv.id }
306
+ saveServers()
307
+ if (!silent) {
308
+ if (chosen) toast(t('ds.speedSwitched', { url: chosen, ms: Number.isFinite(ms) ? ms : 0 }), 'ok')
309
+ }
310
+ if (reconnect && state.token) { openStreams(); refreshSessions() }
311
+ } else if (!silent) {
312
+ if (best) toast(t('ds.speedAlreadyBest', { url: chosen, ms: state.serverLatency[best] }), 'ok')
313
+ else if (chosen) toast(t('ds.speedManualUsing', { url: chosen, ms: Number.isFinite(ms) ? ms : '—' }), 'ok')
314
+ else toast(t('ds.speedAllDown'), 'err')
315
+ }
316
+ return chosen
317
+ } finally { state.selectingServer = false }
318
+ }
319
+
320
+ function serverTitle(s) { return s.note || s.url }
321
+ function renderGroupSelect() {
322
+ const label = $('group-select-label')
323
+ const menu = $('group-select-menu')
324
+ if (!label || !menu) return
325
+ label.textContent = state.activeGroup
326
+ 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('')
327
+ menu.querySelectorAll('[data-group-option]').forEach(b => b.addEventListener('click', () => {
328
+ closeGroupMenu()
329
+ if (b.dataset.groupOption !== state.activeGroup) switchGroup(b.dataset.groupOption)
330
+ }))
331
+ }
332
+ function toggleGroupMenu() { $('group-select-menu').classList.toggle('hidden') }
333
+ function closeGroupMenu() { $('group-select-menu').classList.add('hidden') }
334
+
335
+ function renderServers() {
336
+ const box = $('server-list')
337
+ if (!box) return
338
+ renderGroupSelect()
339
+ box.innerHTML = state.groups.map(g => {
340
+ const list = groupServers(g)
341
+ const auto = state.autoSelect[g] !== false
342
+ const activeManual = state.groupActive[g] || ''
343
+ return `<div class="ds-srv-group" data-group="${esc(g)}">
344
+ <div class="ds-srv-head">
345
+ <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>
346
+ <button class="ds-mini" data-speed-group="${esc(g)}" title="${t('ds.speedTest')}">⚡</button>
347
+ <label class="ds-switch" title="${t('ds.groupsAutoSelect')}"><input type="checkbox" data-auto-group="${esc(g)}" ${auto ? 'checked' : ''}><span class="ds-slider"></span></label>
348
+ ${g !== '默认' ? `<button class="ds-mini" data-del-group="${esc(g)}" title="${t('ds.groupsDelete')}">✕</button>` : ''}
349
+ </div>
350
+ <div class="ds-srv-body ${g === state.activeGroup ? '' : 'hidden'}">
351
+ ${list.map(s => {
352
+ const ms = state.serverLatency[s.url]
353
+ let badge = `<span class="ds-server-badge">${t('ds.serversUntested')}</span>`
354
+ 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>`
355
+ else if (ms !== undefined) badge = `<span class="ds-server-badge bad">${t('ds.serversUnreachable')}</span>`
356
+ const activeInGroup = auto ? s.url === state.server : s.id === activeManual
357
+ return `<div class="ds-server-row ${activeInGroup ? 'active' : ''}" data-use-server="${esc(s.id)}">
358
+ <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}
359
+ <button class="ds-mini" data-edit-server="${esc(s.id)}" title="${t('ds.serversEdit')}">✎</button>
360
+ <button class="ds-mini" data-del-server="${esc(s.id)}" title="${t('ds.serversDelete')}">✕</button>
361
+ </div>`
362
+ }).join('') || `<div class="ds-empty">${t('ds.groupsNoServer')}</div>`}
363
+ </div>
364
+ </div>`
365
+ }).join('')
366
+ box.querySelectorAll('[data-group-name]').forEach(b => {
367
+ b.addEventListener('click', () => switchGroup(b.dataset.groupName))
368
+ b.addEventListener('dblclick', () => renameGroup(b.dataset.groupName))
369
+ })
370
+ box.querySelectorAll('[data-speed-group]').forEach(b => b.addEventListener('click', () => { state.activeGroup = b.dataset.speedGroup; saveServers(); selectFastestServer({ silent: false }) }))
371
+ box.querySelectorAll('[data-auto-group]').forEach(chk => chk.addEventListener('change', (e) => {
372
+ const g = e.target.dataset.autoGroup
373
+ state.autoSelect[g] = e.target.checked
374
+ saveServers()
375
+ if (g === state.activeGroup) selectFastestServer({ silent: false })
376
+ toast(t(e.target.checked ? 'ds.groupsAutoOn' : 'ds.groupsAutoOff', { group: g }), 'ok')
377
+ }))
378
+ box.querySelectorAll('[data-del-group]').forEach(b => b.addEventListener('click', () => deleteGroup(b.dataset.delGroup)))
379
+ box.querySelectorAll('[data-del-server]').forEach(b => b.addEventListener('click', (e) => { e.stopPropagation(); removeServer(b.dataset.delServer) }))
380
+ box.querySelectorAll('[data-edit-server]').forEach(b => b.addEventListener('click', (e) => { e.stopPropagation(); editServer(b.dataset.editServer) }))
381
+ box.querySelectorAll('[data-use-server]').forEach(row => row.addEventListener('click', (e) => {
382
+ if (e.target.closest('button')) return
383
+ const id = row.dataset.useServer
384
+ const s = state.servers.find(x => x.id === id)
385
+ if (!s) return
386
+ if (state.autoSelect[s.group] !== false) { editServer(id); return }
387
+ state.groupActive[s.group] = id
388
+ state.activeGroup = s.group
389
+ state.server = s.url
390
+ saveServers()
391
+ renderServers()
392
+ toast(t('ds.serversManualSelected', { url: serverTitle(s) }), 'ok')
393
+ if (state.token) { openStreams(); refreshSessions() }
394
+ }))
395
+ const cur = state.servers.find(s => s.url === state.server)
396
+ const curGroup = cur ? cur.group : state.activeGroup
397
+ const curLabel = cur ? (cur.note || cur.url) : (state.server || t('ds.origin'))
398
+ const curMs = state.serverLatency[state.server]
399
+ $('server-desc').textContent = t('ds.serversCurrentDesc', { group: curGroup, url: curLabel, ms: Number.isFinite(curMs) ? curMs + 'ms' : '—' })
400
+ updateConn()
401
+ }
402
+
403
+ async function addServer() {
404
+ const input = $('server-input')
405
+ let raw = (input?.value || '').trim().replace(/\/+$/, '')
406
+ if (!raw) return toast(t('ds.serversNeedAddress'), 'err')
407
+ try { const u = new URL(raw); if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad') }
408
+ catch { return toast(t('ds.serversBadProtocol'), 'err') }
409
+ if (state.servers.some(s => s.url === raw)) return toast(t('ds.serversDuplicate'), 'err')
410
+ const note = (prompt(t('ds.serversPromptNote')) || '').trim()
411
+ state.servers.push({ id: newServerId(), url: raw, note, group: state.activeGroup })
412
+ saveServers()
413
+ if (input) input.value = ''
414
+ renderServers()
415
+ toast(t('ds.serversAdded'), 'ok')
416
+ if (state.token) selectFastestServer({ silent: false })
417
+ }
418
+ function editServer(id) {
419
+ const s = state.servers.find(x => x.id === id)
420
+ if (!s) return
421
+ const raw = (prompt(t('ds.serversPromptEditUrl'), s.url) || '').trim().replace(/\/+$/, '')
422
+ if (!raw) return
423
+ try { const u = new URL(raw); if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad') }
424
+ catch { return toast(t('ds.serversBadProtocol'), 'err') }
425
+ if (state.servers.some(x => x.id !== id && x.url === raw)) return toast(t('ds.serversDuplicate'), 'err')
426
+ const note = prompt(t('ds.serversPromptEditNote', { url: raw }), s.note || '')
427
+ if (note === null) return
428
+ const group = prompt(t('ds.serversPromptEditGroup'), s.group || '默认')
429
+ if (group === null) return
430
+ const wasActive = state.server === s.url
431
+ s.url = raw; s.note = note.trim(); s.group = ensureGroup(group.trim() || '默认')
432
+ if (wasActive) state.server = raw
433
+ saveServers(); renderServers(); toast(t('ds.serversEdited'), 'ok')
434
+ if (wasActive && state.token) selectFastestServer({ silent: true })
435
+ }
436
+ function removeServer(id) {
437
+ const s = state.servers.find(x => x.id === id)
438
+ if (!s) return
439
+ state.servers = state.servers.filter(x => x.id !== id)
440
+ const wasActive = state.server === s.url
441
+ for (const g of state.groups) if (state.groupActive[g] === id) state.groupActive[g] = ''
442
+ saveServers(); renderServers()
443
+ if (wasActive) { toast(t('ds.serversRemovedActive')); selectFastestServer({ silent: true }) }
444
+ }
445
+ function switchGroup(name) {
446
+ if (!state.groups.includes(name)) return
447
+ state.activeGroup = name
448
+ saveServers(); renderServers()
449
+ toast(t('ds.groupsSwitched', { group: name }), 'ok')
450
+ selectFastestServer({ silent: false })
451
+ }
452
+ function addGroup() {
453
+ const name = (prompt(t('ds.groupsPromptAdd')) || '').trim()
454
+ if (!name) return
455
+ if (state.groups.includes(name)) return toast(t('ds.groupsDuplicate'), 'err')
456
+ ensureGroup(name); state.activeGroup = name
457
+ saveServers(); renderServers(); toast(t('ds.groupsAdded', { group: name }), 'ok')
458
+ }
459
+ function renameGroup(oldName) {
460
+ if (oldName === '默认') return
461
+ const name = (prompt(t('ds.groupsPromptRename', { group: oldName }), oldName) || '').trim()
462
+ if (!name || name === oldName) return
463
+ if (state.groups.includes(name)) return toast(t('ds.groupsDuplicate'), 'err')
464
+ const idx = state.groups.indexOf(oldName)
465
+ state.groups[idx] = name
466
+ for (const s of state.servers) if (s.group === oldName) s.group = name
467
+ if (state.activeGroup === oldName) state.activeGroup = name
468
+ state.autoSelect[name] = state.autoSelect[oldName] !== false
469
+ delete state.autoSelect[oldName]
470
+ state.groupActive[name] = state.groupActive[oldName] || ''
471
+ delete state.groupActive[oldName]
472
+ saveServers(); renderServers(); toast(t('ds.groupsRenamed', { group: name }), 'ok')
473
+ }
474
+ function deleteGroup(name) {
475
+ if (name === '默认') return toast(t('ds.groupsCannotDeleteDefault'), 'err')
476
+ if (!state.groups.includes(name)) return
477
+ if (!confirm(t('ds.groupsConfirmDelete', { group: name }))) return
478
+ state.groups = state.groups.filter(g => g !== name)
479
+ for (const s of state.servers) if (s.group === name) s.group = '默认'
480
+ delete state.autoSelect[name]; delete state.groupActive[name]
481
+ if (state.activeGroup === name) state.activeGroup = '默认'
482
+ saveServers(); renderServers(); toast(t('ds.groupsDeleted'), 'ok')
483
+ if (state.token) selectFastestServer({ silent: true })
484
+ }
485
+
486
+ /* ---------------- 事件流 ---------------- */
487
+ function openStreams() {
488
+ if (!state.token) return
489
+ openStream('mux', onMuxFrame, true)
490
+ openStream('host', onHostFrame, false)
491
+ }
492
+ function openStream(kind, handler, refreshOnOpen) {
493
+ if (!state.token) return
494
+ let base
495
+ if (state.server) base = state.server.replace(/^http/, 'ws')
496
+ else { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; base = `${proto}//${location.host}` }
497
+ const ws = new WebSocket(`${base}/api/events.${kind}?token=${encodeURIComponent(state.token)}&client=web`)
498
+ try { streams[kind]?.close() } catch {}
499
+ streams[kind] = ws
500
+ ws.onopen = () => {
501
+ state.streamsOk[kind] = true
502
+ state.errCount = 0
503
+ updateConn()
504
+ if (kind === 'mux') { state.approvals = []; state.questions = []; renderNotifStack() }
505
+ if (refreshOnOpen) refreshSessions()
506
+ }
507
+ ws.onmessage = (msg) => {
508
+ state.streamsOk[kind] = true
509
+ updateConn()
510
+ try { handler(JSON.parse(msg.data)) } catch {}
511
+ }
512
+ ws.onclose = () => {
513
+ state.streamsOk[kind] = false
514
+ state.errCount++
515
+ updateConn()
516
+ if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
517
+ if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), 1200)
518
+ }
519
+ ws.onerror = () => { try { ws.close() } catch {} }
520
+ }
521
+ function onMuxFrame(full) {
522
+ const f = full.payload
523
+ if (!f) return
524
+ if (f.type === 'session/event') return onSessionEvent(f.sessionId, f.event)
525
+ if (f.type === 'approval/requested') {
526
+ state.approvals = state.approvals.filter(a => a.approvalId !== f.approvalId)
527
+ state.approvals.push({ ...f, rpcId: full.rpcId })
528
+ renderNotifStack()
529
+ return
530
+ }
531
+ if (f.type === 'approval/resolved') { state.approvals = state.approvals.filter(a => a.approvalId !== f.approvalId); renderNotifStack(); return }
532
+ if (f.type === 'question/requested') {
533
+ state.questions = state.questions.filter(q => q.rpcId !== full.rpcId)
534
+ state.questions.push({ ...f, rpcId: full.rpcId })
535
+ renderNotifStack()
536
+ return
537
+ }
538
+ if (f.type === 'question/resolved') { state.questions = state.questions.filter(q => q.rpcId !== f.questionRpcId); renderNotifStack(); return }
539
+ if (f.type === 'session/projection') { applyProjection(f.sessionId, f.key, f.value, f.seq); return }
540
+ if (f.type === 'stream/error') toast(f.error?.message || 'stream error', 'err')
541
+ }
542
+ function onHostFrame(full) {
543
+ const f = full.payload
544
+ if (!f) return
545
+ 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()
546
+ if (f.type === 'host/session-status') {
547
+ const s = state.byId.get(f.sessionId)
548
+ if (s) { s.running = f.running; if (state.current === f.sessionId) renderSessions() }
549
+ }
550
+ }
551
+ function applyProjection(sessionId, key, value, seq) {
552
+ const s = state.byId.get(sessionId)
553
+ if (s) {
554
+ s.projections = s.projections || { asOfSeq: 0, values: {} }
555
+ s.projections.values = s.projections.values || {}
556
+ s.projections.values[key] = value
557
+ s.projections.asOfSeq = Math.max(s.projections.asOfSeq || 0, seq || 0)
558
+ }
559
+ if (state.current === sessionId) renderSessions()
560
+ if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) refreshSessions()
561
+ }
562
+ function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
563
+ function titleOf(s) { return proj(s, 'title') || short(s.sessionId) }
564
+ function onSessionEvent(sessionId, event) {
565
+ if (state.current === sessionId && event) {
566
+ const h = state.history
567
+ const seq = event.seq
568
+ if (seq != null && !h.seqs.has(seq) && shouldShowEvent(event.type)) {
569
+ h.seqs.add(seq)
570
+ h.visible.push({ seq, event })
571
+ h.visible.sort((a, b) => a.seq - b.seq)
572
+ renderHistory()
573
+ }
574
+ }
575
+ }
576
+
577
+ /* ---------------- 会话 ---------------- */
578
+ async function refreshSessions() {
579
+ const v = await safeRpc('session.list', {}, '')
580
+ if (!v) { renderSessions(); return }
581
+ state.sessions = v.items || []
582
+ state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
583
+ renderSessions()
584
+ }
585
+ function renderSessions() {
586
+ const items = [...state.sessions].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
587
+ const html = items.map(s => {
588
+ const title = titleOf(s)
589
+ return `<button class="ds-session-item ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
590
+ <span class="ds-session-title">${esc(title)}</span>
591
+ <span class="ds-session-meta"><span class="ds-session-dot ${s.running ? 'running' : ''}"></span>${fmtTime(s.updatedAt)}</span>
592
+ </button>`
593
+ }).join('') || `<div class="ds-empty">${t('ds.sessionsEmpty')}</div>`
594
+ $('session-list').innerHTML = html
595
+ $('mobile-session-list').innerHTML = html
596
+ document.querySelectorAll('[data-id]').forEach(b => b.addEventListener('click', () => openSession(b.dataset.id)))
597
+ }
598
+
599
+ async function openSession(id) {
600
+ state.current = id
601
+ state.history = { seqs: new Set(), visible: [], hasMore: false, loading: false, minSeq: Infinity }
602
+ showView('view-chat')
603
+ $('ds-title').textContent = titleOf(state.byId.get(id)) || t('ds.sessions')
604
+ $('history').innerHTML = `<div class="ds-empty">${t('ds.historyLoading')}</div>`
605
+ renderSessions()
606
+ await loadHistory()
607
+ }
608
+ function closeSession() {
609
+ state.current = null
610
+ state.history = { seqs: new Set(), visible: [], hasMore: false, loading: false, minSeq: Infinity }
611
+ showView('view-sessions')
612
+ }
613
+ async function loadHistory() {
614
+ const id = state.current
615
+ if (!id || state.history.loading) return
616
+ state.history.loading = true
617
+ let v
618
+ try { v = await rpc('session.history', { sessionId: id, maxMessages: 60 }) }
619
+ catch (e) {
620
+ state.history.loading = false
621
+ if (e.message === 'AUTH') return
622
+ $('history').innerHTML = `<div class="ds-empty">${e.message}</div>`
623
+ return
624
+ }
625
+ for (const entry of v.events || []) {
626
+ const ev = entry?.event
627
+ const seq = ev?.seq
628
+ if (seq == null || state.history.seqs.has(seq)) continue
629
+ if (!shouldShowEvent(ev.type)) continue
630
+ state.history.seqs.add(seq)
631
+ state.history.visible.push({ seq, event: ev })
632
+ }
633
+ state.history.visible.sort((a, b) => a.seq - b.seq)
634
+ state.history.hasMore = !!v.hasMore
635
+ state.history.loading = false
636
+ renderHistory()
637
+ }
638
+
639
+ const INTERESTING_EVENTS = new Set([
640
+ 'user/message', 'assistant/message', 'tool/call', 'tool/result',
641
+ 'agent/status', 'checkpoint/created', 'compaction/complete', 'compaction/summary',
642
+ 'goal/created', 'goal/updated', 'goal/completed', 'goal/cleared',
643
+ 'todo/updated', 'plan/updated', 'question/asked', 'question/resolved',
644
+ 'approval/asked', 'approval/resolved', 'session/title', 'title'
645
+ ])
646
+ function shouldShowEvent(type) { return INTERESTING_EVENTS.has(type) }
647
+ function safeJson(v) { try { return JSON.stringify(v, null, 2) } catch { return String(v) } }
648
+ function blockHtml(b) {
649
+ if (!b) return ''
650
+ if (b.type === 'text') return `<span>${esc(b.text ?? '')}</span>`
651
+ if (b.type === 'reasoning') return `<span style="opacity:.75">${esc(b.text ?? '')}</span>`
652
+ if (b.type === 'tool-call') return `<div>🔧 ${esc(b.name || '')}</div>`
653
+ if (b.type === 'tool-result') return `<div>📦</div>`
654
+ if (b.type === 'image') return `<div>🖼</div>`
655
+ return ''
656
+ }
657
+ function eventHtml(entry) {
658
+ const ev = entry.event || {}
659
+ const data = ev.data || {}
660
+ const type = ev.type || 'event'
661
+ if (!shouldShowEvent(type)) return ''
662
+ if (type === 'user/message' || type === 'assistant/message') {
663
+ const msg = data.message || {}
664
+ const role = data.role || msg.role || (type.startsWith('user') ? 'user' : 'assistant')
665
+ const blocks = msg.content || data.content || []
666
+ const text = blocks.map(blockHtml).join('')
667
+ 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>`
668
+ }
669
+ if (type === 'tool/call') {
670
+ const name = data.name || data.toolName || t('ds.toolDefault')
671
+ const step = (data.turn != null ? ` · turn ${data.turn}` : '') + (data.step != null ? `.${data.step}` : '')
672
+ return `<details class="ds-tool"><summary>🔧 ${esc(name)}${esc(step)}</summary><pre>${esc(safeJson(data.arguments ?? data.args ?? data.input ?? data))}</pre></details>`
673
+ }
674
+ if (type === 'tool/result') {
675
+ const callId = data.callId || data.message?.source?.callId || ''
676
+ const text = data.text || data.content || safeJson(data.message?.content ?? data)
677
+ return `<details class="ds-tool"><summary>📦 ${esc(callId)}</summary><pre>${esc(safeJson(text))}</pre></details>`
678
+ }
679
+ if (type === 'approval/asked') return `<div class="ds-tool">🔐 ${esc(t('ds.approvalTitle'))} · ${esc(data.toolName || '')}</div>`
680
+ if (type === 'question/asked') return `<div class="ds-tool">❓ ${esc(data.question || '')}</div>`
681
+ return `<div class="ds-tool">${esc(type)}</div>`
682
+ }
683
+ function renderHistory() {
684
+ const box = $('history')
685
+ const items = state.history.visible
686
+ box.innerHTML = items.map(eventHtml).join('') || `<div class="ds-empty">${t('ds.historyEmpty')}</div>`
687
+ box.scrollTop = box.scrollHeight
688
+ }
689
+ async function sendMessage() {
690
+ const input = $('composer')
691
+ const text = input.value.trim()
692
+ if (!text || !state.current) return
693
+ input.value = ''
694
+ const v = await safeRpc('session.prompt', { sessionId: state.current, text }, '')
695
+ if (v) toast(t('ds.toastSent'), 'ok')
696
+ }
697
+
698
+ /* ---------------- 审批/提问通知卡片栈 ---------------- */
699
+ function serverLabel() {
700
+ const cur = state.servers.find(s => s.url === state.server)
701
+ return cur ? (cur.note || cur.url) : (state.server || location.host)
702
+ }
703
+ function renderNotifStack() {
704
+ const stack = $('notif-stack')
705
+ const items = [
706
+ ...state.approvals.map(a => ({ kind: 'approval', a })),
707
+ ...state.questions.map(q => ({ kind: 'question', q }))
708
+ ]
709
+ stack.innerHTML = items.map(it => {
710
+ if (it.kind === 'approval') {
711
+ const a = it.a
712
+ const reason = a.reason || a.arguments ? safeJson(a.arguments ?? a.reason ?? '') : ''
713
+ return `<div class="ds-notif-card" data-approval="${esc(a.approvalId)}" tabindex="0">
714
+ <div class="ds-notif-head">🔐 ${t('ds.approvalTitle')} · ${esc(serverLabel())} · ${fmtTime(a.time || Date.now())}</div>
715
+ <div class="ds-notif-title">${esc(a.toolName || t('ds.toolDefault'))}</div>
716
+ <div class="ds-notif-body">${esc(reason.slice(0, 500))}</div>
717
+ <div class="ds-notif-actions">
718
+ <button class="ds-btn allow" data-approve="1">${t('ds.allow')}</button>
719
+ <button class="ds-btn reject" data-approve="0">${t('ds.reject')}</button>
720
+ <button class="ds-btn" data-ignore-approval>${t('ds.ignore')}</button>
721
+ </div>
722
+ </div>`
723
+ }
724
+ const q = it.q
725
+ const text = q.questions?.map(x => x.question).join(' / ') || ''
726
+ return `<div class="ds-notif-card question" data-question="${esc(q.rpcId)}" tabindex="0">
727
+ <div class="ds-notif-head">❓ ${t('ds.questionNotify')} · ${esc(serverLabel())} · ${fmtTime(q.time || Date.now())}</div>
728
+ <div class="ds-notif-title">${esc(text.slice(0, 120))}</div>
729
+ <div class="ds-notif-actions">
730
+ <button class="ds-btn" data-open-question>${t('ds.submit')}</button>
731
+ <button class="ds-btn" data-ignore-question>${t('ds.ignore')}</button>
732
+ </div>
733
+ </div>`
734
+ }).join('')
735
+ stack.querySelectorAll('[data-approve]').forEach(b => b.addEventListener('click', () => approveApproval(b.closest('[data-approval]')?.dataset.approval || '', b.dataset.approve === '1')))
736
+ stack.querySelectorAll('[data-ignore-approval]').forEach(b => b.addEventListener('click', () => { toast(t('ds.ignored'), 'ok') }))
737
+ stack.querySelectorAll('[data-open-question]').forEach(b => b.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === b.closest('[data-question]')?.dataset.question))))
738
+ stack.querySelectorAll('[data-ignore-question]').forEach(b => b.addEventListener('click', () => { toast(t('ds.ignored'), 'ok') }))
739
+ // Esc 忽略最上方卡片
740
+ stack.querySelectorAll('.ds-notif-card').forEach(card => card.addEventListener('keydown', (e) => {
741
+ if (e.key === 'Escape') toast(t('ds.ignored'), 'ok')
742
+ }))
743
+ }
744
+ async function approveApproval(id, allow) {
745
+ const a = state.approvals.find(x => x.approvalId === id)
746
+ if (!a) return
747
+ const ok = await respond(a.rpcId, { sessionId: a.sessionId, approvalId: a.approvalId, outcome: allow ? 'allowed-once' : 'rejected' })
748
+ toast(ok ? (allow ? t('ds.allowed') : t('ds.rejected')) : t('ds.stale'), ok ? 'ok' : 'err')
749
+ state.approvals = state.approvals.filter(x => x.approvalId !== id)
750
+ renderNotifStack()
751
+ }
752
+ function openQuestionModal(q) {
753
+ if (!q) return
754
+ state.questionModal = q
755
+ $('question-body').innerHTML = q.questions.map((item, i) => `
756
+ <div class="ds-q-item">
757
+ <div class="ds-q-text">${esc(item.header ? item.header + ':' : '')}${esc(item.question)}</div>
758
+ ${(item.options || []).map((o, j) => `
759
+ <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('')}
760
+ <textarea rows="2" placeholder="${t('ds.questionCustom')}" data-qcustom="${i}"></textarea>
761
+ </div>`).join('')
762
+ $('modal-question').classList.remove('hidden')
763
+ }
764
+ async function submitQuestion() {
765
+ const q = state.questionModal
766
+ if (!q) return
767
+ const answers = q.questions.map((item, i) => {
768
+ const sel = [...$('question-body').querySelectorAll(`input[name="q${i}"]:checked`)].map(x => x.value)
769
+ const custom = $('question-body').querySelector(`[data-qcustom="${i}"]`)?.value?.trim()
770
+ const ans = { id: item.id, selected: sel }
771
+ if (custom) ans.custom = custom
772
+ if (!sel.length && !custom) return null
773
+ return ans
774
+ }).filter(Boolean)
775
+ if (!answers.length) return toast(t('ds.questionNeedAnswer'), 'err')
776
+ const ok = await respond(q.rpcId, { sessionId: q.sessionId, answer: { answers } })
777
+ if (ok) {
778
+ toast(t('ds.questionSubmitted'), 'ok')
779
+ $('modal-question').classList.add('hidden')
780
+ state.questions = state.questions.filter(x => x.rpcId !== q.rpcId)
781
+ renderNotifStack()
782
+ } else toast(t('ds.stale'), 'err')
783
+ }
784
+
785
+ /* ---------------- 文件传输 ---------------- */
786
+ function fsApiUrl(sub, params = {}) {
787
+ const u = new URL(apiUrl('/fs' + sub), location.href)
788
+ for (const [k, v] of Object.entries(params)) {
789
+ if (v != null && v !== '') u.searchParams.set(k, v)
790
+ }
791
+ return u.href
792
+ }
793
+ function fsHeaders() {
794
+ return { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' }
795
+ }
796
+ function fsParent(p) {
797
+ if (!p) return null
798
+ const parts = String(p).split('/').filter(Boolean)
799
+ parts.pop()
800
+ return parts.length ? '/' + parts.join('/') : '/'
801
+ }
802
+ async function loadFs(dir, silent) {
803
+ if (!state.token) {
804
+ $('fs-path').textContent = t('ds.toastAuth')
805
+ $('fs-list').innerHTML = `<div class="ds-empty">${t('ds.toastAuth')}</div>`
806
+ return
807
+ }
808
+ const target = dir ?? state.fs.path ?? ''
809
+ if (!silent) {
810
+ $('fs-list').innerHTML = `<div class="ds-empty">${t('ds.loading')}</div>`
811
+ $('fs-path').textContent = target ? '…' + target.slice(-40) : t('ds.loading')
812
+ }
813
+ try {
814
+ const res = await fetch(fsApiUrl('/list', target ? { path: target } : {}), { headers: fsHeaders() })
815
+ if (res.status === 401) { toast(t('ds.toastAuth'), 'err'); return }
816
+ const data = await res.json().catch(() => ({}))
817
+ if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error || ('HTTP ' + res.status))
818
+ state.fs.path = data.path
819
+ if (!state.fs.initial) state.fs.initial = data.path
820
+ state.fs.loaded = true
821
+ $('fs-path').textContent = data.path
822
+ $('fs-list').innerHTML = (data.entries || []).map(e => `
823
+ <div class="ds-fs-row" data-fs-path="${esc(e.path)}" data-fs-dir="${e.type === 'dir' ? '1' : '0'}">
824
+ <span>${e.type === 'dir' ? '📁' : '📄'}</span>
825
+ <span class="ds-fs-name">${esc(e.name)}</span>
826
+ <span class="ds-fs-size">${e.type === 'dir' ? '' : fmtSize(e.size)}</span>
827
+ </div>`).join('') || `<div class="ds-empty">${t('ds.fsEmpty')}</div>`
828
+ $('fs-list').querySelectorAll('[data-fs-path]').forEach(row => row.addEventListener('click', () => {
829
+ if (row.dataset.fsDir === '1') loadFs(row.dataset.fsPath)
830
+ else window.open(fsApiUrl('/file', { path: row.dataset.fsPath, token: state.token }), '_blank')
831
+ }))
832
+ } catch (e) {
833
+ $('fs-path').textContent = target || '~'
834
+ $('fs-list').innerHTML = `<div class="ds-empty">${esc(e.message || t('ds.toastConnFailed'))}</div>`
835
+ }
836
+ }
837
+ function fsUp() {
838
+ if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) {
839
+ loadFs(fsParent(state.fs.path))
840
+ }
841
+ }
842
+
843
+ /* ---------------- 统计 ---------------- */
844
+ function bucketTokens(b) { return (b.input || 0) + (b.cacheRead || 0) + (b.cacheWrite || 0) + (b.output || 0) }
845
+ let statsDrawerOpened = false
846
+ function toggleStatsDrawer() {
847
+ const drawer = $('stats-drawer')
848
+ if (!drawer) return
849
+ const willOpen = drawer.classList.contains('hidden')
850
+ drawer.classList.toggle('hidden')
851
+ drawer.setAttribute('aria-hidden', willOpen ? 'false' : 'true')
852
+ if (willOpen && !statsDrawerOpened) { statsDrawerOpened = true; loadStats() }
853
+ }
854
+ async function loadStats() {
855
+ if (!state.token) {
856
+ $('stats-cards').innerHTML = `<div class="ds-empty">${t('ds.statsGatewayDown')}</div>`
857
+ return
858
+ }
859
+ try {
860
+ const res = await fetch(apiUrl('/stats/summary?days=7'), { headers: { authorization: 'Bearer ' + state.token } })
861
+ if (!res.ok) throw new Error('HTTP ' + res.status)
862
+ const json = await res.json()
863
+ renderStats(json.days || [])
864
+ } catch {
865
+ $('stats-cards').innerHTML = `<div class="ds-empty">${t('ds.statsGatewayDown')}</div>`
866
+ }
867
+ }
868
+ function renderStats(days) {
869
+ if (!days.length) {
870
+ $('stats-cards').innerHTML = `<div class="ds-empty">${t('ds.statsEmpty')}</div>`
871
+ return
872
+ }
873
+ const today = days[days.length - 1]
874
+ const totalTokens = bucketTokens(today.total)
875
+ const peakCost = today.peak.cost || 0
876
+ const offCost = today.off.cost || 0
877
+ const totalCost = peakCost + offCost
878
+ const peakShare = totalCost > 0 ? Math.round(peakCost / totalCost * 100) : 0
879
+ $('stats-cards').innerHTML = `
880
+ <div class="ds-stat-card"><div class="v">${fmtTokens(totalTokens)}</div><div class="k">${t('ds.statsTodayTokens')}
881
+ <div class="ds-bucket-grid">
882
+ <div class="b"><span class="n">${t('ds.statsInput')}</span><span class="t">${fmtTokens(today.total.input)}</span></div>
883
+ <div class="b"><span class="n">${t('ds.statsCacheRead')}</span><span class="t">${fmtTokens(today.total.cacheRead)}</span></div>
884
+ <div class="b"><span class="n">${t('ds.statsCacheWrite')}</span><span class="t">${fmtTokens(today.total.cacheWrite)}</span></div>
885
+ <div class="b"><span class="n">${t('ds.statsOutput')}</span><span class="t">${fmtTokens(today.total.output)}</span></div>
886
+ </div></div></div>
887
+ <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>
888
+ <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>`
889
+ $('stats-legend').innerHTML = `<span class="sw peak"></span>${t('ds.statsPeak')} <span class="sw off"></span>${t('ds.statsOff')}`
890
+ $('stats-note').textContent = t('ds.statsNote')
891
+ const maxCost = Math.max(...days.map(d => (d.total.cost || 0)), 0.0001)
892
+ $('stats-chart').innerHTML = days.map(d => {
893
+ const cost = d.total.cost || 0
894
+ const peakH = cost > 0 ? Math.round((d.peak.cost || 0) / cost * 100) : 0
895
+ const offH = cost > 0 ? Math.max(0, 100 - peakH) : 0
896
+ const totalH = cost > 0 ? Math.max(3, Math.round(cost / maxCost * 100)) : 0
897
+ const tip = `${d.date}\n${t('ds.statsPeak')} ${fmtCost(d.peak.cost)}\n${t('ds.statsOff')} ${fmtCost(d.off.cost)}`
898
+ return `<div class="ds-stats-bar" data-tip="${esc(tip)}">
899
+ <div class="bars" style="height:${totalH}%"><div class="seg peak" style="height:${peakH}%"></div><div class="seg off" style="height:${offH}%"></div></div>
900
+ <div class="val">${cost > 0 ? fmtCost(cost) : ''}</div>
901
+ <div class="lbl">${d.date.slice(5)}</div>
902
+ </div>`
903
+ }).join('')
904
+ }
905
+
906
+ /* ---------------- 视图与连接状态 ---------------- */
907
+ function showView(id) {
908
+ state.view = id
909
+ for (const v of ['view-sessions', 'view-chat', 'view-files', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
910
+ document.querySelectorAll('.ds-nav-item').forEach(b => b.classList.toggle('active', b.dataset.view === id))
911
+ const titles = { 'view-sessions': 'ds.sessions', 'view-chat': 'ds.sessions', 'view-files': 'ds.files', 'view-settings': 'ds.settings' }
912
+ if (id === 'view-chat') { const s = state.byId.get(state.current); $('ds-title').textContent = s ? titleOf(s) : t('ds.sessions') }
913
+ else $('ds-title').textContent = t(titles[id])
914
+ if (id === 'view-files' && !state.fs.loaded) loadFs(null, true)
915
+ }
916
+ function updateConn() {
917
+ const el = $('conn-badge')
918
+ const any = Object.values(state.streamsOk).some(Boolean)
919
+ const all = state.streamsOk.mux && state.streamsOk.host
920
+ el.textContent = '●'
921
+ el.className = 'ds-conn ' + (all ? 'on' : any ? 'ing' : '')
922
+ el.title = all ? t('ds.connOn') : any ? t('ds.connIng') : t('ds.connOff')
923
+ const cur = state.servers.find(s => s.url === state.server)
924
+ const group = cur ? cur.group : state.activeGroup
925
+ const label = cur ? (cur.note || cur.url) : (state.server || t('ds.origin'))
926
+ $('server-badge').textContent = t('ds.currentServer', { group, url: label })
927
+ }
928
+
929
+ /* ---------------- 初始化 ---------------- */
930
+ function bindUi() {
931
+ $('btn-new-session').addEventListener('click', async () => {
932
+ const v = await safeRpc('session.create', {}, '')
933
+ if (v?.sessionId) { await refreshSessions(); openSession(v.sessionId) }
934
+ })
935
+ $('btn-mobile-nav').addEventListener('click', () => {
936
+ const list = $('mobile-session-list')
937
+ list.style.display = list.style.display === 'none' ? 'flex' : 'none'
938
+ })
939
+ document.querySelectorAll('.ds-nav-item').forEach(b => b.addEventListener('click', () => showView(b.dataset.view)))
940
+ $('session-list').addEventListener('click', (e) => {
941
+ const item = e.target.closest('[data-id]')
942
+ if (item) openSession(item.dataset.id)
943
+ })
944
+ $('btn-send').addEventListener('click', sendMessage)
945
+ $('composer').addEventListener('keydown', (e) => {
946
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage() }
947
+ })
948
+ $('btn-stats-top').addEventListener('click', toggleStatsDrawer)
949
+ $('stats-drawer-close').addEventListener('click', toggleStatsDrawer)
950
+ // 反馈
951
+ $('btn-feedback').addEventListener('click', (e) => { e.stopPropagation(); toggleFeedbackMenu() })
952
+ $('feedback-menu').addEventListener('click', (e) => {
953
+ if (e.target.closest('a[role="menuitem"]')) closeFeedbackMenu()
954
+ })
955
+ $('btn-copy-link').addEventListener('click', async () => {
956
+ const ok = await copyText(FEEDBACK_LINKS.repo)
957
+ toast(t(ok ? 'ds.feedbackCopied' : 'ds.feedbackCopyFailed'), ok ? 'ok' : 'err')
958
+ closeFeedbackMenu()
959
+ })
960
+ $('btn-write-feedback').addEventListener('click', () => { closeFeedbackMenu(); openFeedbackModal() })
961
+ $('fb-cancel').addEventListener('click', closeFeedbackModal)
962
+ $('fb-submit').addEventListener('click', submitFeedback)
963
+ document.querySelectorAll('#fb-chips .ds-fb-chip').forEach(btn =>
964
+ btn.addEventListener('click', () => {
965
+ document.querySelectorAll('#fb-chips .ds-fb-chip').forEach(b => b.classList.toggle('current', b === btn))
966
+ }))
967
+ document.addEventListener('click', (e) => {
968
+ if (!e.target.closest('.ds-feedback')) closeFeedbackMenu()
969
+ })
970
+ document.addEventListener('keydown', (e) => {
971
+ if (e.key === 'Escape' && !$('feedback-menu').classList.contains('hidden')) { closeFeedbackMenu(); $('btn-feedback').focus() }
972
+ })
973
+ // 统计柱状图悬停提示: 自定义 tooltip, 限制在视口内, 避免原生 title 溢出抽屉
974
+ $('stats-chart').addEventListener('mouseover', (e) => {
975
+ const bar = e.target.closest('.ds-stats-bar')
976
+ if (bar && bar.dataset.tip) showTip(bar.dataset.tip, bar.getBoundingClientRect())
977
+ })
978
+ $('stats-chart').addEventListener('mouseleave', hideTip)
979
+
980
+ $('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
981
+ $('btn-server-add').addEventListener('click', addServer)
982
+ $('btn-group-add').addEventListener('click', addGroup)
983
+ $('group-select-btn').addEventListener('click', (e) => { e.stopPropagation(); toggleGroupMenu() })
984
+ document.addEventListener('click', (e) => { if (!e.target.closest('#group-select')) closeGroupMenu() })
985
+ $('server-input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); addServer() } })
986
+
987
+ $('btn-copy-token').addEventListener('click', async () => {
988
+ try { await navigator.clipboard.writeText(state.token); toast(t('ds.toastCopied'), 'ok') }
989
+ catch { toast(t('ds.toastOpFailed'), 'err') }
990
+ })
991
+ $('btn-theme').addEventListener('click', () => {
992
+ const cur = themeGet() || 'default'
993
+ const idx = THEME_META.findIndex(m => m.id === cur)
994
+ themeSet(THEME_META[(idx + 1) % THEME_META.length].id)
995
+ })
996
+ $('btn-lang').addEventListener('click', () => {
997
+ I18N.setLang(I18N.lang === 'zh' ? 'en' : 'zh')
998
+ $('btn-lang').textContent = I18N.lang === 'zh' ? 'EN' : '中文'
999
+ renderServers(); renderSessions(); updateConn(); themeApply()
1000
+ })
1001
+ $('fs-up').addEventListener('click', fsUp)
1002
+ $('fs-refresh').addEventListener('click', () => loadFs(state.fs.path || null))
1003
+ $('btn-question-submit').addEventListener('click', submitQuestion)
1004
+ $('btn-question-cancel').addEventListener('click', () => { $('modal-question').classList.add('hidden'); toast(t('ds.ignored'), 'ok') })
1005
+ }
1006
+
1007
+ async function start() {
1008
+ loadServers()
1009
+ renderServers()
1010
+ showView('view-sessions')
1011
+ const urlToken = new URLSearchParams(location.search).get('token')
1012
+ if (urlToken) { state.token = urlToken; LS.set('token', urlToken); history.replaceState(null, '', location.pathname) }
1013
+ if (!state.token) {
1014
+ const input = prompt(t('ds.tokenTitle'))
1015
+ if (input && input.trim()) { state.token = input.trim(); LS.set('token', state.token) }
1016
+ }
1017
+ $('token-desc').textContent = state.token ? '● ' + state.token.slice(0, 12) + '…' : t('ds.toastAuth')
1018
+ bindUi()
1019
+ updateConn()
1020
+ if (state.token) {
1021
+ if (state.servers.length) await selectFastestServer({ silent: true, reconnect: false })
1022
+ openStreams()
1023
+ refreshSessions()
1024
+ }
1025
+ }
1026
+
1027
+ start()