dsh-remote-plugin 0.6.17 → 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.
@@ -303,8 +303,12 @@
303
303
  </div>
304
304
  <div class="ds-drawer-body">
305
305
  <div class="ds-stat-grid" id="stats-cards"></div>
306
+ <div class="ds-stats-chart-title" data-i18n="ds.statsCostTrend">费用趋势</div>
306
307
  <div id="stats-legend" class="ds-stats-legend"></div>
307
308
  <div id="stats-chart" class="ds-stats-chart"></div>
309
+ <div class="ds-stats-chart-title" data-i18n="ds.statsTokenTrend">Token 量趋势</div>
310
+ <div id="stats-token-legend" class="ds-stats-legend"></div>
311
+ <div id="stats-token-chart" class="ds-stats-chart"></div>
308
312
  <div id="stats-note" class="ds-stats-note"></div>
309
313
  </div>
310
314
  </div>
@@ -462,7 +466,7 @@
462
466
  'ds.fsUp': '上级', 'ds.fsRoot': '允许根目录', 'ds.fsNewWorkspace': '新建工作区', 'ds.fsRefresh': '刷新', 'ds.fsEmpty': '目录为空',
463
467
  'ds.workspaceCreateTitle': '新建工作区', 'ds.workspaceCreateDesc': '将在当前文件目录下创建文件夹,并自动打开新会话。', 'ds.workspaceParent': '父目录', 'ds.workspaceNamePlaceholder': '例如:my-project', 'ds.workspaceCreate': '创建并打开', 'ds.cancel': '取消', 'ds.workspaceNameRequired': '请输入工作区名称', 'ds.workspaceExists': '该目录已存在', 'ds.workspaceInvalidName': '名称不能包含路径分隔符', 'ds.workspaceCreateFailed': '创建工作区失败', 'ds.workspaceCreated': '工作区已创建', 'ds.workspaceCreatedNoSession': '工作区已创建,但新会话未能打开',
464
468
  'ds.groupGeneral': '通用', 'ds.groupGeneralDesc': '工具调用、预设提示词',
465
- 'ds.groupServers': '服务器', 'ds.groupServersDesc': '服务器地址',
469
+ 'ds.groupServers': '服务器', 'ds.groupServersDesc': '服务器地址与令牌',
466
470
  'ds.groupNotify': '通知', 'ds.groupNotifyDesc': '通知与提醒',
467
471
  'ds.groupTheme': '皮肤', 'ds.groupThemeDesc': '配色主题',
468
472
  'ds.groupAbout': '关于', 'ds.groupAboutDesc': '版本信息',
@@ -498,18 +502,18 @@
498
502
  'ds.approvalBody': '{tool} · {server}', 'ds.approvalReason': '原因:{reason}',
499
503
  'ds.allowed': '已允许', 'ds.rejected': '已拒绝', 'ds.ignored': '已忽略',
500
504
  'ds.stale': '请求已失效', 'ds.pendingSubmitFailed': '提交失败:{msg}',
501
- 'ds.statsTodayTokens': '今日 Token', 'ds.statsTodayCost': '今日费用', 'ds.statsPeakShare': '高峰占比',
505
+ 'ds.statsTodayTokens': '今日 Token', 'ds.statsTodayCost': '今日费用', 'ds.statsPeakShare': '高峰占比', 'ds.statsCostTrend': '费用趋势', 'ds.statsTokenTrend': 'Token 量趋势', 'ds.statsTokenTotal': 'Token 总量',
502
506
  'ds.statsInput': '未缓存输入', 'ds.statsCacheRead': '缓存命中', 'ds.statsCacheWrite': '缓存写入', 'ds.statsOutput': '输出',
503
507
  'ds.statsPeak': '高峰', 'ds.statsOff': '空闲', 'ds.statsDays': '近 {n} 天',
504
508
  'ds.statsGatewayDown': '统计需要网关运行', 'ds.statsEmpty': '暂无统计',
505
- 'ds.statsNote': '注:本数据仅在使用 DeepSeek 官方 API 时估算;基于 token 计算,与官网账单可能有出入,一切以官网为准。统计自 2026-08-17 定价生效日起。',
506
- 'ds.serversEmpty': '未添加备用服务器', 'ds.serversUntested': '未测速', 'ds.serversCurrent': ' · 当前',
509
+ 'ds.statsNote': '注:Token 量按 DSH usage 事件记录,第三方 provider 需提供 usage 才能计入;费用仅对已知 DeepSeek 模型估算,与官网账单可能有出入,一切以官网为准。统计自 2026-08-17 定价生效日起。',
510
+ 'ds.serversEmpty': '未添加备用服务器', 'ds.serversUntested': '未测速', 'ds.serversCurrent': ' · 当前', 'ds.serversTokenSet': '令牌已配置', 'ds.serversTokenMissing': '未配置令牌',
507
511
  'ds.serversUnreachable': '不可达', 'ds.serversDelete': '删除', 'ds.serversEdit': '编辑',
508
512
  'ds.serversNeedAddress': '请输入服务器地址', 'ds.serversBadProtocol': '地址需以 http:// 或 https:// 开头',
509
- 'ds.serversDuplicate': '该地址已存在', 'ds.serversAdded': '已添加', 'ds.serversEdited': '已更新',
513
+ 'ds.serversDuplicate': '相同地址和令牌已存在', 'ds.serversAdded': '已添加', 'ds.serversEdited': '已更新',
510
514
  'ds.serversRemovedActive': '已删除当前服务器,重新测速…',
