dsh-remote-plugin 0.4.9 → 0.5.2

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
@@ -50,7 +50,8 @@ const state = {
50
50
  history: emptyHistory(),
51
51
  errCount: 0,
52
52
  refreshTimer: null,
53
- fs: { path: null, initial: null, loaded: false, upload: null }
53
+ fs: { path: null, initial: null, loaded: false, upload: null },
54
+ models: { loaded: false, loading: false, groups: [], current: null, failures: [] }
54
55
  }
55
56
 
56
57
  const $ = (id) => document.getElementById(id)
@@ -298,6 +299,13 @@ function openStream(kind, handler, refreshOnOpen) {
298
299
  state.streamsOk[kind] = true
299
300
  state.errCount = 0
300
301
  updateConn()
302
+ // mux 每次(重)连接都会重放“仍待处理”的审批/提问基线:
303
+ // 先清空旧列表, 避免“桌面已自定义回答, 手机漏收 question/resolved”后永久残留。
304
+ if (kind === 'mux') {
305
+ state.approvals = []
306
+ state.questions = []
307
+ renderPending()
308
+ }
301
309
  if (refreshOnOpen) refreshAll()
302
310
  }
303
311
  ws.onmessage = (msg) => {
@@ -386,10 +394,15 @@ function onHostFrame(full) {
386
394
  if (['host/session-added', 'host/session-removed', 'host/workspace-changed', 'host/workspace-removed', 'host/workspace-order-changed', 'host/archived-sessions-changed'].includes(f.type)) return scheduleRefresh()
387
395
  if (f.type === 'host/session-status') {
388
396
  const s = state.byId.get(f.sessionId)
389
- if (s) { s.running = f.running; if (state.current === f.sessionId) { renderSessionCards(); updateCancelBtn() } }
397
+ if (s) { s.running = f.running; if (state.current === f.sessionId) { renderSessionCards(); updateCancelBtn(); renderSessionSub(); updateSessionStatus() } }
390
398
  return
391
399
  }
392
- if (f.type === 'host/agent-error') return toast(`会话出错:${f.message}`, 'err')
400
+ if (f.type === 'host/agent-error') {
401
+ const s = state.byId.get(f.sessionId)
402
+ if (s) { s.error = true; s.running = false }
403
+ if (state.current === f.sessionId) { renderSessionSub(); updateSessionStatus() }
404
+ return toast(`会话出错:${f.message}`, 'err')
405
+ }
393
406
  if (f.type === 'host/remote-event') return scheduleRefresh()
394
407
  }
395
408
 
@@ -398,8 +411,8 @@ function onSessionEvent(sessionId, event) {
398
411
  const s = state.byId.get(sessionId)
399
412
  if (s) s.updatedAt = Date.now()
400
413
  if (event.type === 'agent/status') {
401
- if (s) { s.running = !!event.data?.running; s.blank = false }
402
- if (state.current === sessionId) { updateCancelBtn(); renderSessionSub() }
414
+ if (s) { s.running = !!event.data?.running; s.blank = false; if (s.running) s.error = false }
415
+ if (state.current === sessionId) { updateCancelBtn(); renderSessionSub(); updateSessionStatus() }
403
416
  }
404
417
  if (event.type === 'session/title' || event.type === 'title') {
405
418
  if (event.data?.title && s) s.projections.values.title = event.data.title
@@ -417,7 +430,7 @@ function scheduleRefresh() {
417
430
 
418
431
  async function refreshAll() {
419
432
  await refreshSessions()
420
- if (state.current) { renderSessionCards(); renderSessionSub(); updateCancelBtn() }
433
+ if (state.current) { renderSessionCards(); renderSessionSub(); updateCancelBtn(); updateSessionStatus() }
421
434
  renderPending(); renderQueue(); renderJobs(); updateConn()
422
435
  }
423
436
 
@@ -508,7 +521,7 @@ async function openSession(id) {
508
521
  state.history = emptyHistory()
509
522
  document.body.classList.add('in-session')
510
523
  showView('view-session')
511
- renderSessionTitle(); renderSessionSub(); updateCancelBtn()
524
+ renderSessionTitle(); renderSessionSub(); updateCancelBtn(); updateSessionStatus()
512
525
  $('history').innerHTML = '<div class="empty">加载历史…</div>'
513
526
  await loadHistory(true)
514
527
  renderSessionCards()
@@ -519,6 +532,7 @@ function closeSession() {
519
532
  state.current = null
520
533
  state.history = emptyHistory()
521
534
  document.body.classList.remove('in-session')
535
+ hideComposerMenu()
522
536
  showView('view-home')
523
537
  }
524
538
 
@@ -550,9 +564,21 @@ function renderSessionSub() {
550
564
  const parts = [short(s.sessionId)]
551
565
  if (s.cwd) parts.push(s.cwd)
552
566
  if (s.running) parts.push('运行中')
567
+ else if (s.error) parts.push('已中断')
553
568
  $('session-sub').textContent = parts.join(' · ')
554
569
  }
555
570
 
571
+ /** 顶栏状态: 运行中=蓝色流动渐变, 中断/出错=橙红渐变, 空闲=原样式 */
572
+ function updateSessionStatus() {
573
+ const s = state.byId.get(state.current)
574
+ const head = $('session-head')
575
+ if (!head) return
576
+ head.classList.remove('running', 'interrupted')
577
+ const queued = (state.queues[state.current] || []).some(i => i.placement !== 'context')
578
+ if (s?.running || queued) head.classList.add('running')
579
+ else if (s?.error) head.classList.add('interrupted')
580
+ }
581
+
556
582
  function updateCancelBtn() {
557
583
  const s = state.byId.get(state.current)
558
584
  const running = s?.running || (state.queues[state.current] || []).some(i => i.placement !== 'context')
@@ -966,20 +992,128 @@ async function interruptSubagent(childId) {
966
992
  setTimeout(renderSessionCards, 600)
967
993
  }
968
994
 
969
- /* ---------------- 发送 / 取消 ---------------- */
970
- async function sendMessage() {
971
- const input = $('composer-input')
972
- const text = input.value.trim()
973
- if (!text || !state.current) return
995
+ /* ---------------- 发送 / 取消 / 快捷菜单 ---------------- */
996
+ async function sendSessionText(text) {
997
+ const clean = String(text || '').trim()
998
+ if (!clean || !state.current) return false
974
999
  $('btn-send').disabled = true
975
1000
  const v = await safeRpc('session.prompt', {
976
1001
  sessionId: state.current,
977
1002
  mode: 'queue',
978
- content: [{ type: 'text', text }]
1003
+ content: [{ type: 'text', text: clean }]
979
1004
  }, '发送失败')
980
1005
  $('btn-send').disabled = false
981
- if (v?.accepted) { input.value = ''; autosize(input); toast('已发送', 'ok') }
982
- else if (v?.command?.text) toast('命令已执行')
1006
+ if (v?.accepted) { toast(clean.startsWith('/') ? '指令已发送' : '已发送', 'ok'); return true }
1007
+ if (v?.command?.text) { toast('命令已执行', 'ok'); return true }
1008
+ return false
1009
+ }
1010
+
1011
+ async function sendMessage() {
1012
+ const input = $('composer-input')
1013
+ const text = input.value.trim()
1014
+ if (!text || !state.current) return
1015
+ if (await sendSessionText(text)) { input.value = ''; autosize(input) }
1016
+ }
1017
+
1018
+ function hideComposerMenu() {
1019
+ $('composer-menu').classList.add('hidden')
1020
+ $('btn-plus').classList.remove('active')
1021
+ }
1022
+
1023
+ function toggleComposerMenu() {
1024
+ const menu = $('composer-menu')
1025
+ const show = menu.classList.contains('hidden')
1026
+ menu.classList.toggle('hidden', !show)
1027
+ $('btn-plus').classList.toggle('active', show)
1028
+ if (show && !state.models.loaded && !state.models.loading) loadSessionModels()
1029
+ }
1030
+
1031
+ async function loadSessionModels() {
1032
+ if (!state.current || state.models.loading) return
1033
+ state.models.loading = true
1034
+ renderModelMenu()
1035
+ try {
1036
+ const v = await rpc('session.models', { sessionId: state.current })
1037
+ state.models.groups = v.groups || []
1038
+ state.models.current = v.current || null
1039
+ state.models.failures = v.failures || []
1040
+ state.models.loaded = true
1041
+ } catch (e) {
1042
+ if (e.message === 'AUTH') { authFailure(); return }
1043
+ toast('模型列表加载失败:' + e.message, 'err')
1044
+ }
1045
+ state.models.loading = false
1046
+ renderModelMenu()
1047
+ }
1048
+
1049
+ function renderModelMenu() {
1050
+ const box = $('menu-models')
1051
+ if (!box) return
1052
+ if (state.models.loading) { box.innerHTML = '<span>模型加载中…</span>'; return }
1053
+ const groups = state.models.groups || []
1054
+ if (!groups.length) {
1055
+ box.innerHTML = '<span>' + ((state.models.failures || []).map(f => f.name + '不可用').join(';') || '没有可用模型') + '</span>'
1056
+ const effortGroup = $('menu-effort-group')
1057
+ if (effortGroup) effortGroup.classList.add('hidden')
1058
+ return
1059
+ }
1060
+ const cur = state.models.current
1061
+ box.innerHTML = groups.map(g => `
1062
+ <div style="width:100%">
1063
+ <div class="model-provider">${esc(g.name || g.id)}</div>
1064
+ <div class="menu-chips">${(g.models || []).map(m => {
1065
+ const isCur = cur && cur.provider === g.id && cur.model === m.id
1066
+ return `<button class="model-chip ${isCur ? 'current' : ''}" data-model="${esc(m.id)}" data-provider="${esc(g.id)}">${esc(m.name || m.id)}</button>`
1067
+ }).join('')}</div>
1068
+ </div>`).join('')
1069
+ box.querySelectorAll('[data-model]').forEach(btn =>
1070
+ btn.addEventListener('click', () => selectSessionModel(btn.dataset.provider, btn.dataset.model)))
1071
+ renderEffortMenu()
1072
+ }
1073
+
1074
+ function renderEffortMenu() {
1075
+ const group = $('menu-effort-group')
1076
+ const box = $('menu-efforts')
1077
+ if (!group || !box) return
1078
+ const cur = state.models.current
1079
+ const provider = (state.models.groups || []).find(g => g.id === cur?.provider)
1080
+ const model = (provider?.models || []).find(m => m.id === cur?.model)
1081
+ const efforts = model?.reasoning?.efforts || []
1082
+ group.classList.toggle('hidden', !efforts.length)
1083
+ box.innerHTML = efforts.map(e => {
1084
+ const isCur = cur?.reasoningEffort === e.id || (!cur?.reasoningEffort && e.id === model.reasoning.defaultEffort)
1085
+ return `<button class="menu-chip ${isCur ? 'current' : ''}" data-effort="${esc(e.id)}" title="${esc(e.description || '')}">${esc(e.name || e.id)}</button>`
1086
+ }).join('')
1087
+ box.querySelectorAll('[data-effort]').forEach(btn =>
1088
+ btn.addEventListener('click', () => selectSessionEffort(btn.dataset.effort)))
1089
+ }
1090
+
1091
+ async function selectSessionEffort(effortId) {
1092
+ const cur = state.models.current
1093
+ if (!state.current || !cur) return
1094
+ const v = await safeRpc('session.selectModel', {
1095
+ sessionId: state.current, provider: cur.provider, model: cur.model, reasoningEffort: effortId
1096
+ }, '切换思考深度失败')
1097
+ if (v?.selected) {
1098
+ state.models.current = v.selected
1099
+ renderEffortMenu()
1100
+ toast(`思考深度:${effortId}`, 'ok')
1101
+ }
1102
+ }
1103
+
1104
+ async function selectSessionModel(provider, modelId) {
1105
+ if (!state.current) return
1106
+ const group = (state.models.groups || []).find(g => g.id === provider)
1107
+ const model = (group?.models || []).find(m => m.id === modelId)
1108
+ const payload = { sessionId: state.current, provider, model: modelId }
1109
+ const effort = model?.reasoning?.defaultEffort || model?.reasoning?.efforts?.[0]?.id
1110
+ if (effort) payload.reasoningEffort = effort
1111
+ const v = await safeRpc('session.selectModel', payload, '切换模型失败')
1112
+ if (v?.selected) {
1113
+ state.models.current = v.selected
1114
+ renderModelMenu()
1115
+ toast(`已切换模型:${v.selected.model}`, 'ok')
1116
+ }
983
1117
  }
984
1118
 
985
1119
  async function cancelSession() {
@@ -1219,20 +1353,113 @@ function showFsProgress(pct, loaded, total) {
1219
1353
  $('fs-progress-text').textContent = `${pct}% · ${fmtSize(loaded)} / ${fmtSize(total)}`
1220
1354
  }
1221
1355
 
1356
+ function setFsProgressText(text) {
1357
+ $('fs-progress-text').textContent = text
1358
+ }
1359
+
1222
1360
  function hideFsProgress() {
1223
1361
  $('fs-progress').classList.add('hidden')
1224
1362
  $('fs-progress-bar').style.width = '0%'
1225
1363
  }
1226
1364
 
1365
+ function setFsButtons(show, paused = false) {
1366
+ const pauseBtn = $('fs-pause-btn')
1367
+ const cancelBtn = $('fs-cancel-btn')
1368
+ if (!pauseBtn || !cancelBtn) return
1369
+ pauseBtn.classList.toggle('hidden', !show)
1370
+ cancelBtn.classList.toggle('hidden', !show)
1371
+ if (show) pauseBtn.textContent = paused ? '继续' : '暂停'
1372
+ }
1373
+
1374
+ function pauseFsUpload() {
1375
+ const up = state.fs.upload
1376
+ if (!up) return
1377
+ if (!up.active) {
1378
+ if (up.paused) resumeFsUpload()
1379
+ return
1380
+ }
1381
+ up.pauseRequested = true
1382
+ try { up.xhr?.abort() } catch {}
1383
+ }
1384
+
1385
+ function resumeFsUpload() {
1386
+ const up = state.fs.upload
1387
+ if (!up || !up.file) return
1388
+ up.paused = false
1389
+ runFsUpload(up)
1390
+ }
1391
+
1392
+ async function cancelFsUpload() {
1393
+ const up = state.fs.upload
1394
+ if (!up) return
1395
+ up.cancelled = true
1396
+ try { up.xhr?.abort() } catch {}
1397
+ try {
1398
+ await fetch(fsApiUrl('/upload-control', { path: up.path, name: up.name, session: up.session, action: 'cancel' }), {
1399
+ method: 'POST', headers: fsHeaders()
1400
+ })
1401
+ } catch {}
1402
+ state.fs.upload = null
1403
+ hideFsProgress()
1404
+ setFsButtons(false)
1405
+ toast('已取消上传')
1406
+ }
1407
+
1408
+ const FS_CHUNK_SIZE = 4 * 1024 * 1024 // 4MB/块, 断线后重选同一文件自动续传
1409
+
1410
+ /** 把文件 [start,end) 逐块喂给 hasher(续传时补齐已传前缀); 期间可被暂停打断 */
1411
+ async function hashFsRange(file, hasher, start, end, up) {
1412
+ const step = FS_CHUNK_SIZE
1413
+ for (let off = start; off < end; off += step) {
1414
+ const buf = await file.slice(off, Math.min(off + step, end)).arrayBuffer()
1415
+ if (up?.pauseRequested) {
1416
+ const err = new Error('已暂停'); err.code = 'PAUSED'; throw err
1417
+ }
1418
+ hasher.update(new Uint8Array(buf))
1419
+ }
1420
+ }
1421
+
1227
1422
  function uploadFsFile(file) {
1228
1423
  if (!file) return
1229
1424
  if (!state.token) { toast('请先到「设置」页设置令牌', 'err'); showView('view-settings'); return }
1230
1425
  if (file.size > 2 * 1024 * 1024 * 1024) { toast('单文件超过 2GB 上限', 'err'); return }
1426
+ if (state.fs.upload?.active) { toast('已有上传任务,先暂停或取消', 'err'); return }
1427
+
1428
+ const prev = state.fs.upload
1429
+ if (prev && prev.path === state.fs.path && prev.name === file.name && prev.size === file.size) {
1430
+ prev.file = file
1431
+ runFsUpload(prev)
1432
+ return
1433
+ }
1434
+ // 换传别的文件: 顺手清掉旧任务的服务端分片, 不残留隐藏 .part
1435
+ if (prev) {
1436
+ prev.cancelled = true
1437
+ try { prev.xhr?.abort() } catch {}
1438
+ try {
1439
+ fetch(fsApiUrl('/upload-control', { path: prev.path, name: prev.name, session: prev.session, action: 'cancel' }), {
1440
+ method: 'POST', headers: fsHeaders()
1441
+ })
1442
+ } catch {}
1443
+ }
1444
+ const up = {
1445
+ session: uuid(), path: state.fs.path, name: file.name, size: file.size,
1446
+ offset: 0, file, xhr: null, active: false, paused: false, cancelled: false
1447
+ }
1448
+ state.fs.upload = up
1449
+ runFsUpload(up)
1450
+ }
1231
1451
 
1232
- const FS_CHUNK_SIZE = 4 * 1024 * 1024 // 4MB/块, 断线后重选同一文件自动续传
1452
+ async function runFsUpload(up) {
1453
+ if (!up || !up.file) return
1454
+ up.active = true
1455
+ up.cancelled = false
1456
+ up.paused = false
1457
+ up.pauseRequested = false
1458
+ setFsButtons(true, false)
1233
1459
 
1234
- const uploadChunk = (params, blob, up) => new Promise((resolve, reject) => {
1460
+ const uploadChunk = (params, blob) => new Promise((resolve, reject) => {
1235
1461
  const xhr = new XMLHttpRequest()
1462
+ up.xhr = xhr
1236
1463
  xhr.open('POST', fsApiUrl('/upload', params))
1237
1464
  xhr.setRequestHeader('authorization', 'Bearer ' + state.token)
1238
1465
  xhr.setRequestHeader('x-dsh-remote-client', CAP?.isNativePlatform?.() ? 'app' : 'web')
@@ -1243,17 +1470,23 @@ function uploadFsFile(file) {
1243
1470
  }
1244
1471
  }
1245
1472
  xhr.onload = () => {
1473
+ if (up.xhr === xhr) up.xhr = null
1246
1474
  let json = {}
1247
1475
  try { json = JSON.parse(xhr.responseText || '{}') } catch {}
1248
1476
  resolve({ status: xhr.status, json })
1249
1477
  }
1250
- xhr.onerror = () => reject(new Error('网络错误'))
1251
- xhr.upload.onerror = () => reject(new Error('网络中断'))
1252
- xhr.onabort = () => reject(new Error('已取消'))
1478
+ xhr.onerror = () => { if (up.xhr === xhr) up.xhr = null; reject(new Error('网络错误')) }
1479
+ xhr.upload.onerror = () => { if (up.xhr === xhr) up.xhr = null; reject(new Error('网络中断')) }
1480
+ xhr.onabort = () => {
1481
+ if (up.xhr === xhr) up.xhr = null
1482
+ const err = new Error(up.cancelled ? '已取消' : '已暂停')
1483
+ err.code = up.cancelled ? 'CANCELLED' : 'PAUSED'
1484
+ reject(err)
1485
+ }
1253
1486
  xhr.send(blob)
1254
1487
  })
1255
1488
 
1256
- const probe = async (up) => {
1489
+ const probe = async () => {
1257
1490
  const res = await fetch(fsApiUrl('/upload-probe', { path: up.path, name: up.name, session: up.session }), { headers: fsHeaders() })
1258
1491
  if (res.status === 401) { fsAuthError(401); return null }
1259
1492
  const json = await res.json().catch(() => ({}))
@@ -1261,71 +1494,130 @@ function uploadFsFile(file) {
1261
1494
  return json
1262
1495
  }
1263
1496
 
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
1497
+ let overwrite = false
1498
+ let wasResumed = false
1499
+ let hasher = new SHA256()
1500
+ try {
1501
+ const info = await probe()
1502
+ if (info === null || up.cancelled) return
1503
+ if (info.partialSize > 0) wasResumed = true
1504
+ if (info.targetExists && info.targetSize === up.size && !overwrite) {
1505
+ if (!confirm('文件已存在(大小相同),覆盖它?')) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
1506
+ overwrite = true
1269
1507
  }
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
1508
+ if (up.offset > 0) await hashFsRange(up.file, hasher, 0, up.offset, up)
1509
+ showFsProgress(Math.round(up.offset / Math.max(1, up.size) * 100), up.offset, up.size)
1510
+
1511
+ while (up.offset < up.size) {
1512
+ if (up.cancelled) return
1513
+ const end = Math.min(up.offset + FS_CHUNK_SIZE, up.size)
1514
+ const blob = up.file.slice(up.offset, end)
1515
+ const before = hasher.clone()
1516
+ const chunkBytes = new Uint8Array(await blob.arrayBuffer())
1517
+ if (up.pauseRequested) {
1518
+ const err = new Error('已暂停'); err.code = 'PAUSED'; throw err
1279
1519
  }
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 重发
1520
+ hasher.update(chunkBytes)
1521
+ const isLast = end >= up.size
1522
+ const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset) }
1523
+ if (isLast) { params.finish = '1'; params.sha256 = hasher.hex() }
1524
+ if (overwrite) params.overwrite = '1'
1525
+ const r = await uploadChunk(params, blob)
1526
+ if (r.status === 401) { fsAuthError(401); return }
1527
+ if (r.status === 200 && r.json.offset != null) { up.offset = r.json.offset; continue }
1528
+ if (r.status === 201) {
1529
+ const expected = params.sha256 || hasher.hex()
1530
+ if (r.json.sha256 && r.json.sha256 !== expected) {
1531
+ const err = new Error('文件校验不一致(SHA-256)'); err.checksum = true
1532
+ throw err
1301
1533
  }
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
1534
  hideFsProgress()
1535
+ setFsButtons(false)
1317
1536
  state.fs.upload = null
1318
- toast(`已上传 ${file.name}`, 'ok')
1537
+ toast(`已上传 ${up.name}(SHA-256 已校验${wasResumed ? ' · 断点续传' : ''})`, 'ok')
1319
1538
  loadFs()
1320
1539
  return
1321
1540
  }
1541
+ if (r.status === 409 && r.json.error === 'conflict') {
1542
+ hasher = before // 这段数据没被写入, 回退哈希状态后带 overwrite=1 重发
1543
+ if (!confirm('文件已存在,覆盖它?')) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
1544
+ overwrite = true
1545
+ continue
1546
+ }
1547
+ if (r.status === 409 && r.json.error === 'offset-mismatch') {
1548
+ hasher = before
1549
+ await probe()
1550
+ continue
1551
+ }
1552
+ if (r.status === 422 && r.json.error === 'checksum-mismatch') {
1553
+ const err = new Error('文件校验不一致(SHA-256),已清除坏分片')
1554
+ err.checksum = true
1555
+ throw err
1556
+ }
1557
+ throw new Error(r.json.error || ('HTTP ' + r.status))
1558
+ }
1322
1559
 
1560
+ // offset 已到文件末尾但还没落位(0 字节文件 / 续传时最后一块已完成而 rename 被中断):
1561
+ // 发一个空 finish 块完成收尾, 同时带上全量 SHA-256 校验
1562
+ if (up.offset >= up.size) {
1563
+ const expected = hasher.hex()
1564
+ const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset), finish: '1', sha256: expected }
1565
+ if (overwrite) params.overwrite = '1'
1566
+ const r = await uploadChunk(params, new Blob([]))
1567
+ if (r.status === 401) { fsAuthError(401); return }
1568
+ if (r.status === 409 && r.json.error === 'conflict') {
1569
+ if (!confirm('文件已存在,覆盖它?')) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
1570
+ params.overwrite = '1'
1571
+ return runFsUpload(up) // 目标冲突未写入, 重新走 probe + 空 finish
1572
+ }
1573
+ if (r.status === 422 && r.json.error === 'checksum-mismatch') {
1574
+ const err = new Error('文件校验不一致(SHA-256),已清除坏分片')
1575
+ err.checksum = true
1576
+ throw err
1577
+ }
1578
+ if (r.status !== 201) throw new Error(r.json.error || ('HTTP ' + r.status))
1579
+ if (r.json.sha256 && r.json.sha256 !== expected) {
1580
+ const err = new Error('文件校验不一致(SHA-256)'); err.checksum = true
1581
+ throw err
1582
+ }
1323
1583
  hideFsProgress()
1324
- } catch (e) {
1325
- hideFsProgress()
1326
- toast(`上传中断:${e.message}(重选同一文件可断点续传)`, 'err')
1584
+ setFsButtons(false)
1585
+ state.fs.upload = null
1586
+ toast(`已上传 ${up.name}(SHA-256 已校验${wasResumed ? ' · 断点续传' : ''})`, 'ok')
1587
+ loadFs()
1588
+ return
1327
1589
  }
1328
- })()
1590
+
1591
+ hideFsProgress()
1592
+ setFsButtons(false)
1593
+ } catch (e) {
1594
+ up.active = false
1595
+ if (up.cancelled || e?.code === 'CANCELLED') return
1596
+ if (e?.code === 'PAUSED') {
1597
+ up.paused = true
1598
+ setFsButtons(true, true)
1599
+ setFsProgressText(`已暂停 · ${Math.round(up.offset / Math.max(1, up.size) * 100)}%`)
1600
+ toast('已暂停,点「继续」接着传', 'ok')
1601
+ return
1602
+ }
1603
+ if (e?.checksum) {
1604
+ // 坏分片保留只会反复校验失败: 服务端删掉, 下一次「继续」从 0 完整重传
1605
+ up.paused = true
1606
+ up.offset = 0
1607
+ try {
1608
+ await fetch(fsApiUrl('/upload-control', { path: up.path, name: up.name, session: up.session, action: 'cancel' }), {
1609
+ method: 'POST', headers: fsHeaders()
1610
+ })
1611
+ } catch {}
1612
+ setFsButtons(true, true)
1613
+ setFsProgressText('校验失败 · 点「继续」重新上传')
1614
+ toast(e.message, 'err')
1615
+ return
1616
+ }
1617
+ hideFsProgress()
1618
+ setFsButtons(false)
1619
+ toast(`上传中断:${e.message}(重选同一文件可断点续传)`, 'err')
1620
+ }
1329
1621
  }
1330
1622
 
1331
1623
  function fsUp() {
@@ -1388,13 +1680,30 @@ async function submitGoalEdit() {
1388
1680
  }
1389
1681
 
1390
1682
  /* ---------------- 检查更新 ---------------- */
1683
+ function parseVersion(v) {
1684
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v || '').trim())
1685
+ if (!m) return { core: [0, 0, 0], pre: null }
1686
+ return { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null }
1687
+ }
1391
1688
  function cmpVersion(a, b) {
1392
- const pa = String(a || '').split('.').map(Number)
1393
- const pb = String(b || '').split('.').map(Number)
1394
- for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
1395
- const d = (pa[i] || 0) - (pb[i] || 0)
1689
+ const pa = parseVersion(a), pb = parseVersion(b)
1690
+ for (let i = 0; i < 3; i++) {
1691
+ const d = pa.core[i] - pb.core[i]
1396
1692
  if (d) return d
1397
1693
  }
1694
+ // 无预发布后缀 = 正式版 > 任何 rc; 两个 rc 按段比较(数字段按数值, 字母段按字典序)
1695
+ if (!pa.pre && !pb.pre) return 0
1696
+ if (!pa.pre) return 1
1697
+ if (!pb.pre) return -1
1698
+ const sa = String(pa.pre).split('.'), sb = String(pb.pre).split('.')
1699
+ for (let i = 0; i < Math.max(sa.length, sb.length); i++) {
1700
+ const x = sa[i] ?? '', y = sb[i] ?? ''
1701
+ if (x === y) continue
1702
+ const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y)
1703
+ if (nx && ny) { const d = Number(x) - Number(y); if (d) return d }
1704
+ else if (nx !== ny) return nx ? -1 : 1 // 数字段 < 字母段(semver 规则)
1705
+ else { const d = x.localeCompare(y); if (d) return d }
1706
+ }
1398
1707
  return 0
1399
1708
  }
1400
1709
 
@@ -1415,7 +1724,7 @@ async function checkUpdate(silent) {
1415
1724
  }
1416
1725
  if (!silent) toast('正在检查更新…')
1417
1726
  try {
1418
- const res = await fetch(base + '/update.json?t=' + Date.now())
1727
+ const res = await fetch(base + '/update.json?t=' + Date.now() + '&local=' + encodeURIComponent(state.localVersion))
1419
1728
  if (!res.ok) throw new Error('HTTP ' + res.status)
1420
1729
  const info = await res.json()
1421
1730
  if (info.version && cmpVersion(info.version, state.localVersion) > 0) {
@@ -1526,6 +1835,106 @@ function autosize(el) {
1526
1835
  }
1527
1836
 
1528
1837
  /* ---------------- 初始化 ---------------- */
1838
+ /** 解析 dshremote://pair?token=..&server=.. 配对二维码 */
1839
+ function applyPairUrl(url) {
1840
+ try {
1841
+ const u = new URL(String(url).trim())
1842
+ if (u.protocol !== 'dshremote:' || u.hostname !== 'pair') return false
1843
+ const t = (u.searchParams.get('token') || '').trim()
1844
+ const server = (u.searchParams.get('server') || '').trim().replace(/\/+$/, '')
1845
+ if (!t || !/^https?:\/\//i.test(server)) return false
1846
+ state.token = t
1847
+ LS.set('token', t)
1848
+ state.server = server
1849
+ if (!state.servers.includes(server)) state.servers.unshift(server)
1850
+ saveServers()
1851
+ renderServers()
1852
+ $('token-desc').textContent = '已保存(扫码)'
1853
+ return true
1854
+ } catch {
1855
+ return false
1856
+ }
1857
+ }
1858
+
1859
+ /** 拍照/相册得到的 dataUrl → 画到 canvas → jsQR 纯本地解码(不依赖任何谷歌服务) */
1860
+ async function decodeQrDataUrl(dataUrl) {
1861
+ const img = new Image()
1862
+ await new Promise((resolve, reject) => {
1863
+ img.onload = resolve
1864
+ img.onerror = () => reject(new Error('图片加载失败'))
1865
+ img.src = dataUrl
1866
+ })
1867
+ const maxSide = 1600
1868
+ const scale = Math.min(1, maxSide / Math.max(img.naturalWidth || 1, img.naturalHeight || 1))
1869
+ const w = Math.max(1, Math.round((img.naturalWidth || 1) * scale))
1870
+ const h = Math.max(1, Math.round((img.naturalHeight || 1) * scale))
1871
+ const canvas = document.createElement('canvas')
1872
+ canvas.width = w
1873
+ canvas.height = h
1874
+ const ctx = canvas.getContext('2d', { willReadFrequently: true })
1875
+ if (!ctx) throw new Error('当前设备不支持图片解码')
1876
+ ctx.drawImage(img, 0, 0, w, h)
1877
+ const imageData = ctx.getImageData(0, 0, w, h)
1878
+ const code = window.jsQR?.(imageData.data, w, h, { inversionAttempts: 'attemptBoth' })
1879
+ return code?.data || ''
1880
+ }
1881
+
1882
+ /** App 内扫码: 官方 Camera 拍照/相册 + jsQR 本地解码(无 Google ML Kit/GMS 依赖, 国内可用)。
1883
+ * 冗余路径 1: 系统相机扫 dshremote:// 二维码直接唤起 App(见 bindNativeLinks);
1884
+ * 冗余路径 2: 设置页手动粘贴令牌。 */
1885
+ async function scanPair() {
1886
+ if (!CAP?.isNativePlatform?.()) {
1887
+ toast('浏览器请打开主机管理页,用手机相机扫码', 'err')
1888
+ return
1889
+ }
1890
+ const camera = CAP.Plugins?.Camera
1891
+ if (!camera?.getPhoto) { toast('当前 App 版本不支持拍照扫码,请先更新 App', 'err'); return }
1892
+ try {
1893
+ const perm = await camera.requestPermissions?.({ permissions: ['camera'] })
1894
+ if (perm && perm.camera !== 'granted') { toast('未授予相机权限', 'err'); return }
1895
+ const photo = await camera.getPhoto({
1896
+ resultType: 'dataUrl',
1897
+ source: 'PROMPT', // 拍照 / 从相册选择都行, 相册可扫截图
1898
+ quality: 85,
1899
+ correctOrientation: true,
1900
+ saveToGallery: false,
1901
+ promptLabelHeader: '扫描 DSH Remote 配对二维码',
1902
+ promptLabelPhoto: '拍照扫描',
1903
+ promptLabelPicture: '从相册选择',
1904
+ })
1905
+ if (!photo?.dataUrl) { toast('没有拿到图片,请重试', 'err'); return }
1906
+ const raw = await decodeQrDataUrl(photo.dataUrl)
1907
+ if (!raw) { toast('未识别到二维码,请对准后重试', 'err'); return }
1908
+ if (applyPairUrl(raw)) {
1909
+ toast('配对成功,正在连接', 'ok')
1910
+ openStreams()
1911
+ refreshAll()
1912
+ } else {
1913
+ toast('这不是 DSH Remote 的配对二维码', 'err')
1914
+ }
1915
+ } catch (e) {
1916
+ const msg = String(e?.message || e || '')
1917
+ toast(/cancel/i.test(msg) ? '已取消扫码' : '扫码失败:' + msg, 'err')
1918
+ }
1919
+ }
1920
+
1921
+ /** 系统相机/浏览器扫码后通过 dshremote:// 链接唤起 App: 这里接住并配对 */
1922
+ function bindNativeLinks() {
1923
+ if (!CAP?.isNativePlatform?.()) return
1924
+ try {
1925
+ CAP.Plugins?.App?.addListener?.('appUrlOpen', (data) => {
1926
+ if (data?.url && applyPairUrl(data.url)) {
1927
+ toast('已通过二维码配对', 'ok')
1928
+ openStreams()
1929
+ refreshAll()
1930
+ }
1931
+ })
1932
+ CAP.Plugins?.App?.getLaunchUrl?.().then((data) => {
1933
+ if (data?.url) applyPairUrl(data.url)
1934
+ }).catch(() => {})
1935
+ } catch {}
1936
+ }
1937
+
1529
1938
  function initToken() {
1530
1939
  const urlToken = new URLSearchParams(location.search).get('token')
1531
1940
  if (urlToken) {
@@ -1553,13 +1962,19 @@ function bindUi() {
1553
1962
  $('btn-back').addEventListener('click', closeSession)
1554
1963
  $('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
1555
1964
  $('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
1556
- $('btn-refresh').addEventListener('click', () => { toast('刷新中…'); refreshAll() })
1965
+ $('btn-refresh').addEventListener('click', () => { toast('刷新中…'); openStreams(); refreshAll() })
1557
1966
  $('btn-admin').addEventListener('click', () => {
1558
1967
  location.href = state.server ? state.server.replace(/\/+$/, '') + '/admin' : 'admin'
1559
1968
  })
1560
1969
  $('btn-new-session').addEventListener('click', newSession)
1561
1970
  $('btn-cancel').addEventListener('click', cancelSession)
1562
1971
  $('btn-send').addEventListener('click', sendMessage)
1972
+ $('btn-plus').addEventListener('click', toggleComposerMenu)
1973
+ $('composer-menu').addEventListener('click', (e) => {
1974
+ const chip = e.target.closest('[data-cmd]')
1975
+ if (chip) { hideComposerMenu(); sendSessionText(chip.dataset.cmd) }
1976
+ })
1977
+ $('btn-model-refresh').addEventListener('click', loadSessionModels)
1563
1978
  const input = $('composer-input')
1564
1979
  input.addEventListener('input', () => autosize(input))
1565
1980
  input.addEventListener('keydown', (e) => {
@@ -1582,6 +1997,7 @@ function bindUi() {
1582
1997
  $('goal-close').addEventListener('click', () => $('modal-goal').classList.add('hidden'))
1583
1998
  $('goal-edit').addEventListener('click', submitGoalEdit)
1584
1999
  // 设置
2000
+ $('btn-scan-pair').addEventListener('click', scanPair)
1585
2001
  $('btn-change-token').addEventListener('click', () => {
1586
2002
  const t = prompt('输入访问令牌(网关启动时打印的 token):', state.token)
1587
2003
  if (t && t.trim()) { state.token = t.trim(); LS.set('token', t.trim()); $('token-desc').textContent = '已保存'; toast('已保存,正在重连', 'ok'); openStreams(); refreshAll() }
@@ -1629,6 +2045,8 @@ function bindUi() {
1629
2045
  if (f) uploadFsFile(f)
1630
2046
  e.target.value = '' // 允许连续选同一个文件
1631
2047
  })
2048
+ $('fs-pause-btn').addEventListener('click', pauseFsUpload)
2049
+ $('fs-cancel-btn').addEventListener('click', cancelFsUpload)
1632
2050
  bindFsPullRefresh()
1633
2051
 
1634
2052
  bindRail()
@@ -1671,6 +2089,7 @@ async function boot() {
1671
2089
  initToken()
1672
2090
  bindUi()
1673
2091
  bindNativeBack()
2092
+ bindNativeLinks()
1674
2093
  applyNativeInsets()
1675
2094
  updateConn()
1676
2095
  loadLocalVersion()