dsh-remote-plugin 0.6.7 → 0.6.8

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
@@ -63,6 +63,7 @@ const state = {
63
63
  pollSeq: { mux: 0, host: 0 },
64
64
  refreshTimer: null,
65
65
  fs: { path: null, initial: null, loaded: false, upload: null },
66
+ composerImages: [], // 当前草稿中的图片附件:只在发送成功后释放
66
67
  models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
67
68
  wb: null,
68
69
  wbProjects: [],
@@ -1174,6 +1175,29 @@ async function refreshWorkbench() {
1174
1175
  state.wbProjects = []
1175
1176
  state.wbArchived = []
1176
1177
  }
1178
+ // 以磁盘实际目录为准同步工作台项目:删除目录后不再残留,新增子目录自动收纳。
1179
+ if (state.wb?.bound && state.wb.path) {
1180
+ try {
1181
+ const listRes = await fetch(fsApiUrl('/list', { path: state.wb.path }), { headers: fsHeaders() })
1182
+ if (listRes.ok) {
1183
+ const listData = await listRes.json().catch(() => ({}))
1184
+ if (Array.isArray(listData.entries)) {
1185
+ const diskDirs = new Set(listData.entries.filter(e => e.type === 'dir').map(e => wbPathKey(wbJoin(state.wb.path, e.name))))
1186
+ state.wbProjects = state.wbProjects.filter(w => diskDirs.has(wbPathKey(w.path)))
1187
+ const have = new Set(state.wbProjects.map(w => wbPathKey(w.path)))
1188
+ for (const entry of listData.entries) {
1189
+ if (entry.type !== 'dir') continue
1190
+ const projectPath = wbJoin(state.wb.path, entry.name)
1191
+ if (have.has(wbPathKey(projectPath))) continue
1192
+ try {
1193
+ const created = await rpc('workspace.create', { path: projectPath })
1194
+ if (created?.workspace) { state.wbProjects.push(created.workspace); have.add(wbPathKey(projectPath)) }
1195
+ } catch {}
1196
+ }
1197
+ }
1198
+ }
1199
+ } catch {}
1200
+ }
1177
1201
  renderWorkbench()
1178
1202
  renderSessions()
1179
1203
  }
@@ -1201,15 +1225,19 @@ function renderWorkbench() {
1201
1225
  panel.innerHTML = `<div class="wb-empty">${esc(t('wb.noProjects'))}</div>`
1202
1226
  return
1203
1227
  }
1228
+ const archivedSet = new Set(state.wbArchived || [])
1204
1229
  panel.innerHTML = projects.map(w => {
1205
1230
  const id = String(w.workspaceId || '')
1206
1231
  const open = !!state.wbOpenProjects[id]
1207
- const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(Boolean)
1232
+ const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(Boolean).filter(s => !archivedSet.has(s.sessionId))
1208
1233
  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>` : ''
1234
+ <div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
1235
+ <button class="wb-session" type="button" data-wb-session="${esc(s.sessionId)}">
1236
+ <span class="wb-session-title">${esc(titleOf(s))}</span>
1237
+ <span class="wb-session-meta">${s.running ? esc(t('sessions.running')) : esc(fmtTime(s.updatedAt))}</span>
1238
+ </button>
1239
+ <button type="button" class="sc-archive-btn" data-archive-session="${esc(s.sessionId)}">${esc(t('session.archive'))}</button>
1240
+ </div>`).join('') : `<div class="wb-empty">${esc(t('wb.noSessions'))}</div>`}</div>` : ''
1213
1241
  return `<div class="wb-project ${open ? 'open' : ''}" data-wb-project="${esc(id)}">
1214
1242
  <div class="wb-project-head">
1215
1243
  <span class="wb-chevron" aria-hidden="true">${open ? '▾' : '▸'}</span>