511
515
  'ds.serversCurrentDesc': '当前:{group} · {url}({ms})',
512
- 'ds.serversPromptNote': '服务器备注(可留空):', 'ds.serversPromptEditUrl': '编辑服务器地址:',
516
+ 'ds.serversPromptNote': '服务器备注(可留空):', 'ds.serversPromptEditUrl': '编辑服务器地址:', 'ds.serversPromptEditToken': '编辑服务器令牌(留空保持不变):',
513
517
  'ds.serversPromptEditNote': '编辑备注({url}):', 'ds.serversPromptEditGroup': '所属组:',
514
518
  'ds.serversManualSelected': '已切换至:{url}',
515
519
  'ds.speedTesting': '正在测速…', 'ds.speedSwitched': '已切换到:{url}({ms}ms)',
@@ -564,7 +568,7 @@
564
568
  'ds.fsUp': 'Up', 'ds.fsRoot': 'Allowed root', 'ds.fsNewWorkspace': 'New workspace', 'ds.fsRefresh': 'Refresh', 'ds.fsEmpty': 'Empty directory',
565
569
  'ds.workspaceCreateTitle': 'New workspace', 'ds.workspaceCreateDesc': 'Create a folder in the current file directory and open a new session there.', 'ds.workspaceParent': 'Parent folder', 'ds.workspaceNamePlaceholder': 'For example: my-project', 'ds.workspaceCreate': 'Create & open', 'ds.cancel': 'Cancel', 'ds.workspaceNameRequired': 'Enter a workspace name', 'ds.workspaceExists': 'That folder already exists', 'ds.workspaceInvalidName': 'The name cannot contain path separators', 'ds.workspaceCreateFailed': 'Could not create workspace', 'ds.workspaceCreated': 'Workspace created', 'ds.workspaceCreatedNoSession': 'Workspace created, but the new session could not be opened',
566
570
  'ds.groupGeneral': 'General', 'ds.groupGeneralDesc': 'Tool calls, prompt presets',
567
- 'ds.groupServers': 'Servers', 'ds.groupServersDesc': 'Server address',
571
+ 'ds.groupServers': 'Servers', 'ds.groupServersDesc': 'Server address and token',
568
572
  'ds.groupNotify': 'Notifications', 'ds.groupNotifyDesc': 'Notifications & reminders',
569
573
  'ds.groupTheme': 'Theme', 'ds.groupThemeDesc': 'Color themes',
570
574
  'ds.groupAbout': 'About', 'ds.groupAboutDesc': 'Version info',
@@ -600,18 +604,18 @@
600
604
  'ds.approvalBody': '{tool} · {server}', 'ds.approvalReason': 'Reason: {reason}',
601
605
  'ds.allowed': 'Allowed', 'ds.rejected': 'Rejected', 'ds.ignored': 'Ignored',
602
606
  'ds.stale': 'Request is stale', 'ds.pendingSubmitFailed': 'Submit failed: {msg}',
603
- 'ds.statsTodayTokens': 'Tokens today', 'ds.statsTodayCost': 'Cost today', 'ds.statsPeakShare': 'Peak share',
607
+ 'ds.statsTodayTokens': 'Tokens today', 'ds.statsTodayCost': 'Cost today', 'ds.statsPeakShare': 'Peak share', 'ds.statsCostTrend': 'Cost trend', 'ds.statsTokenTrend': 'Token volume trend', 'ds.statsTokenTotal': 'Total tokens',
604
608
  'ds.statsInput': 'Uncached input', 'ds.statsCacheRead': 'Cache read', 'ds.statsCacheWrite': 'Cache write', 'ds.statsOutput': 'Output',
605
609
  'ds.statsPeak': 'Peak', 'ds.statsOff': 'Off-peak', 'ds.statsDays': 'Last {n} days',
606
610
  'ds.statsGatewayDown': 'Stats require the gateway', 'ds.statsEmpty': 'No stats yet',
607
- 'ds.statsNote': 'Note: estimates assume the official DeepSeek API. Token-based calculation may differ from the official bill; always defer to deepseek.com. Stats start from the 2026-08-17 pricing date.',
608
- 'ds.serversEmpty': 'No backup servers', 'ds.serversUntested': 'Untested', 'ds.serversCurrent': ' · current',
611
+ 'ds.statsNote': 'Note: token volume uses DSH usage events; a third-party provider must expose usage to be counted. Costs are estimates for known DeepSeek models and may differ from the official bill; always defer to deepseek.com. Stats start from the 2026-08-17 pricing date.',
612
+ 'ds.serversEmpty': 'No backup servers', 'ds.serversUntested': 'Untested', 'ds.serversCurrent': ' · current', 'ds.serversTokenSet': 'Token set', 'ds.serversTokenMissing': 'No token',
609
613
  'ds.serversUnreachable': 'Unreachable', 'ds.serversDelete': 'Delete', 'ds.serversEdit': 'Edit',
610
614
  'ds.serversNeedAddress': 'Enter a server address', 'ds.serversBadProtocol': 'Address must start with http:// or https://',
611
- 'ds.serversDuplicate': 'Address already exists', 'ds.serversAdded': 'Added', 'ds.serversEdited': 'Updated',
615
+ 'ds.serversDuplicate': 'The same address and token already exist', 'ds.serversAdded': 'Added', 'ds.serversEdited': 'Updated',
612
616
  'ds.serversRemovedActive': 'Removed active server, re-testing…',
613
617
  'ds.serversCurrentDesc': 'Current: {group} · {url} ({ms})',
