dsh-remote-plugin 0.6.7 → 0.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -11,6 +11,16 @@ const LS = {
11
11
  set(k, v) { try { localStorage.setItem(k, v) } catch {} },
12
12
  del(k) { try { localStorage.removeItem(k) } catch {} }
13
13
  }
14
+ const CLIENT_ID = (() => {
15
+ try {
16
+ let id = sessionStorage.getItem('dshRemoteClientId')
17
+ if (!id) {
18
+ id = (globalThis.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`)
19
+ sessionStorage.setItem('dshRemoteClientId', id)
20
+ }
21
+ return id
22
+ } catch { return '' }
23
+ })()
14
24
 
15
25
  /* 离线缓存: 会话列表 + 每会话聊天记录。只在网络失败时兜底展示, 不会替代线上数据。 */
16
26
  const CACHE = {
@@ -37,6 +47,7 @@ function writeHistoryCache(cache) {
37
47
 
38
48
  const state = {
39
49
  token: '',
50
+ wsTicket: { token: '', server: '', value: '', expiresAt: 0 },
40
51
  server: '', // 当前生效的网关地址, 空 = 同源(浏览器模式)
41
52
  servers: [], // 服务器列表: [{id,url,note,group}]
42
53
  groups: ['默认'], // 组名列表(顺序保留)
@@ -59,10 +70,15 @@ const state = {
59
70
  jobs: {}, // sessionId -> jobs
60
71
  history: emptyHistory(),
61
72
  errCount: 0,
73
+ streamInfo: {
74
+ mux: { status: 'idle', lastOpenAt: 0, lastCloseAt: 0, lastCloseCode: 0, lastCloseReason: '' },
75
+ host: { status: 'idle', lastOpenAt: 0, lastCloseAt: 0, lastCloseCode: 0, lastCloseReason: '' },
76
+ },
62
77
  streamMode: 'ws', // 'ws' | 'poll'
63
78
  pollSeq: { mux: 0, host: 0 },
64
79
  refreshTimer: null,
65
80
  fs: { path: null, initial: null, loaded: false, upload: null },
81
+ composerImages: [], // 当前草稿中的图片附件:只在发送成功后释放
66
82
  models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
67
83
  wb: null,
68
84
  wbProjects: [],
@@ -190,6 +206,31 @@ function apiUrl(path) {
190
206
  return (state.server || '') + path
191
207
  }
192
208
 
209
+ let wsTicketPromise = null
210
+ async function getWsTicket() {
211
+ const now = Date.now()
212
+ if (state.wsTicket.token === state.token && state.wsTicket.server === state.server &&
213
+ state.wsTicket.value && state.wsTicket.expiresAt > now + 15000) return state.wsTicket.value
214
+ if (wsTicketPromise) return wsTicketPromise
215
+ const token = state.token
216
+ const server = state.server
217
+ wsTicketPromise = (async () => {
218
+ const res = await fetch(apiUrl('/api/ws-ticket'), {
219
+ method: 'POST',
220
+ headers: {
221
+ authorization: 'Bearer ' + token,
222
+ 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web',
223
+ }
224
+ })
225
+ if (!res.ok) throw new Error('ws ticket HTTP ' + res.status)
226
+ const data = await res.json()
227
+ if (!data?.ticket || !Number(data.expiresAt)) throw new Error('invalid ws ticket')
228
+ state.wsTicket = { token, server, value: data.ticket, expiresAt: Number(data.expiresAt) }
229
+ return data.ticket
230
+ })()
231
+ try { return await wsTicketPromise } finally { wsTicketPromise = null }
232
+ }
233
+
193
234
  function updateBase() {
194
235
  const configured = String(state.server || '').replace(/\/+$/, '')
195
236
  if (configured) return configured
@@ -737,6 +778,10 @@ function deleteGroup(name) {
737
778
  /* ---------------- 事件流 (WebSocket + 轮询降级) ---------------- */
738
779
  const streams = {}
739
780
  state.streamsOk = { mux: false, host: false }
781
+ const streamMeta = {
782
+ mux: { generation: 0, attempt: 0, failures: 0, retryTimer: null },
783
+ host: { generation: 0, attempt: 0, failures: 0, retryTimer: null },
784
+ }
740
785
  let pollTimer = null
741
786
  let wsRetryTimer = null
742
787
  let connTickTimer = null
@@ -744,10 +789,40 @@ let reconnectInfo = null
744
789
 
745
790
  function clearStreamTimers(ws) {
746
791
  if (!ws) return
747
- clearInterval(ws._hbTimer)
748
- clearInterval(ws._staleTimer)
749
- ws._hbTimer = null
750
- ws._staleTimer = null
792
+ if (ws._retryTimer) clearTimeout(ws._retryTimer)
793
+ ws._retryTimer = null
794
+ }
795
+
796
+ function streamIsCurrent(kind, ws, generation) {
797
+ return streams[kind] === ws && streamMeta[kind].generation === generation
798
+ }
799
+
800
+ function aggregateStreamFailures() {
801
+ state.errCount = Math.max(streamMeta.mux.failures, streamMeta.host.failures)
802
+ }
803
+
804
+ function markStreamInfo(kind, patch) {
805
+ state.streamInfo[kind] = { ...state.streamInfo[kind], ...patch }
806
+ }
807
+
808
+ function allStreamsOpen() {
809
+ return streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN
810
+ }
811
+
812
+ function clearStreamRetry(kind) {
813
+ const meta = streamMeta[kind]
814
+ if (meta.retryTimer) clearTimeout(meta.retryTimer)
815
+ meta.retryTimer = null
816
+ }
817
+
818
+ function closeStream(kind) {
819
+ const meta = streamMeta[kind]
820
+ clearStreamRetry(kind)
821
+ meta.generation++
822
+ const ws = streams[kind]
823
+ streams[kind] = null
824
+ state.streamsOk[kind] = false
825
+ try { ws?.close() } catch {}
751
826
  }
752
827
 
753
828
  function clearConnTick() {
@@ -779,15 +854,23 @@ function clearReconnect() {
779
854
 
780
855
  function openStreams() {
781
856
  if (!state.token) return
782
- if (state.streamMode === 'poll') stopPolling()
783
- state.streamMode = 'ws'
784
- clearReconnect()
857
+ if (state.streamMode !== 'poll') state.streamMode = 'ws'
785
858
  openStream('mux', onMuxFrame, true)
786
859
  openStream('host', onHostFrame, false)
787
860
  }
788
861
 
789
- function openStream(kind, handler, refreshOnOpen, isRestore) {
862
+ function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
790
863
  if (!state.token) return
864
+ if (ticket === null) {
865
+ const token = state.token
866
+ void getWsTicket().then((value) => {
867
+ if (state.token === token) openStream(kind, handler, refreshOnOpen, isRestore, value)
868
+ }).catch(() => {
869
+ // 兼容旧网关/插件副本: ticket 接口不可用时临时回退旧 token 握手。
870
+ if (state.token === token) openStream(kind, handler, refreshOnOpen, isRestore, '')
871
+ })
872
+ return
873
+ }
791
874
  let base
792
875
  if (state.server) {
793
876
  base = state.server.replace(/^http/, 'ws')
@@ -796,31 +879,40 @@ function openStream(kind, handler, refreshOnOpen, isRestore) {
796
879
  base = `${proto}//${location.host}`
797
880
  }
798
881
  const clientMark = CAP?.isNativePlatform?.() ? 'app' : 'web'
799
- const ws = new WebSocket(`${base}/api/events.${kind}?token=${encodeURIComponent(state.token)}&client=${clientMark}`)
800
- try { streams[kind]?.close() } catch {}
882
+ const auth = ticket ? `ticket=${encodeURIComponent(ticket)}` : `token=${encodeURIComponent(state.token)}`
883
+ const clientId = CLIENT_ID ? `&clientId=${encodeURIComponent(CLIENT_ID)}` : ''
884
+ const streamUrl = `${base}/api/events.${kind}?${auth}&client=${clientMark}${clientId}`
885
+ const current = streams[kind]
886
+ if (current?._streamUrl === streamUrl &&
887
+ (current.readyState === WebSocket.OPEN || current.readyState === WebSocket.CONNECTING)) return
888
+ if (current && current._streamUrl !== streamUrl) {
889
+ streamMeta[kind].attempt = 0
890
+ streamMeta[kind].failures = 0
891
+ aggregateStreamFailures()
892
+ }
893
+ closeStream(kind)
894
+ const meta = streamMeta[kind]
895
+ const generation = meta.generation
896
+ const ws = new WebSocket(streamUrl)
801
897
  streams[kind] = ws
802
- ws._attempt = 0
803
- ws._lastMsgAt = 0
898
+ ws._streamUrl = streamUrl
899
+ ws._generation = generation
804
900
  ws._isRestore = !!isRestore
805
901
  ws.onopen = () => {
902
+ if (!streamIsCurrent(kind, ws, generation)) return
806
903
  state.streamsOk[kind] = true
807
- state.errCount = 0
808
- ws._attempt = 0
809
- ws._lastMsgAt = Date.now()
904
+ markStreamInfo(kind, { status: 'open', lastOpenAt: Date.now(), lastCloseCode: 0, lastCloseReason: '' })
905
+ meta.attempt = 0
906
+ meta.failures = 0
907
+ aggregateStreamFailures()
810
908
  clearStreamTimers(ws)
811
- // 应用层心跳: 25s 发纯文本 ping, NAT/WiFi 切换后的 WS 半开假活
812
- ws._hbTimer = setInterval(() => {
813
- try { if (ws.readyState === WebSocket.OPEN) ws.send('ping') } catch {}
814
- }, 25000)
815
- // 60s 没有任何消息(含 pong/业务帧)就主动断开, 触发指数退避重连
816
- ws._staleTimer = setInterval(() => {
817
- if (ws.readyState === WebSocket.OPEN && Date.now() - (ws._lastMsgAt || 0) > 60000) {
818
- try { ws.close() } catch {}
819
- }
820
- }, 10000)
821
- // 重连成功:切回 WS 并停止轮询
822
- if (state.streamMode === 'poll') { stopPolling(); state.streamMode = 'ws' }
823
- if (streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN) clearReconnect()
909
+ // DSH mux/host 是只下行 WebSocket, 浏览器不能发送应用层 ping。
910
+ // 网关负责 RFC6455 Ping/Pong, 前端只监听业务帧和 close 事件。
911
+ if (state.streamMode === 'poll' && allStreamsOpen()) {
912
+ stopPolling()
913
+ state.streamMode = 'ws'
914
+ }
915
+ if (allStreamsOpen()) clearReconnect()
824
916
  updateConn()
825
917
  // mux 每次(重)连接都会重放“仍待处理”的审批/提问基线:
826
918
  // 先清空旧列表, 避免“桌面已自定义回答, 手机漏收 question/resolved”后永久残留。
@@ -832,9 +924,8 @@ function openStream(kind, handler, refreshOnOpen, isRestore) {
832
924
  if (refreshOnOpen) refreshAll()
833
925
  }
834
926
  ws.onmessage = (msg) => {
835
- ws._lastMsgAt = Date.now()
927
+ if (!streamIsCurrent(kind, ws, generation)) return
836
928
  state.streamsOk[kind] = true
837
- state.errCount = 0
838
929
  updateConn()
839
930
  try {
840
931
  const full = JSON.parse(msg.data)
@@ -843,33 +934,33 @@ function openStream(kind, handler, refreshOnOpen, isRestore) {
843
934
  }
844
935
  ws.onclose = () => {
845
936
  clearStreamTimers(ws)
937
+ if (!streamIsCurrent(kind, ws, generation)) return
938
+ streams[kind] = null
846
939
  state.streamsOk[kind] = false
847
- state.errCount++
940
+ markStreamInfo(kind, {
941
+ status: 'closed',
942
+ lastCloseAt: Date.now(),
943
+ lastCloseCode: Number(ws.code) || 0,
944
+ lastCloseReason: String(ws.reason || ''),
945
+ })
946
+ meta.failures++
947
+ aggregateStreamFailures()
848
948
  updateConn()
849
949
  if (!navigator.onLine) { clearReconnect(); return }
850
- if (state.streamMode === 'poll') {
851
- // 降级轮询期间: 恢复尝试失败也用退避, 避免 30s 固定间隔内空转
852
- if (ws._isRestore && streams[kind] === ws) {
853
- const attempt = ws._attempt || 0
854
- ws._attempt = attempt + 1
855
- const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
856
- const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
857
- setTimeout(() => openStream(kind, handler, refreshOnOpen, true), delay)
858
- }
859
- return
860
- }
861
- // 连续失败 3 次 -> 降级为轮询
862
- if (state.errCount >= 3) { enterPollMode(); return }
950
+ // 任一通道连续失败 3 次就降级轮询;另一个通道不会清零它的失败计数。
951
+ if (state.streamMode !== 'poll' && meta.failures >= 3) { enterPollMode(); return }
863
952
  // 多服务器: 连续掉线若干次就重测速, 自动换到当前可达的最快地址
864
- if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
865
- // 指数退避 + 20% 抖动: min(1200 * 2^attempt, 30000)
866
- const attempt = ws._attempt || 0
867
- ws._attempt = attempt + 1
868
- const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
953
+ if (state.servers.length && meta.failures % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
954
+ // VPN/跨地域链路使用更宽松的指数退避: 1.5s 起步, 最大 60s, 20% 抖动。
955
+ const attempt = meta.attempt++
956
+ const baseDelay = Math.min(1500 * Math.pow(2, attempt), 60000)
869
957
  const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
870
958
  setReconnect(delay)
871
- // 页面被挂起时定时器暂停, visibilitychange 会再触发一次
872
- if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), delay)
959
+ clearStreamRetry(kind)
960
+ meta.retryTimer = setTimeout(() => {
961
+ meta.retryTimer = null
962
+ if (state.token && navigator.onLine) openStream(kind, handler, refreshOnOpen, state.streamMode === 'poll')
963
+ }, delay)
873
964
  }
874
965
  ws.onerror = () => { try { ws.close() } catch {} }
875
966
  }
@@ -880,10 +971,8 @@ function enterPollMode() {
880
971
  state.streamMode = 'poll'
881
972
  state.pollSeq = { mux: 0, host: 0 }
882
973
  state.streamsOk = { mux: false, host: false }
883
- try { streams.mux?.close() } catch {}
884
- try { streams.host?.close() } catch {}
885
- streams.mux = null
886
- streams.host = null
974
+ closeStream('mux')
975
+ closeStream('host')
887
976
  refreshAll()
888
977
  startPolling()
889
978
  updateConn()
@@ -952,8 +1041,8 @@ async function pollKind(kind) {
952
1041
  function tryRestoreWs() {
953
1042
  if (state.streamMode !== 'poll' || !state.token) return
954
1043
  // 轮询继续跑,等 WS 真正 onopen 后再切回,避免重连窗口丢事件
955
- openStream('mux', onMuxFrame, true, true)
956
- openStream('host', onHostFrame, false, true)
1044
+ if (!streams.mux && !streamMeta.mux.retryTimer) openStream('mux', onMuxFrame, true, true)
1045
+ if (!streams.host && !streamMeta.host.retryTimer) openStream('host', onHostFrame, false, true)
957
1046
  }
958
1047
 
959
1048
  /* 回前台恢复: 强制重排修复 MIUI WebView 后台切回时 sticky 顶栏不绘制的问题 */
@@ -994,8 +1083,8 @@ setInterval(() => {
994
1083
  /* 网络感知: 离线立刻关 WS + 显示离线, 在线立即重连 */
995
1084
  window.addEventListener('offline', () => {
996
1085
  clearReconnect()
997
- try { streams.mux?.close() } catch {}
998
- try { streams.host?.close() } catch {}
1086
+ closeStream('mux')
1087
+ closeStream('host')
999
1088
  if (state.streamMode === 'poll') stopPolling()
1000
1089
  updateConn()
1001
1090
  })
@@ -1174,6 +1263,29 @@ async function refreshWorkbench() {
1174
1263
  state.wbProjects = []
1175
1264
  state.wbArchived = []
1176
1265
  }
1266
+ // 以磁盘实际目录为准同步工作台项目:删除目录后不再残留,新增子目录自动收纳。
1267
+ if (state.wb?.bound && state.wb.path) {
1268
+ try {
1269
+ const listRes = await fetch(fsApiUrl('/list', { path: state.wb.path }), { headers: fsHeaders() })
1270
+ if (listRes.ok) {
1271
+ const listData = await listRes.json().catch(() => ({}))
1272
+ if (Array.isArray(listData.entries)) {
1273
+ const diskDirs = new Set(listData.entries.filter(e => e.type === 'dir').map(e => wbPathKey(wbJoin(state.wb.path, e.name))))
1274
+ state.wbProjects = state.wbProjects.filter(w => diskDirs.has(wbPathKey(w.path)))
1275
+ const have = new Set(state.wbProjects.map(w => wbPathKey(w.path)))
1276
+ for (const entry of listData.entries) {
1277
+ if (entry.type !== 'dir') continue
1278
+ const projectPath = wbJoin(state.wb.path, entry.name)
1279
+ if (have.has(wbPathKey(projectPath))) continue
1280
+ try {
1281
+ const created = await rpc('workspace.create', { path: projectPath })
1282
+ if (created?.workspace) { state.wbProjects.push(created.workspace); have.add(wbPathKey(projectPath)) }
1283
+ } catch {}
1284
+ }
1285
+ }
1286
+ }
1287
+ } catch {}
1288
+ }
1177
1289
  renderWorkbench()
1178
1290
  renderSessions()
1179
1291
  }
@@ -1201,15 +1313,19 @@ function renderWorkbench() {
1201
1313
  panel.innerHTML = `<div class="wb-empty">${esc(t('wb.noProjects'))}</div>`
1202
1314
  return
1203
1315
  }
1316
+ const archivedSet = new Set(state.wbArchived || [])
1204
1317
  panel.innerHTML = projects.map(w => {
1205
1318
  const id = String(w.workspaceId || '')
1206
1319
  const open = !!state.wbOpenProjects[id]
1207
- const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(Boolean)
1320
+ const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(Boolean).filter(s => !archivedSet.has(s.sessionId))
1208
1321
  const body = open ? `<div class="wb-sessions">${sessions.length ? sessions.map(s => `
1209
- <button class="wb-session" type="button" data-wb-session="${esc(s.sessionId)}">
1210
- <span class="wb-session-title">${esc(titleOf(s))}</span>
1211
- <span class="wb-session-meta">${s.running ? esc(t('sessions.running')) : esc(fmtTime(s.updatedAt))}</span>
1212
- </button>`).join('') : `<div class="wb-empty">${esc(t('wb.noSessions'))}</div>`}</div>` : ''
1322
+ <div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
1323
+ <button class="wb-session" type="button" data-wb-session="${esc(s.sessionId)}">
1324
+ <span class="wb-session-title">${esc(titleOf(s))}</span>
1325
+ <span class="wb-session-meta">${s.running ? esc(t('sessions.running')) : esc(fmtTime(s.updatedAt))}</span>
1326
+ </button>
1327
+ <button type="button" class="sc-archive-btn" data-archive-session="${esc(s.sessionId)}">${esc(t('session.archive'))}</button>
1328
+ </div>`).join('') : `<div class="wb-empty">${esc(t('wb.noSessions'))}</div>`}</div>` : ''
1213
1329
  return `<div class="wb-project ${open ? 'open' : ''}" data-wb-project="${esc(id)}">
1214
1330
  <div class="wb-project-head">
1215
1331
  <span class="wb-chevron" aria-hidden="true">${open ? '▾' : '▸'}</span>
@@ -1250,8 +1366,12 @@ function renderSessions() {
1250
1366
  const wbIds = new Set()
1251
1367
  if (state.wb?.bound) for (const w of state.wbProjects) for (const id of (w.sessionIds || [])) wbIds.add(id)
1252
1368
  const root = workbenchRoot()
1253
- const visible = allItems.filter(s => !(state.wb?.bound && (wbIds.has(s.sessionId) || wbStrictInside(s.cwd, root))))
1254
1369
  const archivedSet = new Set(state.wbArchived || [])
1370
+ const visible = allItems.filter(s => {
1371
+ if (!state.wb?.bound) return true
1372
+ if (archivedSet.has(s.sessionId)) return true
1373
+ return !(wbIds.has(s.sessionId) || wbStrictInside(s.cwd, root))
1374
+ })
1255
1375
  const archived = visible.filter(s => archivedSet.has(s.sessionId))
1256
1376
  const main = visible.filter(s => !archivedSet.has(s.sessionId))
1257
1377
  const showArchived = LS.get('showArchivedV1', '0') === '1'
@@ -1325,6 +1445,8 @@ async function openSession(id) {
1325
1445
  }
1326
1446
 
1327
1447
  function closeSession() {
1448
+ setComposerFullscreen(false)
1449
+ clearComposerImages()
1328
1450
  state.current = null
1329
1451
  state.history = emptyHistory()
1330
1452
  document.body.classList.remove('in-session')
@@ -1337,6 +1459,7 @@ function bindNativeBack() {
1337
1459
  if (!CAP?.isNativePlatform?.()) return
1338
1460
  try {
1339
1461
  CAP.Plugins?.App?.addListener?.('backButton', () => {
1462
+ if ($('composer-wrap')?.classList.contains('fs')) { setComposerFullscreen(false); return }
1340
1463
  const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
1341
1464
  if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else if (openModal.id === 'modal-archive') closeArchiveConfirm(); else openModal.classList.add('hidden'); return } // 先关弹窗
1342
1465
  if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
@@ -1663,7 +1786,7 @@ function eventHtml(entry, ctx = {}) {
1663
1786
  const blocks = msg.content || data.content || []
1664
1787
  const sysText = type === 'user/message' ? systemReminderText(blocks) : ''
1665
1788
  if (sysText) {
1666
- inner = `<details class="event" data-seq="${seq}"><summary>${esc(t('event.systemReminder'))}</summary><pre>${esc(truncate(sysText, 400))}</pre></details>`
1789
+ inner = `<details class="event event-detail" data-seq="${seq}"><summary>${esc(t('event.systemReminder'))}</summary><pre>${esc(truncate(sysText, 4000))}</pre></details>`
1667
1790
  } else {
1668
1791
  inner = `<div class="msg ${esc(role)}" data-seq="${seq}"><div class="role">${esc(role === 'user' ? t('role.me') : t('role.dsh'))}</div>${blocks.map(blockHtml).join('')}</div>`
1669
1792
  }
@@ -1841,27 +1964,147 @@ async function runSlashCommand(text) {
1841
1964
  return false
1842
1965
  }
1843
1966
 
1967
+ function bytesToBase64(bytes) {
1968
+ let binary = ''
1969
+ const step = 0x8000
1970
+ for (let i = 0; i < bytes.length; i += step) {
1971
+ binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + step, bytes.length)))
1972
+ }
1973
+ return btoa(binary)
1974
+ }
1975
+
1976
+ function imageTypeOk(type) {
1977
+ return ['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(String(type || '').toLowerCase())
1978
+ }
1979
+
1980
+ function renderComposerImages() {
1981
+ const box = $('composer-attachments')
1982
+ if (!box) return
1983
+ document.body.classList.toggle('has-composer-images', state.composerImages.length > 0)
1984
+ box.classList.toggle('hidden', state.composerImages.length === 0)
1985
+ box.innerHTML = state.composerImages.map(item => `<div class="composer-attachment" title="${esc(item.file.name || t('block.image'))}">
1986
+ <img src="${esc(item.url)}" alt="${esc(item.file.name || t('block.image'))}">
1987
+ <button type="button" class="composer-attachment-remove" data-remove-image="${esc(item.id)}" aria-label="${esc(t('composer.removeImage'))}">×</button>
1988
+ </div>`).join('')
1989
+ }
1990
+
1991
+ function clearComposerImages() {
1992
+ state.composerImages.splice(0).forEach(item => { try { URL.revokeObjectURL(item.url) } catch {} })
1993
+ renderComposerImages()
1994
+ }
1995
+
1996
+ function removeComposerImage(id) {
1997
+ const index = state.composerImages.findIndex(item => item.id === id)
1998
+ if (index < 0) return
1999
+ const [item] = state.composerImages.splice(index, 1)
2000
+ try { URL.revokeObjectURL(item.url) } catch {}
2001
+ renderComposerImages()
2002
+ toast(t('composer.imageRemoved'), 'ok')
2003
+ }
2004
+
2005
+ function addComposerImages(files) {
2006
+ const incoming = Array.from(files || []).filter(Boolean)
2007
+ if (!incoming.length) return
2008
+ if (state.composerImages.length + incoming.length > 20) {
2009
+ toast(t('composer.imageLimit', { count: 20 }), 'err')
2010
+ return
2011
+ }
2012
+ for (const file of incoming) {
2013
+ if (!imageTypeOk(file.type)) { toast(t('composer.imageUnsupported'), 'err'); continue }
2014
+ if (file.size > 3.5 * 1024 * 1024) { toast(t('composer.imageTooLarge', { size: '3.5 MB' }), 'err'); continue }
2015
+ state.composerImages.push({ id: uuid(), file, url: URL.createObjectURL(file) })
2016
+ }
2017
+ renderComposerImages()
2018
+ if (incoming.length) toast(t('composer.imageAdded'), 'ok')
2019
+ }
2020
+
2021
+ function dataUrlToFile(dataUrl, name = 'photo.jpg') {
2022
+ const m = /^data:([^;,]+);base64,(.*)$/i.exec(String(dataUrl || ''))
2023
+ if (!m) return null
2024
+ const bytes = Uint8Array.from(atob(m[2]), c => c.charCodeAt(0))
2025
+ return new File([bytes], name, { type: m[1] })
2026
+ }
2027
+
2028
+ async function captureComposerImage(source) {
2029
+ if (!CAP?.isNativePlatform?.()) {
2030
+ const input = $(source === 'CAMERA' ? 'composer-camera-input' : 'composer-gallery-input')
2031
+ input?.click()
2032
+ return
2033
+ }
2034
+ const camera = CAP.Plugins?.Camera
2035
+ if (!camera?.getPhoto) { toast(t('scan.unsupported'), 'err'); return }
2036
+ try {
2037
+ if (source === 'CAMERA') {
2038
+ const perm = await camera.requestPermissions?.({ permissions: ['camera'] })
2039
+ if (perm && perm.camera !== 'granted') { toast(t('scan.permissionDenied'), 'err'); return }
2040
+ }
2041
+ const photo = await camera.getPhoto({
2042
+ resultType: 'dataUrl', source: source === 'PHOTOS' ? 'PHOTOS' : 'CAMERA', quality: 85,
2043
+ correctOrientation: true, saveToGallery: false
2044
+ })
2045
+ const file = dataUrlToFile(photo?.dataUrl, `dsh-image-${Date.now()}.${photo?.format || 'jpg'}`)
2046
+ if (file) addComposerImages([file])
2047
+ } catch (e) {
2048
+ const msg = String(e?.message || e || '')
2049
+ if (!/cancel/i.test(msg)) toast(t('composer.imageReadFailed', { msg }), 'err')
2050
+ }
2051
+ }
2052
+
2053
+ function toggleComposerImageMenu() {
2054
+ const menu = $('composer-image-menu')
2055
+ if (!menu) return
2056
+ const show = menu.classList.contains('hidden')
2057
+ menu.classList.toggle('hidden', !show)
2058
+ $('btn-image')?.classList.toggle('active', show)
2059
+ }
2060
+
1844
2061
  async function sendSessionText(text) {
2062
+ return sendSessionContent(text, [])
2063
+ }
2064
+
2065
+ async function sendSessionContent(text, images) {
1845
2066
  const clean = String(text || '').trim()
1846
- if (!clean || !state.current) return false
1847
- if (await runSlashCommand(clean)) return true
1848
- $('btn-send').disabled = true
1849
- const v = await safeRpc('session.prompt', {
1850
- sessionId: state.current,
1851
- mode: 'queue',
1852
- content: [{ type: 'text', text: clean }]
1853
- }, t('send.failed'))
1854
- $('btn-send').disabled = false
1855
- if (v?.accepted) { toast(clean.startsWith('/') ? t('send.commandSent') : t('send.sent'), 'ok'); return true }
1856
- if (v?.command?.text) { toast(t('send.commandExecuted'), 'ok'); return true }
1857
- return false
2067
+ if ((!clean && !images.length) || !state.current) return false
2068
+ if (images.length === 0 && clean && await runSlashCommand(clean)) return true
2069
+ const buttons = [$('btn-send'), $('btn-fs-send')].filter(Boolean)
2070
+ buttons.forEach(button => { button.disabled = true })
2071
+ try {
2072
+ const content = [...await encodeComposerImagesFor(images)]
2073
+ if (clean) content.push({ type: 'text', text: clean })
2074
+ const v = await safeRpc('session.prompt', {
2075
+ sessionId: state.current,
2076
+ mode: 'queue',
2077
+ content
2078
+ }, t('send.failed'))
2079
+ if (v?.accepted) { toast(images.length ? t('send.imageSent') : (clean.startsWith('/') ? t('send.commandSent') : t('send.sent')), 'ok'); return true }
2080
+ if (v?.command?.text) { toast(t('send.commandExecuted'), 'ok'); return true }
2081
+ return false
2082
+ } catch (e) {
2083
+ toast(t('composer.imageReadFailed', { msg: e?.message || e }), 'err')
2084
+ return false
2085
+ } finally {
2086
+ buttons.forEach(button => { button.disabled = false })
2087
+ }
2088
+ }
2089
+
2090
+ async function encodeComposerImagesFor(images) {
2091
+ return Promise.all(images.map(async item => ({
2092
+ type: 'image', mediaType: item.file.type, data: bytesToBase64(new Uint8Array(await item.file.arrayBuffer())),
2093
+ ...(item.file.name ? { name: item.file.name } : {})
2094
+ })))
1858
2095
  }
1859
2096
 
1860
2097
  async function sendMessage() {
1861
2098
  const input = $('composer-input')
1862
2099
  const text = input.value.trim()
1863
- if (!text || !state.current) return
1864
- if (await sendSessionText(text)) { input.value = ''; autosize(input) }
2100
+ const images = state.composerImages.slice()
2101
+ if ((!text && !images.length) || !state.current) return
2102
+ if (images.length && text.startsWith('/')) { toast(t('composer.imageSlashUnsupported'), 'err'); return }
2103
+ if (await sendSessionContent(text, images)) {
2104
+ input.value = ''
2105
+ autosize(input)
2106
+ clearComposerImages()
2107
+ }
1865
2108
  }
1866
2109
 
1867
2110
  function hideComposerMenu() {
@@ -2031,13 +2274,13 @@ async function confirmArchiveSession() {
2031
2274
  let swipeTracking = null
2032
2275
  let swipeSuppressClickUntil = 0
2033
2276
  function closeRevealedSwipes(except = null) {
2034
- document.querySelectorAll('#session-list .session-swipe.revealed').forEach(row => {
2277
+ document.querySelectorAll('.session-swipe.revealed').forEach(row => {
2035
2278
  if (row !== except) row.classList.remove('revealed')
2036
2279
  })
2037
2280
  }
2038
2281
  function bindSessionSwipe() {
2039
- const list = $('session-list')
2040
- list.addEventListener('touchstart', e => {
2282
+ const containers = [$('session-list'), $('wb-panel')].filter(Boolean)
2283
+ for (const list of containers) list.addEventListener('touchstart', e => {
2041
2284
  if (e.touches.length !== 1) return
2042
2285
  const row = e.target.closest('[data-session-swipe]')
2043
2286
  if (!row) return
@@ -2051,7 +2294,7 @@ function bindSessionSwipe() {
2051
2294
  axis: null
2052
2295
  }
2053
2296
  }, { passive: true })
2054
- list.addEventListener('touchmove', e => {
2297
+ for (const list of containers) list.addEventListener('touchmove', e => {
2055
2298
  if (!swipeTracking || e.touches.length !== 1) return
2056
2299
  const touch = e.touches[0]
2057
2300
  const dx = touch.clientX - swipeTracking.startX
@@ -2064,7 +2307,7 @@ function bindSessionSwipe() {
2064
2307
  const offset = Math.max(-92, Math.min(0, swipeTracking.offset + dx))
2065
2308
  swipeTracking.row.style.setProperty('--swipe-x', offset + 'px')
2066
2309
  }, { passive: false })
2067
- list.addEventListener('touchend', () => {
2310
+ for (const list of containers) list.addEventListener('touchend', () => {
2068
2311
  if (!swipeTracking) return
2069
2312
  if (swipeTracking.axis === 'x') {
2070
2313
  const row = swipeTracking.row
@@ -2075,10 +2318,99 @@ function bindSessionSwipe() {
2075
2318
  }
2076
2319
  swipeTracking = null
2077
2320
  }, { passive: true })
2078
- list.addEventListener('touchcancel', () => { swipeTracking = null }, { passive: true })
2321
+ for (const list of containers) list.addEventListener('touchcancel', () => { swipeTracking = null }, { passive: true })
2322
+ }
2323
+
2324
+ /* ---------------- 系统总览 / 待办 ---------------- */
2325
+ function renderOverview() {
2326
+ const ring = $('overview-pulse-ring')
2327
+ if (!ring) return
2328
+ const checks = {
2329
+ gateway: !!state.token && !!state.server,
2330
+ dsh: !!state.hostInfo,
2331
+ mux: !!state.streamsOk?.mux,
2332
+ host: !!state.streamsOk?.host
2333
+ }
2334
+ const online = Object.values(checks).filter(Boolean).length
2335
+ const status = online === 4 ? 'nominal' : online > 0 ? 'degraded' : 'offline'
2336
+ const pulseCard = document.querySelector('.overview-pulse-card')
2337
+ if (pulseCard) {
2338
+ pulseCard.classList.remove('status-nominal', 'status-degraded', 'status-offline')
2339
+ pulseCard.classList.add('status-' + status)
2340
+ }
2341
+ ring.style.setProperty('--pulse-pct', `${online / 4 * 100}%`)
2342
+ $('overview-health').textContent = online === 4 ? t('overview.live') : online ? `${online}/4` : t('overview.offlineCore')
2343
+ $('overview-health-caption').textContent = online === 4 ? t('overview.allLinked') : online ? t('overview.components', { n: online }) : t('overview.offlineShort')
2344
+ $('overview-status').textContent = t(`overview.${status}`)
2345
+ $('overview-status-desc').textContent = t('overview.components', { n: online })
2346
+ for (const [name, ok] of Object.entries(checks)) {
2347
+ const item = document.querySelector(`[data-overview-link="${name}"]`)
2348
+ if (!item) continue
2349
+ item.classList.toggle('ok', ok)
2350
+ item.classList.toggle('off', !ok)
2351
+ const value = item.querySelector('b')
2352
+ if (value) value.textContent = ok ? t('overview.online') : t('overview.offlineShort')
2353
+ }
2354
+
2355
+ const pending = [
2356
+ ...state.approvals.map(a => ({ kind: 'approval', item: a })),
2357
+ ...state.questions.map(q => ({ kind: 'question', item: q }))
2358
+ ]
2359
+ $('overview-attention-count').textContent = pending.length ? t('overview.pendingCount', { n: pending.length }) : '—'
2360
+ $('overview-attention-list').innerHTML = pending.length ? pending.slice(0, 3).map(({ kind, item }) => {
2361
+ const title = titleOf(state.byId.get(item.sessionId))
2362
+ if (kind === 'approval') return `<div class="overview-attention-item" data-overview-approval="${esc(item.approvalId)}">
2363
+ <span class="overview-item-mark">⌁</span><span class="overview-item-copy"><span class="overview-item-title">${esc(item.toolName || t('tool.default'))}</span><span class="overview-item-desc">${esc(item.reason || t('pending.noReason'))} · ${esc(title)}</span></span>
2364
+ <span class="overview-item-actions"><button type="button" class="mini-btn" data-overview-approve="1">${t('pending.allow')}</button><button type="button" class="mini-btn" data-overview-approve="0">${t('pending.reject')}</button></span>
2365
+ </div>`
2366
+ return `<button type="button" class="overview-attention-item question" data-overview-question="${esc(item.rpcId)}">
2367
+ <span class="overview-item-mark">?</span><span class="overview-item-copy"><span class="overview-item-title">${esc(item.questions?.[0]?.question || t('notify.questionTitle'))}</span><span class="overview-item-desc">${esc(title)}</span></span><span class="overview-item-arrow">›</span>
2368
+ </button>`
2369
+ }).join('') : `<div class="overview-empty">${t('pending.empty')}</div>`
2370
+ $('overview-attention-list').querySelectorAll('[data-overview-approve]').forEach(btn => {
2371
+ btn.addEventListener('click', () => approveApproval(btn.closest('[data-overview-approval]')?.dataset.overviewApproval || '', btn.dataset.overviewApprove === '1'))
2372
+ })
2373
+ $('overview-attention-list').querySelectorAll('[data-overview-question]').forEach(btn => {
2374
+ btn.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === btn.dataset.overviewQuestion)))
2375
+ })
2376
+
2377
+ const running = state.sessions.filter(s => s.running).length
2378
+ 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)
2379
+ const primary = $('overview-primary-action')
2380
+ if (primary) {
2381
+ let action = 'new'
2382
+ let label = t('overview.action.newSession')
2383
+ let sessionId = ''
2384
+ if (!state.token) {
2385
+ action = 'settings'
2386
+ label = t('overview.action.connect')
2387
+ } else if (online > 0 && online < 4) {
2388
+ action = 'refresh'
2389
+ label = t('overview.action.refresh')
2390
+ } else if (pending.length) {
2391
+ action = 'attention'
2392
+ label = t('overview.action.attention')
2393
+ } else if (sessions.length) {
2394
+ action = 'session'
2395
+ sessionId = sessions[0].sessionId
2396
+ label = t('overview.action.openSession')
2397
+ }
2398
+ primary.textContent = label
2399
+ primary.dataset.overviewAction = action
2400
+ primary.dataset.overviewSession = sessionId
2401
+ primary.disabled = status === 'offline' && action === 'refresh'
2402
+ }
2403
+ $('overview-dsh-version').textContent = state.hostInfo?.version || '—'
2404
+ $('overview-gateway-version').textContent = checks.gateway ? t('overview.online') : t('overview.offlineShort')
2405
+ $('overview-active-sessions').textContent = String(running)
2406
+ $('overview-connection-mode').textContent = state.token ? t(state.streamMode === 'poll' ? 'overview.poll' : 'overview.ws') : '—'
2407
+ $('overview-active-count').textContent = running ? t('overview.activeCount', { n: running }) : ''
2408
+ $('overview-session-list').innerHTML = sessions.length ? sessions.map(s => `<button type="button" class="overview-session-item ${s.running ? 'running' : ''}" data-overview-session="${esc(s.sessionId)}">
2409
+ <span class="overview-item-mark">${s.running ? '●' : '○'}</span><span class="overview-item-copy"><span class="overview-item-title">${esc(titleOf(s))}</span><span class="overview-item-desc">${s.running ? esc(t('sessions.running')) + ' · ' : ''}${esc(fmtTime(s.updatedAt))}</span></span><span class="overview-item-arrow">›</span>
2410
+ </button>`).join('') : `<div class="overview-empty">${t('overview.noSession')}</div>`
2411
+ $('overview-session-list').querySelectorAll('[data-overview-session]').forEach(btn => btn.addEventListener('click', () => openSession(btn.dataset.overviewSession)))
2079
2412
  }
2080
2413
 
2081
- /* ---------------- 待办 ---------------- */
2082
2414
  function renderPending() {
2083
2415
  const list = $('pending-list')
2084
2416
  const items = [
@@ -2113,6 +2445,7 @@ function renderPending() {
2113
2445
  list.querySelectorAll('[data-question]').forEach(btn =>
2114
2446
  btn.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === btn.dataset.question))))
2115
2447
  updatePendingBadge()
2448
+ renderOverview()
2116
2449
  }
2117
2450
 
2118
2451
  async function approveApproval(id, allow) {
@@ -2306,7 +2639,7 @@ function renderFs(data) {
2306
2639
  list.innerHTML = data.entries.map(e => {
2307
2640
  const isDir = e.type === 'dir'
2308
2641
  return `<div class="fs-row" data-name="${esc(e.name)}" data-type="${esc(e.type)}">
2309
- <span class="fs-ico">${isDir ? '📁' : '📄'}</span>
2642
+ <span class="fs-ico">${fsIconSvg(isDir)}</span>
2310
2643
  <span class="fs-meta">
2311
2644
  <span class="fs-name">${esc(e.name)}</span>
2312
2645
  <span class="fs-sub">${isDir ? t('fs.dir') : fmtSize(e.size)} · ${fmtFullTime(e.mtimeMs)}</span>
@@ -2318,6 +2651,12 @@ function renderFs(data) {
2318
2651
  row.addEventListener('click', () => fsOpenEntry(row.dataset.name, row.dataset.type)))
2319
2652
  }
2320
2653
 
2654
+ function fsIconSvg(isDir) {
2655
+ return isDir
2656
+ ? '<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>'
2657
+ : '<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>'
2658
+ }
2659
+
2321
2660
  function fsOpenEntry(name, type) {
2322
2661
  if (!name) return
2323
2662
  const p = fsJoin(state.fs.path, name)
@@ -2744,6 +3083,7 @@ async function loadLocalVersion() {
2744
3083
  * 公告只读取文本并用 textContent/转义后的换行渲染,不执行服务端下发的 HTML/脚本。
2745
3084
  */
2746
3085
  const ANNOUNCEMENTS_KEY = 'seenAnnouncementsV1'
3086
+ const ANNOUNCEMENT_HISTORY_KEY = 'announcementHistoryV1'
2747
3087
  function readSeenAnnouncements() {
2748
3088
  try {
2749
3089
  const value = JSON.parse(LS.get(ANNOUNCEMENTS_KEY, '{}'))
@@ -2761,6 +3101,40 @@ function markAnnouncementSeen(id) {
2761
3101
  }
2762
3102
  LS.set(ANNOUNCEMENTS_KEY, JSON.stringify(seen))
2763
3103
  }
3104
+ function readAnnouncementHistory() {
3105
+ try {
3106
+ const value = JSON.parse(LS.get(ANNOUNCEMENT_HISTORY_KEY, '[]'))
3107
+ return Array.isArray(value) ? value.filter(item => item && typeof item.id === 'string') : []
3108
+ } catch { return [] }
3109
+ }
3110
+ function storeAnnouncementHistory(items) {
3111
+ const merged = new Map(readAnnouncementHistory().map(item => [item.id, item]))
3112
+ for (const item of items) if (item?.id) merged.set(item.id, item)
3113
+ const list = [...merged.values()].sort((a, b) => Number(b.publishedAt || 0) - Number(a.publishedAt || 0)).slice(0, 50)
3114
+ LS.set(ANNOUNCEMENT_HISTORY_KEY, JSON.stringify(list))
3115
+ return list
3116
+ }
3117
+ function renderAnnouncementHistory() {
3118
+ const box = $('announcement-history-list')
3119
+ if (!box) return
3120
+ const list = readAnnouncementHistory()
3121
+ if (!list.length) {
3122
+ box.innerHTML = `<div class="empty">${esc(t('announcement.historyEmpty'))}</div>`
3123
+ return
3124
+ }
3125
+ box.innerHTML = list.map(item => {
3126
+ const date = Number(item.publishedAt) > 0 ? fmtFullTime(item.publishedAt) : t('announcement.noDate')
3127
+ const action = item.actionUrl ? `<a class="announcement-action" href="${esc(item.actionUrl)}" target="_blank" rel="noopener">${esc(item.actionText || t('announcement.open'))}</a>` : ''
3128
+ return `<details class="announcement-history-item"><summary><span>${esc(item.title)}</span><small>${esc(date)}</small></summary><div class="announcement-history-content">${esc(item.content).replace(/\r?\n/g, '<br>')}${action}</div></details>`
3129
+ }).join('')
3130
+ }
3131
+ function openAnnouncementHistory() {
3132
+ renderAnnouncementHistory()
3133
+ $('modal-announcement-history')?.classList.remove('hidden')
3134
+ }
3135
+ function closeAnnouncementHistory() {
3136
+ $('modal-announcement-history')?.classList.add('hidden')
3137
+ }
2764
3138
  function announcementVersionMatch(item) {
2765
3139
  const min = String(item.minVersion || item.minAppVersion || '').trim()
2766
3140
  const max = String(item.maxVersion || item.maxAppVersion || '').trim()
@@ -2829,8 +3203,10 @@ async function checkAnnouncements() {
2829
3203
  if (raw.length > 512 * 1024) return false
2830
3204
  const data = JSON.parse(raw)
2831
3205
  const source = Array.isArray(data) ? data : (Array.isArray(data?.items) ? data.items : [data])
3206
+ const normalized = source.map(item => normalizeAnnouncement(item, base)).filter(Boolean)
3207
+ storeAnnouncementHistory(normalized)
2832
3208
  const seen = readSeenAnnouncements()
2833
- const items = source.map(item => normalizeAnnouncement(item, base)).filter(item => item && !seen[item.id])
3209
+ const items = normalized.filter(item => !seen[item.id])
2834
3210
  .sort((a, b) => b.publishedAt - a.publishedAt)
2835
3211
  if (!items.length) return false
2836
3212
  openAnnouncementModal(items[0])
@@ -3294,8 +3670,84 @@ function updateConn() {
3294
3670
  }
3295
3671
 
3296
3672
  function autosize(el) {
3673
+ // 全屏编辑时 textarea 由 flex 容器提供整块高度;普通的 120px 限高
3674
+ // 不能覆盖这里,否则输入超过约五行后会被重新压回小输入框。
3675
+ if (el?.id === 'composer-input' && $('composer-wrap')?.classList.contains('fs')) {
3676
+ el.style.height = '100%'
3677
+ updateComposerFullscreenButton()
3678
+ return
3679
+ }
3297
3680
  el.style.height = 'auto'
3298
3681
  el.style.height = Math.min(el.scrollHeight, 120) + 'px'
3682
+ if (el.id === 'composer-input') updateComposerFullscreenButton()
3683
+ }
3684
+
3685
+ function updateComposerFullscreenButton() {
3686
+ const input = $('composer-input')
3687
+ const wrap = $('composer-wrap')
3688
+ const button = $('btn-fs-toggle')
3689
+ if (!input || !wrap || !button) return
3690
+ const active = wrap.classList.contains('fs')
3691
+ const shouldShow = active || input.scrollHeight > 120
3692
+ button.classList.toggle('hidden', !shouldShow)
3693
+ $('composer-input-wrap')?.classList.toggle('has-fs-btn', shouldShow)
3694
+ $('fs-ico-expand')?.classList.toggle('hidden', active)
3695
+ $('fs-ico-collapse')?.classList.toggle('hidden', !active)
3696
+ button.title = t(active ? 'composer.exitFullscreen' : 'composer.fullscreen')
3697
+ button.setAttribute('aria-label', t(active ? 'composer.exitFullscreen' : 'composer.fullscreen'))
3698
+ }
3699
+
3700
+ function setComposerFullscreen(on) {
3701
+ const wrap = $('composer-wrap')
3702
+ if (!wrap) return
3703
+ $('btn-stats')?.classList.toggle('hidden', !!on)
3704
+ $('btn-fs-send')?.classList.toggle('hidden', !on)
3705
+ if (on) {
3706
+ $('composer-image-menu')?.classList.add('hidden')
3707
+ $('btn-image')?.classList.remove('active')
3708
+ }
3709
+ wrap.classList.toggle('fs', !!on)
3710
+ document.body.classList.toggle('composer-fullscreen', !!on)
3711
+ if (on) {
3712
+ $('composer-input')?.style.removeProperty('height')
3713
+ } else {
3714
+ wrap.style.transform = ''
3715
+ wrap.classList.remove('dragging')
3716
+ autosize($('composer-input'))
3717
+ }
3718
+ updateComposerFullscreenButton()
3719
+ }
3720
+
3721
+ function bindComposerFullscreenGesture() {
3722
+ const handle = $('composer-fs-handle')
3723
+ if (!handle) return
3724
+ let startY = 0
3725
+ let tracking = false
3726
+ handle.addEventListener('touchstart', e => {
3727
+ if (!$('composer-wrap').classList.contains('fs') || e.touches.length !== 1) return
3728
+ tracking = true
3729
+ startY = e.touches[0].clientY
3730
+ }, { passive: true })
3731
+ handle.addEventListener('touchmove', e => {
3732
+ if (!tracking || e.touches.length !== 1) return
3733
+ const dy = e.touches[0].clientY - startY
3734
+ if (dy <= 0) return
3735
+ e.preventDefault()
3736
+ $('composer-wrap').classList.add('dragging')
3737
+ $('composer-wrap').style.transform = `translateY(${Math.min(dy, 180)}px)`
3738
+ }, { passive: false })
3739
+ const finish = () => {
3740
+ if (!tracking) return
3741
+ const wrap = $('composer-wrap')
3742
+ const transform = wrap.style.transform.match(/translateY\(([-\d.]+)px\)/)
3743
+ const dy = transform ? Number(transform[1]) : 0
3744
+ tracking = false
3745
+ wrap.classList.remove('dragging')
3746
+ if (dy > 60) setComposerFullscreen(false)
3747
+ else wrap.style.transform = ''
3748
+ }
3749
+ handle.addEventListener('touchend', finish, { passive: true })
3750
+ handle.addEventListener('touchcancel', finish, { passive: true })
3299
3751
  }
3300
3752
 
3301
3753
  /* ---------------- 初始化 ---------------- */
@@ -3547,6 +3999,19 @@ function bindUi() {
3547
3999
  // 底部导航
3548
4000
  document.querySelectorAll('.nav-btn').forEach(b =>
3549
4001
  b.addEventListener('click', () => showView(b.dataset.view)))
4002
+ $('overview-primary-action').addEventListener('click', () => {
4003
+ const button = $('overview-primary-action')
4004
+ const action = button.dataset.overviewAction
4005
+ if (action === 'session' && button.dataset.overviewSession) return openSession(button.dataset.overviewSession)
4006
+ if (action === 'new') return newSession()
4007
+ if (action === 'settings') return showView('view-settings')
4008
+ if (action === 'refresh') return openStreams()
4009
+ const first = document.querySelector('.overview-attention-item')
4010
+ if (first) {
4011
+ first.scrollIntoView({ behavior: 'smooth', block: 'center' })
4012
+ if (first.matches('button')) first.focus({ preventScroll: true })
4013
+ }
4014
+ })
3550
4015
  // 会话列表点击
3551
4016
  $('session-list').addEventListener('click', (e) => {
3552
4017
  if (swipeSuppressClickUntil > Date.now()) { swipeSuppressClickUntil = 0; return }
@@ -3576,6 +4041,12 @@ function bindUi() {
3576
4041
  renderWorkbench()
3577
4042
  })
3578
4043
  $('wb-panel').addEventListener('click', (e) => {
4044
+ const archive = e.target.closest('[data-archive-session]')
4045
+ if (archive) {
4046
+ e.stopPropagation()
4047
+ archiveSession(archive.dataset.archiveSession)
4048
+ return
4049
+ }
3579
4050
  const newButton = e.target.closest('[data-wb-new]')
3580
4051
  if (newButton) {
3581
4052
  safeRpc('session.create', { workspaceId: newButton.dataset.wbNew }, t('home.createFailed')).then(async v => {
@@ -3634,7 +4105,28 @@ function bindUi() {
3634
4105
  })
3635
4106
  $('btn-cancel').addEventListener('click', cancelSession)
3636
4107
  $('btn-send').addEventListener('click', sendMessage)
4108
+ $('btn-fs-send').addEventListener('click', sendMessage)
3637
4109
  $('btn-plus').addEventListener('click', toggleComposerMenu)
4110
+ $('btn-image').addEventListener('click', toggleComposerImageMenu)
4111
+ $('composer-image-menu').addEventListener('click', (e) => {
4112
+ const option = e.target.closest('[data-image-source]')
4113
+ if (!option) return
4114
+ $('composer-image-menu').classList.add('hidden')
4115
+ $('btn-image').classList.remove('active')
4116
+ captureComposerImage(option.dataset.imageSource)
4117
+ })
4118
+ $('composer-attachments').addEventListener('click', (e) => {
4119
+ const button = e.target.closest('[data-remove-image]')
4120
+ if (button) removeComposerImage(button.dataset.removeImage)
4121
+ })
4122
+ $('composer-camera-input').addEventListener('change', (e) => { addComposerImages(e.target.files); e.target.value = '' })
4123
+ $('composer-gallery-input').addEventListener('change', (e) => { addComposerImages(e.target.files); e.target.value = '' })
4124
+ document.addEventListener('click', (e) => {
4125
+ if (!e.target.closest('#composer-image-menu, #btn-image')) {
4126
+ $('composer-image-menu')?.classList.add('hidden')
4127
+ $('btn-image')?.classList.remove('active')
4128
+ }
4129
+ })
3638
4130
  $('composer-menu').addEventListener('click', async (e) => {
3639
4131
  const chip = e.target.closest('[data-cmd]')
3640
4132
  if (chip) {
@@ -3675,6 +4167,9 @@ function bindUi() {
3675
4167
  $('btn-model-refresh').addEventListener('click', loadSessionModels)
3676
4168
  const input = $('composer-input')
3677
4169
  input.addEventListener('input', () => autosize(input))
4170
+ $('btn-fs-toggle').addEventListener('click', () => setComposerFullscreen(!$('composer-wrap').classList.contains('fs')))
4171
+ bindComposerFullscreenGesture()
4172
+ updateComposerFullscreenButton()
3678
4173
  input.addEventListener('keydown', (e) => {
3679
4174
  if (e.key !== 'Enter' || e.isComposing) return
3680
4175
  if (isMobileDevice() && mobileEnterAction() !== 'send') return
@@ -3708,6 +4203,10 @@ function bindUi() {
3708
4203
  $('modal-announcement').addEventListener('click', (e) => {
3709
4204
  if (e.target === $('modal-announcement') && !state.announcement?.force) closeAnnouncement(false)
3710
4205
  })
4206
+ $('announcement-history-close').addEventListener('click', closeAnnouncementHistory)
4207
+ $('modal-announcement-history').addEventListener('click', (e) => {
4208
+ if (e.target === $('modal-announcement-history')) closeAnnouncementHistory()
4209
+ })
3711
4210
  // 设置
3712
4211
  $('view-settings').addEventListener('click', (e) => {
3713
4212
  const group = e.target.closest('[data-settings-group]')
@@ -3744,7 +4243,7 @@ function bindUi() {
3744
4243
  $('btn-update-expand').addEventListener('click', toggleUpdateExpand)
3745
4244
  $('btn-reset').addEventListener('click', () => {
3746
4245
  if (!confirm(t('settings.confirmReset'))) return
3747
- LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction')
4246
+ LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction'); LS.del(ANNOUNCEMENTS_KEY); LS.del(ANNOUNCEMENT_HISTORY_KEY)
3748
4247
  if (bgBridge()?.saveBackgroundConfig) saveBgConfig(false)
3749
4248
  location.reload()
3750
4249
  })
@@ -3788,6 +4287,7 @@ function bindUi() {
3788
4287
  LS.set('peakRemind', e.target.checked ? '1' : '0')
3789
4288
  })
3790
4289
  $('btn-test-notify').addEventListener('click', sendTestNotification)
4290
+ $('btn-announcement-history').addEventListener('click', openAnnouncementHistory)
3791
4291
  // 已开启则启动时重新调度, 防止系统清理后丢失
3792
4292
  if (peakRemindOn() && CAP?.isNativePlatform?.()) schedulePeakReminders()
3793
4293
  applyBgConfigFromNative()
@@ -3883,10 +4383,11 @@ async function boot() {
3883
4383
  bindNativeBack()
3884
4384
  bindNativeLinks()
3885
4385
  applyNativeInsets()
4386
+ showView('view-activity')
3886
4387
  updateConn()
3887
4388
  await loadLocalVersion()
3888
4389
  if (!state.token) {
3889
- showView('view-settings')
4390
+ showView('view-activity')
3890
4391
  $('token-desc').textContent = t('token.notSetHint')
3891
4392
  } else {
3892
4393
  // 多服务器: 启动时静默测速一次, 选最快的连接(同源页面也参与比较)