dsh-remote-plugin 0.4.5 → 0.4.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.
- package/README.md +6 -3
- package/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +618 -0
- package/package.json +2 -2
- package/public/app.js +548 -18
- package/public/index.html +25 -2
- package/public/styles.css +71 -0
- package/public/update.json +3 -3
- package/public/version.json +1 -1
package/public/app.js
CHANGED
|
@@ -8,9 +8,35 @@ const LS = {
|
|
|
8
8
|
del(k) { try { localStorage.removeItem(k) } catch {} }
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/* 离线缓存: 会话列表 + 每会话聊天记录。只在网络失败时兜底展示, 不会替代线上数据。 */
|
|
12
|
+
const CACHE = {
|
|
13
|
+
sessions: 'sessionsCacheV1',
|
|
14
|
+
history: 'historyCacheV1'
|
|
15
|
+
}
|
|
16
|
+
function cacheRead(key, d = null) {
|
|
17
|
+
try { return JSON.parse(LS.get(key, '')) || d } catch { return d }
|
|
18
|
+
}
|
|
19
|
+
function cacheWrite(key, value) {
|
|
20
|
+
try { LS.set(key, JSON.stringify(value)) } catch { LS.del(key) }
|
|
21
|
+
}
|
|
22
|
+
function readHistoryCache() { return cacheRead(CACHE.history, {}) || {} }
|
|
23
|
+
function writeHistoryCache(cache) {
|
|
24
|
+
try { LS.set(CACHE.history, JSON.stringify(cache)); return }
|
|
25
|
+
catch {
|
|
26
|
+
// localStorage 配额不足: 每会话只留最近 50 条再试一次
|
|
27
|
+
try {
|
|
28
|
+
for (const k of Object.keys(cache)) cache[k].events = (cache[k].events || []).slice(-50)
|
|
29
|
+
LS.set(CACHE.history, JSON.stringify(cache))
|
|
30
|
+
} catch { LS.del(CACHE.history) }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
const state = {
|
|
12
35
|
token: '',
|
|
13
|
-
server: '', //
|
|
36
|
+
server: '', // 当前生效的网关地址, 空 = 同源(浏览器模式)
|
|
37
|
+
servers: [], // 备选服务器列表(局域网/远程多地址)
|
|
38
|
+
serverLatency: {}, // url -> 最近一次 /health 测速毫秒数
|
|
39
|
+
selectingServer: false, // 防重入: 测速/切换中
|
|
14
40
|
sessions: [],
|
|
15
41
|
byId: new Map(),
|
|
16
42
|
current: null, // 当前打开的 sessionId
|
|
@@ -23,7 +49,8 @@ const state = {
|
|
|
23
49
|
jobs: {}, // sessionId -> jobs
|
|
24
50
|
history: emptyHistory(),
|
|
25
51
|
errCount: 0,
|
|
26
|
-
refreshTimer: null
|
|
52
|
+
refreshTimer: null,
|
|
53
|
+
fs: { path: null, initial: null, loaded: false, upload: null }
|
|
27
54
|
}
|
|
28
55
|
|
|
29
56
|
const $ = (id) => document.getElementById(id)
|
|
@@ -57,6 +84,21 @@ function fmtTokens(n) {
|
|
|
57
84
|
return String(n)
|
|
58
85
|
}
|
|
59
86
|
|
|
87
|
+
function fmtSize(n) {
|
|
88
|
+
if (n == null || Number.isNaN(Number(n))) return '—'
|
|
89
|
+
const b = Number(n)
|
|
90
|
+
if (b >= 1024 ** 3) return (b / 1024 ** 3).toFixed(2) + ' GB'
|
|
91
|
+
if (b >= 1024 ** 2) return (b / 1024 ** 2).toFixed(1) + ' MB'
|
|
92
|
+
if (b >= 1024) return (b / 1024).toFixed(1) + ' KB'
|
|
93
|
+
return b + ' B'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function fmtFullTime(ts) {
|
|
97
|
+
if (!ts) return '—'
|
|
98
|
+
const d = new Date(ts)
|
|
99
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
100
|
+
}
|
|
101
|
+
|
|
60
102
|
/* ---------------- API ---------------- */
|
|
61
103
|
function apiUrl(path) {
|
|
62
104
|
return (state.server || '') + path
|
|
@@ -104,6 +146,133 @@ function authFailure() {
|
|
|
104
146
|
$('token-desc').textContent = '令牌无效,点「更换」重新设置'
|
|
105
147
|
}
|
|
106
148
|
|
|
149
|
+
/* ---------------- 多服务器 + 自动选优 ---------------- */
|
|
150
|
+
function loadServers() {
|
|
151
|
+
let arr = null
|
|
152
|
+
try { arr = JSON.parse(LS.get('servers', '')) } catch {}
|
|
153
|
+
if (!Array.isArray(arr)) {
|
|
154
|
+
const legacy = LS.get('server', '')
|
|
155
|
+
arr = legacy ? [legacy] : []
|
|
156
|
+
}
|
|
157
|
+
state.servers = arr.map(s => String(s || '').trim().replace(/\/+$/, ''))
|
|
158
|
+
.filter(s => /^https?:\/\//i.test(s))
|
|
159
|
+
const active = LS.get('activeServer', '')
|
|
160
|
+
if (active === 'origin') state.server = '' // 浏览器里明确选了同源页面
|
|
161
|
+
else state.server = state.servers.includes(active) ? active : (state.servers[0] || '')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function saveServers() {
|
|
165
|
+
LS.set('servers', JSON.stringify(state.servers))
|
|
166
|
+
LS.set('server', state.server) // 兼容旧字段
|
|
167
|
+
LS.set('activeServer', state.server === '' && !CAP?.isNativePlatform?.() ? 'origin' : state.server)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function serverCandidates() {
|
|
171
|
+
const list = [...state.servers]
|
|
172
|
+
// 浏览器控制台: 当前页面(同源网关)也作为候选, 通常 0 跳内最快
|
|
173
|
+
if (!CAP?.isNativePlatform?.() && location.origin && !list.includes(location.origin)) list.push(location.origin)
|
|
174
|
+
return list
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function pingServer(base) {
|
|
178
|
+
const u = String(base || '').replace(/\/+$/, '')
|
|
179
|
+
if (!u) return Infinity
|
|
180
|
+
const t0 = performance.now()
|
|
181
|
+
const ctrl = new AbortController()
|
|
182
|
+
const timer = setTimeout(() => ctrl.abort(), 3500)
|
|
183
|
+
try {
|
|
184
|
+
const res = await fetch(u + '/health?t=' + Date.now(), { signal: ctrl.signal, cache: 'no-store' })
|
|
185
|
+
return res.ok ? Math.round(performance.now() - t0) : Infinity
|
|
186
|
+
} catch {
|
|
187
|
+
return Infinity
|
|
188
|
+
} finally {
|
|
189
|
+
clearTimeout(timer)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function selectFastestServer({ silent = false, reconnect = true } = {}) {
|
|
194
|
+
if (state.selectingServer) return null
|
|
195
|
+
state.selectingServer = true
|
|
196
|
+
try {
|
|
197
|
+
if (!silent) toast('正在测速各服务器…')
|
|
198
|
+
const candidates = serverCandidates()
|
|
199
|
+
for (const u of candidates) state.serverLatency[u] = await pingServer(u)
|
|
200
|
+
const best = candidates
|
|
201
|
+
.filter(u => Number.isFinite(state.serverLatency[u]))
|
|
202
|
+
.sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0]
|
|
203
|
+
const sameOrigin = !CAP?.isNativePlatform?.() && best === location.origin
|
|
204
|
+
// 全部不可达时保持原服务器, 不硬切到空(同源)地址
|
|
205
|
+
const chosen = best ? (sameOrigin && !state.servers.includes(best) ? '' : best) : (state.server || '')
|
|
206
|
+
renderServers()
|
|
207
|
+
if (chosen !== state.server) {
|
|
208
|
+
state.server = chosen
|
|
209
|
+
saveServers()
|
|
210
|
+
if (!silent) toast(`已切换到最快服务器:${chosen || '当前页面'}(${state.serverLatency[best]}ms)`, 'ok')
|
|
211
|
+
if (reconnect && state.token) { openStreams(); refreshAll() }
|
|
212
|
+
} else if (!silent) {
|
|
213
|
+
if (best) toast(`当前已是最快:${chosen || '当前页面'}(${state.serverLatency[best]}ms)`, 'ok')
|
|
214
|
+
else toast('全部服务器不可达', 'err')
|
|
215
|
+
}
|
|
216
|
+
return chosen
|
|
217
|
+
} finally {
|
|
218
|
+
state.selectingServer = false
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function renderServers() {
|
|
223
|
+
const box = $('server-list')
|
|
224
|
+
if (!box) return
|
|
225
|
+
if (!state.servers.length) {
|
|
226
|
+
box.innerHTML = '<div class="server-empty">未添加备用服务器 · 默认使用当前页面地址</div>'
|
|
227
|
+
} else {
|
|
228
|
+
box.innerHTML = state.servers.map(u => {
|
|
229
|
+
const ms = state.serverLatency[u]
|
|
230
|
+
let badge = '<span class="server-badge">未测速</span>'
|
|
231
|
+
if (Number.isFinite(ms)) badge = `<span class="server-badge ${u === state.server ? 'good' : ''}">${ms}ms${u === state.server ? ' · 当前' : ''}</span>`
|
|
232
|
+
else if (ms !== undefined) badge = '<span class="server-badge bad">不可达</span>'
|
|
233
|
+
return `<div class="server-row ${u === state.server ? 'active' : ''}">
|
|
234
|
+
<span class="server-url">${esc(u)}</span>${badge}
|
|
235
|
+
<button class="server-del" data-del="${esc(u)}" aria-label="删除服务器">✕</button>
|
|
236
|
+
</div>`
|
|
237
|
+
}).join('')
|
|
238
|
+
box.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', () => removeServer(b.dataset.del)))
|
|
239
|
+
}
|
|
240
|
+
$('server-desc').textContent = state.server
|
|
241
|
+
? `当前: ${state.server}`
|
|
242
|
+
: (CAP?.isNativePlatform?.() ? '未设置服务器' : '默认 = 当前页面地址')
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function addServer() {
|
|
246
|
+
const input = $('server-input')
|
|
247
|
+
let raw = (input?.value || '').trim().replace(/\/+$/, '')
|
|
248
|
+
if (!raw) return toast('请输入服务器地址', 'err')
|
|
249
|
+
try {
|
|
250
|
+
const u = new URL(raw)
|
|
251
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad')
|
|
252
|
+
} catch {
|
|
253
|
+
return toast('地址需以 http:// 或 https:// 开头', 'err')
|
|
254
|
+
}
|
|
255
|
+
if (state.servers.includes(raw)) return toast('该地址已在列表中')
|
|
256
|
+
state.servers.push(raw)
|
|
257
|
+
saveServers()
|
|
258
|
+
if (input) input.value = ''
|
|
259
|
+
renderServers()
|
|
260
|
+
toast('已添加服务器', 'ok')
|
|
261
|
+
if (state.token) selectFastestServer({ silent: false })
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function removeServer(url) {
|
|
265
|
+
state.servers = state.servers.filter(s => s !== url)
|
|
266
|
+
const wasActive = state.server === url
|
|
267
|
+
if (wasActive) state.server = ''
|
|
268
|
+
saveServers()
|
|
269
|
+
renderServers()
|
|
270
|
+
if (wasActive) {
|
|
271
|
+
toast('已删除当前服务器,重新测速…')
|
|
272
|
+
selectFastestServer({ silent: true })
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
107
276
|
/* ---------------- 事件流 (WebSocket) ---------------- */
|
|
108
277
|
const streams = {}
|
|
109
278
|
state.streamsOk = { mux: false, host: false }
|
|
@@ -145,6 +314,8 @@ function openStream(kind, handler, refreshOnOpen) {
|
|
|
145
314
|
state.errCount++
|
|
146
315
|
updateConn()
|
|
147
316
|
if (state.errCount === 3) toast('连接中断,正在重连…', 'err')
|
|
317
|
+
// 多服务器: 连续掉线若干次就重测速, 自动换到当前可达的最快地址
|
|
318
|
+
if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
|
|
148
319
|
// 无条件重连; 页面被挂起时定时器暂停, visibilitychange 会再触发一次
|
|
149
320
|
if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), 1200)
|
|
150
321
|
}
|
|
@@ -167,11 +338,12 @@ function onResume() {
|
|
|
167
338
|
updateConn()
|
|
168
339
|
}
|
|
169
340
|
|
|
170
|
-
/* 回前台 / 定时兜底: 任何流不在 OPEN
|
|
341
|
+
/* 回前台 / 定时兜底: 任何流不在 OPEN 就重连; 多服务器时顺便重测速 */
|
|
171
342
|
document.addEventListener('visibilitychange', () => {
|
|
172
343
|
if (document.visibilityState === 'visible') {
|
|
173
344
|
onResume()
|
|
174
|
-
if (
|
|
345
|
+
if (state.servers.length) selectFastestServer({ silent: true })
|
|
346
|
+
else if (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN) openStreams()
|
|
175
347
|
}
|
|
176
348
|
})
|
|
177
349
|
window.addEventListener('pageshow', onResume)
|
|
@@ -180,6 +352,10 @@ setInterval(() => {
|
|
|
180
352
|
if (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN) openStreams()
|
|
181
353
|
}
|
|
182
354
|
}, 15000)
|
|
355
|
+
// 多服务器: 每 5 分钟重测一次延迟, 网络环境变化(离开 Wi-Fi / 挂上 Tailscale)时自动换线
|
|
356
|
+
setInterval(() => {
|
|
357
|
+
if (document.visibilityState === 'visible' && state.servers.length) selectFastestServer({ silent: true })
|
|
358
|
+
}, 300000)
|
|
183
359
|
function onMuxFrame(full) {
|
|
184
360
|
const f = full.payload
|
|
185
361
|
if (!f) return
|
|
@@ -247,9 +423,22 @@ async function refreshAll() {
|
|
|
247
423
|
|
|
248
424
|
async function refreshSessions() {
|
|
249
425
|
const v = await safeRpc('session.list', {}, '拉取会话列表失败')
|
|
250
|
-
if (!v)
|
|
426
|
+
if (!v) {
|
|
427
|
+
// 网关不可达: 用上次成功的会话列表兜底, 用户仍能打开历史缓存
|
|
428
|
+
if (!state.sessions.length) {
|
|
429
|
+
const cached = cacheRead(CACHE.sessions, [])
|
|
430
|
+
if (Array.isArray(cached) && cached.length) {
|
|
431
|
+
state.sessions = cached
|
|
432
|
+
state.byId = new Map(cached.map(s => [s.sessionId, s]))
|
|
433
|
+
renderSessions()
|
|
434
|
+
toast('网络不可用:显示本地缓存的会话列表', 'ok')
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return
|
|
438
|
+
}
|
|
251
439
|
state.sessions = v.items || []
|
|
252
440
|
state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
|
|
441
|
+
cacheWrite(CACHE.sessions, state.sessions.slice(0, 80))
|
|
253
442
|
renderSessions()
|
|
254
443
|
}
|
|
255
444
|
|
|
@@ -341,6 +530,10 @@ function bindNativeBack() {
|
|
|
341
530
|
const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
|
|
342
531
|
if (openModal) { openModal.classList.add('hidden'); return } // 先关弹窗
|
|
343
532
|
if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
|
|
533
|
+
if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
|
|
534
|
+
if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
|
|
535
|
+
showView('view-home'); return
|
|
536
|
+
}
|
|
344
537
|
try { CAP.Plugins?.App?.exitApp?.() } catch {} // 主页再返回 → 退出(与系统一致)
|
|
345
538
|
})
|
|
346
539
|
} catch {}
|
|
@@ -384,6 +577,50 @@ function trimVisible() {
|
|
|
384
577
|
h.renderEnd = Math.max(h.renderStart, h.renderEnd - drop.length)
|
|
385
578
|
}
|
|
386
579
|
|
|
580
|
+
/* 聊天记录本地缓存: 每会话最多 250 条, 全局最多 10 个会话 */
|
|
581
|
+
function scheduleHistoryCacheSave() {
|
|
582
|
+
clearTimeout(scheduleHistoryCacheSave._t)
|
|
583
|
+
scheduleHistoryCacheSave._t = setTimeout(saveHistoryCache, 400)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function saveHistoryCache() {
|
|
587
|
+
const id = state.current
|
|
588
|
+
if (!id || !state.history.visible.length) return
|
|
589
|
+
const s = state.byId.get(id)
|
|
590
|
+
const cache = readHistoryCache()
|
|
591
|
+
cache[id] = {
|
|
592
|
+
title: s ? titleOf(s) : '',
|
|
593
|
+
updatedAt: Date.now(),
|
|
594
|
+
events: state.history.visible.slice(-250).map(e => ({ seq: e.seq, event: e.event }))
|
|
595
|
+
}
|
|
596
|
+
const keys = Object.entries(cache)
|
|
597
|
+
.sort((a, b) => (b[1].updatedAt || 0) - (a[1].updatedAt || 0))
|
|
598
|
+
.slice(0, 10)
|
|
599
|
+
.map(([k]) => k)
|
|
600
|
+
const pruned = {}
|
|
601
|
+
for (const k of keys) pruned[k] = cache[k]
|
|
602
|
+
writeHistoryCache(pruned)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** 网关不可达时回填本地缓存的历史; 返回是否命中。 */
|
|
606
|
+
function restoreCachedHistory() {
|
|
607
|
+
const id = state.current
|
|
608
|
+
if (!id) return false
|
|
609
|
+
const cached = readHistoryCache()[id]
|
|
610
|
+
if (!cached?.events?.length) return false
|
|
611
|
+
const h = emptyHistory()
|
|
612
|
+
for (const e of cached.events) {
|
|
613
|
+
if (!e?.seq) continue
|
|
614
|
+
h.seqs.add(e.seq)
|
|
615
|
+
h.visible.push(e)
|
|
616
|
+
}
|
|
617
|
+
h.visible.sort((a, b) => a.seq - b.seq)
|
|
618
|
+
state.history = h
|
|
619
|
+
$('history-hint').textContent = `离线缓存 ${h.visible.length} 条`
|
|
620
|
+
renderHistory(true)
|
|
621
|
+
return true
|
|
622
|
+
}
|
|
623
|
+
|
|
387
624
|
async function loadHistory(reset) {
|
|
388
625
|
const id = state.current
|
|
389
626
|
if (!id || state.history.loading) return
|
|
@@ -392,8 +629,18 @@ async function loadHistory(reset) {
|
|
|
392
629
|
if (moreBtn) moreBtn.classList.add('hidden')
|
|
393
630
|
const payload = { sessionId: id, maxMessages: 60 }
|
|
394
631
|
if (!reset && state.history.minSeq !== Infinity) payload.beforeSeq = state.history.minSeq
|
|
395
|
-
|
|
396
|
-
|
|
632
|
+
|
|
633
|
+
let v
|
|
634
|
+
try {
|
|
635
|
+
v = await rpc('session.history', payload)
|
|
636
|
+
} catch (e) {
|
|
637
|
+
state.history.loading = false
|
|
638
|
+
if (e.message === 'AUTH') { authFailure(); return }
|
|
639
|
+
if (restoreCachedHistory()) toast('网络不可用:显示本地缓存的历史', 'ok')
|
|
640
|
+
else toast('加载历史失败:' + e.message, 'err')
|
|
641
|
+
return
|
|
642
|
+
}
|
|
643
|
+
|
|
397
644
|
const incoming = v.events || []
|
|
398
645
|
let added = 0
|
|
399
646
|
for (const entry of incoming) {
|
|
@@ -416,6 +663,7 @@ async function loadHistory(reset) {
|
|
|
416
663
|
else if (added) renderHistory(false, 'keep')
|
|
417
664
|
if (moreBtn) moreBtn.classList.toggle('hidden', !state.history.hasMore)
|
|
418
665
|
$('history-hint').textContent = state.history.visible.length ? `${state.history.visible.length} 条` : ''
|
|
666
|
+
scheduleHistoryCacheSave()
|
|
419
667
|
}
|
|
420
668
|
|
|
421
669
|
function insertLiveEvent(event) {
|
|
@@ -435,6 +683,7 @@ function insertLiveEvent(event) {
|
|
|
435
683
|
} else {
|
|
436
684
|
renderHistory(false, 'keep')
|
|
437
685
|
}
|
|
686
|
+
scheduleHistoryCacheSave()
|
|
438
687
|
}
|
|
439
688
|
|
|
440
689
|
function isToolEvent(type) { return type === 'tool/call' || type === 'tool/result' }
|
|
@@ -848,6 +1097,274 @@ function renderJobs() {
|
|
|
848
1097
|
}).join(''))
|
|
849
1098
|
}
|
|
850
1099
|
|
|
1100
|
+
/* ---------------- 文件传输 ---------------- */
|
|
1101
|
+
function fsHeaders() {
|
|
1102
|
+
return {
|
|
1103
|
+
authorization: 'Bearer ' + state.token,
|
|
1104
|
+
'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web'
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function fsJoin(dir, name) {
|
|
1109
|
+
return dir.replace(/\/+$/, '') + '/' + name
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function fsParent(p) {
|
|
1113
|
+
const clean = String(p || '').replace(/\/+$/, '')
|
|
1114
|
+
const idx = clean.lastIndexOf('/')
|
|
1115
|
+
if (idx <= 0) return clean === '' ? '' : '/'
|
|
1116
|
+
return clean.slice(0, idx)
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function fsApiUrl(sub, params = {}) {
|
|
1120
|
+
const u = new URL(apiUrl('/fs' + sub), location.href)
|
|
1121
|
+
for (const [k, v] of Object.entries(params)) {
|
|
1122
|
+
if (v != null && v !== '') u.searchParams.set(k, v)
|
|
1123
|
+
}
|
|
1124
|
+
return u.href
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function fsAuthError(status) {
|
|
1128
|
+
if (status === 401) authFailure()
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
async function loadFs(dir, { silent = false, resetRoot = false } = {}) {
|
|
1132
|
+
if (!state.token) {
|
|
1133
|
+
$('fs-path').textContent = '未设置令牌'
|
|
1134
|
+
$('fs-list').innerHTML = '<div class="empty">请先到「设置」页粘贴网关令牌</div>'
|
|
1135
|
+
return
|
|
1136
|
+
}
|
|
1137
|
+
if (resetRoot) { state.fs.initial = null; state.fs.path = null }
|
|
1138
|
+
const target = dir ?? state.fs.path ?? ''
|
|
1139
|
+
if (!silent) {
|
|
1140
|
+
$('fs-list').innerHTML = '<div class="empty">加载中…</div>'
|
|
1141
|
+
$('fs-path').textContent = target ? '…' + target.slice(-40) : '加载中…'
|
|
1142
|
+
}
|
|
1143
|
+
try {
|
|
1144
|
+
const res = await fetch(fsApiUrl('/list', target ? { path: target } : {}), { headers: fsHeaders() })
|
|
1145
|
+
if (res.status === 401) { fsAuthError(401); return }
|
|
1146
|
+
const data = await res.json().catch(() => ({}))
|
|
1147
|
+
if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error === 'not-found' ? '目录不存在' : data.error === 'forbidden' ? '路径不在允许范围内' : data.error || ('HTTP ' + res.status))
|
|
1148
|
+
state.fs.path = data.path
|
|
1149
|
+
if (!state.fs.initial) state.fs.initial = data.path
|
|
1150
|
+
state.fs.loaded = true
|
|
1151
|
+
renderFs(data)
|
|
1152
|
+
} catch (e) {
|
|
1153
|
+
if (e.message === 'AUTH') return
|
|
1154
|
+
$('fs-path').textContent = target || '~'
|
|
1155
|
+
$('fs-list').innerHTML = `<div class="empty">加载失败:${esc(e.message || '网络错误')}</div>`
|
|
1156
|
+
if (!silent) toast('文件列表加载失败:' + e.message, 'err')
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function renderFs(data) {
|
|
1161
|
+
$('fs-path').textContent = data.path || '~'
|
|
1162
|
+
const list = $('fs-list')
|
|
1163
|
+
if (!data.entries.length) {
|
|
1164
|
+
list.innerHTML = '<div class="empty">空目录</div>'
|
|
1165
|
+
return
|
|
1166
|
+
}
|
|
1167
|
+
list.innerHTML = data.entries.map(e => {
|
|
1168
|
+
const isDir = e.type === 'dir'
|
|
1169
|
+
return `<div class="fs-row" data-name="${esc(e.name)}" data-type="${esc(e.type)}">
|
|
1170
|
+
<span class="fs-ico">${isDir ? '📁' : '📄'}</span>
|
|
1171
|
+
<span class="fs-meta">
|
|
1172
|
+
<span class="fs-name">${esc(e.name)}</span>
|
|
1173
|
+
<span class="fs-sub">${isDir ? '目录' : fmtSize(e.size)} · ${fmtFullTime(e.mtimeMs)}</span>
|
|
1174
|
+
</span>
|
|
1175
|
+
<span class="fs-arrow">${isDir ? '›' : '↓'}</span>
|
|
1176
|
+
</div>`
|
|
1177
|
+
}).join('')
|
|
1178
|
+
list.querySelectorAll('.fs-row').forEach(row =>
|
|
1179
|
+
row.addEventListener('click', () => fsOpenEntry(row.dataset.name, row.dataset.type)))
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function fsOpenEntry(name, type) {
|
|
1183
|
+
if (!name) return
|
|
1184
|
+
const p = fsJoin(state.fs.path, name)
|
|
1185
|
+
if (type === 'dir') return loadFs(p)
|
|
1186
|
+
downloadFsFile(name)
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function downloadFsFile(name) {
|
|
1190
|
+
const p = fsJoin(state.fs.path, name)
|
|
1191
|
+
const url = fsApiUrl('/file', { path: p })
|
|
1192
|
+
if (CAP?.isNativePlatform?.()) {
|
|
1193
|
+
if (window.NativeFile?.downloadToDownloads) {
|
|
1194
|
+
try {
|
|
1195
|
+
window.NativeFile.downloadToDownloads(url, name, state.token)
|
|
1196
|
+
toast('开始下载到「下载/dsh-remote」目录', 'ok')
|
|
1197
|
+
} catch (e) {
|
|
1198
|
+
toast('无法启动下载:' + (e?.message || ''), 'err')
|
|
1199
|
+
}
|
|
1200
|
+
return
|
|
1201
|
+
}
|
|
1202
|
+
toast('当前 App 版本不支持系统下载,请先更新 App', 'err')
|
|
1203
|
+
return
|
|
1204
|
+
}
|
|
1205
|
+
// 浏览器控制台: <a download> + ?token= 兜底(主通道仍是 Bearer 头)
|
|
1206
|
+
const a = document.createElement('a')
|
|
1207
|
+
const u = new URL(url)
|
|
1208
|
+
u.searchParams.set('token', state.token)
|
|
1209
|
+
a.href = u.href
|
|
1210
|
+
a.download = name
|
|
1211
|
+
document.body.appendChild(a)
|
|
1212
|
+
a.click()
|
|
1213
|
+
a.remove()
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function showFsProgress(pct, loaded, total) {
|
|
1217
|
+
$('fs-progress').classList.remove('hidden')
|
|
1218
|
+
$('fs-progress-bar').style.width = Math.max(2, Math.min(100, pct)) + '%'
|
|
1219
|
+
$('fs-progress-text').textContent = `${pct}% · ${fmtSize(loaded)} / ${fmtSize(total)}`
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function hideFsProgress() {
|
|
1223
|
+
$('fs-progress').classList.add('hidden')
|
|
1224
|
+
$('fs-progress-bar').style.width = '0%'
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function uploadFsFile(file) {
|
|
1228
|
+
if (!file) return
|
|
1229
|
+
if (!state.token) { toast('请先到「设置」页设置令牌', 'err'); showView('view-settings'); return }
|
|
1230
|
+
if (file.size > 2 * 1024 * 1024 * 1024) { toast('单文件超过 2GB 上限', 'err'); return }
|
|
1231
|
+
|
|
1232
|
+
const FS_CHUNK_SIZE = 4 * 1024 * 1024 // 4MB/块, 断线后重选同一文件自动续传
|
|
1233
|
+
|
|
1234
|
+
const uploadChunk = (params, blob, up) => new Promise((resolve, reject) => {
|
|
1235
|
+
const xhr = new XMLHttpRequest()
|
|
1236
|
+
xhr.open('POST', fsApiUrl('/upload', params))
|
|
1237
|
+
xhr.setRequestHeader('authorization', 'Bearer ' + state.token)
|
|
1238
|
+
xhr.setRequestHeader('x-dsh-remote-client', CAP?.isNativePlatform?.() ? 'app' : 'web')
|
|
1239
|
+
xhr.upload.onprogress = (e) => {
|
|
1240
|
+
if (e.lengthComputable) {
|
|
1241
|
+
const loaded = up.offset + Math.min(e.loaded, e.total)
|
|
1242
|
+
showFsProgress(Math.round(loaded / Math.max(1, up.size) * 100), loaded, up.size)
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
xhr.onload = () => {
|
|
1246
|
+
let json = {}
|
|
1247
|
+
try { json = JSON.parse(xhr.responseText || '{}') } catch {}
|
|
1248
|
+
resolve({ status: xhr.status, json })
|
|
1249
|
+
}
|
|
1250
|
+
xhr.onerror = () => reject(new Error('网络错误'))
|
|
1251
|
+
xhr.upload.onerror = () => reject(new Error('网络中断'))
|
|
1252
|
+
xhr.onabort = () => reject(new Error('已取消'))
|
|
1253
|
+
xhr.send(blob)
|
|
1254
|
+
})
|
|
1255
|
+
|
|
1256
|
+
const probe = async (up) => {
|
|
1257
|
+
const res = await fetch(fsApiUrl('/upload-probe', { path: up.path, name: up.name, session: up.session }), { headers: fsHeaders() })
|
|
1258
|
+
if (res.status === 401) { fsAuthError(401); return null }
|
|
1259
|
+
const json = await res.json().catch(() => ({}))
|
|
1260
|
+
if (json.ok) up.offset = json.partialSize || 0
|
|
1261
|
+
return json
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
;(async () => {
|
|
1265
|
+
let up = state.fs.upload
|
|
1266
|
+
if (!up || up.path !== state.fs.path || up.name !== file.name || up.size !== file.size) {
|
|
1267
|
+
up = { session: uuid(), path: state.fs.path, name: file.name, size: file.size, offset: 0 }
|
|
1268
|
+
state.fs.upload = up
|
|
1269
|
+
}
|
|
1270
|
+
let overwrite = false
|
|
1271
|
+
let wasResumed = false
|
|
1272
|
+
try {
|
|
1273
|
+
const info = await probe(up)
|
|
1274
|
+
if (info === null) return
|
|
1275
|
+
if (info.partialSize > 0) wasResumed = true
|
|
1276
|
+
if (info.targetExists && info.targetSize === up.size && !overwrite) {
|
|
1277
|
+
if (!confirm('文件已存在(大小相同),覆盖它?')) { state.fs.upload = null; return }
|
|
1278
|
+
overwrite = true
|
|
1279
|
+
}
|
|
1280
|
+
showFsProgress(Math.round(up.offset / Math.max(1, up.size) * 100), up.offset, up.size)
|
|
1281
|
+
|
|
1282
|
+
while (up.offset < up.size) {
|
|
1283
|
+
const end = Math.min(up.offset + FS_CHUNK_SIZE, up.size)
|
|
1284
|
+
const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset) }
|
|
1285
|
+
if (end >= up.size) params.finish = '1'
|
|
1286
|
+
if (overwrite) params.overwrite = '1'
|
|
1287
|
+
const r = await uploadChunk(params, file.slice(up.offset, end), up)
|
|
1288
|
+
if (r.status === 401) { fsAuthError(401); return }
|
|
1289
|
+
if (r.status === 200 && r.json.offset != null) { up.offset = r.json.offset; continue }
|
|
1290
|
+
if (r.status === 201) {
|
|
1291
|
+
hideFsProgress()
|
|
1292
|
+
state.fs.upload = null
|
|
1293
|
+
toast(`已上传 ${file.name}${wasResumed ? '(断点续传完成)' : ''}`, 'ok')
|
|
1294
|
+
loadFs()
|
|
1295
|
+
return
|
|
1296
|
+
}
|
|
1297
|
+
if (r.status === 409 && r.json.error === 'conflict') {
|
|
1298
|
+
if (!confirm('文件已存在,覆盖它?')) { state.fs.upload = null; return }
|
|
1299
|
+
overwrite = true
|
|
1300
|
+
continue // 同一 offset 带 overwrite=1 重发
|
|
1301
|
+
}
|
|
1302
|
+
if (r.status === 409 && r.json.error === 'offset-mismatch') {
|
|
1303
|
+
await probe(up)
|
|
1304
|
+
continue
|
|
1305
|
+
}
|
|
1306
|
+
throw new Error(r.json.error || ('HTTP ' + r.status))
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// 0 字节文件: 循环不执行, 发一个空 finish 块
|
|
1310
|
+
if (up.size === 0 && up.offset === 0) {
|
|
1311
|
+
const params = { path: up.path, name: up.name, session: up.session, offset: '0', finish: '1' }
|
|
1312
|
+
if (overwrite) params.overwrite = '1'
|
|
1313
|
+
const r = await uploadChunk(params, new Blob([]), up)
|
|
1314
|
+
if (r.status === 401) { fsAuthError(401); return }
|
|
1315
|
+
if (r.status !== 201) throw new Error(r.json.error || ('HTTP ' + r.status))
|
|
1316
|
+
hideFsProgress()
|
|
1317
|
+
state.fs.upload = null
|
|
1318
|
+
toast(`已上传 ${file.name}`, 'ok')
|
|
1319
|
+
loadFs()
|
|
1320
|
+
return
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
hideFsProgress()
|
|
1324
|
+
} catch (e) {
|
|
1325
|
+
hideFsProgress()
|
|
1326
|
+
toast(`上传中断:${e.message}(重选同一文件可断点续传)`, 'err')
|
|
1327
|
+
}
|
|
1328
|
+
})()
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function fsUp() {
|
|
1332
|
+
if (!state.fs.path || !state.fs.initial) return
|
|
1333
|
+
if (state.fs.path === state.fs.initial) {
|
|
1334
|
+
toast('已在允许的根目录')
|
|
1335
|
+
return
|
|
1336
|
+
}
|
|
1337
|
+
loadFs(fsParent(state.fs.path))
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function bindFsPullRefresh() {
|
|
1341
|
+
const view = $('view-files')
|
|
1342
|
+
if (!view) return
|
|
1343
|
+
const pull = $('fs-pull')
|
|
1344
|
+
let startY = null
|
|
1345
|
+
view.addEventListener('touchstart', (e) => {
|
|
1346
|
+
if (window.scrollY <= 0) { startY = e.touches[0].clientY; pull.style.height = '0px' }
|
|
1347
|
+
}, { passive: true })
|
|
1348
|
+
view.addEventListener('touchmove', (e) => {
|
|
1349
|
+
if (startY == null) return
|
|
1350
|
+
const dy = e.touches[0].clientY - startY
|
|
1351
|
+
if (dy > 4 && window.scrollY <= 0) {
|
|
1352
|
+
pull.style.height = Math.min(64, dy / 2) + 'px'
|
|
1353
|
+
pull.textContent = dy > 80 ? '松开刷新' : '下拉刷新'
|
|
1354
|
+
}
|
|
1355
|
+
}, { passive: true })
|
|
1356
|
+
view.addEventListener('touchend', () => {
|
|
1357
|
+
if (startY == null) return
|
|
1358
|
+
const h = parseFloat(pull.style.height || '0')
|
|
1359
|
+
startY = null
|
|
1360
|
+
if (h >= 40) {
|
|
1361
|
+
pull.textContent = '刷新中…'
|
|
1362
|
+
loadFs(null, { silent: true })
|
|
1363
|
+
}
|
|
1364
|
+
pull.style.height = '0px'
|
|
1365
|
+
})
|
|
1366
|
+
}
|
|
1367
|
+
|
|
851
1368
|
/* ---------------- goal 编辑 ---------------- */
|
|
852
1369
|
function openGoalModal(goal) {
|
|
853
1370
|
state.goalEdit = goal
|
|
@@ -988,9 +1505,12 @@ function notify(title, body) {
|
|
|
988
1505
|
|
|
989
1506
|
/* ---------------- 视图切换 ---------------- */
|
|
990
1507
|
function showView(id) {
|
|
991
|
-
for (const v of ['view-home', 'view-session', 'view-activity', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
|
|
1508
|
+
for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
|
|
1509
|
+
// 离开会话页必须清掉 in-session, 否则其他页面顶栏被 body 样式隐藏
|
|
1510
|
+
document.body.classList.toggle('in-session', id === 'view-session')
|
|
992
1511
|
document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === id))
|
|
993
1512
|
window.scrollTo(0, 0)
|
|
1513
|
+
if (id === 'view-files' && !state.fs.loaded) loadFs(null, { silent: true })
|
|
994
1514
|
}
|
|
995
1515
|
|
|
996
1516
|
function updateConn() {
|
|
@@ -1015,12 +1535,13 @@ function initToken() {
|
|
|
1015
1535
|
} else {
|
|
1016
1536
|
state.token = LS.get('token', '')
|
|
1017
1537
|
}
|
|
1018
|
-
|
|
1538
|
+
loadServers()
|
|
1019
1539
|
$('token-desc').textContent = state.token ? '已保存(本机)' : '未设置'
|
|
1020
1540
|
$('server-desc').textContent = state.server || '默认 = 当前页面地址'
|
|
1021
1541
|
}
|
|
1022
1542
|
|
|
1023
1543
|
function bindUi() {
|
|
1544
|
+
renderServers()
|
|
1024
1545
|
// 底部导航
|
|
1025
1546
|
document.querySelectorAll('.nav-btn').forEach(b =>
|
|
1026
1547
|
b.addEventListener('click', () => showView(b.dataset.view)))
|
|
@@ -1065,15 +1586,10 @@ function bindUi() {
|
|
|
1065
1586
|
const t = prompt('输入访问令牌(网关启动时打印的 token):', state.token)
|
|
1066
1587
|
if (t && t.trim()) { state.token = t.trim(); LS.set('token', t.trim()); $('token-desc').textContent = '已保存'; toast('已保存,正在重连', 'ok'); openStreams(); refreshAll() }
|
|
1067
1588
|
})
|
|
1068
|
-
$('btn-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
state.server = v
|
|
1073
|
-
v ? LS.set('server', v) : LS.del('server')
|
|
1074
|
-
$('server-desc').textContent = v || '默认 = 当前页面地址'
|
|
1075
|
-
toast('服务器已设置,正在重连', 'ok')
|
|
1076
|
-
openStreams(); refreshAll()
|
|
1589
|
+
$('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
|
|
1590
|
+
$('btn-server-add').addEventListener('click', addServer)
|
|
1591
|
+
$('server-input').addEventListener('keydown', (e) => {
|
|
1592
|
+
if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); addServer() }
|
|
1077
1593
|
})
|
|
1078
1594
|
$('btn-host-describe').addEventListener('click', async () => {
|
|
1079
1595
|
const v = await safeRpc('host.describe', {}, '探测失败')
|
|
@@ -1103,6 +1619,18 @@ function bindUi() {
|
|
|
1103
1619
|
if (state.current) renderHistory(true)
|
|
1104
1620
|
toast(e.target.checked ? '已显示工具调用' : '已隐藏工具调用', 'ok')
|
|
1105
1621
|
})
|
|
1622
|
+
|
|
1623
|
+
// 文件页
|
|
1624
|
+
$('fs-up').addEventListener('click', fsUp)
|
|
1625
|
+
$('fs-refresh').addEventListener('click', () => { toast('刷新中…'); loadFs() })
|
|
1626
|
+
$('fs-upload-btn').addEventListener('click', () => $('fs-file-input').click())
|
|
1627
|
+
$('fs-file-input').addEventListener('change', (e) => {
|
|
1628
|
+
const f = e.target.files?.[0]
|
|
1629
|
+
if (f) uploadFsFile(f)
|
|
1630
|
+
e.target.value = '' // 允许连续选同一个文件
|
|
1631
|
+
})
|
|
1632
|
+
bindFsPullRefresh()
|
|
1633
|
+
|
|
1106
1634
|
bindRail()
|
|
1107
1635
|
|
|
1108
1636
|
// 向上翻历史 / 向下回最新
|
|
@@ -1150,6 +1678,8 @@ async function boot() {
|
|
|
1150
1678
|
showView('view-settings')
|
|
1151
1679
|
$('token-desc').textContent = '未设置——点「更换」粘贴网关启动时打印的 token'
|
|
1152
1680
|
} else {
|
|
1681
|
+
// 多服务器: 启动时静默测速一次, 选最快的连接(同源页面也参与比较)
|
|
1682
|
+
await selectFastestServer({ silent: true, reconnect: false })
|
|
1153
1683
|
openStreams()
|
|
1154
1684
|
await refreshAll()
|
|
1155
1685
|
const host = await safeRpc('host.describe', {}, '')
|