614
- 'ds.serversPromptNote': 'Server note (optional):', 'ds.serversPromptEditUrl': 'Edit server URL:',
618
+ 'ds.serversPromptNote': 'Server note (optional):', 'ds.serversPromptEditUrl': 'Edit server URL:', 'ds.serversPromptEditToken': 'Edit server token (blank keeps the current token):',
615
619
  'ds.serversPromptEditNote': 'Edit note ({url}):', 'ds.serversPromptEditGroup': 'Group:',
616
620
  'ds.serversManualSelected': 'Switched to: {url}',
617
621
  'ds.speedTesting': 'Testing servers…', 'ds.speedSwitched': 'Switched to: {url} ({ms}ms)',
@@ -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: [],
@@ -661,6 +661,27 @@ function ensureGroup(name) {
661
661
  }
662
662
  function groupServers(g) { return state.servers.filter(s => s.group === g) }
663
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
+ }
664
685
 
665
686
  function migrateServersV1() {
666
687
  if (LS.get('servers-v2', null) !== null) return
@@ -671,7 +692,7 @@ function migrateServersV1() {
671
692
  arr = legacy ? [legacy] : []
672
693
  }
673
694
  const urls = arr.map(s => String(s || '').trim().replace(/\/+$/, '')).filter(s => /^https?:\/\//i.test(s))
674
- 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) }))
675
696
  state.groups = ['默认']; state.activeGroup = '默认'
676
697
  state.autoSelect = { '默认': true }; state.groupActive = { '默认': '' }
677
698
  const active = LS.get('activeServer', '')
@@ -687,7 +708,8 @@ function loadServers() {
687
708
  let data = null
688
709
  try { data = JSON.parse(LS.get('servers-v2', '')) } catch {}
689
710
  if (!data || !Array.isArray(data.servers)) { migrateServersV1(); return }
690
- 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) }))
691
713
  state.groups = Array.isArray(data.groups) && data.groups.length ? data.groups : ['默认']
692
714
  state.activeGroup = state.groups.includes(data.activeGroup) ? data.activeGroup : '默认'
693
715
  state.autoSelect = data.autoSelect || {}
@@ -696,7 +718,12 @@ function loadServers() {
696
718
  for (const s of state.servers) ensureGroup(s.group)
697
719
  const manual = state.groupActive[state.activeGroup]
698
720
  const manualSrv = manual ? state.servers.find(s => s.id === manual) : null
699
- 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
+ }
700
727
  }
701
728
  function saveServers() {
702
729
  LS.set('servers-v2', JSON.stringify({
@@ -705,8 +732,8 @@ function saveServers() {
705
732
  }))
706
733
  }
707
734
  function serverCandidates() {
708
- const list = activeServers().map(s => s.url)
709
- 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 })
710
737
  return list
711
738
  }
712
739
  async function pingServer(base) {
@@ -749,36 +776,47 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
749
776
  if (!silent) toast(t('ds.speedTesting'))
750
777
  const candidates = serverCandidates()
751
778
  let chosen = ''
779
+ let chosenToken = state.token
752
780
  let best = null
753
781
  let ms = Infinity
754
782
  if (state.autoSelect[state.activeGroup] !== false) {
755
- const measured = await Promise.all(candidates.map(async (u) => [u, await pingServer(u)]))
756
- for (const [u, latency] of measured) state.serverLatency[u] = latency
757
- best = candidates.filter(u => Number.isFinite(state.serverLatency[u])).sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0] || null
758
- chosen = best || (state.server || '')
759
- 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
760
790
  } else {
761
791
  const manual = state.groupActive[state.activeGroup]
762
792
  const manualSrv = manual ? state.servers.find(s => s.id === manual) : null
763
793
  chosen = manualSrv ? manualSrv.url : (activeServers()[0]?.url || '')
764
- 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
+ }
765
800
  }
766
801
  renderServers()
767
- if (chosen !== state.server) {
802
+ if (chosen !== state.server || chosenToken !== state.token) {
768
803
  state.hostInfo = null
769
804
  hostDescribeFailures = 0
770
805
  if (hostDescribeRetryTimer) clearTimeout(hostDescribeRetryTimer)
771
806
  hostDescribeRetryTimer = null
772
807
  resetFsForServerDesktop()
773
808
  state.server = chosen
774
- 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 }
775
812
  saveServers()
813
+ renderServers()
776
814
  if (!silent) {
777
815
  if (chosen) toast(t('ds.speedSwitched', { url: chosen, ms: Number.isFinite(ms) ? ms : 0 }), 'ok')
778
816
  }
779
817
  if (reconnect && state.token) { openStreams(); refreshSessions() }
780
818
  } else if (!silent) {
781
- 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')
782
820
  else if (chosen) toast(t('ds.speedManualUsing', { url: chosen, ms: Number.isFinite(ms) ? ms : '—' }), 'ok')
783
821
  else toast(t('ds.speedAllDown'), 'err')
784
822
  }
