dsh-remote-plugin 0.6.16 → 0.6.18

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.
@@ -57,12 +57,12 @@ const state = {
57
57
  token: LS.get('token', ''),
58
58
  wsTicket: { token: '', server: '', value: '', expiresAt: 0 },
59
59
  server: '',
60
- servers: [],
60
+ servers: [], // { id, url, note, group, token }; connection identity = URL + token
61
61
  groups: ['默认'],
62
62
  activeGroup: '默认',
63
63
  autoSelect: { '默认': true },
64
64
  groupActive: { '默认': '' },
65
- serverLatency: {},
65
+ serverLatency: {}, // URL + token -> latest /health latency
66
66
  gatewayHealth: {},
67
67
  selectingServer: false,
68
68
  sessions: [],
@@ -88,7 +88,7 @@ const state = {
88
88
  },
89
89
  streamMode: 'ws', // 'ws' | 'poll'
90
90
  pollSeq: { mux: 0, host: 0 },
91
- fs: { path: null, initial: null, loaded: false },
91
+ fs: { path: null, initial: null, loaded: false, roots: [], rootIndex: 0 },
92
92
  models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
93
93
  wb: { bound: false, path: '', title: '', expanded: false, projects: null, open: null, apiMissing: false },
94
94
  archivedIds: [],
@@ -603,6 +603,49 @@ async function safeRpc(method, payload, errText) {
603
603
  return null
604
604
  }
605
605
  }
606
+ let hostDescribePromise = null
607
+ let hostDescribeRetryTimer = null
608
+ let hostDescribeFailures = 0
609
+
610
+ function scheduleHostDescribeRetryDesktop() {
611
+ if (!state.token || hostDescribeRetryTimer) return
612
+ const delays = [2000, 5000, 15000, 30000, 60000]
613
+ const delay = delays[Math.min(Math.max(0, hostDescribeFailures - 1), delays.length - 1)]
614
+ hostDescribeRetryTimer = setTimeout(() => {
615
+ hostDescribeRetryTimer = null
616
+ void refreshHostDescriptionDesktop()
617
+ }, delay)
618
+ }
619
+
620
+ async function refreshHostDescriptionDesktop({ notify = false } = {}) {
621
+ if (!state.token) return null
622
+ if (hostDescribePromise) return hostDescribePromise
623
+ const server = state.server
624
+ hostDescribePromise = (async () => {
625
+ try {
626
+ const host = await rpc('host.describe', {}, 5000)
627
+ if (server !== state.server) return null
628
+ state.hostInfo = host
629
+ const health = activeGatewayHealth()
630
+ if (health) health.upstreamReachable = true
631
+ hostDescribeFailures = 0
632
+ if (hostDescribeRetryTimer) clearTimeout(hostDescribeRetryTimer)
633
+ hostDescribeRetryTimer = null
634
+ renderOverviewDesktop()
635
+ return host
636
+ } catch (error) {
637
+ if (server !== state.server) return null
638
+ hostDescribeFailures++
639
+ scheduleHostDescribeRetryDesktop()
640
+ if (notify) toast(error.message === 'AUTH' ? t('ds.toastAuth') : error.message, 'err')
641
+ return null
642
+ } finally {
643
+ hostDescribePromise = null
644
+ if (server !== state.server) scheduleHostDescribeRetryDesktop()
645
+ }
646
+ })()
647
+ return hostDescribePromise
648
+ }
606
649
  function uuid() {
607
650
  try { return crypto.randomUUID() } catch { return 'id-' + Date.now() + '-' + Math.random().toString(36).slice(2) }
608
651
  }
@@ -618,6 +661,27 @@ function ensureGroup(name) {
618
661
  }
619
662
  function groupServers(g) { return state.servers.filter(s => s.group === g) }
620
663
  function activeServers() { return groupServers(state.activeGroup) }
664
+ function normalizedServerToken(token) { return typeof token === 'string' ? token.trim() : '' }
665
+ function serverIdentity(url, token) {
666
+ return `${String(url || '').replace(/\/+$/, '')}\u0000${normalizedServerToken(token)}`
667
+ }
668
+ function serverKey(server) { return serverIdentity(server?.url ?? server, server?.token) }
669
+ function serverToken(server) { return normalizedServerToken(server?.token) }
670
+ function currentServerEntry() {
671
+ return state.servers.find(s => s.url === state.server && serverToken(s) === state.token)
672
+ || state.servers.find(s => s.url === state.server)
673
+ || null
674
+ }
675
+ function setCurrentAccessToken(value) {
676
+ const token = normalizedServerToken(value)
677
+ if (!token) return false
678
+ const current = currentServerEntry()
679
+ if (current) current.token = token
680
+ state.token = token
681
+ LS.set('token', token)
682
+ saveServers()
683
+ return true
684
+ }
621
685
 
