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.
@@ -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 => ({ '&': '&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
+ /* ---------------- 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()