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/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 (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN) openStreams()
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) return
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
- const v = await safeRpc('session.history', payload, '加载历史失败')
396
- if (!v) { state.history.loading = false; return }
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
- state.server = LS.get('server', '')
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-change-server').addEventListener('click', () => {
1069
- const s = prompt('输入网关地址(留空 = 当前页面地址):\n例: http://192.168.1.100:8787', state.server)
1070
- if (s === null) return
1071
- const v = (s || '').trim().replace(/\/+$/, '')
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', {}, '')