622
686
  function migrateServersV1() {
623
687
  if (LS.get('servers-v2', null) !== null) return
@@ -628,7 +692,7 @@ function migrateServersV1() {
628
692
  arr = legacy ? [legacy] : []
629
693
  }
630
694
  const urls = arr.map(s => String(s || '').trim().replace(/\/+$/, '')).filter(s => /^https?:\/\//i.test(s))
631
- state.servers = urls.map((url, i) => ({ id: 's' + (i + 1), url, note: '', group: '默认' }))
695
+ state.servers = urls.map((url, i) => ({ id: 's' + (i + 1), url, note: '', group: '默认', token: normalizedServerToken(state.token) }))
632
696
  state.groups = ['默认']; state.activeGroup = '默认'
633
697
  state.autoSelect = { '默认': true }; state.groupActive = { '默认': '' }
634
698
  const active = LS.get('activeServer', '')
@@ -644,7 +708,8 @@ function loadServers() {
644
708
  let data = null
645
709
  try { data = JSON.parse(LS.get('servers-v2', '')) } catch {}
646
710
  if (!data || !Array.isArray(data.servers)) { migrateServersV1(); return }
647
- state.servers = data.servers.filter(s => s && typeof s.url === 'string').map(s => ({ id: s.id || newServerId(), url: s.url.replace(/\/+$/, ''), note: s.note || '', group: s.group || '默认' }))
711
+ const legacyToken = normalizedServerToken(state.token || LS.get('token', ''))
712
+ state.servers = data.servers.filter(s => s && typeof s.url === 'string').map(s => ({ id: s.id || newServerId(), url: s.url.replace(/\/+$/, ''), note: s.note || '', group: s.group || '默认', token: s.token == null ? legacyToken : normalizedServerToken(s.token) }))
648
713
  state.groups = Array.isArray(data.groups) && data.groups.length ? data.groups : ['默认']
649
714
  state.activeGroup = state.groups.includes(data.activeGroup) ? data.activeGroup : '默认'
650
715
  state.autoSelect = data.autoSelect || {}
@@ -653,7 +718,12 @@ function loadServers() {
653
718
  for (const s of state.servers) ensureGroup(s.group)
654
719
  const manual = state.groupActive[state.activeGroup]
655
720
  const manualSrv = manual ? state.servers.find(s => s.id === manual) : null
656
- state.server = manualSrv ? manualSrv.url : (activeServers()[0]?.url || '')
721
+ const selected = manualSrv || activeServers()[0]
722
+ state.server = selected?.url || ''
723
+ if (selected) {
724
+ state.token = serverToken(selected)
725
+ LS.set('token', state.token)
726
+ }
657
727
  }
658
728
  function saveServers() {
659
729
  LS.set('servers-v2', JSON.stringify({
@@ -662,8 +732,8 @@ function saveServers() {
662
732
  }))
663
733
  }
664
734
  function serverCandidates() {
665
- const list = activeServers().map(s => s.url)
666
- if (location.origin && !list.includes(location.origin)) list.push(location.origin)
735
+ const list = activeServers().map(s => ({ ...s, token: serverToken(s) }))
736
+ if (location.origin && !list.some(s => s.url === location.origin)) list.push({ id: 'origin', url: location.origin, token: state.token, origin: true })
667
737
  return list
668
738
  }
669
739
  async function pingServer(base) {
@@ -676,16 +746,29 @@ async function pingServer(base) {
676
746
  const res = await fetch(u + '/health?t=' + Date.now(), { signal: ctrl.signal, cache: 'no-store' })
677
747
  if (!res.ok) return Infinity
678
748
  const health = await res.json().catch(() => null)
679
- if (health && typeof health === 'object') state.gatewayHealth[u] = health
749
+ if (health && typeof health === 'object') {
750
+ state.gatewayHealth[u] = health
751
+ renderOverviewDesktop()
752
+ }
680
753
  return Math.round(performance.now() - t0)
681
754
  } catch { return Infinity } finally { clearTimeout(timer) }
682
755
  }
683
756
  function activeGatewayCapability(name) {
684
- const key = String(state.server || location.origin || '').replace(/\/+$/, '')
685
- const capabilities = state.gatewayHealth[key]?.capabilities
757
+ const capabilities = activeGatewayHealth()?.capabilities
686
758
  if (!capabilities || !Object.prototype.hasOwnProperty.call(capabilities, name)) return null
687
759
  return Number(capabilities[name]) > 0
688
760
  }
761
+
762
+ function activeGatewayHealth() {
763
+ const key = String(state.server || location.origin || '').replace(/\/+$/, '')
764
+ return state.gatewayHealth[key] || null
765
+ }
766
+
767
+ function dshReachable() {
768
+ const health = activeGatewayHealth()
769
+ if (health && typeof health.upstreamReachable === 'boolean') return health.upstreamReachable
770
+ return !!state.hostInfo
771
+ }
689
772
  async function selectFastestServer({ silent = false, reconnect = true } = {}) {
690
773
  if (state.selectingServer) return null
691
774
  state.selectingServer = true
@@ -693,31 +776,47 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
693
776
  if (!silent) toast(t('ds.speedTesting'))
694
777
  const candidates = serverCandidates()
695
778
  let chosen = ''
779
+ let chosenToken = state.token
696
780
  let best = null
697
781
  let ms = Infinity
698
782
  if (state.autoSelect[state.activeGroup] !== false) {
699
- const measured = await Promise.all(candidates.map(async (u) => [u, await pingServer(u)]))
700
- for (const [u, latency] of measured) state.serverLatency[u] = latency
701
- best = candidates.filter(u => Number.isFinite(state.serverLatency[u])).sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0] || null
702
- chosen = best || (state.server || '')
703
- ms = best ? state.serverLatency[best] : Infinity
783
+ const measured = await Promise.all(candidates.map(async (server) => [server, await pingServer(server.url)]))
784
+ for (const [server, latency] of measured) state.serverLatency[serverKey(server)] = latency
785
+ best = candidates.filter(server => Number.isFinite(state.serverLatency[serverKey(server)])).sort((a, b) => state.serverLatency[serverKey(a)] - state.serverLatency[serverKey(b)])[0] || null
786
+ const sameOrigin = best?.url === location.origin && best.origin
787
+ chosen = best ? (sameOrigin ? '' : best.url) : (state.server || '')
788
+ chosenToken = best ? serverToken(best) : state.token
789
+ ms = best ? state.serverLatency[serverKey(best)] : Infinity
704
790
  } else {
705
791
  const manual = state.groupActive[state.activeGroup]
706
792
  const manualSrv = manual ? state.servers.find(s => s.id === manual) : null
707
793
  chosen = manualSrv ? manualSrv.url : (activeServers()[0]?.url || '')
708
- if (chosen) { state.serverLatency[chosen] = await pingServer(chosen); ms = state.serverLatency[chosen] }
794
+ chosenToken = manualSrv ? serverToken(manualSrv) : state.token
795
+ if (chosen) {
796
+ const key = serverKey(manualSrv || { url: chosen, token: state.token })
797
+ state.serverLatency[key] = await pingServer(chosen)
798
+ ms = state.serverLatency[key]
799
+ }
709
800
  }
710
801
  renderServers()
711
- if (chosen !== state.server) {
802
+ if (chosen !== state.server || chosenToken !== state.token) {
803
+ state.hostInfo = null
804
+ hostDescribeFailures = 0
805
+ if (hostDescribeRetryTimer) clearTimeout(hostDescribeRetryTimer)
806
+ hostDescribeRetryTimer = null
807
+ resetFsForServerDesktop()
712
808
  state.server = chosen
713
- if (best) { const srv = state.servers.find(s => s.url === best); if (srv) state.groupActive[state.activeGroup] = srv.id }
809
+ state.token = chosenToken
810
+ LS.set('token', state.token)
811
+ if (best) { const srv = state.servers.find(s => s.id === best.id); if (srv) state.groupActive[state.activeGroup] = srv.id }
714
812
  saveServers()
813
+ renderServers()
715
814
  if (!silent) {
716
815
  if (chosen) toast(t('ds.speedSwitched', { url: chosen, ms: Number.isFinite(ms) ? ms : 0 }), 'ok')
717
816
  }
718
817
  if (reconnect && state.token) { openStreams(); refreshSessions() }
719
818
  } else if (!silent) {
720
- if (best) toast(t('ds.speedAlreadyBest', { url: chosen, ms: state.serverLatency[best] }), 'ok')
819
+ if (best) toast(t('ds.speedAlreadyBest', { url: chosen, ms: state.serverLatency[serverKey(best)] }), 'ok')
721
820
  else if (chosen) toast(t('ds.speedManualUsing', { url: chosen, ms: Number.isFinite(ms) ? ms : '—' }), 'ok')
722
821
  else toast(t('ds.speedAllDown'), 'err')
723
822
  }
@@ -757,13 +856,14 @@ function renderServers() {
757
856
  </div>
758
857
  <div class="ds-srv-body ${g === state.activeGroup ? '' : 'hidden'}">
759
858
  ${list.map(s => {
760
- const ms = state.serverLatency[s.url]
859
+ const ms = state.serverLatency[serverKey(s)]
761
860
  let badge = `<span class="ds-server-badge">${t('ds.serversUntested')}</span>`
762
- if (Number.isFinite(ms)) badge = `<span class="ds-server-badge ${s.url === state.server ? 'good' : ''}">${ms}ms${s.url === state.server ? t('ds.serversCurrent') : ''}</span>`
861
+ const active = s.url === state.server && serverToken(s) === state.token
862
+ if (Number.isFinite(ms)) badge = `<span class="ds-server-badge ${active ? 'good' : ''}">${ms}ms${active ? t('ds.serversCurrent') : ''}</span>`
763
863
  else if (ms !== undefined) badge = `<span class="ds-server-badge bad">${t('ds.serversUnreachable')}</span>`
764
- const activeInGroup = auto ? s.url === state.server : s.id === activeManual
864
+ const activeInGroup = auto ? active : s.id === activeManual
765
865
  return `<div class="ds-server-row ${activeInGroup ? 'active' : ''}" data-use-server="${esc(s.id)}">
766
- <span class="ds-server-main"><span class="ds-server-note">${esc(serverTitle(s))}</span>${s.note ? `<span class="ds-server-url">${esc(s.url)}</span>` : ''}</span>${badge}
866
+ <span class="ds-server-main"><span class="ds-server-note">${esc(serverTitle(s))}</span>${s.note ? `<span class="ds-server-url">${esc(s.url)}</span>` : ''}<span class="ds-server-token-badge">${s.token ? t('ds.serversTokenSet') : t('ds.serversTokenMissing')}</span></span>${badge}
767
867
  <button class="ds-mini" data-edit-server="${esc(s.id)}" title="${t('ds.serversEdit')}">✎</button>
768
868
  <button class="ds-mini" data-del-server="${esc(s.id)}" title="${t('ds.serversDelete')}">✕</button>
769
869
  </div>`
@@ -794,16 +894,19 @@ function renderServers() {
794
894
  if (state.autoSelect[s.group] !== false) { editServer(id); return }
795
895
  state.groupActive[s.group] = id
796
896
  state.activeGroup = s.group
897
+ if (state.server !== s.url) resetFsForServerDesktop()
797
898
  state.server = s.url
899
+ state.token = serverToken(s)
900
+ LS.set('token', state.token)
798
901
  saveServers()
799
902
  renderServers()
800
903
  toast(t('ds.serversManualSelected', { url: serverTitle(s) }), 'ok')
801
904
  if (state.token) { openStreams(); refreshSessions() }
802
905
  }))
803
- const cur = state.servers.find(s => s.url === state.server)
906
+ const cur = currentServerEntry()
804
907
  const curGroup = cur ? cur.group : state.activeGroup
805
908
  const curLabel = cur ? (cur.note || cur.url) : (state.server || t('ds.origin'))
806
- const curMs = state.serverLatency[state.server]
909
+ const curMs = cur ? state.serverLatency[serverKey(cur)] : undefined
807
910
  $('server-desc').textContent = t('ds.serversCurrentDesc', { group: curGroup, url: curLabel, ms: Number.isFinite(curMs) ? curMs + 'ms' : '—' })
808
911
  updateConn()
809
912
  }
@@ -814,9 +917,10 @@ async function addServer() {
814
917
  if (!raw) return toast(t('ds.serversNeedAddress'), 'err')
815
918
  try { const u = new URL(raw); if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad') }
816
919
  catch { return toast(t('ds.serversBadProtocol'), 'err') }
817
- if (state.servers.some(s => s.url === raw)) return toast(t('ds.serversDuplicate'), 'err')
920
+ const token = normalizedServerToken(state.token)
921
+ if (state.servers.some(s => serverIdentity(s.url, serverToken(s)) === serverIdentity(raw, token))) return toast(t('ds.serversDuplicate'), 'err')
818
922
  const note = (prompt(t('ds.serversPromptNote')) || '').trim()
819
- state.servers.push({ id: newServerId(), url: raw, note, group: state.activeGroup })
923
+ state.servers.push({ id: newServerId(), url: raw, note, group: state.activeGroup, token })
820
924
  saveServers()
821
925
  if (input) input.value = ''
822
926
  renderServers()
@@ -830,14 +934,23 @@ function editServer(id) {
830
934
  if (!raw) return
831
935
  try { const u = new URL(raw); if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad') }
832
936
  catch { return toast(t('ds.serversBadProtocol'), 'err') }
833
- if (state.servers.some(x => x.id !== id && x.url === raw)) return toast(t('ds.serversDuplicate'), 'err')
937
+ const tokenInput = prompt(t('ds.serversPromptEditToken'), '')
938
+ if (tokenInput === null) return
939
+ const token = normalizedServerToken(tokenInput) || serverToken(s)
940
+ if (state.servers.some(x => x.id !== id && serverIdentity(x.url, serverToken(x)) === serverIdentity(raw, token))) return toast(t('ds.serversDuplicate'), 'err')
834
941
  const note = prompt(t('ds.serversPromptEditNote', { url: raw }), s.note || '')
835
942
  if (note === null) return
836
943
  const group = prompt(t('ds.serversPromptEditGroup'), s.group || '默认')
837
944
  if (group === null) return
838
- const wasActive = state.server === s.url
839
- s.url = raw; s.note = note.trim(); s.group = ensureGroup(group.trim() || '默认')
840
- if (wasActive) state.server = raw
945
+ const wasActive = state.server === s.url && state.token === serverToken(s)
946
+ const changedActiveUrl = wasActive && state.server !== raw
947
+ s.url = raw; s.note = note.trim(); s.group = ensureGroup(group.trim() || '默认'); s.token = token
948
+ if (wasActive) {
949
+ if (changedActiveUrl) resetFsForServerDesktop()
950
+ state.server = raw
951
+ state.token = token
952
+ LS.set('token', token)
953
+ }
841
954
  saveServers(); renderServers(); toast(t('ds.serversEdited'), 'ok')
842
955
  if (wasActive && state.token) selectFastestServer({ silent: true })
843
956
  }
@@ -845,7 +958,7 @@ function removeServer(id) {
845
958
  const s = state.servers.find(x => x.id === id)
846
959
  if (!s) return
847
960
  state.servers = state.servers.filter(x => x.id !== id)
848
- const wasActive = state.server === s.url
961
+ const wasActive = state.server === s.url && state.token === serverToken(s)
849
962
  for (const g of state.groups) if (state.groupActive[g] === id) state.groupActive[g] = ''
850
963
  saveServers(); renderServers()
851
964
  if (wasActive) { toast(t('ds.serversRemovedActive')); selectFastestServer({ silent: true }) }
@@ -1020,7 +1133,11 @@ function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
1020
1133
  updateConn()
1021
1134
  if (kind === 'mux') { state.approvals = []; state.questions = []; renderNotifStack() }
1022
1135
  if (refreshOnOpen) refreshSessions()
1023
- if (allStreamsOpen()) resyncAfterStreamOpen()
1136
+ if (allStreamsOpen()) {
1137
+ resyncAfterStreamOpen()
1138
+ void pingServer(state.server || location.origin)
1139
+ void refreshHostDescriptionDesktop()
1140
+ }
1024
1141
  }
1025
1142
  ws.onmessage = (msg) => {
1026
1143
  if (!streamIsCurrent(kind, ws, generation)) return
@@ -1854,7 +1971,7 @@ function renderQueue() {
1854
1971
 
1855
1972
  /* ---------------- 审批/提问通知卡片栈 ---------------- */
1856
1973
  function serverLabel() {
1857
- const cur = state.servers.find(s => s.url === state.server)
1974
+ const cur = currentServerEntry()
1858
1975
  return cur ? (cur.note || cur.url) : (state.server || location.host)
1859
1976
  }
1860
1977
  function renderNotifStack() {
@@ -1966,10 +2083,70 @@ function fsHeaders() {
1966
2083
  return { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() }
1967
2084
  }
1968
2085
  function fsParent(p) {
1969
- if (!p) return null
1970
- const parts = String(p).split('/').filter(Boolean)
1971
- parts.pop()
1972
- return parts.length ? '/' + parts.join('/') : '/'
2086
+ const meta = fsPathMeta(p)
2087
+ if (!meta.value) return null
2088
+ if (meta.root && fsPathEqual(meta.value, meta.root)) return meta.root
2089
+ const idx = meta.value.lastIndexOf(meta.separator)
2090
+ if (idx < 0) return meta.root || null
2091
+ const parent = meta.value.slice(0, idx)
2092
+ return meta.root && parent.length < meta.root.length ? meta.root : (parent || meta.root)
2093
+ }
2094
+ function fsJoin(dir, name) {
2095
+ const meta = fsPathMeta(dir)
2096
+ if (!meta.value) return String(name || '')
2097
+ return meta.value.endsWith(meta.separator) ? meta.value + name : meta.value + meta.separator + name
2098
+ }
2099
+ function fsPathMeta(input) {
2100
+ const source = String(input || '').trim()
2101
+ const windows = /^[A-Za-z]:(?:[\\/]|$)/.test(source) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(source) || source.includes('\\')
2102
+ if (!windows) {
2103
+ let value = source.replace(/\/+/g, '/')
2104
+ const root = value.startsWith('/') ? '/' : ''
2105
+ if (root && value.length > root.length) value = value.replace(/\/+$/, '')
2106
+ return { value, root, separator: '/', windows: false }
2107
+ }
2108
+ let value = source.replace(/\//g, '\\')
2109
+ const unc = /^\\{2,}/.test(value)
2110
+ value = unc
2111
+ ? '\\\\' + value.replace(/^\\+/, '').replace(/\\+/g, '\\')
2112
+ : value.replace(/\\+/g, '\\')
2113
+ let root = ''
2114
+ if (unc) {
2115
+ const parts = value.slice(2).split('\\').filter(Boolean)
2116
+ root = parts.length >= 2 ? `\\\\${parts[0]}\\${parts[1]}` : value
2117
+ } else {
2118
+ const drive = /^([A-Za-z]:)/.exec(value)
2119
+ if (drive) {
2120
+ root = drive[1] + '\\'
2121
+ if (value === drive[1]) value = root
2122
+ }
2123
+ }
2124
+ if (root && value.length > root.length) value = value.replace(/\\+$/, '')
2125
+ return { value, root, separator: '\\', windows: true }
2126
+ }
2127
+ function fsPathKey(value) {
2128
+ const meta = fsPathMeta(value)
2129
+ return meta.windows ? meta.value.toLowerCase() : meta.value
2130
+ }
2131
+ function fsPathEqual(left, right) { return fsPathKey(left) === fsPathKey(right) }
2132
+ function fsPathInside(candidate, root) {
2133
+ const child = fsPathMeta(candidate)
2134
+ const boundary = fsPathMeta(root)
2135
+ if (!child.value || !boundary.value || child.windows !== boundary.windows) return false
2136
+ const childKey = child.windows ? child.value.toLowerCase() : child.value
2137
+ const rootKey = boundary.windows ? boundary.value.toLowerCase() : boundary.value
2138
+ if (childKey === rootKey) return true
2139
+ const prefix = rootKey.endsWith(boundary.separator) ? rootKey : rootKey + boundary.separator
2140
+ return childKey.startsWith(prefix)
2141
+ }
2142
+ function resetFsForServerDesktop() {
2143
+ state.fs.path = null
2144
+ state.fs.initial = null
2145
+ state.fs.loaded = false
2146
+ state.fs.roots = []
2147
+ state.fs.rootIndex = 0
2148
+ const select = $('fs-root')
2149
+ if (select) select.classList.add('hidden')
1973
2150
  }
1974
2151
  async function openWorkspaceModal() {
1975
2152
  if (!state.token) { toast(t('ds.toastAuth'), 'err'); showView('view-settings'); return }
@@ -2031,10 +2208,15 @@ async function loadFs(dir, silent) {
2031
2208
  if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error || ('HTTP ' + res.status))
2032
2209
  state.fs.path = data.path
2033
2210
  if (!state.fs.initial) state.fs.initial = data.path
2211
+ if (Array.isArray(data.roots) && data.roots.length) state.fs.roots = data.roots.map(value => String(value || '')).filter(Boolean)
2212
+ if (!state.fs.roots.length) state.fs.roots = [data.path]
2213
+ const rootIndex = state.fs.roots.findIndex(root => fsPathInside(data.path, root))
2214
+ if (rootIndex >= 0) state.fs.rootIndex = rootIndex
2034
2215
  state.fs.loaded = true
2216
+ renderFsRootsDesktop()
2035
2217
  $('fs-path').textContent = data.path
2036
2218
  $('fs-list').innerHTML = (data.entries || []).map(e => `
2037
- <div class="ds-fs-row" data-fs-path="${esc(e.path)}" data-fs-dir="${e.type === 'dir' ? '1' : '0'}">
2219
+ <div class="ds-fs-row" data-fs-path="${esc(e.path || fsJoin(data.path, e.name))}" data-fs-dir="${e.type === 'dir' ? '1' : '0'}">
2038
2220
  <span class="ds-fs-type">${desktopFsIconSvg(e.type === 'dir')}</span>
2039
2221
  <span class="ds-fs-name">${esc(e.name)}</span>
2040
2222
  <span class="ds-fs-size">${e.type === 'dir' ? '' : fmtSize(e.size)}</span>
@@ -2049,15 +2231,23 @@ async function loadFs(dir, silent) {
2049
2231
  }
2050
2232
  }
2051
2233
 
2234
+ function renderFsRootsDesktop() {
2235
+ const select = $('fs-root')
2236
+ if (!select) return
2237
+ select.classList.toggle('hidden', state.fs.roots.length <= 1)
2238
+ select.innerHTML = state.fs.roots.map((root, index) => `<option value="${index}"${index === state.fs.rootIndex ? ' selected' : ''}>${esc(root)}</option>`).join('')
2239
+ }
2240
+
2052
2241
  function desktopFsIconSvg(isDir) {
2053
2242
  return isDir
2054
2243
  ? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 6.5h6l2 2H20a1 1 0 0 1 1 1v8.5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.5a1 1 0 0 1 .5-1Z"/></svg>'
2055
2244
  : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3.5h8l4 4V20a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1Z"/><path d="M14 3.5v4h4M8 13h8M8 16h6"/></svg>'
2056
2245
  }
2057
2246
  function fsUp() {
2058
- if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) {
2059
- loadFs(fsParent(state.fs.path))
2060
- }
2247
+ if (!state.fs.path || !state.fs.initial) return
2248
+ const parent = fsParent(state.fs.path)
2249
+ if (fsPathEqual(state.fs.path, state.fs.initial) || !fsPathInside(parent, state.fs.initial)) return
2250
+ loadFs(parent)
2061
2251
  }
2062
2252
 
2063
2253
  /* ---------------- 工作台绑定 / 项目会话 ---------------- */
@@ -2375,6 +2565,8 @@ function toggleStatsDrawer() {
2375
2565
  async function loadStats() {
2376
2566
  if (!state.token) {
2377
2567
  $('stats-cards').innerHTML = `<div class="ds-empty">${t('ds.statsGatewayDown')}</div>`
2568
+ $('stats-token-chart').innerHTML = ''
2569
+ $('stats-token-legend').innerHTML = ''
2378
2570
  return
2379
2571
  }
2380
2572
  try {
@@ -2384,11 +2576,15 @@ async function loadStats() {
2384
2576
  renderStats(json.days || [])
2385
2577
  } catch {
2386
2578
  $('stats-cards').innerHTML = `<div class="ds-empty">${t('ds.statsGatewayDown')}</div>`
2579
+ $('stats-token-chart').innerHTML = ''
2580
+ $('stats-token-legend').innerHTML = ''
2387
2581
  }
2388
2582
  }
2389
2583
  function renderStats(days) {
2390
2584
  if (!days.length) {
2391
2585
  $('stats-cards').innerHTML = `<div class="ds-empty">${t('ds.statsEmpty')}</div>`
2586
+ $('stats-token-chart').innerHTML = ''
2587
+ $('stats-token-legend').innerHTML = ''
2392
2588
  return
2393
2589
  }
2394
2590
  const today = days[days.length - 1]
@@ -2408,6 +2604,7 @@ function renderStats(days) {
2408
2604
  <div class="ds-stat-card"><div class="v">${fmtCost(totalCost)}</div><div class="k">${t('ds.statsTodayCost')}<br>${t('ds.statsPeak')} ${fmtCost(peakCost)} / ${t('ds.statsOff')} ${fmtCost(offCost)}</div></div>
2409
2605
  <div class="ds-stat-card"><div class="v">${peakShare}%</div><div class="k">${t('ds.statsPeakShare')}<br>${t('ds.statsDays', { n: days.length })}</div></div>`
2410
2606
  $('stats-legend').innerHTML = `<span class="sw peak"></span>${t('ds.statsPeak')} <span class="sw off"></span>${t('ds.statsOff')}`
2607
+ $('stats-token-legend').innerHTML = `<span class="sw token"></span>${t('ds.statsTokenTotal')}`
2411
2608
  $('stats-note').textContent = t('ds.statsNote')
2412
2609
  const maxCost = Math.max(...days.map(d => (d.total.cost || 0)), 0.0001)
2413
2610
  $('stats-chart').innerHTML = days.map(d => {
@@ -2422,6 +2619,17 @@ function renderStats(days) {
2422
2619
  <div class="lbl">${d.date.slice(5)}</div>
2423
2620
  </div>`
2424
2621
  }).join('')
2622
+ const maxTokens = Math.max(...days.map(d => bucketTokens(d.total)), 1)
2623
+ $('stats-token-chart').innerHTML = days.map(d => {
2624
+ const tokens = bucketTokens(d.total)
2625
+ const totalH = tokens > 0 ? Math.max(3, Math.round(tokens / maxTokens * 100)) : 0
2626
+ const tip = `${d.date}\n${t('ds.statsTokenTotal')} ${fmtTokens(tokens)}\n${t('ds.statsInput')} ${fmtTokens(d.total.input)} · ${t('ds.statsOutput')} ${fmtTokens(d.total.output)}`
2627
+ return `<div class="ds-stats-bar" data-tip="${esc(tip)}">
2628
+ <div class="bars token" style="height:${totalH}%"></div>
2629
+ <div class="val">${tokens > 0 ? fmtTokens(tokens) : ''}</div>
2630
+ <div class="lbl">${d.date.slice(5)}</div>
2631
+ </div>`
2632
+ }).join('')
2425
2633
  }
2426
2634
 
2427
2635
  /* ---------------- 视图与连接状态 ---------------- */
@@ -2431,7 +2639,7 @@ function renderOverviewDesktop() {
2431
2639
  const checks = {
2432
2640
  // 桌面独立页面默认使用当前 origin,state.server 为空不代表网关离线。
2433
2641
  gateway: !!state.token && (!!state.server || /^https?:$/.test(location.protocol)),
2434
- dsh: !!state.hostInfo,
2642
+ dsh: dshReachable(),
2435
2643
  mux: !!state.streamsOk?.mux,
2436
2644
  host: !!state.streamsOk?.host
2437
2645
  }
@@ -2557,7 +2765,7 @@ function updateConn() {
2557
2765
  const el = $('conn-badge')
2558
2766
  // 双实时通道可能按任意顺序打开,连接刷新必须同时刷新系统总览。
2559
2767
  renderOverviewDesktop()
2560
- const cur = state.servers.find(s => s.url === state.server)
2768
+ const cur = currentServerEntry()
2561
2769
  const group = cur ? cur.group : state.activeGroup
2562
2770
  const label = cur ? (cur.note || cur.url) : (state.server || t('ds.origin'))
2563
2771
  const serverText = t('ds.currentServer', { group, url: label })
@@ -2626,8 +2834,7 @@ function bindUi() {
2626
2834
  toast(t('ds.loading'))
2627
2835
  if (state.token) {
2628
2836
  await refreshSessions()
2629
- const host = await safeRpc('host.describe', {}, '')
2630
- if (host) state.hostInfo = host
2837
+ await refreshHostDescriptionDesktop({ notify: true })
2631
2838
  }
2632
2839
  renderOverviewDesktop()
2633
2840
  })
@@ -2759,6 +2966,11 @@ function bindUi() {
2759
2966
  if (bar && bar.dataset.tip) showTip(bar.dataset.tip, bar.getBoundingClientRect())
2760
2967
  })
2761
2968
  $('stats-chart').addEventListener('mouseleave', hideTip)
2969
+ $('stats-token-chart').addEventListener('mouseover', (e) => {
2970
+ const bar = e.target.closest('.ds-stats-bar')
2971
+ if (bar && bar.dataset.tip) showTip(bar.dataset.tip, bar.getBoundingClientRect())
2972
+ })
2973
+ $('stats-token-chart').addEventListener('mouseleave', hideTip)
2762
2974
 
2763
2975
  $('view-settings').addEventListener('click', (e) => {
2764
2976
  const group = e.target.closest('[data-settings-group]')
@@ -2791,6 +3003,12 @@ function bindUi() {
2791
3003
  renderServers(); renderSessions(); renderNotifStack(); renderOverviewDesktop(); updateConn(); themeApply()
2792
3004
  })
2793
3005
  $('fs-up').addEventListener('click', fsUp)
3006
+ $('fs-root').addEventListener('change', (event) => {
3007
+ const index = Math.min(Number(event.target.value) || 0, Math.max(0, state.fs.roots.length - 1))
3008
+ state.fs.rootIndex = index
3009
+ state.fs.initial = null
3010
+ loadFs(state.fs.roots[index] || null, true)
3011
+ })
2794
3012
  $('fs-new-workspace').addEventListener('click', openWorkspaceModal)
2795
3013
  $('fs-refresh').addEventListener('click', () => loadFs(state.fs.path || null))
2796
3014
  $('btn-question-submit').addEventListener('click', submitQuestion)
@@ -2798,11 +3016,23 @@ function bindUi() {
2798
3016
  }
2799
3017
 
2800
3018
  async function start() {
3019
+ const urlToken = new URLSearchParams(location.search).get('token')?.trim() || ''
3020
+ if (urlToken) {
3021
+ state.token = urlToken
3022
+ LS.set('token', urlToken)
3023
+ history.replaceState(null, '', location.pathname)
3024
+ }
2801
3025
  loadServers()
2802
3026
  renderServers()
2803
3027
  showView('view-overview')
2804
- const urlToken = new URLSearchParams(location.search).get('token')
2805
- if (urlToken) { state.token = urlToken; LS.set('token', urlToken); history.replaceState(null, '', location.pathname) }
3028
+ if (urlToken) {
3029
+ state.token = urlToken
3030
+ const currentId = state.groupActive[state.activeGroup]
3031
+ const current = state.servers.find(s => s.id === currentId) || state.servers.find(s => s.url === state.server)
3032
+ if (current) current.token = urlToken
3033
+ LS.set('token', urlToken)
3034
+ saveServers()
3035
+ }
2806
3036
  if (!state.token) {
2807
3037
  const input = prompt(t('ds.tokenTitle'))
2808
3038
  if (input && input.trim()) { state.token = input.trim(); LS.set('token', state.token) }
@@ -2813,11 +3043,10 @@ async function start() {
2813
3043
  updateConn()
2814
3044
  checkNotesOnStart()
2815
3045
  if (state.token) {
2816
- if (state.servers.length) await selectFastestServer({ silent: true, reconnect: false })
3046
+ await selectFastestServer({ silent: true, reconnect: false })
2817
3047
  openStreams()
2818
3048
  await refreshSessions()
2819
- const host = await safeRpc('host.describe', {}, '')
2820
- if (host) state.hostInfo = host
3049
+ await refreshHostDescriptionDesktop()
2821
3050
  refreshWorkbench({ silent: true })
2822
3051
  }
2823
3052
  renderOverviewDesktop()