@@ -1250,8 +1278,12 @@ function renderSessions() {
1250
1278
  const wbIds = new Set()
1251
1279
  if (state.wb?.bound) for (const w of state.wbProjects) for (const id of (w.sessionIds || [])) wbIds.add(id)
1252
1280
  const root = workbenchRoot()
1253
- const visible = allItems.filter(s => !(state.wb?.bound && (wbIds.has(s.sessionId) || wbStrictInside(s.cwd, root))))
1254
1281
  const archivedSet = new Set(state.wbArchived || [])
1282
+ const visible = allItems.filter(s => {
1283
+ if (!state.wb?.bound) return true
1284
+ if (archivedSet.has(s.sessionId)) return true
1285
+ return !(wbIds.has(s.sessionId) || wbStrictInside(s.cwd, root))
1286
+ })
1255
1287
  const archived = visible.filter(s => archivedSet.has(s.sessionId))
1256
1288
  const main = visible.filter(s => !archivedSet.has(s.sessionId))
1257
1289
  const showArchived = LS.get('showArchivedV1', '0') === '1'
@@ -1325,6 +1357,8 @@ async function openSession(id) {
1325
1357
  }
1326
1358
 
1327
1359
  function closeSession() {
1360
+ setComposerFullscreen(false)
1361
+ clearComposerImages()
1328
1362
  state.current = null
1329
1363
  state.history = emptyHistory()
1330
1364
  document.body.classList.remove('in-session')
@@ -1337,6 +1371,7 @@ function bindNativeBack() {
1337
1371
  if (!CAP?.isNativePlatform?.()) return
1338
1372
  try {
1339
1373
  CAP.Plugins?.App?.addListener?.('backButton', () => {
1374
+ if ($('composer-wrap')?.classList.contains('fs')) { setComposerFullscreen(false); return }
1340
1375
  const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
1341
1376
  if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else if (openModal.id === 'modal-archive') closeArchiveConfirm(); else openModal.classList.add('hidden'); return } // 先关弹窗
1342
1377
  if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
@@ -1663,7 +1698,7 @@ function eventHtml(entry, ctx = {}) {
1663
1698
  const blocks = msg.content || data.content || []
1664
1699
  const sysText = type === 'user/message' ? systemReminderText(blocks) : ''
1665
1700
  if (sysText) {
1666
- inner = `<details class="event" data-seq="${seq}"><summary>${esc(t('event.systemReminder'))}</summary><pre>${esc(truncate(sysText, 400))}</pre></details>`
1701
+ inner = `<details class="event event-detail" data-seq="${seq}"><summary>${esc(t('event.systemReminder'))}</summary><pre>${esc(truncate(sysText, 4000))}</pre></details>`
1667
1702
  } else {
1668
1703
  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
1704
  }
@@ -1841,27 +1876,147 @@ async function runSlashCommand(text) {
1841
1876
  return false
1842
1877
  }
1843
1878
 
1879
+ function bytesToBase64(bytes) {
1880
+ let binary = ''
1881
+ const step = 0x8000
1882
+ for (let i = 0; i < bytes.length; i += step) {
1883
+ binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + step, bytes.length)))
1884
+ }
1885
+ return btoa(binary)
1886
+ }
1887
+
1888
+ function imageTypeOk(type) {
1889
+ return ['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(String(type || '').toLowerCase())
1890
+ }
1891
+
1892
+ function renderComposerImages() {
1893
+ const box = $('composer-attachments')
1894
+ if (!box) return
1895
+ document.body.classList.toggle('has-composer-images', state.composerImages.length > 0)
1896
+ box.classList.toggle('hidden', state.composerImages.length === 0)
1897
+ box.innerHTML = state.composerImages.map(item => `<div class="composer-attachment" title="${esc(item.file.name || t('block.image'))}">
1898
+ <img src="${esc(item.url)}" alt="${esc(item.file.name || t('block.image'))}">
1899
+ <button type="button" class="composer-attachment-remove" data-remove-image="${esc(item.id)}" aria-label="${esc(t('composer.removeImage'))}">×</button>
1900
+ </div>`).join('')
1901
+ }
1902
+
1903
+ function clearComposerImages() {
1904
+ state.composerImages.splice(0).forEach(item => { try { URL.revokeObjectURL(item.url) } catch {} })
1905
+ renderComposerImages()
1906
+ }
1907
+
1908
+ function removeComposerImage(id) {
1909
+ const index = state.composerImages.findIndex(item => item.id === id)
1910
+ if (index < 0) return
1911
+ const [item] = state.composerImages.splice(index, 1)
1912
+ try { URL.revokeObjectURL(item.url) } catch {}
1913
+ renderComposerImages()
1914
+ toast(t('composer.imageRemoved'), 'ok')
1915
+ }
1916
+
1917
+ function addComposerImages(files) {
1918
+ const incoming = Array.from(files || []).filter(Boolean)
1919
+ if (!incoming.length) return
1920
+ if (state.composerImages.length + incoming.length > 20) {
1921
+ toast(t('composer.imageLimit', { count: 20 }), 'err')
1922
+ return
1923
+ }
1924
+ for (const file of incoming) {
1925
+ if (!imageTypeOk(file.type)) { toast(t('composer.imageUnsupported'), 'err'); continue }
1926
+ if (file.size > 3.5 * 1024 * 1024) { toast(t('composer.imageTooLarge', { size: '3.5 MB' }), 'err'); continue }
1927
+ state.composerImages.push({ id: uuid(), file, url: URL.createObjectURL(file) })
1928
+ }
1929
+ renderComposerImages()
1930
+ if (incoming.length) toast(t('composer.imageAdded'), 'ok')
1931
+ }
1932
+
1933
+ function dataUrlToFile(dataUrl, name = 'photo.jpg') {
1934
+ const m = /^data:([^;,]+);base64,(.*)$/i.exec(String(dataUrl || ''))
1935
+ if (!m) return null
1936
+ const bytes = Uint8Array.from(atob(m[2]), c => c.charCodeAt(0))
1937
+ return new File([bytes], name, { type: m[1] })
1938
+ }
1939
+
1940
+ async function captureComposerImage(source) {
1941
+ if (!CAP?.isNativePlatform?.()) {
1942
+ const input = $(source === 'CAMERA' ? 'composer-camera-input' : 'composer-gallery-input')
1943
+ input?.click()
1944
+ return
1945
+ }
1946
+ const camera = CAP.Plugins?.Camera
1947
+ if (!camera?.getPhoto) { toast(t('scan.unsupported'), 'err'); return }
1948
+ try {
1949
+ if (source === 'CAMERA') {
1950
+ const perm = await camera.requestPermissions?.({ permissions: ['camera'] })
1951
+ if (perm && perm.camera !== 'granted') { toast(t('scan.permissionDenied'), 'err'); return }
1952
+ }
1953
+ const photo = await camera.getPhoto({
1954
+ resultType: 'dataUrl', source: source === 'PHOTOS' ? 'PHOTOS' : 'CAMERA', quality: 85,
1955
+ correctOrientation: true, saveToGallery: false
1956
+ })
1957
+ const file = dataUrlToFile(photo?.dataUrl, `dsh-image-${Date.now()}.${photo?.format || 'jpg'}`)
1958
+ if (file) addComposerImages([file])
1959
+ } catch (e) {
1960
+ const msg = String(e?.message || e || '')
1961
+ if (!/cancel/i.test(msg)) toast(t('composer.imageReadFailed', { msg }), 'err')
1962
+ }
1963
+ }
1964
+
1965
+ function toggleComposerImageMenu() {
1966
+ const menu = $('composer-image-menu')
1967
+ if (!menu) return
1968
+ const show = menu.classList.contains('hidden')
1969
+ menu.classList.toggle('hidden', !show)
1970
+ $('btn-image')?.classList.toggle('active', show)
1971
+ }
1972
+
1844
1973
  async function sendSessionText(text) {
1974
+ return sendSessionContent(text, [])
1975
+ }
1976
+
1977
+ async function sendSessionContent(text, images) {
1845
1978
  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
1979
+ if ((!clean && !images.length) || !state.current) return false
1980
+ if (images.length === 0 && clean && await runSlashCommand(clean)) return true
1981
+ const buttons = [$('btn-send'), $('btn-fs-send')].filter(Boolean)
1982
+ buttons.forEach(button => { button.disabled = true })
1983
+ try {
1984
+ const content = [...await encodeComposerImagesFor(images)]
1985
+ if (clean) content.push({ type: 'text', text: clean })
1986
+ const v = await safeRpc('session.prompt', {
1987
+ sessionId: state.current,
1988
+ mode: 'queue',
1989
+ content
1990
+ }, t('send.failed'))
1991
+ if (v?.accepted) { toast(images.length ? t('send.imageSent') : (clean.startsWith('/') ? t('send.commandSent') : t('send.sent')), 'ok'); return true }
1992
+ if (v?.command?.text) { toast(t('send.commandExecuted'), 'ok'); return true }
1993
+ return false
1994
+ } catch (e) {
1995
+ toast(t('composer.imageReadFailed', { msg: e?.message || e }), 'err')
1996
+ return false
1997
+ } finally {
1998
+ buttons.forEach(button => { button.disabled = false })
1999
+ }
2000
+ }
2001
+
2002
+ async function encodeComposerImagesFor(images) {
2003
+ return Promise.all(images.map(async item => ({
2004
+ type: 'image', mediaType: item.file.type, data: bytesToBase64(new Uint8Array(await item.file.arrayBuffer())),
2005
+ ...(item.file.name ? { name: item.file.name } : {})
2006
+ })))
1858
2007
  }
1859
2008
 
1860
2009
  async function sendMessage() {
1861
2010
  const input = $('composer-input')
1862
2011
  const text = input.value.trim()
1863
- if (!text || !state.current) return
1864
- if (await sendSessionText(text)) { input.value = ''; autosize(input) }
2012
+ const images = state.composerImages.slice()
2013
+ if ((!text && !images.length) || !state.current) return
2014
+ if (images.length && text.startsWith('/')) { toast(t('composer.imageSlashUnsupported'), 'err'); return }
2015
+ if (await sendSessionContent(text, images)) {
2016
+ input.value = ''
2017
+ autosize(input)
2018
+ clearComposerImages()
2019
+ }
1865
2020
  }
1866
2021
 
1867
2022
  function hideComposerMenu() {
@@ -2031,13 +2186,13 @@ async function confirmArchiveSession() {
2031
2186
  let swipeTracking = null
2032
2187
  let swipeSuppressClickUntil = 0
2033
2188
  function closeRevealedSwipes(except = null) {
2034
- document.querySelectorAll('#session-list .session-swipe.revealed').forEach(row => {
2189
+ document.querySelectorAll('.session-swipe.revealed').forEach(row => {
2035
2190
  if (row !== except) row.classList.remove('revealed')
2036
2191
  })
2037
2192
  }
2038
2193
  function bindSessionSwipe() {
2039
- const list = $('session-list')
2040
- list.addEventListener('touchstart', e => {
2194
+ const containers = [$('session-list'), $('wb-panel')].filter(Boolean)
2195
+ for (const list of containers) list.addEventListener('touchstart', e => {
2041
2196
  if (e.touches.length !== 1) return
2042
2197
  const row = e.target.closest('[data-session-swipe]')
2043
2198
  if (!row) return
@@ -2051,7 +2206,7 @@ function bindSessionSwipe() {
2051
2206
  axis: null
2052
2207
  }
2053
2208
  }, { passive: true })
2054
- list.addEventListener('touchmove', e => {
2209
+ for (const list of containers) list.addEventListener('touchmove', e => {
2055
2210
  if (!swipeTracking || e.touches.length !== 1) return
2056
2211
  const touch = e.touches[0]
2057
2212
  const dx = touch.clientX - swipeTracking.startX
@@ -2064,7 +2219,7 @@ function bindSessionSwipe() {
2064
2219
  const offset = Math.max(-92, Math.min(0, swipeTracking.offset + dx))
2065
2220
  swipeTracking.row.style.setProperty('--swipe-x', offset + 'px')
2066
2221
  }, { passive: false })
2067
- list.addEventListener('touchend', () => {
2222
+ for (const list of containers) list.addEventListener('touchend', () => {
2068
2223
  if (!swipeTracking) return
2069
2224
  if (swipeTracking.axis === 'x') {
2070
2225
  const row = swipeTracking.row
@@ -2075,10 +2230,99 @@ function bindSessionSwipe() {
2075
2230
  }
2076
2231
  swipeTracking = null
2077
2232
  }, { passive: true })
2078
- list.addEventListener('touchcancel', () => { swipeTracking = null }, { passive: true })
2233
+ for (const list of containers) list.addEventListener('touchcancel', () => { swipeTracking = null }, { passive: true })
2234
+ }
2235
+
2236
+ /* ---------------- 系统总览 / 待办 ---------------- */
2237
+ function renderOverview() {
2238
+ const ring = $('overview-pulse-ring')
2239
+ if (!ring) return
2240
+ const checks = {
2241
+ gateway: !!state.token && !!state.server,
2242
+ dsh: !!state.hostInfo,
2243
+ mux: !!state.streamsOk?.mux,
2244
+ host: !!state.streamsOk?.host
2245
+ }
2246
+ const online = Object.values(checks).filter(Boolean).length
2247
+ const status = online === 4 ? 'nominal' : online > 0 ? 'degraded' : 'offline'
2248
+ const pulseCard = document.querySelector('.overview-pulse-card')
2249
+ if (pulseCard) {
2250
+ pulseCard.classList.remove('status-nominal', 'status-degraded', 'status-offline')
2251
+ pulseCard.classList.add('status-' + status)
2252
+ }
2253
+ ring.style.setProperty('--pulse-pct', `${online / 4 * 100}%`)
2254
+ $('overview-health').textContent = online === 4 ? t('overview.live') : online ? `${online}/4` : t('overview.offlineCore')
2255
+ $('overview-health-caption').textContent = online === 4 ? t('overview.allLinked') : online ? t('overview.components', { n: online }) : t('overview.offlineShort')
2256
+ $('overview-status').textContent = t(`overview.${status}`)
2257
+ $('overview-status-desc').textContent = t('overview.components', { n: online })
2258
+ for (const [name, ok] of Object.entries(checks)) {
2259
+ const item = document.querySelector(`[data-overview-link="${name}"]`)
2260
+ if (!item) continue
2261
+ item.classList.toggle('ok', ok)
2262
+ item.classList.toggle('off', !ok)
2263
+ const value = item.querySelector('b')
2264
+ if (value) value.textContent = ok ? t('overview.online') : t('overview.offlineShort')
2265
+ }
2266
+
2267
+ const pending = [
2268
+ ...state.approvals.map(a => ({ kind: 'approval', item: a })),
2269
+ ...state.questions.map(q => ({ kind: 'question', item: q }))
2270
+ ]
2271
+ $('overview-attention-count').textContent = pending.length ? t('overview.pendingCount', { n: pending.length }) : '—'
2272
+ $('overview-attention-list').innerHTML = pending.length ? pending.slice(0, 3).map(({ kind, item }) => {
2273
+ const title = titleOf(state.byId.get(item.sessionId))
2274
+ if (kind === 'approval') return `<div class="overview-attention-item" data-overview-approval="${esc(item.approvalId)}">
2275
+ <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>
2276
+ <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>
2277
+ </div>`
2278
+ return `<button type="button" class="overview-attention-item question" data-overview-question="${esc(item.rpcId)}">
2279
+ <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>
2280
+ </button>`
2281
+ }).join('') : `<div class="overview-empty">${t('pending.empty')}</div>`
2282
+ $('overview-attention-list').querySelectorAll('[data-overview-approve]').forEach(btn => {
2283
+ btn.addEventListener('click', () => approveApproval(btn.closest('[data-overview-approval]')?.dataset.overviewApproval || '', btn.dataset.overviewApprove === '1'))
2284
+ })
2285
+ $('overview-attention-list').querySelectorAll('[data-overview-question]').forEach(btn => {
2286
+ btn.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === btn.dataset.overviewQuestion)))
2287
+ })
2288
+
2289
+ const running = state.sessions.filter(s => s.running).length
2290
+ 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)
2291
+ const primary = $('overview-primary-action')
2292
+ if (primary) {
2293
+ let action = 'new'
2294
+ let label = t('overview.action.newSession')
2295
+ let sessionId = ''
2296
+ if (!state.token) {
2297
+ action = 'settings'
2298
+ label = t('overview.action.connect')
2299
+ } else if (online > 0 && online < 4) {
2300
+ action = 'refresh'
2301
+ label = t('overview.action.refresh')
2302
+ } else if (pending.length) {
2303
+ action = 'attention'
2304
+ label = t('overview.action.attention')
2305
+ } else if (sessions.length) {
2306
+ action = 'session'
2307
+ sessionId = sessions[0].sessionId
2308
+ label = t('overview.action.openSession')
2309
+ }
2310
+ primary.textContent = label
2311
+ primary.dataset.overviewAction = action
2312
+ primary.dataset.overviewSession = sessionId
2313
+ primary.disabled = status === 'offline' && action === 'refresh'
2314
+ }
2315
+ $('overview-dsh-version').textContent = state.hostInfo?.version || '—'
2316
+ $('overview-gateway-version').textContent = checks.gateway ? t('overview.online') : t('overview.offlineShort')
2317
+ $('overview-active-sessions').textContent = String(running)
2318
+ $('overview-connection-mode').textContent = state.token ? t(state.streamMode === 'poll' ? 'overview.poll' : 'overview.ws') : '—'
2319
+ $('overview-active-count').textContent = running ? t('overview.activeCount', { n: running }) : ''
2320
+ $('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)}">
2321
+ <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>
2322
+ </button>`).join('') : `<div class="overview-empty">${t('overview.noSession')}</div>`
2323
+ $('overview-session-list').querySelectorAll('[data-overview-session]').forEach(btn => btn.addEventListener('click', () => openSession(btn.dataset.overviewSession)))
2079
2324
  }
2080
2325
 
2081
- /* ---------------- 待办 ---------------- */
2082
2326
  function renderPending() {
2083
2327
  const list = $('pending-list')
2084
2328
  const items = [
@@ -2113,6 +2357,7 @@ function renderPending() {
2113
2357
  list.querySelectorAll('[data-question]').forEach(btn =>
2114
2358
  btn.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === btn.dataset.question))))
2115
2359
  updatePendingBadge()
2360
+ renderOverview()
2116
2361
  }
2117
2362
 
2118
2363
  async function approveApproval(id, allow) {
@@ -2306,7 +2551,7 @@ function renderFs(data) {
2306
2551
  list.innerHTML = data.entries.map(e => {
2307
2552
  const isDir = e.type === 'dir'
2308
2553
  return `<div class="fs-row" data-name="${esc(e.name)}" data-type="${esc(e.type)}">
2309
- <span class="fs-ico">${isDir ? '📁' : '📄'}</span>
2554
+ <span class="fs-ico">${fsIconSvg(isDir)}</span>
2310
2555
  <span class="fs-meta">
2311
2556
  <span class="fs-name">${esc(e.name)}</span>
2312
2557
  <span class="fs-sub">${isDir ? t('fs.dir') : fmtSize(e.size)} · ${fmtFullTime(e.mtimeMs)}</span>
@@ -2318,6 +2563,12 @@ function renderFs(data) {
2318
2563
  row.addEventListener('click', () => fsOpenEntry(row.dataset.name, row.dataset.type)))
2319
2564
  }
2320
2565
 
2566
+ function fsIconSvg(isDir) {
2567
+ return isDir
2568
+ ? '<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>'
2569
+ : '<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>'
2570
+ }
2571
+
2321
2572
  function fsOpenEntry(name, type) {
2322
2573
  if (!name) return
2323
2574
  const p = fsJoin(state.fs.path, name)
@@ -2744,6 +2995,7 @@ async function loadLocalVersion() {
2744
2995
  * 公告只读取文本并用 textContent/转义后的换行渲染,不执行服务端下发的 HTML/脚本。
2745
2996
  */
2746
2997
  const ANNOUNCEMENTS_KEY = 'seenAnnouncementsV1'
2998
+ const ANNOUNCEMENT_HISTORY_KEY = 'announcementHistoryV1'
2747
2999
  function readSeenAnnouncements() {
2748
3000
  try {
2749
3001
  const value = JSON.parse(LS.get(ANNOUNCEMENTS_KEY, '{}'))
@@ -2761,6 +3013,40 @@ function markAnnouncementSeen(id) {
2761
3013
  }
2762
3014
  LS.set(ANNOUNCEMENTS_KEY, JSON.stringify(seen))
2763
3015
  }
3016
+ function readAnnouncementHistory() {
3017
+ try {
3018
+ const value = JSON.parse(LS.get(ANNOUNCEMENT_HISTORY_KEY, '[]'))
3019
+ return Array.isArray(value) ? value.filter(item => item && typeof item.id === 'string') : []
3020
+ } catch { return [] }
3021
+ }
3022
+ function storeAnnouncementHistory(items) {
3023
+ const merged = new Map(readAnnouncementHistory().map(item => [item.id, item]))
3024
+ for (const item of items) if (item?.id) merged.set(item.id, item)
3025
+ const list = [...merged.values()].sort((a, b) => Number(b.publishedAt || 0) - Number(a.publishedAt || 0)).slice(0, 50)
3026
+ LS.set(ANNOUNCEMENT_HISTORY_KEY, JSON.stringify(list))
3027
+ return list
3028
+ }
3029
+ function renderAnnouncementHistory() {
3030
+ const box = $('announcement-history-list')
3031
+ if (!box) return
3032
+ const list = readAnnouncementHistory()
3033
+ if (!list.length) {
3034
+ box.innerHTML = `<div class="empty">${esc(t('announcement.historyEmpty'))}</div>`
3035
+ return
3036
+ }
3037
+ box.innerHTML = list.map(item => {
3038
+ const date = Number(item.publishedAt) > 0 ? fmtFullTime(item.publishedAt) : t('announcement.noDate')
3039
+ const action = item.actionUrl ? `<a class="announcement-action" href="${esc(item.actionUrl)}" target="_blank" rel="noopener">${esc(item.actionText || t('announcement.open'))}</a>` : ''
3040
+ 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>`
3041
+ }).join('')
3042
+ }
3043
+ function openAnnouncementHistory() {
3044
+ renderAnnouncementHistory()
3045
+ $('modal-announcement-history')?.classList.remove('hidden')
3046
+ }
3047
+ function closeAnnouncementHistory() {
3048
+ $('modal-announcement-history')?.classList.add('hidden')
3049
+ }
2764
3050
  function announcementVersionMatch(item) {
2765
3051
  const min = String(item.minVersion || item.minAppVersion || '').trim()
2766
3052
  const max = String(item.maxVersion || item.maxAppVersion || '').trim()
@@ -2829,8 +3115,10 @@ async function checkAnnouncements() {
2829
3115
  if (raw.length > 512 * 1024) return false
2830
3116
  const data = JSON.parse(raw)
2831
3117
  const source = Array.isArray(data) ? data : (Array.isArray(data?.items) ? data.items : [data])
3118
+ const normalized = source.map(item => normalizeAnnouncement(item, base)).filter(Boolean)
3119
+ storeAnnouncementHistory(normalized)
2832
3120
  const seen = readSeenAnnouncements()
2833
- const items = source.map(item => normalizeAnnouncement(item, base)).filter(item => item && !seen[item.id])
3121
+ const items = normalized.filter(item => !seen[item.id])
2834
3122
  .sort((a, b) => b.publishedAt - a.publishedAt)
2835
3123
  if (!items.length) return false
2836
3124
  openAnnouncementModal(items[0])
@@ -3294,8 +3582,84 @@ function updateConn() {
3294
3582
  }
3295
3583
 
3296
3584
  function autosize(el) {
3585
+ // 全屏编辑时 textarea 由 flex 容器提供整块高度;普通的 120px 限高
3586
+ // 不能覆盖这里,否则输入超过约五行后会被重新压回小输入框。
3587
+ if (el?.id === 'composer-input' && $('composer-wrap')?.classList.contains('fs')) {
3588
+ el.style.height = '100%'
3589
+ updateComposerFullscreenButton()
3590
+ return
3591
+ }
3297
3592
  el.style.height = 'auto'
3298
3593
  el.style.height = Math.min(el.scrollHeight, 120) + 'px'
3594
+ if (el.id === 'composer-input') updateComposerFullscreenButton()
3595
+ }
3596
+
3597
+ function updateComposerFullscreenButton() {
3598
+ const input = $('composer-input')
3599
+ const wrap = $('composer-wrap')
3600
+ const button = $('btn-fs-toggle')
3601
+ if (!input || !wrap || !button) return
3602
+ const active = wrap.classList.contains('fs')
3603
+ const shouldShow = active || input.scrollHeight > 120
3604
+ button.classList.toggle('hidden', !shouldShow)
3605
+ $('composer-input-wrap')?.classList.toggle('has-fs-btn', shouldShow)
3606
+ $('fs-ico-expand')?.classList.toggle('hidden', active)
3607
+ $('fs-ico-collapse')?.classList.toggle('hidden', !active)
3608
+ button.title = t(active ? 'composer.exitFullscreen' : 'composer.fullscreen')
3609
+ button.setAttribute('aria-label', t(active ? 'composer.exitFullscreen' : 'composer.fullscreen'))
3610
+ }
3611
+
3612
+ function setComposerFullscreen(on) {
3613
+ const wrap = $('composer-wrap')
3614
+ if (!wrap) return
3615
+ $('btn-stats')?.classList.toggle('hidden', !!on)
3616
+ $('btn-fs-send')?.classList.toggle('hidden', !on)
3617
+ if (on) {
3618
+ $('composer-image-menu')?.classList.add('hidden')
3619
+ $('btn-image')?.classList.remove('active')
3620
+ }
3621
+ wrap.classList.toggle('fs', !!on)
3622
+ document.body.classList.toggle('composer-fullscreen', !!on)
3623
+ if (on) {
3624
+ $('composer-input')?.style.removeProperty('height')
3625
+ } else {
3626
+ wrap.style.transform = ''
3627
+ wrap.classList.remove('dragging')
3628
+ autosize($('composer-input'))
3629
+ }
3630
+ updateComposerFullscreenButton()
3631
+ }
3632
+
3633
+ function bindComposerFullscreenGesture() {
3634
+ const handle = $('composer-fs-handle')
3635
+ if (!handle) return
3636
+ let startY = 0
3637
+ let tracking = false
3638
+ handle.addEventListener('touchstart', e => {
3639
+ if (!$('composer-wrap').classList.contains('fs') || e.touches.length !== 1) return
3640
+ tracking = true
3641
+ startY = e.touches[0].clientY
3642
+ }, { passive: true })
3643
+ handle.addEventListener('touchmove', e => {
3644
+ if (!tracking || e.touches.length !== 1) return
3645
+ const dy = e.touches[0].clientY - startY
3646
+ if (dy <= 0) return
3647
+ e.preventDefault()
3648
+ $('composer-wrap').classList.add('dragging')
3649
+ $('composer-wrap').style.transform = `translateY(${Math.min(dy, 180)}px)`
3650
+ }, { passive: false })
3651
+ const finish = () => {
3652
+ if (!tracking) return
3653
+ const wrap = $('composer-wrap')
3654
+ const transform = wrap.style.transform.match(/translateY\(([-\d.]+)px\)/)
3655
+ const dy = transform ? Number(transform[1]) : 0
3656
+ tracking = false
3657
+ wrap.classList.remove('dragging')
3658
+ if (dy > 60) setComposerFullscreen(false)
3659
+ else wrap.style.transform = ''
3660
+ }
3661
+ handle.addEventListener('touchend', finish, { passive: true })
3662
+ handle.addEventListener('touchcancel', finish, { passive: true })
3299
3663
  }
3300
3664
 
3301
3665
  /* ---------------- 初始化 ---------------- */
@@ -3547,6 +3911,19 @@ function bindUi() {
3547
3911
  // 底部导航
3548
3912
  document.querySelectorAll('.nav-btn').forEach(b =>
3549
3913
  b.addEventListener('click', () => showView(b.dataset.view)))
3914
+ $('overview-primary-action').addEventListener('click', () => {
3915
+ const button = $('overview-primary-action')
3916
+ const action = button.dataset.overviewAction
3917
+ if (action === 'session' && button.dataset.overviewSession) return openSession(button.dataset.overviewSession)
3918
+ if (action === 'new') return newSession()
3919
+ if (action === 'settings') return showView('view-settings')
3920
+ if (action === 'refresh') return openStreams()
3921
+ const first = document.querySelector('.overview-attention-item')
3922
+ if (first) {
3923
+ first.scrollIntoView({ behavior: 'smooth', block: 'center' })
3924
+ if (first.matches('button')) first.focus({ preventScroll: true })
3925
+ }
3926
+ })
3550
3927
  // 会话列表点击
3551
3928
  $('session-list').addEventListener('click', (e) => {
3552
3929
  if (swipeSuppressClickUntil > Date.now()) { swipeSuppressClickUntil = 0; return }
@@ -3576,6 +3953,12 @@ function bindUi() {
3576
3953
  renderWorkbench()
3577
3954
  })
3578
3955
  $('wb-panel').addEventListener('click', (e) => {
3956
+ const archive = e.target.closest('[data-archive-session]')
3957
+ if (archive) {
3958
+ e.stopPropagation()
3959
+ archiveSession(archive.dataset.archiveSession)
3960
+ return
3961
+ }
3579
3962
  const newButton = e.target.closest('[data-wb-new]')
3580
3963
  if (newButton) {
3581
3964
  safeRpc('session.create', { workspaceId: newButton.dataset.wbNew }, t('home.createFailed')).then(async v => {
@@ -3634,7 +4017,28 @@ function bindUi() {
3634
4017
  })
3635
4018
  $('btn-cancel').addEventListener('click', cancelSession)
3636
4019
  $('btn-send').addEventListener('click', sendMessage)
4020
+ $('btn-fs-send').addEventListener('click', sendMessage)
3637
4021
  $('btn-plus').addEventListener('click', toggleComposerMenu)
4022
+ $('btn-image').addEventListener('click', toggleComposerImageMenu)
4023
+ $('composer-image-menu').addEventListener('click', (e) => {
4024
+ const option = e.target.closest('[data-image-source]')
4025
+ if (!option) return
4026
+ $('composer-image-menu').classList.add('hidden')
4027
+ $('btn-image').classList.remove('active')
4028
+ captureComposerImage(option.dataset.imageSource)
4029
+ })
4030
+ $('composer-attachments').addEventListener('click', (e) => {
4031
+ const button = e.target.closest('[data-remove-image]')
4032
+ if (button) removeComposerImage(button.dataset.removeImage)
4033
+ })
4034
+ $('composer-camera-input').addEventListener('change', (e) => { addComposerImages(e.target.files); e.target.value = '' })
4035
+ $('composer-gallery-input').addEventListener('change', (e) => { addComposerImages(e.target.files); e.target.value = '' })
4036
+ document.addEventListener('click', (e) => {
4037
+ if (!e.target.closest('#composer-image-menu, #btn-image')) {
4038
+ $('composer-image-menu')?.classList.add('hidden')
4039
+ $('btn-image')?.classList.remove('active')
4040
+ }
4041
+ })
3638
4042
  $('composer-menu').addEventListener('click', async (e) => {
3639
4043
  const chip = e.target.closest('[data-cmd]')
3640
4044
  if (chip) {
@@ -3675,6 +4079,9 @@ function bindUi() {
3675
4079
  $('btn-model-refresh').addEventListener('click', loadSessionModels)
3676
4080
  const input = $('composer-input')
3677
4081
  input.addEventListener('input', () => autosize(input))
4082
+ $('btn-fs-toggle').addEventListener('click', () => setComposerFullscreen(!$('composer-wrap').classList.contains('fs')))
4083
+ bindComposerFullscreenGesture()
4084
+ updateComposerFullscreenButton()
3678
4085
  input.addEventListener('keydown', (e) => {
3679
4086
  if (e.key !== 'Enter' || e.isComposing) return
3680
4087
  if (isMobileDevice() && mobileEnterAction() !== 'send') return
@@ -3708,6 +4115,10 @@ function bindUi() {
3708
4115
  $('modal-announcement').addEventListener('click', (e) => {
3709
4116
  if (e.target === $('modal-announcement') && !state.announcement?.force) closeAnnouncement(false)
3710
4117
  })
4118
+ $('announcement-history-close').addEventListener('click', closeAnnouncementHistory)
4119
+ $('modal-announcement-history').addEventListener('click', (e) => {
4120
+ if (e.target === $('modal-announcement-history')) closeAnnouncementHistory()
4121
+ })
3711
4122
  // 设置
3712
4123
  $('view-settings').addEventListener('click', (e) => {
3713
4124
  const group = e.target.closest('[data-settings-group]')
@@ -3744,7 +4155,7 @@ function bindUi() {
3744
4155
  $('btn-update-expand').addEventListener('click', toggleUpdateExpand)
3745
4156
  $('btn-reset').addEventListener('click', () => {
3746
4157
  if (!confirm(t('settings.confirmReset'))) return
3747
- LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction')
4158
+ LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction'); LS.del(ANNOUNCEMENTS_KEY); LS.del(ANNOUNCEMENT_HISTORY_KEY)
3748
4159
  if (bgBridge()?.saveBackgroundConfig) saveBgConfig(false)
3749
4160
  location.reload()
3750
4161
  })
@@ -3788,6 +4199,7 @@ function bindUi() {
3788
4199
  LS.set('peakRemind', e.target.checked ? '1' : '0')
3789
4200
  })
3790
4201
  $('btn-test-notify').addEventListener('click', sendTestNotification)
4202
+ $('btn-announcement-history').addEventListener('click', openAnnouncementHistory)
3791
4203
  // 已开启则启动时重新调度, 防止系统清理后丢失
3792
4204
  if (peakRemindOn() && CAP?.isNativePlatform?.()) schedulePeakReminders()
3793
4205
  applyBgConfigFromNative()
@@ -3883,10 +4295,11 @@ async function boot() {
3883
4295
  bindNativeBack()
3884
4296
  bindNativeLinks()
3885
4297
  applyNativeInsets()
4298
+ showView('view-activity')
3886
4299
  updateConn()
3887
4300
  await loadLocalVersion()
3888
4301
  if (!state.token) {
3889
- showView('view-settings')
4302
+ showView('view-activity')
3890
4303
  $('token-desc').textContent = t('token.notSetHint')
3891
4304
  } else {
3892
4305
  // 多服务器: 启动时静默测速一次, 选最快的连接(同源页面也参与比较)