@@ -818,13 +856,14 @@ function renderServers() {
818
856
  </div>
819
857
  <div class="ds-srv-body ${g === state.activeGroup ? '' : 'hidden'}">
820
858
  ${list.map(s => {
821
- const ms = state.serverLatency[s.url]
859
+ const ms = state.serverLatency[serverKey(s)]
822
860
  let badge = `<span class="ds-server-badge">${t('ds.serversUntested')}</span>`
823
- 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>`
824
863
  else if (ms !== undefined) badge = `<span class="ds-server-badge bad">${t('ds.serversUnreachable')}</span>`
825
- const activeInGroup = auto ? s.url === state.server : s.id === activeManual
864
+ const activeInGroup = auto ? active : s.id === activeManual
826
865
  return `<div class="ds-server-row ${activeInGroup ? 'active' : ''}" data-use-server="${esc(s.id)}">
827
- <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}
828
867
  <button class="ds-mini" data-edit-server="${esc(s.id)}" title="${t('ds.serversEdit')}">✎</button>
829
868
  <button class="ds-mini" data-del-server="${esc(s.id)}" title="${t('ds.serversDelete')}">✕</button>
830
869
  </div>`
@@ -857,15 +896,17 @@ function renderServers() {
857
896
  state.activeGroup = s.group
858
897
  if (state.server !== s.url) resetFsForServerDesktop()
859
898
  state.server = s.url
899
+ state.token = serverToken(s)
900
+ LS.set('token', state.token)
860
901
  saveServers()
861
902
  renderServers()
862
903
  toast(t('ds.serversManualSelected', { url: serverTitle(s) }), 'ok')
863
904
  if (state.token) { openStreams(); refreshSessions() }
864
905
  }))
865
- const cur = state.servers.find(s => s.url === state.server)
906
+ const cur = currentServerEntry()
866
907
  const curGroup = cur ? cur.group : state.activeGroup
867
908
  const curLabel = cur ? (cur.note || cur.url) : (state.server || t('ds.origin'))
868
- const curMs = state.serverLatency[state.server]
909
+ const curMs = cur ? state.serverLatency[serverKey(cur)] : undefined
869
910
  $('server-desc').textContent = t('ds.serversCurrentDesc', { group: curGroup, url: curLabel, ms: Number.isFinite(curMs) ? curMs + 'ms' : '—' })
870
911
  updateConn()
871
912
  }
@@ -876,9 +917,10 @@ async function addServer() {
876
917
  if (!raw) return toast(t('ds.serversNeedAddress'), 'err')
877
918
  try { const u = new URL(raw); if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad') }
878
919
  catch { return toast(t('ds.serversBadProtocol'), 'err') }
879
- 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')
880
922
  const note = (prompt(t('ds.serversPromptNote')) || '').trim()
881
- state.servers.push({ id: newServerId(), url: raw, note, group: state.activeGroup })
923
+ state.servers.push({ id: newServerId(), url: raw, note, group: state.activeGroup, token })
882
924
  saveServers()
883
925
  if (input) input.value = ''
884
926
  renderServers()
@@ -892,17 +934,22 @@ function editServer(id) {
892
934
  if (!raw) return
893
935
  try { const u = new URL(raw); if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad') }
894
936
  catch { return toast(t('ds.serversBadProtocol'), 'err') }
895
- 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')
896
941
  const note = prompt(t('ds.serversPromptEditNote', { url: raw }), s.note || '')
897
942
  if (note === null) return
898
943
  const group = prompt(t('ds.serversPromptEditGroup'), s.group || '默认')
899
944
  if (group === null) return
900
- const wasActive = state.server === s.url
945
+ const wasActive = state.server === s.url && state.token === serverToken(s)
901
946
  const changedActiveUrl = wasActive && state.server !== raw
902
- s.url = raw; s.note = note.trim(); s.group = ensureGroup(group.trim() || '默认')
947
+ s.url = raw; s.note = note.trim(); s.group = ensureGroup(group.trim() || '默认'); s.token = token
903
948
  if (wasActive) {
904
949
  if (changedActiveUrl) resetFsForServerDesktop()
905
950
  state.server = raw
951
+ state.token = token
952
+ LS.set('token', token)
906
953
  }
907
954
  saveServers(); renderServers(); toast(t('ds.serversEdited'), 'ok')
908
955
  if (wasActive && state.token) selectFastestServer({ silent: true })
@@ -911,7 +958,7 @@ function removeServer(id) {
911
958
  const s = state.servers.find(x => x.id === id)
912
959
  if (!s) return
913
960
  state.servers = state.servers.filter(x => x.id !== id)
914
- const wasActive = state.server === s.url
961
+ const wasActive = state.server === s.url && state.token === serverToken(s)
915
962
  for (const g of state.groups) if (state.groupActive[g] === id) state.groupActive[g] = ''
916
963
  saveServers(); renderServers()
917
964
  if (wasActive) { toast(t('ds.serversRemovedActive')); selectFastestServer({ silent: true }) }
@@ -1924,7 +1971,7 @@ function renderQueue() {
1924
1971
 
1925
1972
  /* ---------------- 审批/提问通知卡片栈 ---------------- */
1926
1973
  function serverLabel() {
1927
- const cur = state.servers.find(s => s.url === state.server)
1974
+ const cur = currentServerEntry()
1928
1975
  return cur ? (cur.note || cur.url) : (state.server || location.host)
1929
1976
  }
1930
1977
  function renderNotifStack() {
@@ -2518,6 +2565,8 @@ function toggleStatsDrawer() {
2518
2565
  async function loadStats() {
2519
2566
  if (!state.token) {
2520
2567
  $('stats-cards').innerHTML = `<div class="ds-empty">${t('ds.statsGatewayDown')}</div>`
2568
+ $('stats-token-chart').innerHTML = ''
2569
+ $('stats-token-legend').innerHTML = ''
2521
2570
  return
2522
2571
  }
2523
2572
  try {
@@ -2527,11 +2576,15 @@ async function loadStats() {
2527
2576
  renderStats(json.days || [])
2528
2577
  } catch {
2529
2578
  $('stats-cards').innerHTML = `<div class="ds-empty">${t('ds.statsGatewayDown')}</div>`
2579
+ $('stats-token-chart').innerHTML = ''
2580
+ $('stats-token-legend').innerHTML = ''
2530
2581
  }
2531
2582
  }
2532
2583
  function renderStats(days) {
2533
2584
  if (!days.length) {
2534
2585
  $('stats-cards').innerHTML = `<div class="ds-empty">${t('ds.statsEmpty')}</div>`
2586
+ $('stats-token-chart').innerHTML = ''
2587
+ $('stats-token-legend').innerHTML = ''
2535
2588
  return
2536
2589
  }
2537
2590
  const today = days[days.length - 1]
@@ -2551,6 +2604,7 @@ function renderStats(days) {
2551
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>
2552
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>`
2553
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')}`
2554
2608
  $('stats-note').textContent = t('ds.statsNote')
2555
2609
  const maxCost = Math.max(...days.map(d => (d.total.cost || 0)), 0.0001)
2556
2610
  $('stats-chart').innerHTML = days.map(d => {
@@ -2565,6 +2619,17 @@ function renderStats(days) {
2565
2619
  <div class="lbl">${d.date.slice(5)}</div>
2566
2620
  </div>`
2567
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('')
2568
2633
  }
2569
2634
 
2570
2635
  /* ---------------- 视图与连接状态 ---------------- */
@@ -2700,7 +2765,7 @@ function updateConn() {
2700
2765
  const el = $('conn-badge')
2701
2766
  // 双实时通道可能按任意顺序打开,连接刷新必须同时刷新系统总览。
2702
2767
  renderOverviewDesktop()
2703
- const cur = state.servers.find(s => s.url === state.server)
2768
+ const cur = currentServerEntry()
2704
2769
  const group = cur ? cur.group : state.activeGroup
2705
2770
  const label = cur ? (cur.note || cur.url) : (state.server || t('ds.origin'))
2706
2771
  const serverText = t('ds.currentServer', { group, url: label })
@@ -2901,6 +2966,11 @@ function bindUi() {
2901
2966
  if (bar && bar.dataset.tip) showTip(bar.dataset.tip, bar.getBoundingClientRect())
2902
2967
  })
2903
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)
2904
2974
 
2905
2975
  $('view-settings').addEventListener('click', (e) => {
2906
2976
  const group = e.target.closest('[data-settings-group]')
@@ -2946,11 +3016,23 @@ function bindUi() {
2946
3016
  }
2947
3017
 
2948
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
+ }
2949
3025
  loadServers()
2950
3026
  renderServers()
2951
3027
  showView('view-overview')
2952
- const urlToken = new URLSearchParams(location.search).get('token')
2953
- 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
+ }
2954
3036
  if (!state.token) {
2955
3037
  const input = prompt(t('ds.tokenTitle'))
2956
3038
  if (input && input.trim()) { state.token = input.trim(); LS.set('token', state.token) }
package/public/index.html CHANGED
@@ -229,12 +229,47 @@
229
229
  </section>
230
230
 
231
231
  <!-- Token 统计 -->
232
- <section id="view-stats" class="view hidden">
233
- <div class="section-head"><span data-i18n="statsPage.title">Token 统计</span><span id="stats-sub" class="muted"></span></div>
232
+ <section id="view-stats" class="view hidden stats-view" aria-labelledby="stats-page-title">
233
+ <header class="stats-page-head">
234
+ <div class="stats-page-copy">
235
+ <div class="stats-eyebrow" data-i18n="statsPage.eyebrow">USAGE OVERVIEW</div>
236
+ <h1 id="stats-page-title" data-i18n="statsPage.title">Token 统计</h1>
237
+ <p class="stats-page-subtitle" data-i18n="statsPage.subtitle">按日查看 Token 用量与费用估算</p>
238
+ </div>
239
+ <div class="stats-date-wrap">
240
+ <span class="stats-date-label" data-i18n="statsPage.latest">最新日期</span>
241
+ <span id="stats-sub" class="stats-date">—</span>
242
+ </div>
243
+ </header>
244
+ <div class="stats-toolbar">
245
+ <span class="stats-toolbar-label" data-i18n="statsPage.chartMetric">图表指标</span>
246
+ <div id="stats-mode-picker" class="stats-mode-picker" role="group" data-i18n-aria="statsPage.chartMetric">
247
+ <button type="button" class="stats-mode-btn active" data-stats-mode="token" aria-pressed="true" data-i18n="statsPage.modeTokens">Token 数</button>
248
+ <button type="button" class="stats-mode-btn" data-stats-mode="cost" aria-pressed="false" data-i18n="statsPage.modeCost">费用(官方)</button>
249
+ </div>
250
+ </div>
234
251
  <div class="stats-cards" id="stats-cards"></div>
235
- <div id="stats-legend" class="stats-legend"></div>
236
- <div id="stats-chart" class="stats-chart"></div>
237
- <div id="stats-note" class="stats-note"></div>
252
+ <section class="stats-panel" data-stats-panel="cost" aria-labelledby="stats-cost-title">
253
+ <div class="stats-panel-head">
254
+ <div>
255
+ <div class="stats-panel-kicker" data-i18n="statsPage.costKicker">ESTIMATED COST</div>
256
+ <h2 id="stats-cost-title" class="stats-panel-title" data-i18n="statsPage.costTrend">费用趋势</h2>
257
+ </div>
258
+ <div id="stats-legend" class="stats-legend"></div>
259
+ </div>
260
+ <div id="stats-chart" class="stats-chart" role="list" aria-live="polite" data-i18n-aria="statsPage.costChartLabel"></div>
261
+ </section>
262
+ <section class="stats-panel" data-stats-panel="token" aria-labelledby="stats-token-title">
263
+ <div class="stats-panel-head">
264
+ <div>
265
+ <div class="stats-panel-kicker" data-i18n="statsPage.tokenKicker">TOKEN VOLUME</div>
266
+ <h2 id="stats-token-title" class="stats-panel-title" data-i18n="statsPage.tokenTrend">Token 量趋势</h2>
267
+ </div>
268
+ <div id="stats-token-legend" class="stats-legend"></div>
269
+ </div>
270
+ <div id="stats-token-chart" class="stats-chart" role="list" aria-live="polite" data-i18n-aria="statsPage.tokenChartLabel"></div>
271
+ </section>
272
+ <p id="stats-note" class="stats-note"></p>
238
273
  </section>
239
274
 
240
275
  <!-- 设置 -->
@@ -877,12 +912,14 @@
877
912
  'overview.action.openSession': '打开最近会话', 'overview.action.newSession': '新建会话', 'overview.action.attention': '处理待办', 'overview.action.connect': '去设置连接', 'overview.action.refresh': '重新检查连接',
878
913
  'overview.communityEyebrow': 'COMMUNITY BOARD', 'overview.communityTitle': '公告与投票', 'overview.communityHistory': '历史公告', 'overview.communityEmpty': '暂无未读公告',
879
914
  'overview.communityPoll': '投票', 'overview.communityNotice': '公告', 'overview.communityNew': '新', 'overview.communityVote': '参与投票', 'overview.communityView': '查看公告', 'overview.communityVoted': '已参与',
880
- 'statsPage.title': 'Token 统计',
881
- 'statsPage.todayTokens': '今日 Token', 'statsPage.todayCost': '今日费用', 'statsPage.peakShare': '高峰占比',
915
+ 'statsPage.eyebrow': '使用概览', 'statsPage.title': 'Token 统计', 'statsPage.subtitle': '按日查看 Token 用量与费用估算', 'statsPage.latest': '最新日期', 'statsPage.chartMetric': '图表指标', 'statsPage.modeTokens': 'Token 数', 'statsPage.modeCost': '费用(官方)',
916
+ 'statsPage.costKicker': 'ESTIMATED COST', 'statsPage.tokenKicker': 'TOKEN VOLUME', 'statsPage.costChartLabel': '近 7 天费用趋势', 'statsPage.tokenChartLabel': '近 7 天 Token 用量趋势',
917
+ 'statsPage.today': '今日', 'statsPage.tokenUnit': 'Tokens', 'statsPage.breakdown': '今日用量构成', 'statsPage.estimated': '估算费用', 'statsPage.peakShareLabel': '高峰费用占比 {n}%',
918
+ 'statsPage.todayTokens': '今日 Token', 'statsPage.todayCost': '今日费用', 'statsPage.peakShare': '高峰占比', 'statsPage.costTrend': '费用趋势', 'statsPage.tokenTrend': 'Token 量趋势', 'statsPage.tokenTotal': 'Token 总量',
882
919
  'statsPage.input': '未缓存输入', 'statsPage.cacheRead': '缓存命中', 'statsPage.cacheWrite': '缓存写入', 'statsPage.output': '输出',
883
920
  'statsPage.peak': '高峰', 'statsPage.off': '空闲', 'statsPage.days': '近 {n} 天',
884
921
  'statsPage.gatewayDown': '统计需要网关运行', 'statsPage.empty': '暂无统计,产生会话后自动聚合',
885
- 'statsPage.note': '注:本数据仅在使用 DeepSeek 官方 API 时估算;基于 token 计算,与官网账单可能有出入,一切以官网为准。统计自 2026-08-17 定价生效日起。',
922
+ 'statsPage.note': '注:Token 量按 DSH usage 事件记录,第三方 provider 需提供 usage 才能计入;费用仅对已知 DeepSeek 模型估算,与官网账单可能有出入,一切以官网为准。统计自 2026-08-17 定价生效日起。',
886
923
  'home.newSession': '+ 新会话', 'home.newWorkspace': '+ 工作区', 'home.empty': '暂无会话', 'home.createFailed': '新建会话失败', 'home.created': '会话已创建',
887
924
  'wb.unbound': '未绑定工作台', 'wb.bound': '绑定 {title}', 'wb.noProjects': '暂无项目', 'wb.noSessions': '暂无会话', 'wb.newSession': '新会话', 'wb.orderSaved': '顺序已保存到当前设备', 'wb.flatHidden': '工作台会话已收起({n} 个)', 'wb.archivedHidden': '------隐藏已归档会话------', 'wb.archivedShown': '------显示已归档会话------',
888
925
  'workspace.createTitle': '新建工作区', 'workspace.createDesc': '将在当前文件目录下创建文件夹,并自动打开新会话。', 'workspace.parent': '父目录', 'workspace.namePlaceholder': '例如:my-project', 'workspace.cancel': '取消', 'workspace.create': '创建并打开', 'workspace.nameRequired': '请输入工作区名称', 'workspace.exists': '该目录已存在', 'workspace.invalidName': '名称不能包含路径分隔符', 'workspace.createFailed': '创建工作区失败:{msg}', 'workspace.created': '工作区已创建', 'workspace.createdNoSession': '工作区已创建,但新会话未能打开',
@@ -903,13 +940,13 @@
903
940
  'speed.switched': '已切换到最快服务器:{url}({ms}ms)', 'speed.alreadyBest': '当前已是最快:{url}({ms}ms)',
904
941
  'speed.allDown': '全部服务器不可达',
905
942
  'servers.empty': '未添加备用服务器 · 默认使用当前页面地址', 'servers.untested': '未测速',
906
- 'servers.current': ' · 当前', 'servers.unreachable': '不可达', 'servers.delete': '删除服务器',
943
+ 'servers.current': ' · 当前', 'servers.unreachable': '不可达', 'servers.delete': '删除服务器', 'servers.tokenSet': '令牌已配置', 'servers.tokenMissing': '未配置令牌',
907
944
  'servers.currentDesc': '当前: {url}', 'servers.notSet': '未设置服务器', 'servers.defaultDesc': '默认 = 当前页面地址',
908
945
  'servers.needAddress': '请输入服务器地址', 'servers.badProtocol': '地址需以 http:// 或 https:// 开头',
909
- 'servers.duplicate': '该地址已在列表中', 'servers.added': '已添加服务器', 'servers.removedActive': '已删除当前服务器,重新测速…',
946
+ 'servers.duplicate': '相同地址和令牌已在列表中', 'servers.added': '已添加服务器', 'servers.removedActive': '已删除当前服务器,重新测速…',
910
947
  'servers.currentDescGroup': '当前:{group} · {url}({ms})',
911
948
  'servers.edit': '编辑', 'servers.manualSelected': '已切换至:{url}',
912
- 'servers.promptNote': '服务器备注(可留空):', 'servers.promptEditNote': '编辑备注({url}):',
949
+ 'servers.promptNote': '服务器备注(可留空):', 'servers.promptEditNote': '编辑备注({url}):', 'servers.promptEditToken': '编辑服务器令牌(留空保持不变):',
913
950
  'servers.promptEditUrl': '编辑服务器地址:', 'servers.promptEditGroup': '所属组:', 'servers.edited': '服务器已更新',
914
951
  'speed.switchedOrigin': '已切换到当前页面(同源)', 'speed.manualUsing': '手动模式:{url}({ms})',
915
952
  'groups.current': '当前组', 'groups.add': '+ 组', 'groups.noServer': '组内暂无服务器',
@@ -1004,7 +1041,7 @@
1004
1041
  'settings.groupGeneral': '通用', 'settings.groupGeneralDesc': '显示工具调用、预设提示词',
1005
1042
  'settings.groupModel': '模型', 'settings.groupModelDesc': '提供方、API 密钥和模型目录',
1006
1043
  'settings.groupTests': '功能测试', 'settings.groupTestsDesc': '验证 Android 和 DSH 原生能力',
1007
- 'settings.groupServers': '服务器', 'settings.groupServersDesc': '服务器地址、扫码连接',
1044
+ 'settings.groupServers': '服务器', 'settings.groupServersDesc': '服务器地址、令牌与扫码连接',
1008
1045
  'settings.groupNotify': '通知', 'settings.groupNotifyDesc': '通知、提醒、后台轮询',
1009
1046
  'settings.groupTheme': '皮肤', 'settings.groupThemeDesc': '配色主题',
1010
1047
  'settings.groupAbout': '关于', 'settings.groupAboutDesc': 'DSH 状态、检查更新',
@@ -1104,12 +1141,14 @@
1104
1141
  'overview.action.openSession': 'Open recent session', 'overview.action.newSession': 'New session', 'overview.action.attention': 'Review pending', 'overview.action.connect': 'Set up connection', 'overview.action.refresh': 'Check connection',
1105
1142
  'overview.communityEyebrow': 'COMMUNITY BOARD', 'overview.communityTitle': 'Announcements & polls', 'overview.communityHistory': 'History', 'overview.communityEmpty': 'No unread announcements',
1106
1143
  'overview.communityPoll': 'Poll', 'overview.communityNotice': 'Notice', 'overview.communityNew': 'New', 'overview.communityVote': 'Vote now', 'overview.communityView': 'View notice', 'overview.communityVoted': 'Voted',
1107
- 'statsPage.title': 'Token stats',
1108
- 'statsPage.todayTokens': 'Tokens today', 'statsPage.todayCost': 'Cost today', 'statsPage.peakShare': 'Peak share',
1144
+ 'statsPage.eyebrow': 'USAGE OVERVIEW', 'statsPage.title': 'Token stats', 'statsPage.subtitle': 'Daily token volume and estimated costs', 'statsPage.latest': 'Latest date', 'statsPage.chartMetric': 'Chart metric', 'statsPage.modeTokens': 'Token count', 'statsPage.modeCost': 'Cost (official)',
1145
+ 'statsPage.costKicker': 'ESTIMATED COST', 'statsPage.tokenKicker': 'TOKEN VOLUME', 'statsPage.costChartLabel': 'Cost trend for the last 7 days', 'statsPage.tokenChartLabel': 'Token volume trend for the last 7 days',
1146
+ 'statsPage.today': 'Today', 'statsPage.tokenUnit': 'Tokens', 'statsPage.breakdown': 'Today’s usage breakdown', 'statsPage.estimated': 'Estimated cost', 'statsPage.peakShareLabel': 'Peak cost share {n}%',
1147
+ 'statsPage.todayTokens': 'Tokens today', 'statsPage.todayCost': 'Cost today', 'statsPage.peakShare': 'Peak share', 'statsPage.costTrend': 'Cost trend', 'statsPage.tokenTrend': 'Token volume trend', 'statsPage.tokenTotal': 'Total tokens',
1109
1148
  'statsPage.input': 'Uncached input', 'statsPage.cacheRead': 'Cache read', 'statsPage.cacheWrite': 'Cache write', 'statsPage.output': 'Output',
1110
1149
  'statsPage.peak': 'Peak', 'statsPage.off': 'Off-peak', 'statsPage.days': 'Last {n} days',
1111
1150
  'statsPage.gatewayDown': 'Stats require the gateway', 'statsPage.empty': 'No stats yet — they aggregate as sessions happen',
1112
- 'statsPage.note': 'Note: estimates assume the official DeepSeek API. Token-based calculation may differ from the official bill; always defer to deepseek.com. Stats start from the 2026-08-17 pricing date.',
1151
+ 'statsPage.note': 'Note: token volume uses DSH usage events; a third-party provider must expose usage to be counted. Costs are estimates for known DeepSeek models and may differ from the official bill; always defer to deepseek.com. Stats start from the 2026-08-17 pricing date.',
1113
1152
  'home.newSession': '+ New session', 'home.newWorkspace': '+ Workspace', 'home.empty': 'No sessions yet', 'home.createFailed': 'Failed to create session', 'home.created': 'Session created',
1114
1153
  'wb.unbound': 'No workbench bound', 'wb.bound': 'Bound {title}', 'wb.noProjects': 'No projects', 'wb.noSessions': 'No sessions', 'wb.newSession': 'New session', 'wb.orderSaved': 'Order saved on this device', 'wb.flatHidden': 'Workbench sessions folded ({n})', 'wb.archivedHidden': '------ Hide archived ------', 'wb.archivedShown': '------ Show archived ------',
1115
1154
  'workspace.createTitle': 'New workspace', 'workspace.createDesc': 'Create a folder in the current file directory and open a new session there.', 'workspace.parent': 'Parent folder', 'workspace.namePlaceholder': 'For example: my-project', 'workspace.cancel': 'Cancel', 'workspace.create': 'Create & open', 'workspace.nameRequired': 'Enter a workspace name', 'workspace.exists': 'That folder already exists', 'workspace.invalidName': 'The name cannot contain path separators', 'workspace.createFailed': 'Could not create workspace: {msg}', 'workspace.created': 'Workspace created', 'workspace.createdNoSession': 'Workspace created, but the new session could not be opened',
@@ -1130,13 +1169,13 @@
1130
1169
  'speed.switched': 'Switched to fastest server: {url} ({ms}ms)', 'speed.alreadyBest': 'Already on fastest: {url} ({ms}ms)',
1131
1170
  'speed.allDown': 'All servers unreachable',
1132
1171
  'servers.empty': 'No backup servers · using the current page URL', 'servers.untested': 'Untested',
1133
- 'servers.current': ' · current', 'servers.unreachable': 'Unreachable', 'servers.delete': 'Remove server',
1172
+ 'servers.current': ' · current', 'servers.unreachable': 'Unreachable', 'servers.delete': 'Remove server', 'servers.tokenSet': 'Token set', 'servers.tokenMissing': 'No token',
1134
1173
  'servers.currentDesc': 'Current: {url}', 'servers.notSet': 'No server set', 'servers.defaultDesc': 'Default = current page URL',
1135
1174
  'servers.needAddress': 'Enter a server address', 'servers.badProtocol': 'Address must start with http:// or https://',
1136
- 'servers.duplicate': 'This address is already in the list', 'servers.added': 'Server added', 'servers.removedActive': 'Removed active server, re-testing…',
1175
+ 'servers.duplicate': 'The same address and token is already in the list', 'servers.added': 'Server added', 'servers.removedActive': 'Removed active server, re-testing…',
1137
1176
  'servers.currentDescGroup': 'Current: {group} · {url} ({ms})',
1138
1177
  'servers.edit': 'Edit', 'servers.manualSelected': 'Switched to: {url}',
1139
- 'servers.promptNote': 'Server note (optional):', 'servers.promptEditNote': 'Edit note ({url}):',
1178
+ 'servers.promptNote': 'Server note (optional):', 'servers.promptEditNote': 'Edit note ({url}):', 'servers.promptEditToken': 'Edit server token (blank keeps the current token):',
1140
1179
  'servers.promptEditUrl': 'Edit server URL:', 'servers.promptEditGroup': 'Group:', 'servers.edited': 'Server updated',
1141
1180
  'speed.switchedOrigin': 'Switched to current page (same origin)', 'speed.manualUsing': 'Manual mode: {url} ({ms})',
1142
1181
  'groups.current': 'Current group', 'groups.add': '+ Group', 'groups.noServer': 'No servers in this group',
@@ -1231,7 +1270,7 @@
1231
1270
  'settings.groupGeneral': 'General', 'settings.groupGeneralDesc': 'Tool calls, prompt presets',
1232
1271
  'settings.groupModel': 'Models', 'settings.groupModelDesc': 'Providers, API keys, and model catalogs',
1233
1272
  'settings.groupTests': 'Feature tests', 'settings.groupTestsDesc': 'Check Android and DSH native capabilities',
1234
- 'settings.groupServers': 'Servers', 'settings.groupServersDesc': 'Server address, scan to pair',
1273
+ 'settings.groupServers': 'Servers', 'settings.groupServersDesc': 'Server address, token, and scan to pair',
1235
1274
  'settings.groupNotify': 'Notifications', 'settings.groupNotifyDesc': 'Notifications, reminders, background polling',
1236
1275
  'settings.groupTheme': 'Theme', 'settings.groupThemeDesc': 'Color themes',
1237
1276
  'settings.groupAbout': 'About', 'settings.groupAboutDesc': 'DSH status, updates',