dsh-remote-plugin 0.6.11 → 0.6.13

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
@@ -55,6 +55,7 @@ const state = {
55
55
  autoSelect: { '默认': true }, // 组内自动测速选优 / 手动指定
56
56
  groupActive: { '默认': '' }, // 每组当前生效的 server id(手动模式)
57
57
  serverLatency: {}, // url -> 最近一次 /health 测速毫秒数
58
+ gatewayHealth: {}, // url -> /health 协议版本与能力声明
58
59
  selectingServer: false, // 防重入: 测速/切换中
59
60
  sessions: [],
60
61
  sessionSort: LS.get('sessionSort', 'time') === 'workspace' ? 'workspace' : 'time',
@@ -64,7 +65,9 @@ const state = {
64
65
  hostInfo: null,
65
66
  localVersion: '',
66
67
  updateInfo: null,
68
+ warnedGatewayVersions: new Set(),
67
69
  announcement: null,
70
+ announcements: [],
68
71
  approvals: [], // 待处理审批
69
72
  questions: [], // 待处理提问
70
73
  queues: {}, // sessionId -> queue items
@@ -272,6 +275,14 @@ function openFeedbackModal() {
272
275
  setTimeout(() => $('fb-msg').focus(), 50)
273
276
  }
274
277
  function closeFeedbackModal() { $('modal-feedback').classList.add('hidden') }
278
+ function openFeedbackSuccess() {
279
+ $('modal-feedback-success').classList.remove('hidden')
280
+ setTimeout(() => $('feedback-success-confirm').focus(), 50)
281
+ }
282
+ function closeFeedbackSuccess() {
283
+ $('modal-feedback-success').classList.add('hidden')
284
+ $('btn-feedback')?.focus()
285
+ }
275
286
  async function submitFeedback() {
276
287
  const type = state.feedbackType || 'bug'
277
288
  const message = $('fb-msg').value.trim()
@@ -290,7 +301,7 @@ async function submitFeedback() {
290
301
  })
291
302
  let json = {}
292
303
  try { json = await res.json() } catch {}
293
- if (res.ok && json.ok) { toast(t('feedback.submitted'), 'ok'); closeFeedbackModal() }
304
+ if (res.ok && json.ok) { closeFeedbackModal(); openFeedbackSuccess() }
294
305
  else if (res.status === 429) { toast(json.retryAfter ? t('feedback.rateLimitedAt', { n: json.retryAfter }) : t('feedback.rateLimited'), 'err') }
295
306
  else { toast(t('feedback.submitFailed', { msg: json.error || res.status }), 'err') }
296
307
  } catch {
@@ -346,6 +357,7 @@ async function getWsTicket() {
346
357
  const token = state.token
347
358
  const server = state.server
348
359
  wsTicketPromise = (async () => {
360
+ if (activeGatewayCapability('wsTicket') === false) throw new Error('ws ticket unsupported')
349
361
  const res = await fetch(apiUrl('/api/ws-ticket'), {
350
362
  method: 'POST',
351
363
  headers: {
@@ -612,7 +624,10 @@ async function pingServer(base) {
612
624
  const timer = setTimeout(() => ctrl.abort(), 3500)
613
625
  try {
614
626
  const res = await fetch(u + '/health?t=' + Date.now(), { signal: ctrl.signal, cache: 'no-store' })
615
- return res.ok ? Math.round(performance.now() - t0) : Infinity
627
+ if (!res.ok) return Infinity
628
+ const health = await res.json().catch(() => null)
629
+ if (health && typeof health === 'object') state.gatewayHealth[u] = health
630
+ return Math.round(performance.now() - t0)
616
631
  } catch {
617
632
  return Infinity
618
633
  } finally {
@@ -620,6 +635,13 @@ async function pingServer(base) {
620
635
  }
621
636
  }
622
637
 
638
+ function activeGatewayCapability(name) {
639
+ const key = String(state.server || location.origin || '').replace(/\/+$/, '')
640
+ const capabilities = state.gatewayHealth[key]?.capabilities
641
+ if (!capabilities || !Object.prototype.hasOwnProperty.call(capabilities, name)) return null
642
+ return Number(capabilities[name]) > 0
643
+ }
644
+
623
645
  async function selectFastestServer({ silent = false, reconnect = true } = {}) {
624
646
  if (state.selectingServer) return null
625
647
  state.selectingServer = true
@@ -668,6 +690,7 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
668
690
  else if (chosen) toast(t('speed.manualUsing', { url: chosen, ms: Number.isFinite(ms) ? ms : '—' }), 'ok')
669
691
  else toast(t('speed.allDown'), 'err')
670
692
  }
693
+ await maybeWarnAppBehindGateway()
671
694
  return chosen
672
695
  } finally {
673
696
  state.selectingServer = false
@@ -1334,6 +1357,10 @@ function applyProjection(sessionId, key, value, seq) {
1334
1357
  }
1335
1358
  function titleOf(s) { return proj(s, 'title') || (s?.sessionId ? short(s.sessionId) : t('session.unknown')) }
1336
1359
  function short(id) { return '…' + String(id).slice(-8) }
1360
+ function isTopLevelSession(session) {
1361
+ return !!session && !session.parentSessionId && session.origin !== 'subagent'
1362
+ }
1363
+ function topLevelSessions() { return state.sessions.filter(isTopLevelSession) }
1337
1364
  const GOAL_TERMINAL_PHASES = new Set(['complete', 'cleared'])
1338
1365
  function isGoalTerminal(goal) {
1339
1366
  return !!goal && GOAL_TERMINAL_PHASES.has(goal.phase)
@@ -1382,12 +1409,17 @@ function workspaceItems() {
1382
1409
  function workspaceById(workspaceId) {
1383
1410
  return workspaceItems().find(w => w.workspaceId === workspaceId) || null
1384
1411
  }
1412
+ function workspaceOwnsSession(workspace, session) {
1413
+ if (!workspace || !session?.sessionId) return false
1414
+ if (String(session.workspaceId || '') === workspace.workspaceId) return true
1415
+ if (Array.isArray(workspace.sessionIds) && workspace.sessionIds.some(id => String(id) === String(session.sessionId))) return true
1416
+ const cwdKey = wbPathKey(sessionCwd(session))
1417
+ const workspaceKey = wbPathKey(workspace.path)
1418
+ return !!cwdKey && !!workspaceKey && (cwdKey === workspaceKey || cwdKey.startsWith(workspaceKey.endsWith('/') ? workspaceKey : workspaceKey + '/'))
1419
+ }
1385
1420
  function workspaceForSession(session) {
1386
1421
  if (!session) return null
1387
- const byMembership = workspaceItems().find(w => Array.isArray(w.sessionIds) && w.sessionIds.includes(session.sessionId))
1388
- if (byMembership) return byMembership
1389
- const cwdKey = wbPathKey(sessionCwd(session))
1390
- return cwdKey ? workspaceItems().find(w => wbPathKey(w.path) === cwdKey) || null : null
1422
+ return workspaceItems().find(workspace => workspaceOwnsSession(workspace, session)) || null
1391
1423
  }
1392
1424
  function workspaceName(workspace) {
1393
1425
  return String(workspace?.title || wbBaseName(workspace?.path) || workspace?.path || '').trim()
@@ -1411,7 +1443,10 @@ function renderWorkspaceNavigation() {
1411
1443
  LS.del('workspaceFilterV1')
1412
1444
  }
1413
1445
  const sessionSelect = $('session-workspace-filter')
1414
- if (sessionSelect) sessionSelect.innerHTML = workspaceOptionsHtml({ all: true, ungrouped: true, selected: state.workspaceFilter })
1446
+ if (sessionSelect) {
1447
+ sessionSelect.innerHTML = workspaceOptionsHtml({ all: true, ungrouped: true, selected: state.workspaceFilter })
1448
+ syncCustomSelect(sessionSelect)
1449
+ }
1415
1450
  const selectedWorkspace = workspaceById(state.workspaceFilter)
1416
1451
  const pathBox = $('session-workspace-path')
1417
1452
  if (pathBox) pathBox.textContent = selectedWorkspace?.path || (state.workspaceFilter === WORKSPACE_UNGROUPED ? t('workspace.ungrouped') : t('workspace.allPath'))
@@ -1423,7 +1458,10 @@ function renderWorkspaceNavigation() {
1423
1458
  LS.del('fsWorkspaceIdV1')
1424
1459
  }
1425
1460
  const fsSelect = $('fs-workspace')
1426
- if (fsSelect) fsSelect.innerHTML = workspaceOptionsHtml({ root: true, selected: state.fs.workspaceId })
1461
+ if (fsSelect) {
1462
+ fsSelect.innerHTML = workspaceOptionsHtml({ root: true, selected: state.fs.workspaceId })
1463
+ syncCustomSelect(fsSelect)
1464
+ }
1427
1465
  if ($('modal-new-session') && !$('modal-new-session').classList.contains('hidden')) renderNewSessionWorkspace()
1428
1466
  return items
1429
1467
  }
@@ -1503,7 +1541,7 @@ function renderWorkbench() {
1503
1541
  panel.innerHTML = projects.map(w => {
1504
1542
  const id = String(w.workspaceId || '')
1505
1543
  const open = !!state.wbOpenProjects[id]
1506
- const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(Boolean).filter(s => !archivedSet.has(s.sessionId))
1544
+ const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isTopLevelSession).filter(s => !archivedSet.has(s.sessionId))
1507
1545
  const body = open ? `<div class="wb-sessions">${sessions.length ? sessions.map(s => `
1508
1546
  <div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
1509
1547
  <button class="wb-session" type="button" data-wb-session="${esc(s.sessionId)}">
@@ -1539,7 +1577,7 @@ function workspaceDisplayName(label) {
1539
1577
  return parts[parts.length - 1] || value
1540
1578
  }
1541
1579
  function sortedSessions() {
1542
- const items = [...state.sessions]
1580
+ const items = topLevelSessions()
1543
1581
  if (state.sessionSort === 'workspace') {
1544
1582
  return items.sort((a, b) => {
1545
1583
  const aw = sessionCwd(a) || '\uffff'
@@ -1556,9 +1594,8 @@ function renderSessions() {
1556
1594
  const archivedSet = new Set(state.wbArchived || [])
1557
1595
  const visible = allItems.filter(s => {
1558
1596
  if (!state.workspaceFilter) return true
1559
- const workspace = workspaceForSession(s)
1560
- if (state.workspaceFilter === WORKSPACE_UNGROUPED) return !workspace
1561
- return workspace?.workspaceId === state.workspaceFilter
1597
+ if (state.workspaceFilter === WORKSPACE_UNGROUPED) return !workspaceForSession(s)
1598
+ return workspaceOwnsSession(workspaceById(state.workspaceFilter), s)
1562
1599
  })
1563
1600
  const archived = visible.filter(s => archivedSet.has(s.sessionId))
1564
1601
  const main = visible.filter(s => !archivedSet.has(s.sessionId))
@@ -1607,7 +1644,7 @@ function renderSessions() {
1607
1644
  const sort = $('session-sort')
1608
1645
  if (sort) { sort.value = state.sessionSort; syncCustomSelect(sort) }
1609
1646
  $('home-empty').classList.toggle('hidden', visible.length > 0)
1610
- const running = state.sessions.filter(s => s.running).length
1647
+ const running = topLevelSessions().filter(s => s.running).length
1611
1648
  const pending = state.approvals.length + state.questions.length
1612
1649
  $('stat-strip').innerHTML = `
1613
1650
  <div class="stat running"><div class="v">${running}</div><div class="k">${t('sessions.statRunning')}</div></div>
@@ -1649,7 +1686,7 @@ function bindNativeBack() {
1649
1686
  if ($('composer-wrap')?.classList.contains('fs')) { setComposerFullscreen(false); return }
1650
1687
  if (customSelectCurrent) { closeCustomSelect(); return }
1651
1688
  const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
1652
- if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else if (openModal.id === 'modal-archive') closeArchiveConfirm(); else openModal.classList.add('hidden'); return } // 先关弹窗
1689
+ if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else if (openModal.id === 'modal-archive') closeArchiveConfirm(); else if (openModal.id === 'modal-app-version-warning') closeAppVersionWarning(false); else openModal.classList.add('hidden'); return } // 先关弹窗
1653
1690
  if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
1654
1691
  if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
1655
1692
  if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
@@ -1697,10 +1734,78 @@ const HISTORY_MAX_VISIBLE = 5000 // 已加载的可显示事件上限(消息/
1697
1734
  function emptyHistory() {
1698
1735
  return {
1699
1736
  visible: [], seqs: new Set(), minSeq: Infinity,
1700
- hasMore: false, loading: false, renderStart: 0, renderEnd: 0
1737
+ hasMore: false, loading: false, renderStart: 0, renderEnd: 0,
1738
+ partialReasoning: new Map()
1701
1739
  }
1702
1740
  }
1703
1741
 
1742
+ function reasoningStreamKey(data, index) {
1743
+ return `${data?.turn ?? '?'}:${data?.step ?? '?'}:${index ?? '?'}`
1744
+ }
1745
+
1746
+ /**
1747
+ * DSH 的实时思考以 assistant/chunk 下发,历史尾页可能把增量压成
1748
+ * reasoning-chunks。最终 assistant/message 到达后再由正式消息接管展示。
1749
+ */
1750
+ function applyReasoningStreamEvent(event) {
1751
+ const h = state.history
1752
+ const data = event?.data || {}
1753
+ let changed = false
1754
+ if (event?.type === 'assistant/chunk') {
1755
+ const chunk = data.chunk || {}
1756
+ const key = reasoningStreamKey(data, chunk.index)
1757
+ if (chunk.type === 'block-start' && chunk.blockType === 'reasoning') {
1758
+ h.partialReasoning.set(key, { turn: data.turn, step: data.step, index: chunk.index, text: '' })
1759
+ changed = true
1760
+ } else if (chunk.type === 'reasoning-delta') {
1761
+ const item = h.partialReasoning.get(key) || { turn: data.turn, step: data.step, index: chunk.index, text: '' }
1762
+ item.text += String(chunk.text || '')
1763
+ h.partialReasoning.set(key, item)
1764
+ changed = true
1765
+ } else if (chunk.type === 'block-end' && chunk.block?.type === 'reasoning') {
1766
+ h.partialReasoning.set(key, {
1767
+ turn: data.turn, step: data.step, index: chunk.index,
1768
+ text: String(chunk.block.text ?? chunk.block.content ?? '')
1769
+ })
1770
+ changed = true
1771
+ }
1772
+ } else if (event?.type === 'reasoning-chunks') {
1773
+ const key = reasoningStreamKey(data, data.index)
1774
+ const item = h.partialReasoning.get(key) || { turn: data.turn, step: data.step, index: data.index, text: '' }
1775
+ item.text += Array.isArray(data.texts) ? data.texts.join('') : String(data.text || '')
1776
+ h.partialReasoning.set(key, item)
1777
+ changed = true
1778
+ } else if (event?.type === 'assistant/message') {
1779
+ for (const [key, item] of h.partialReasoning) {
1780
+ if (item.turn === data.turn && item.step === data.step) {
1781
+ h.partialReasoning.delete(key)
1782
+ changed = true
1783
+ }
1784
+ }
1785
+ }
1786
+ return changed
1787
+ }
1788
+
1789
+ let reasoningRenderTimer = null
1790
+ function scheduleReasoningRender() {
1791
+ if (reasoningRenderTimer) return
1792
+ reasoningRenderTimer = setTimeout(() => {
1793
+ reasoningRenderTimer = null
1794
+ if (!state.current) return
1795
+ const box = $('history')
1796
+ const nearBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 240
1797
+ renderHistory(false, nearBottom ? 'bottom' : 'fixed')
1798
+ }, 80)
1799
+ }
1800
+
1801
+ function partialReasoningHtml() {
1802
+ return [...state.history.partialReasoning.values()]
1803
+ .filter(item => item.text)
1804
+ .sort((a, b) => (a.turn ?? 0) - (b.turn ?? 0) || (a.step ?? 0) - (b.step ?? 0) || (a.index ?? 0) - (b.index ?? 0))
1805
+ .map(item => `<div class="msg assistant reasoning-live"><div class="role">${esc(t('role.dsh'))}</div><details class="tool" open><summary>${esc(t('block.thinkingLive'))}</summary><div class="tool-text">${esc(truncate(item.text, 12000))}</div></details></div>`)
1806
+ .join('')
1807
+ }
1808
+
1704
1809
  function trimVisible() {
1705
1810
  const h = state.history
1706
1811
  if (h.visible.length <= HISTORY_MAX_VISIBLE) return
@@ -1787,9 +1892,11 @@ async function loadHistory(reset) {
1787
1892
 
1788
1893
  const incoming = v.events || []
1789
1894
  let added = 0
1895
+ if (reset) state.history.partialReasoning.clear()
1790
1896
  for (const entry of incoming) {
1791
1897
  const ev = entry?.event
1792
1898
  const seq = ev?.seq
1899
+ applyReasoningStreamEvent(ev)
1793
1900
  if (seq == null || state.history.seqs.has(seq)) continue
1794
1901
  if (!shouldShowEvent(ev.type)) continue // chunk 等内部事件不保留
1795
1902
  state.history.seqs.add(seq)
@@ -1816,8 +1923,16 @@ async function loadHistory(reset) {
1816
1923
 
1817
1924
  function insertLiveEvent(event) {
1818
1925
  const h = state.history
1926
+ const reasoningChanged = applyReasoningStreamEvent(event)
1927
+ if (event?.type === 'assistant/chunk' || event?.type === 'reasoning-chunks') {
1928
+ if (reasoningChanged) scheduleReasoningRender()
1929
+ return
1930
+ }
1819
1931
  const seq = event?.seq
1820
- if (seq == null || h.seqs.has(seq) || !shouldShowEvent(event.type)) return
1932
+ if (seq == null || h.seqs.has(seq) || !shouldShowEvent(event.type)) {
1933
+ if (reasoningChanged) scheduleReasoningRender()
1934
+ return
1935
+ }
1821
1936
  h.seqs.add(seq)
1822
1937
  h.visible.push({ seq, event })
1823
1938
  h.visible.sort((a, b) => a.seq - b.seq)
@@ -1851,7 +1966,8 @@ function renderHistory(reset, mode = 'bottom') {
1851
1966
  const h = state.history
1852
1967
  const filtered = filteredEntries()
1853
1968
  const len = filtered.length
1854
- if (!len) {
1969
+ const reasoningHtml = partialReasoningHtml()
1970
+ if (!len && !reasoningHtml) {
1855
1971
  box.innerHTML = '<div class="empty">' + t('history.empty') + '</div>'
1856
1972
  h.renderStart = 0; h.renderEnd = 0
1857
1973
  updateRail()
@@ -1872,7 +1988,7 @@ function renderHistory(reset, mode = 'bottom') {
1872
1988
  const d = e.event.data || {}
1873
1989
  if (d.callId && d.name) toolNames.set(d.callId, d.name)
1874
1990
  }
1875
- box.innerHTML = filtered.slice(start, end).map(e => eventHtml(e, { toolNames })).join('')
1991
+ box.innerHTML = filtered.slice(start, end).map(e => eventHtml(e, { toolNames })).join('') + reasoningHtml
1876
1992
  if (reset || mode === 'bottom') box.scrollTop = box.scrollHeight
1877
1993
  else if (mode === 'keep') box.scrollTop = Math.max(0, oldTop + (box.scrollHeight - oldH))
1878
1994
  else if (mode === 'fixed') box.scrollTop = oldTop
@@ -2374,16 +2490,37 @@ function renderEffortMenu() {
2374
2490
  const cur = state.models.current
2375
2491
  const provider = (state.models.groups || []).find(g => g.id === cur?.provider)
2376
2492
  const model = (provider?.models || []).find(m => m.id === cur?.model)
2377
- const efforts = model?.reasoning?.efforts || []
2378
- group.classList.toggle('hidden', !efforts.length)
2493
+ const { efforts, defaultEffort, custom } = reasoningEffortOptions(model)
2494
+ group.classList.toggle('hidden', !cur || !efforts.length)
2379
2495
  box.innerHTML = efforts.map(e => {
2380
- const isCur = cur?.reasoningEffort === e.id || (!cur?.reasoningEffort && e.id === model.reasoning.defaultEffort)
2496
+ const isCur = cur?.reasoningEffort === e.id || (!cur?.reasoningEffort && e.id === defaultEffort)
2381
2497
  return `<button class="menu-chip ${isCur ? 'current' : ''}" data-effort="${esc(e.id)}" title="${esc(e.description || '')}">${esc(e.name || e.id)}</button>`
2382
- }).join('')
2498
+ }).join('') + (custom ? `<span class="effort-hint">${esc(t('models.effortCustomHint'))}</span>` : '')
2383
2499
  box.querySelectorAll('[data-effort]').forEach(btn =>
2384
2500
  btn.addEventListener('click', () => selectSessionEffort(btn.dataset.effort)))
2385
2501
  }
2386
2502
 
2503
+ function reasoningEffortOptions(model) {
2504
+ const raw = Array.isArray(model?.reasoning?.efforts) && model.reasoning.efforts.length
2505
+ ? model.reasoning.efforts
2506
+ : (Array.isArray(model?.reasoningEfforts) && model.reasoningEfforts.length ? model.reasoningEfforts : null)
2507
+ const names = {
2508
+ low: t('models.effortLow'), high: t('models.effortHigh'), max: t('models.effortMax'), off: t('models.effortOff')
2509
+ }
2510
+ if (raw) {
2511
+ return {
2512
+ efforts: raw.map(e => typeof e === 'string' ? { id: e, name: names[e] || e } : e),
2513
+ defaultEffort: model?.reasoning?.defaultEffort,
2514
+ custom: !model?.reasoning?.efforts
2515
+ }
2516
+ }
2517
+ return {
2518
+ efforts: ['low', 'high', 'max'].map(id => ({ id, name: names[id] })),
2519
+ defaultEffort: undefined,
2520
+ custom: true
2521
+ }
2522
+ }
2523
+
2387
2524
  async function selectSessionEffort(effortId) {
2388
2525
  const cur = state.models.current
2389
2526
  if (!state.current || !cur) return
@@ -2433,7 +2570,9 @@ function renderNewSessionWorkspace(preferredId = '') {
2433
2570
  const current = workspaceById(preferredId || select.value)?.workspaceId || items[0]?.workspaceId || ''
2434
2571
  select.innerHTML = workspaceOptionsHtml({ selected: current })
2435
2572
  select.disabled = !items.length
2573
+ syncCustomSelect(select)
2436
2574
  const workspace = workspaceById(select.value)
2575
+ $('new-session-workspace-name').textContent = workspace ? workspaceName(workspace) : ''
2437
2576
  $('new-session-workspace-path').textContent = workspace?.path || ''
2438
2577
  $('new-session-empty').classList.toggle('hidden', !!items.length)
2439
2578
  $('new-session-create').disabled = !workspace
@@ -2594,8 +2733,9 @@ function renderOverview() {
2594
2733
  btn.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === btn.dataset.overviewQuestion)))
2595
2734
  })
2596
2735
 
2597
- const running = state.sessions.filter(s => s.running).length
2598
- const sessions = [...state.sessions].sort((a, b) => Number(b.running) - Number(a.running) || (new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0))).slice(0, 4)
2736
+ const topSessions = topLevelSessions()
2737
+ const running = topSessions.filter(s => s.running).length
2738
+ const sessions = topSessions.sort((a, b) => Number(b.running) - Number(a.running) || (new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0))).slice(0, 4)
2599
2739
  const primary = $('overview-primary-action')
2600
2740
  if (primary) {
2601
2741
  let action = 'new'
@@ -3364,6 +3504,54 @@ function cmpVersion(a, b) {
3364
3504
  return 0
3365
3505
  }
3366
3506
 
3507
+ function isComparableVersion(value) {
3508
+ return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(String(value || '').trim())
3509
+ }
3510
+
3511
+ function isAppBehindGateway(appVersion, gatewayVersion) {
3512
+ return isComparableVersion(appVersion) && isComparableVersion(gatewayVersion) && cmpVersion(gatewayVersion, appVersion) > 0
3513
+ }
3514
+
3515
+ function isAppVersionWarningOpen() {
3516
+ const modal = $('modal-app-version-warning')
3517
+ return !!modal && !modal.classList.contains('hidden')
3518
+ }
3519
+
3520
+ async function maybeWarnAppBehindGateway({ probe = false } = {}) {
3521
+ if (!CAP?.isNativePlatform?.() || !state.localVersion) return false
3522
+ const base = String(state.server || '').replace(/\/+$/, '')
3523
+ if (!base) return false
3524
+ let health = state.gatewayHealth[base]
3525
+ if (!health && probe) {
3526
+ await pingServer(base)
3527
+ health = state.gatewayHealth[base]
3528
+ }
3529
+ const gatewayVersion = String(health?.version || '').trim()
3530
+ if (!isAppBehindGateway(state.localVersion, gatewayVersion)) return false
3531
+ const warningKey = `${state.localVersion}\u0000${gatewayVersion}`
3532
+ if (state.warnedGatewayVersions.has(warningKey)) return false
3533
+ const anotherModal = [...document.querySelectorAll('.modal')]
3534
+ .some(modal => modal.id !== 'modal-app-version-warning' && !modal.classList.contains('hidden'))
3535
+ if (anotherModal) return false
3536
+ state.warnedGatewayVersions.add(warningKey)
3537
+ $('app-version-current').textContent = 'v' + state.localVersion
3538
+ $('app-version-gateway').textContent = 'v' + gatewayVersion
3539
+ $('modal-app-version-warning').classList.remove('hidden')
3540
+ setTimeout(() => $('app-version-update')?.focus(), 50)
3541
+ return true
3542
+ }
3543
+
3544
+ function closeAppVersionWarning(checkNow = false) {
3545
+ $('modal-app-version-warning').classList.add('hidden')
3546
+ if (checkNow) {
3547
+ showView('view-settings')
3548
+ showSettingsPage('about')
3549
+ void checkUpdate(false)
3550
+ } else {
3551
+ scheduleStartupNotices(150)
3552
+ }
3553
+ }
3554
+
3367
3555
  function resetUpdateExpand() {
3368
3556
  const desc = $('update-desc')
3369
3557
  if (desc) desc.classList.remove('expanded')
@@ -3397,6 +3585,10 @@ async function loadLocalVersion() {
3397
3585
  const ANNOUNCEMENTS_KEY = 'seenAnnouncementsV1'
3398
3586
  const ANNOUNCEMENT_HISTORY_KEY = 'announcementHistoryV1'
3399
3587
  const ANNOUNCEMENT_VOTES_KEY = 'announcementVotesV1'
3588
+ const ANNOUNCEMENTS_POLL_MS = 30 * 1000
3589
+ let announcementCheckPromise = null
3590
+ let announcementPollTimer = null
3591
+ let announcementPollingStarted = false
3400
3592
  function readSeenAnnouncements() {
3401
3593
  try {
3402
3594
  const value = JSON.parse(LS.get(ANNOUNCEMENTS_KEY, '{}'))
@@ -3413,6 +3605,7 @@ function markAnnouncementSeen(id) {
3413
3605
  for (const key of keys.slice(0, keys.length - 100)) delete seen[key]
3414
3606
  }
3415
3607
  LS.set(ANNOUNCEMENTS_KEY, JSON.stringify(seen))
3608
+ renderAnnouncementBoard()
3416
3609
  }
3417
3610
  function readAnnouncementVotes() {
3418
3611
  try {
@@ -3432,6 +3625,7 @@ function storeAnnouncementVote(announcementId, pollId, optionId) {
3432
3625
  for (const key of keys.slice(0, keys.length - 100)) delete votes[key]
3433
3626
  }
3434
3627
  LS.set(ANNOUNCEMENT_VOTES_KEY, JSON.stringify(votes))
3628
+ renderAnnouncementBoard()
3435
3629
  }
3436
3630
  function announcementVote(item) {
3437
3631
  if (!item?.poll?.id) return null
@@ -3453,6 +3647,36 @@ function storeAnnouncementHistory(items) {
3453
3647
  LS.set(ANNOUNCEMENT_HISTORY_KEY, JSON.stringify(list))
3454
3648
  return list
3455
3649
  }
3650
+ function announcementBoardItems() {
3651
+ const seen = readSeenAnnouncements()
3652
+ const merged = new Map(readAnnouncementHistory().map(item => [item.id, item]))
3653
+ for (const item of state.announcements) if (item?.id) merged.set(item.id, item)
3654
+ return [...merged.values()]
3655
+ .filter(item => !seen[item.id])
3656
+ .sort((a, b) => Number(b.publishedAt || 0) - Number(a.publishedAt || 0))
3657
+ .slice(0, 1)
3658
+ }
3659
+ function renderAnnouncementBoard() {
3660
+ const box = $('overview-announcement-list')
3661
+ if (!box) return
3662
+ const items = announcementBoardItems()
3663
+ if (!items.length) {
3664
+ box.innerHTML = `<div class="overview-empty">${esc(t('overview.communityEmpty'))}</div>`
3665
+ return
3666
+ }
3667
+ box.innerHTML = items.map(item => {
3668
+ const poll = !!item.poll
3669
+ const vote = announcementVote(item)
3670
+ const date = Number(item.publishedAt) > 0 ? fmtFullTime(item.publishedAt) : t('announcement.noDate')
3671
+ const action = poll ? (vote ? t('overview.communityVoted') : t('overview.communityVote')) : t('overview.communityView')
3672
+ return `<button class="overview-announcement-card ${poll ? 'poll' : 'notice'}" type="button" data-home-announcement="${esc(item.id)}">
3673
+ <span class="overview-announcement-meta"><span class="overview-announcement-badge">${esc(t(poll ? 'overview.communityPoll' : 'overview.communityNotice'))}</span><span class="overview-announcement-new">${esc(t('overview.communityNew'))}</span><span class="overview-announcement-date">${esc(date)}</span></span>
3674
+ <span class="overview-announcement-title">${esc(item.title)}</span>
3675
+ <span class="overview-announcement-copy">${esc(item.content)}</span>
3676
+ <span class="overview-announcement-footer"><span>${esc(action)}</span><span aria-hidden="true">›</span></span>
3677
+ </button>`
3678
+ }).join('')
3679
+ }
3456
3680
  function renderAnnouncementHistory() {
3457
3681
  const box = $('announcement-history-list')
3458
3682
  if (!box) return
@@ -3613,9 +3837,9 @@ function closeAnnouncement(markSeen) {
3613
3837
  state.announcement = null
3614
3838
  $('modal-announcement').classList.add('hidden')
3615
3839
  }
3616
- async function checkAnnouncements() {
3840
+ async function fetchAnnouncements() {
3617
3841
  const base = updateBase()
3618
- if (!base || !state.localVersion) return false
3842
+ if (!base || !state.localVersion || isAppVersionWarningOpen()) return false
3619
3843
  try {
3620
3844
  const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(8000) : undefined
3621
3845
  const url = base + '/announcements.json?t=' + Date.now()
@@ -3626,16 +3850,46 @@ async function checkAnnouncements() {
3626
3850
  const data = JSON.parse(raw)
3627
3851
  const source = Array.isArray(data) ? data : (Array.isArray(data?.items) ? data.items : [data])
3628
3852
  const normalized = source.map(item => normalizeAnnouncement(item, base)).filter(Boolean)
3853
+ state.announcements = normalized.sort((a, b) => Number(b.publishedAt || 0) - Number(a.publishedAt || 0))
3629
3854
  storeAnnouncementHistory(normalized)
3855
+ renderAnnouncementBoard()
3630
3856
  const seen = readSeenAnnouncements()
3631
3857
  const items = normalized.filter(item => !seen[item.id])
3632
3858
  .sort((a, b) => b.publishedAt - a.publishedAt)
3633
- if (!items.length) return false
3859
+ if (!items.length || state.announcement) return false
3634
3860
  openAnnouncementModal(items[0])
3635
3861
  return true
3636
3862
  } catch { return false }
3637
3863
  }
3638
3864
 
3865
+ function checkAnnouncements() {
3866
+ if (announcementCheckPromise) return announcementCheckPromise
3867
+ announcementCheckPromise = fetchAnnouncements().finally(() => { announcementCheckPromise = null })
3868
+ return announcementCheckPromise
3869
+ }
3870
+
3871
+ function startAnnouncementPolling() {
3872
+ if (announcementPollingStarted) return
3873
+ announcementPollingStarted = true
3874
+ announcementPollTimer = setInterval(() => {
3875
+ if (!document.hidden) void checkAnnouncements()
3876
+ }, ANNOUNCEMENTS_POLL_MS)
3877
+ document.addEventListener('visibilitychange', () => {
3878
+ if (!document.hidden) void checkAnnouncements()
3879
+ })
3880
+ window.addEventListener('online', () => { void checkAnnouncements() })
3881
+ }
3882
+
3883
+ let startupNoticeTimer = null
3884
+ function scheduleStartupNotices(delay = 4000) {
3885
+ clearTimeout(startupNoticeTimer)
3886
+ startupNoticeTimer = setTimeout(async () => {
3887
+ if (isAppVersionWarningOpen()) return
3888
+ const shown = await checkAnnouncements()
3889
+ if (!shown && state.token && !isAppVersionWarningOpen()) checkUpdate(true)
3890
+ }, delay)
3891
+ }
3892
+
3639
3893
  /* ---------------- 更新内容弹窗 ---------------- */
3640
3894
  const NOTES_KEY = 'seenNotesVersion'
3641
3895
  let notesVersion = ''
@@ -4321,36 +4575,159 @@ function initToken() {
4321
4575
  $('server-desc').textContent = state.server || t('servers.defaultDesc')
4322
4576
  }
4323
4577
 
4324
- function renderDshControlStatus(v) {
4325
- const desc = $('dsh-control-desc')
4326
- if (!desc || !v) return
4578
+ let dshControlPollPromise = null
4579
+
4580
+ function dshControlButtonsBusy(busy, supported = true) {
4327
4581
  const buttons = [$('btn-dsh-start'), $('btn-dsh-restart')].filter(Boolean)
4328
- if (v.supported === false) {
4329
- desc.textContent = v.message || t('settings.dshUnsupported')
4330
- buttons.forEach(b => { b.disabled = true; b.classList.add('hidden') })
4582
+ buttons.forEach(button => {
4583
+ button.disabled = busy || !supported
4584
+ button.classList.toggle('hidden', !supported)
4585
+ })
4586
+ }
4587
+
4588
+ function dshControlFailureText(value) {
4589
+ const key = {
4590
+ SERVICE_NOT_FOUND: 'settings.dshErrorServiceNotFound',
4591
+ INVALID_SERVICE: 'settings.dshErrorInvalidService',
4592
+ SYSTEMCTL_NOT_FOUND: 'settings.dshErrorSystemctlNotFound',
4593
+ SYSTEMD_UNAVAILABLE: 'settings.dshErrorSystemdUnavailable',
4594
+ PERMISSION_DENIED: 'settings.dshErrorPermissionDenied',
4595
+ COMMAND_TIMEOUT: 'settings.dshErrorCommandTimeout',
4596
+ COMMAND_FAILED: 'settings.dshErrorCommandFailed',
4597
+ SERVICE_FAILED: 'settings.dshErrorServiceFailed',
4598
+ SERVICE_TIMEOUT: 'settings.dshErrorServiceTimeout',
4599
+ UPSTREAM_TIMEOUT: 'settings.dshErrorUpstreamTimeout',
4600
+ EVENTS_TIMEOUT: 'settings.dshErrorEventsTimeout',
4601
+ OPERATION_NOT_FOUND: 'settings.dshErrorOperationNotFound',
4602
+ OPERATION_IN_PROGRESS: 'settings.dshErrorOperationProgress',
4603
+ }[value?.code]
4604
+ return key
4605
+ ? t(key, { service: value?.service || value?.status?.service || 'dsh-web' })
4606
+ : t('settings.dshErrorGeneric', { msg: value?.error || value?.message || value?.code || t('conn.off') })
4607
+ }
4608
+
4609
+ function dshControlStageText(operation, step = operation) {
4610
+ const seconds = Math.max(0, Math.round(Number(step?.elapsedMs ?? operation?.elapsedMs ?? 0) / 1000))
4611
+ const action = operation?.action === 'start' ? t('settings.dshStart') : t('settings.dshRestart')
4612
+ const service = operation?.service || operation?.status?.service || 'dsh-web'
4613
+ if (step?.stage === 'queued') return t('settings.dshStageQueued')
4614
+ if (step?.stage === 'checking') return t('settings.dshStageChecking', { service })
4615
+ if (step?.stage === 'command') return t('settings.dshStageCommand', { action })
4616
+ if (step?.stage === 'waiting-service') return t('settings.dshStageWaitingService', { seconds })
4617
+ if (step?.stage === 'waiting-upstream') return t('settings.dshStageWaitingUpstream', { seconds })
4618
+ if (step?.stage === 'waiting-events') return t('settings.dshStageWaitingEvents', { seconds })
4619
+ if (step?.stage === 'complete') {
4620
+ if (operation?.code === 'ALREADY_RUNNING') return t('settings.dshAlreadyRunning', { pid: operation?.status?.mainPid || '—' })
4621
+ return t('settings.dshSuccessDetail', {
4622
+ action,
4623
+ pid: operation?.status?.mainPid || '—',
4624
+ status: operation?.upstream?.status || '—',
4625
+ seconds: Math.max(0, Math.round(Number(operation?.elapsedMs || step?.elapsedMs || 0) / 1000)),
4626
+ })
4627
+ }
4628
+ if (step?.stage === 'failed') return dshControlFailureText(operation)
4629
+ return step?.message || operation?.message || '—'
4630
+ }
4631
+
4632
+ function renderDshControlOperation(operation) {
4633
+ const desc = $('dsh-control-desc')
4634
+ const box = $('dsh-control-steps')
4635
+ if (!desc || !box || !operation) return
4636
+ const failed = operation.stage === 'failed' || operation.done && operation.ok === false
4637
+ desc.textContent = failed ? t('settings.dshFailed', { msg: dshControlFailureText(operation) }) : dshControlStageText(operation)
4638
+ const steps = Array.isArray(operation.steps) && operation.steps.length
4639
+ ? operation.steps
4640
+ : [{ stage: operation.stage || 'queued', elapsedMs: operation.elapsedMs || 0, message: operation.message || '' }]
4641
+ box.classList.remove('hidden')
4642
+ box.innerHTML = steps.map((step, index) => {
4643
+ const current = !operation.done && index === steps.length - 1
4644
+ const isFailed = step.stage === 'failed'
4645
+ const success = step.stage === 'complete'
4646
+ const mark = isFailed ? '×' : success ? '✓' : current ? '…' : '✓'
4647
+ const cls = isFailed ? 'failed' : success ? 'success' : current ? 'current' : ''
4648
+ const detail = isFailed && operation.detail
4649
+ ? `<span class="dsh-control-step-detail">${esc(t('settings.dshErrorDetail', { detail: operation.detail }))}</span>`
4650
+ : ''
4651
+ return `<div class="dsh-control-step ${cls}"><span class="dsh-control-step-mark" aria-hidden="true">${mark}</span><span>${esc(dshControlStageText(operation, step))}</span>${detail}</div>`
4652
+ }).join('')
4653
+ dshControlButtonsBusy(!operation.done, true)
4654
+ }
4655
+
4656
+ async function readDshControlResponse(res) {
4657
+ const text = await res.text()
4658
+ if (!text) return {}
4659
+ try { return JSON.parse(text) } catch { return { error: `HTTP ${res.status}`, detail: text.slice(0, 500) } }
4660
+ }
4661
+
4662
+ async function pollDshControlOperation(operationId, initial) {
4663
+ let value = initial
4664
+ while (true) {
4665
+ renderDshControlOperation(value)
4666
+ if (value.done) return value
4667
+ await new Promise(resolvePromise => setTimeout(resolvePromise, 700))
4668
+ const res = await fetch(adminApiUrl(`/admin/api/dsh?operation=${encodeURIComponent(operationId)}`), {
4669
+ headers: { authorization: 'Bearer ' + state.token }, cache: 'no-store'
4670
+ })
4671
+ if (res.status === 401) { authFailure(); throw Object.assign(new Error('unauthorized'), { auth: true }) }
4672
+ const next = await readDshControlResponse(res)
4673
+ if (!res.ok) throw Object.assign(new Error(next.error || next.message || `HTTP ${res.status}`), { dshPayload: { ...next, httpStatus: res.status } })
4674
+ value = next
4675
+ }
4676
+ }
4677
+
4678
+ function renderDshControlStatus(value) {
4679
+ const desc = $('dsh-control-desc')
4680
+ const box = $('dsh-control-steps')
4681
+ if (!desc || !value) return
4682
+ if (value.supported === false) {
4683
+ desc.textContent = value.code ? dshControlFailureText(value) : value.message || t('settings.dshUnsupported')
4684
+ dshControlButtonsBusy(false, false)
4685
+ return
4686
+ }
4687
+ if (value.ok === false) {
4688
+ desc.textContent = t('settings.dshStatusFailed', { msg: dshControlFailureText(value) })
4689
+ dshControlButtonsBusy(false, false)
4331
4690
  return
4332
4691
  }
4333
- buttons.forEach(b => { b.disabled = false; b.classList.remove('hidden') })
4334
- desc.textContent = `${t(v.running ? 'settings.dshRunning' : 'settings.dshStopped')} · ${v.service || 'dsh-web'}`
4692
+ dshControlButtonsBusy(false, true)
4693
+ if (box && !dshControlPollPromise) box.classList.add('hidden')
4694
+ const service = value.service || 'dsh-web'
4695
+ const serviceState = `${value.activeState || value.state || 'unknown'}/${value.subState || 'unknown'}`
4696
+ desc.textContent = value.running
4697
+ ? t('settings.dshRunningDetail', { service, pid: value.mainPid || '—', state: serviceState })
4698
+ : t('settings.dshStoppedDetail', { service, state: serviceState })
4335
4699
  }
4336
4700
 
4337
4701
  async function loadDshControl() {
4338
4702
  if (!state.token || !$('dsh-control-desc')) return
4703
+ if (activeGatewayCapability('dshLifecycle') === false) {
4704
+ renderDshControlStatus({ supported: false, message: t('settings.dshUnsupported') })
4705
+ return
4706
+ }
4339
4707
  try {
4340
- const res = await fetch(adminApiUrl('/admin/api/dsh'), { headers: { authorization: 'Bearer ' + state.token } })
4708
+ const res = await fetch(adminApiUrl('/admin/api/dsh'), { headers: { authorization: 'Bearer ' + state.token }, cache: 'no-store' })
4341
4709
  if (res.status === 401) return authFailure()
4342
- renderDshControlStatus(await res.json())
4343
- } catch {
4344
- $('dsh-control-desc').textContent = t('settings.dshFailed', { msg: t('conn.off') })
4710
+ const value = await readDshControlResponse(res)
4711
+ if (!res.ok) throw Object.assign(new Error(value.error || value.message || `HTTP ${res.status}`), { dshPayload: value })
4712
+ renderDshControlStatus(value)
4713
+ if (value.operation?.operationId && !value.operation.done && !dshControlPollPromise) {
4714
+ dshControlPollPromise = pollDshControlOperation(value.operation.operationId, value.operation)
4715
+ .then(renderDshControlOperation)
4716
+ .catch(error => {
4717
+ if (!error?.auth) $('dsh-control-desc').textContent = t('settings.dshFailed', { msg: error?.dshPayload ? dshControlFailureText(error.dshPayload) : t('settings.dshResultUnknown') })
4718
+ })
4719
+ .finally(() => { dshControlPollPromise = null; dshControlButtonsBusy(false, true) })
4720
+ }
4721
+ } catch (error) {
4722
+ $('dsh-control-desc').textContent = t('settings.dshStatusFailed', { msg: error?.dshPayload ? dshControlFailureText(error.dshPayload) : t('conn.off') })
4345
4723
  }
4346
4724
  }
4347
4725
 
4348
4726
  async function controlDsh(action) {
4727
+ if (dshControlPollPromise) return
4349
4728
  const label = action === 'start' ? t('settings.dshStart') : t('settings.dshRestart')
4350
- const buttons = [$('btn-dsh-start'), $('btn-dsh-restart')].filter(Boolean)
4351
- buttons.forEach(b => { b.disabled = true })
4352
- const desc = $('dsh-control-desc')
4353
- if (desc) desc.textContent = t('settings.dshStarting', { action: label })
4729
+ dshControlButtonsBusy(true, true)
4730
+ renderDshControlOperation({ action, service: 'dsh-web', accepted: true, done: false, stage: 'queued', elapsedMs: 0, steps: [] })
4354
4731
  try {
4355
4732
  const res = await fetch(adminApiUrl('/admin/api/dsh'), {
4356
4733
  method: 'POST',
@@ -4358,15 +4735,33 @@ async function controlDsh(action) {
4358
4735
  body: JSON.stringify({ action }),
4359
4736
  })
4360
4737
  if (res.status === 401) return authFailure()
4361
- const v = await res.json().catch(() => ({}))
4362
- if (!res.ok || v.ok === false) throw new Error(v.error || v.message || `HTTP ${res.status}`)
4738
+ let v = await readDshControlResponse(res)
4739
+ if (res.status === 409 && v.operation?.operationId) v = v.operation
4740
+ else if (!res.ok) throw Object.assign(new Error(v.error || v.message || `HTTP ${res.status}`), { dshPayload: { ...v, httpStatus: res.status } })
4741
+
4742
+ if (v.accepted && v.operationId) {
4743
+ dshControlPollPromise = pollDshControlOperation(v.operationId, v)
4744
+ v = await dshControlPollPromise
4745
+ if (!v.ok) throw Object.assign(new Error(dshControlFailureText(v)), { dshPayload: v })
4746
+ renderDshControlOperation(v)
4747
+ toast(dshControlStageText(v), 'ok')
4748
+ return
4749
+ }
4750
+
4751
+ // 兼容旧网关:它会在单个 POST 中直接返回最终状态。
4752
+ if (v.ok === false) throw Object.assign(new Error(v.error || v.message || `HTTP ${res.status}`), { dshPayload: v })
4363
4753
  renderDshControlStatus(v)
4364
4754
  toast(t('settings.dshStarted', { action: label }), 'ok')
4365
- } catch (e) {
4366
- if (desc) desc.textContent = t('settings.dshFailed', { msg: e?.message || e })
4367
- toast(t('settings.dshFailed', { msg: e?.message || e }), 'err')
4755
+ } catch (error) {
4756
+ if (error?.auth) return
4757
+ const payload = error?.dshPayload
4758
+ const message = payload ? dshControlFailureText(payload) : t('settings.dshResultUnknown')
4759
+ if (payload) renderDshControlOperation({ action, service: payload.service || 'dsh-web', done: true, ok: false, stage: 'failed', steps: payload.steps || [], ...payload })
4760
+ else $('dsh-control-desc').textContent = t('settings.dshFailed', { msg: message })
4761
+ toast(t('settings.dshFailed', { msg: message }), 'err')
4368
4762
  } finally {
4369
- buttons.forEach(b => { b.disabled = false })
4763
+ dshControlPollPromise = null
4764
+ dshControlButtonsBusy(false, true)
4370
4765
  }
4371
4766
  }
4372
4767
 
@@ -4427,6 +4822,7 @@ function bindUi() {
4427
4822
  renderServers()
4428
4823
  renderSessions()
4429
4824
  renderWorkbench()
4825
+ renderAnnouncementBoard()
4430
4826
  renderPending(); renderQueue(); renderJobs()
4431
4827
  updateConn()
4432
4828
  if (state.current) { renderSessionTitle(); renderSessionSub(); renderSessionCards(); renderHistory(true) }
@@ -4526,6 +4922,11 @@ function bindUi() {
4526
4922
  $('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
4527
4923
  $('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
4528
4924
  $('btn-refresh').addEventListener('click', () => { toast(t('common.refreshing')); openStreams(); refreshAll() })
4925
+ $('overview-refresh').addEventListener('click', () => {
4926
+ toast(t('common.refreshing'))
4927
+ openStreams()
4928
+ void Promise.all([refreshAll(), checkAnnouncements()])
4929
+ })
4529
4930
  // 反馈
4530
4931
  $('btn-feedback').addEventListener('click', (e) => { e.stopPropagation(); toggleFeedbackSheet() })
4531
4932
  $('feedback-backdrop').addEventListener('click', closeFeedbackSheet)
@@ -4540,6 +4941,10 @@ function bindUi() {
4540
4941
  $('btn-write-feedback').addEventListener('click', () => { closeFeedbackSheet(); openFeedbackModal() })
4541
4942
  $('fb-cancel').addEventListener('click', closeFeedbackModal)
4542
4943
  $('fb-submit').addEventListener('click', submitFeedback)
4944
+ $('feedback-success-confirm').addEventListener('click', closeFeedbackSuccess)
4945
+ $('modal-feedback-success').addEventListener('click', (e) => {
4946
+ if (e.target === $('modal-feedback-success')) closeFeedbackSuccess()
4947
+ })
4543
4948
  document.querySelectorAll('#fb-chips .fb-chip').forEach(btn =>
4544
4949
  btn.addEventListener('click', () => {
4545
4950
  state.feedbackType = btn.dataset.fbType
@@ -4550,6 +4955,8 @@ function bindUi() {
4550
4955
  })
4551
4956
  document.addEventListener('keydown', (e) => {
4552
4957
  if (e.key === 'Escape' && !$('feedback-sheet').classList.contains('hidden')) { closeFeedbackSheet(); $('btn-feedback').focus() }
4958
+ else if (e.key === 'Escape' && !$('modal-feedback-success').classList.contains('hidden')) closeFeedbackSuccess()
4959
+ else if (e.key === 'Escape' && isAppVersionWarningOpen()) closeAppVersionWarning(false)
4553
4960
  })
4554
4961
  $('btn-new-session').addEventListener('click', newSession)
4555
4962
  $('session-workspace-filter').addEventListener('change', (e) => {
@@ -4699,6 +5106,13 @@ function bindUi() {
4699
5106
  $('modal-announcement-history').addEventListener('click', (e) => {
4700
5107
  if (e.target === $('modal-announcement-history')) closeAnnouncementHistory()
4701
5108
  })
5109
+ $('overview-announcement-history').addEventListener('click', openAnnouncementHistory)
5110
+ $('overview-announcement-list').addEventListener('click', (e) => {
5111
+ const button = e.target.closest('[data-home-announcement]')
5112
+ if (!button) return
5113
+ const item = announcementBoardItems().find(entry => entry.id === button.dataset.homeAnnouncement)
5114
+ if (item) openAnnouncementModal(item)
5115
+ })
4702
5116
  // 设置
4703
5117
  $('view-settings').addEventListener('click', (e) => {
4704
5118
  const group = e.target.closest('[data-settings-group]')
@@ -4733,6 +5147,11 @@ function bindUi() {
4733
5147
  $('btn-check-update').addEventListener('click', () => checkUpdate(false))
4734
5148
  $('btn-download-update').addEventListener('click', downloadUpdate)
4735
5149
  $('btn-update-expand').addEventListener('click', toggleUpdateExpand)
5150
+ $('app-version-later').addEventListener('click', () => closeAppVersionWarning(false))
5151
+ $('app-version-update').addEventListener('click', () => closeAppVersionWarning(true))
5152
+ $('modal-app-version-warning').addEventListener('click', (e) => {
5153
+ if (e.target === $('modal-app-version-warning')) closeAppVersionWarning(false)
5154
+ })
4736
5155
  $('btn-reset').addEventListener('click', () => {
4737
5156
  if (!confirm(t('settings.confirmReset'))) return
4738
5157
  LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction'); LS.del(ANNOUNCEMENTS_KEY); LS.del(ANNOUNCEMENT_HISTORY_KEY); LS.del(ANNOUNCEMENT_VOTES_KEY)
@@ -4873,6 +5292,7 @@ function applyNativeInsets() {
4873
5292
  async function boot() {
4874
5293
  initToken()
4875
5294
  bindUi()
5295
+ renderAnnouncementBoard()
4876
5296
  renderLangBtn()
4877
5297
  bindNativeBack()
4878
5298
  bindNativeLinks()
@@ -4886,17 +5306,17 @@ async function boot() {
4886
5306
  } else {
4887
5307
  // 多服务器: 启动时静默测速一次, 选最快的连接(同源页面也参与比较)
4888
5308
  await selectFastestServer({ silent: true, reconnect: false })
5309
+ await maybeWarnAppBehindGateway({ probe: true })
4889
5310
  openStreams()
4890
5311
  await refreshAll()
4891
5312
  const host = await safeRpc('host.describe', {}, '')
4892
5313
  if (host) { state.hostInfo = host; $('host-desc').textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions }) }
4893
5314
  loadDshControl()
4894
5315
  }
4895
- // 公告与更新共用当前服务器;公告优先,避免启动时两个弹窗重叠。
4896
- setTimeout(async () => {
4897
- const shown = await checkAnnouncements()
4898
- if (!shown && state.token) checkUpdate(true)
4899
- }, 4000)
5316
+ // 网关从中央 HTTPS 公告源读取并在不可达时回退内置文件。前台每 30 秒检查,
5317
+ // 回到前台或网络恢复时立即补查;公告优先,避免启动时两个弹窗重叠。
5318
+ startAnnouncementPolling()
5319
+ scheduleStartupNotices()
4900
5320
  renderPending()
4901
5321
  }
4902
5322