dsh-remote-plugin 0.6.6 → 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
@@ -52,6 +52,7 @@ const state = {
52
52
  hostInfo: null,
53
53
  localVersion: '',
54
54
  updateInfo: null,
55
+ announcement: null,
55
56
  approvals: [], // 待处理审批
56
57
  questions: [], // 待处理提问
57
58
  queues: {}, // sessionId -> queue items
@@ -62,7 +63,13 @@ const state = {
62
63
  pollSeq: { mux: 0, host: 0 },
63
64
  refreshTimer: null,
64
65
  fs: { path: null, initial: null, loaded: false, upload: null },
65
- models: { loaded: false, loading: false, groups: [], current: null, failures: [] }
66
+ composerImages: [], // 当前草稿中的图片附件:只在发送成功后释放
67
+ models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
68
+ wb: null,
69
+ wbProjects: [],
70
+ wbArchived: [],
71
+ wbOpen: false,
72
+ wbOpenProjects: {}
66
73
  }
67
74
 
68
75
  const $ = (id) => document.getElementById(id)
@@ -1090,6 +1097,7 @@ async function refreshSessions() {
1090
1097
  state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
1091
1098
  cacheWrite(CACHE.sessions, state.sessions.slice(0, 80))
1092
1099
  renderSessions()
1100
+ refreshWorkbench()
1093
1101
  }
1094
1102
 
1095
1103
  function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
@@ -1123,6 +1131,123 @@ function updatePendingBadge() {
1123
1131
  if (pending) $('nav-pending').textContent = pending
1124
1132
  }
1125
1133
 
1134
+ /* ---------------- 工作台与归档会话 ---------------- */
1135
+ function wbPathKey(p) {
1136
+ let value = String(p || '').replace(/\\/g, '/').replace(/\/+/g, '/')
1137
+ if (value.length > 1) value = value.replace(/\/+$/, '')
1138
+ const windows = /^[A-Za-z]:\//.test(value) || /Windows/i.test(navigator.platform || navigator.userAgent || '')
1139
+ return windows ? value.toLowerCase() : value
1140
+ }
1141
+ function wbBaseName(p) {
1142
+ const value = String(p || '').replace(/[\\/]+$/, '')
1143
+ return value.split(/[\\/]/).pop() || value
1144
+ }
1145
+ function wbStrictInside(pathValue, rootValue) {
1146
+ const pathKey = wbPathKey(pathValue)
1147
+ const rootKey = wbPathKey(rootValue)
1148
+ if (!pathKey || !rootKey || pathKey === rootKey) return false
1149
+ return pathKey.startsWith(rootKey.endsWith('/') ? rootKey : rootKey + '/')
1150
+ }
1151
+ function wbJoin(root, name) {
1152
+ const raw = String(root || '')
1153
+ const separator = raw.includes('\\') ? '\\' : '/'
1154
+ return raw.replace(/[\\/]+$/, '') + separator + String(name || '')
1155
+ }
1156
+ function workbenchRoot() {
1157
+ return state.wb?.bound && state.wb.path ? state.wb.path : ''
1158
+ }
1159
+ async function refreshWorkbench() {
1160
+ if (!state.token) return
1161
+ try {
1162
+ const res = await fetch(apiUrl('/workbench'), {
1163
+ headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
1164
+ })
1165
+ if (res.ok) {
1166
+ const value = await res.json().catch(() => null)
1167
+ if (value && typeof value.bound === 'boolean') state.wb = value
1168
+ }
1169
+ } catch {}
1170
+ try {
1171
+ const value = await rpc('workspace.list', {})
1172
+ state.wbProjects = Array.isArray(value?.items) ? value.items : []
1173
+ state.wbArchived = Array.isArray(value?.archivedSessionIds) ? value.archivedSessionIds : []
1174
+ } catch {
1175
+ state.wbProjects = []
1176
+ state.wbArchived = []
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
+ }
1201
+ renderWorkbench()
1202
+ renderSessions()
1203
+ }
1204
+ function renderWorkbench() {
1205
+ const bar = $('workbench-bar')
1206
+ if (!bar) return
1207
+ const bound = !!state.wb?.bound && !!state.wb.path
1208
+ const toggle = $('wb-toggle')
1209
+ const panel = $('wb-panel')
1210
+ bar.classList.toggle('bound', bound)
1211
+ bar.classList.toggle('unbound', !bound)
1212
+ if (!bound) {
1213
+ $('wb-label').textContent = t('wb.unbound')
1214
+ toggle.setAttribute('aria-expanded', 'false')
1215
+ panel.classList.add('hidden')
1216
+ panel.innerHTML = ''
1217
+ return
1218
+ }
1219
+ $('wb-label').textContent = t('wb.bound', { title: state.wb.title || wbBaseName(state.wb.path) })
1220
+ toggle.setAttribute('aria-expanded', state.wbOpen ? 'true' : 'false')
1221
+ panel.classList.toggle('hidden', !state.wbOpen)
1222
+ if (!state.wbOpen) { panel.innerHTML = ''; return }
1223
+ const projects = state.wbProjects.filter(w => wbStrictInside(w.path, workbenchRoot()))
1224
+ if (!projects.length) {
1225
+ panel.innerHTML = `<div class="wb-empty">${esc(t('wb.noProjects'))}</div>`
1226
+ return
1227
+ }
1228
+ const archivedSet = new Set(state.wbArchived || [])
1229
+ panel.innerHTML = projects.map(w => {
1230
+ const id = String(w.workspaceId || '')
1231
+ const open = !!state.wbOpenProjects[id]
1232
+ const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(Boolean).filter(s => !archivedSet.has(s.sessionId))
1233
+ const body = open ? `<div class="wb-sessions">${sessions.length ? sessions.map(s => `
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>` : ''
1241
+ return `<div class="wb-project ${open ? 'open' : ''}" data-wb-project="${esc(id)}">
1242
+ <div class="wb-project-head">
1243
+ <span class="wb-chevron" aria-hidden="true">${open ? '▾' : '▸'}</span>
1244
+ <span class="wb-project-title">${esc(w.title || wbBaseName(w.path) || w.path)}</span>
1245
+ <button class="mini-btn wb-new" type="button" data-wb-new="${esc(id)}">${esc(t('wb.newSession'))}</button>
1246
+ </div>${body}
1247
+ </div>`
1248
+ }).join('')
1249
+ }
1250
+
1126
1251
  function sessionCwd(s) { return typeof s?.cwd === 'string' ? s.cwd.trim() : '' }
1127
1252
  function sessionWorkspaceLabel(s) {
1128
1253
  const cwd = sessionCwd(s)
@@ -1149,48 +1274,70 @@ function sortedSessions() {
1149
1274
  }
1150
1275
  function renderSessions() {
1151
1276
  const list = $('session-list')
1152
- const items = sortedSessions()
1153
- let lastWorkspace = null
1154
- const rows = []
1155
- for (const s of items) {
1156
- const workspace = sessionWorkspaceLabel(s)
1157
- const workspaceName = workspaceDisplayName(workspace)
1158
- if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
1159
- rows.push(`<div class="session-group-label" title="${esc(workspace)}"><span class="session-group-icon" aria-hidden="true">⌂</span><span class="session-group-name">${esc(workspaceName)}</span></div>`)
1160
- lastWorkspace = workspace
1277
+ const allItems = sortedSessions()
1278
+ const wbIds = new Set()
1279
+ if (state.wb?.bound) for (const w of state.wbProjects) for (const id of (w.sessionIds || [])) wbIds.add(id)
1280
+ const root = workbenchRoot()
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
+ })
1287
+ const archived = visible.filter(s => archivedSet.has(s.sessionId))
1288
+ const main = visible.filter(s => !archivedSet.has(s.sessionId))
1289
+ const showArchived = LS.get('showArchivedV1', '0') === '1'
1290
+ const renderItems = (items) => {
1291
+ let lastWorkspace = null
1292
+ const rows = []
1293
+ for (const s of items) {
1294
+ const workspace = sessionWorkspaceLabel(s)
1295
+ const workspaceName = workspaceDisplayName(workspace)
1296
+ if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
1297
+ rows.push(`<div class="session-group-label" title="${esc(workspace)}"><span class="session-group-icon" aria-hidden="true">⌂</span><span class="session-group-name">${esc(workspaceName)}</span></div>`)
1298
+ lastWorkspace = workspace
1299
+ }
1300
+ const title = titleOf(s)
1301
+ const goal = goalOf(s)
1302
+ const pending = (state.approvals.some(a => a.sessionId === s.sessionId) || state.questions.some(q => q.sessionId === s.sessionId)) ? 'pending' : ''
1303
+ const queueN = (state.queues[s.sessionId] || []).filter(i => i.placement === 'queued').length
1304
+ const dots = []
1305
+ if (s.running) dots.push('running')
1306
+ if (pending) dots.push('pending')
1307
+ const badge = goal ? `<span class="sc-badge ${goal.phase === 'active' ? 'goal-active' : ''}">${esc(t('sessions.goalBadge', { phase: goal.phase || '?' }))}</span>` : ''
1308
+ const queueBadge = queueN ? `<span class="sc-badge">${esc(t('sessions.queueBadge', { n: queueN }))}</span>` : ''
1309
+ const archiveButton = archivedSet.has(s.sessionId) ? '' : `<button type="button" class="sc-archive-btn" data-archive-session="${esc(s.sessionId)}">${esc(t('session.archive'))}</button>`
1310
+ rows.push(`<div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
1311
+ <div class="session-card ${state.current === s.sessionId ? 'current' : ''}">
1312
+ <div class="sc-title">${esc(title)}</div>
1313
+ <div class="sc-meta">
1314
+ <span class="sc-dot ${dots.join(' ')}"></span>
1315
+ <span>${fmtTime(s.updatedAt)}</span>
1316
+ ${s.running ? '<span>' + t('sessions.running') + '</span>' : ''}
1317
+ ${badge}${queueBadge}
1318
+ </div>
1319
+ <div class="sc-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</div>
1320
+ <span class="sc-arrow">›</span>
1321
+ </div>
1322
+ ${archiveButton}
1323
+ </div>`)
1161
1324
  }
1162
- const title = titleOf(s)
1163
- const goal = goalOf(s)
1164
- const pending = (state.approvals.some(a => a.sessionId === s.sessionId) || state.questions.some(q => q.sessionId === s.sessionId)) ? 'pending' : ''
1165
- const queueN = (state.queues[s.sessionId] || []).filter(i => i.placement === 'queued').length
1166
- const dots = []
1167
- if (s.running) dots.push('running')
1168
- if (pending) dots.push('pending')
1169
- const badge = goal ? `<span class="sc-badge ${goal.phase === 'active' ? 'goal-active' : ''}">${esc(t('sessions.goalBadge', { phase: goal.phase || '?' }))}</span>` : ''
1170
- const queueBadge = queueN ? `<span class="sc-badge">${esc(t('sessions.queueBadge', { n: queueN }))}</span>` : ''
1171
- rows.push(`<div class="session-card ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
1172
- <div class="sc-title">${esc(title)}</div>
1173
- <div class="sc-meta">
1174
- <span class="sc-dot ${dots.join(' ')}"></span>
1175
- <span>${fmtTime(s.updatedAt)}</span>
1176
- ${s.running ? '<span>' + t('sessions.running') + '</span>' : ''}
1177
- ${badge}${queueBadge}
1178
- </div>
1179
- <div class="sc-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</div>
1180
- <span class="sc-arrow">›</span>
1181
- </div>`)
1325
+ return rows.join('')
1182
1326
  }
1183
- list.innerHTML = rows.join('')
1327
+ const divider = archived.length ? `<button class="archived-toggle" type="button" data-archived-toggle>${esc(showArchived ? t('wb.archivedShown') : t('wb.archivedHidden'))}</button>` : ''
1328
+ const rows = renderItems(main) + divider + (showArchived ? renderItems(archived) : '')
1329
+ const hiddenByWorkbench = allItems.length - visible.length
1330
+ list.innerHTML = rows || `<div class="empty">${esc(hiddenByWorkbench ? t('wb.flatHidden', { n: hiddenByWorkbench }) : t('home.empty'))}</div>`
1184
1331
  list.classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1185
1332
  const sort = $('session-sort')
1186
1333
  if (sort) sort.value = state.sessionSort
1187
- $('home-empty').classList.toggle('hidden', items.length > 0)
1334
+ $('home-empty').classList.toggle('hidden', visible.length > 0)
1188
1335
  const running = state.sessions.filter(s => s.running).length
1189
1336
  const pending = state.approvals.length + state.questions.length
1190
1337
  $('stat-strip').innerHTML = `
1191
1338
  <div class="stat running"><div class="v">${running}</div><div class="k">${t('sessions.statRunning')}</div></div>
1192
1339
  <div class="stat pending"><div class="v">${pending}</div><div class="k">${t('sessions.statPending')}</div></div>
1193
- <div class="stat ctx"><div class="v">${items.length}</div><div class="k">${t('sessions.statTotal')}</div></div>`
1340
+ <div class="stat ctx"><div class="v">${visible.length}</div><div class="k">${t('sessions.statTotal')}</div></div>`
1194
1341
  updatePendingBadge()
1195
1342
  }
1196
1343
 
@@ -1210,6 +1357,8 @@ async function openSession(id) {
1210
1357
  }
1211
1358
 
1212
1359
  function closeSession() {
1360
+ setComposerFullscreen(false)
1361
+ clearComposerImages()
1213
1362
  state.current = null
1214
1363
  state.history = emptyHistory()
1215
1364
  document.body.classList.remove('in-session')
@@ -1222,8 +1371,9 @@ function bindNativeBack() {
1222
1371
  if (!CAP?.isNativePlatform?.()) return
1223
1372
  try {
1224
1373
  CAP.Plugins?.App?.addListener?.('backButton', () => {
1374
+ if ($('composer-wrap')?.classList.contains('fs')) { setComposerFullscreen(false); return }
1225
1375
  const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
1226
- if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else openModal.classList.add('hidden'); return } // 先关弹窗
1376
+ if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else if (openModal.id === 'modal-archive') closeArchiveConfirm(); else openModal.classList.add('hidden'); return } // 先关弹窗
1227
1377
  if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
1228
1378
  if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
1229
1379
  if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
@@ -1548,7 +1698,7 @@ function eventHtml(entry, ctx = {}) {
1548
1698
  const blocks = msg.content || data.content || []
1549
1699
  const sysText = type === 'user/message' ? systemReminderText(blocks) : ''
1550
1700
  if (sysText) {
1551
- 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>`
1552
1702
  } else {
1553
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>`
1554
1704
  }
@@ -1726,32 +1876,153 @@ async function runSlashCommand(text) {
1726
1876
  return false
1727
1877
  }
1728
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
+
1729
1973
  async function sendSessionText(text) {
1974
+ return sendSessionContent(text, [])
1975
+ }
1976
+
1977
+ async function sendSessionContent(text, images) {
1730
1978
  const clean = String(text || '').trim()
1731
- if (!clean || !state.current) return false
1732
- if (await runSlashCommand(clean)) return true
1733
- $('btn-send').disabled = true
1734
- const v = await safeRpc('session.prompt', {
1735
- sessionId: state.current,
1736
- mode: 'queue',
1737
- content: [{ type: 'text', text: clean }]
1738
- }, t('send.failed'))
1739
- $('btn-send').disabled = false
1740
- if (v?.accepted) { toast(clean.startsWith('/') ? t('send.commandSent') : t('send.sent'), 'ok'); return true }
1741
- if (v?.command?.text) { toast(t('send.commandExecuted'), 'ok'); return true }
1742
- 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
+ })))
1743
2007
  }
1744
2008
 
1745
2009
  async function sendMessage() {
1746
2010
  const input = $('composer-input')
1747
2011
  const text = input.value.trim()
1748
- if (!text || !state.current) return
1749
- 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
+ }
1750
2020
  }
1751
2021
 
1752
2022
  function hideComposerMenu() {
1753
2023
  $('composer-menu').classList.add('hidden')
1754
2024
  $('btn-plus').classList.remove('active')
2025
+ $('permission-submenu')?.classList.add('hidden')
1755
2026
  }
1756
2027
 
1757
2028
  function toggleComposerMenu() {
@@ -1762,6 +2033,13 @@ function toggleComposerMenu() {
1762
2033
  if (show && !state.models.loaded && !state.models.loading) loadSessionModels()
1763
2034
  }
1764
2035
 
2036
+ function isMobileDevice() {
2037
+ return !!CAP?.isNativePlatform?.() || /Android|iPhone|iPad|iPod|Mobile|Windows Phone/i.test(navigator.userAgent || '')
2038
+ }
2039
+ function mobileEnterAction() {
2040
+ return LS.get('mobileEnterAction', 'newline') === 'send' ? 'send' : 'newline'
2041
+ }
2042
+
1765
2043
  async function loadSessionModels() {
1766
2044
  if (!state.current || state.models.loading) return
1767
2045
  state.models.loading = true
@@ -1876,7 +2154,175 @@ async function newSession() {
1876
2154
  openSession(v.sessionId)
1877
2155
  }
1878
2156
 
1879
- /* ---------------- 待办 ---------------- */
2157
+ let archivePendingSessionId = null
2158
+ function archiveSession(sessionId) {
2159
+ const session = state.byId.get(sessionId)
2160
+ if (!session) return
2161
+ archivePendingSessionId = sessionId
2162
+ $('archive-session-title').textContent = titleOf(session)
2163
+ $('archive-session-workspace').textContent = sessionWorkspaceLabel(session)
2164
+ $('modal-archive').classList.remove('hidden')
2165
+ }
2166
+ function closeArchiveConfirm() {
2167
+ archivePendingSessionId = null
2168
+ $('modal-archive').classList.add('hidden')
2169
+ }
2170
+ async function confirmArchiveSession() {
2171
+ const sessionId = archivePendingSessionId
2172
+ if (!sessionId) return
2173
+ const button = $('archive-confirm')
2174
+ button.disabled = true
2175
+ try {
2176
+ const value = await safeRpc('workspace.archiveSession', { sessionId }, t('session.archiveFailed', { msg: '' }))
2177
+ if (value == null) return
2178
+ closeArchiveConfirm()
2179
+ toast(t('session.archived'), 'ok')
2180
+ await refreshSessions()
2181
+ } finally {
2182
+ button.disabled = false
2183
+ }
2184
+ }
2185
+
2186
+ let swipeTracking = null
2187
+ let swipeSuppressClickUntil = 0
2188
+ function closeRevealedSwipes(except = null) {
2189
+ document.querySelectorAll('.session-swipe.revealed').forEach(row => {
2190
+ if (row !== except) row.classList.remove('revealed')
2191
+ })
2192
+ }
2193
+ function bindSessionSwipe() {
2194
+ const containers = [$('session-list'), $('wb-panel')].filter(Boolean)
2195
+ for (const list of containers) list.addEventListener('touchstart', e => {
2196
+ if (e.touches.length !== 1) return
2197
+ const row = e.target.closest('[data-session-swipe]')
2198
+ if (!row) return
2199
+ closeRevealedSwipes(row)
2200
+ const touch = e.touches[0]
2201
+ swipeTracking = {
2202
+ row,
2203
+ startX: touch.clientX,
2204
+ startY: touch.clientY,
2205
+ offset: row.classList.contains('revealed') ? -92 : 0,
2206
+ axis: null
2207
+ }
2208
+ }, { passive: true })
2209
+ for (const list of containers) list.addEventListener('touchmove', e => {
2210
+ if (!swipeTracking || e.touches.length !== 1) return
2211
+ const touch = e.touches[0]
2212
+ const dx = touch.clientX - swipeTracking.startX
2213
+ const dy = touch.clientY - swipeTracking.startY
2214
+ if (!swipeTracking.axis && Math.max(Math.abs(dx), Math.abs(dy)) >= 8) {
2215
+ swipeTracking.axis = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y'
2216
+ }
2217
+ if (swipeTracking.axis !== 'x') return
2218
+ e.preventDefault()
2219
+ const offset = Math.max(-92, Math.min(0, swipeTracking.offset + dx))
2220
+ swipeTracking.row.style.setProperty('--swipe-x', offset + 'px')
2221
+ }, { passive: false })
2222
+ for (const list of containers) list.addEventListener('touchend', () => {
2223
+ if (!swipeTracking) return
2224
+ if (swipeTracking.axis === 'x') {
2225
+ const row = swipeTracking.row
2226
+ const offset = parseFloat(row.style.getPropertyValue('--swipe-x') || swipeTracking.offset)
2227
+ row.classList.toggle('revealed', offset <= -46)
2228
+ row.style.removeProperty('--swipe-x')
2229
+ swipeSuppressClickUntil = Date.now() + 350
2230
+ }
2231
+ swipeTracking = null
2232
+ }, { 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)))
2324
+ }
2325
+
1880
2326
  function renderPending() {
1881
2327
  const list = $('pending-list')
1882
2328
  const items = [
@@ -1911,6 +2357,7 @@ function renderPending() {
1911
2357
  list.querySelectorAll('[data-question]').forEach(btn =>
1912
2358
  btn.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === btn.dataset.question))))
1913
2359
  updatePendingBadge()
2360
+ renderOverview()
1914
2361
  }
1915
2362
 
1916
2363
  async function approveApproval(id, allow) {
@@ -2104,7 +2551,7 @@ function renderFs(data) {
2104
2551
  list.innerHTML = data.entries.map(e => {
2105
2552
  const isDir = e.type === 'dir'
2106
2553
  return `<div class="fs-row" data-name="${esc(e.name)}" data-type="${esc(e.type)}">
2107
- <span class="fs-ico">${isDir ? '📁' : '📄'}</span>
2554
+ <span class="fs-ico">${fsIconSvg(isDir)}</span>
2108
2555
  <span class="fs-meta">
2109
2556
  <span class="fs-name">${esc(e.name)}</span>
2110
2557
  <span class="fs-sub">${isDir ? t('fs.dir') : fmtSize(e.size)} · ${fmtFullTime(e.mtimeMs)}</span>
@@ -2116,6 +2563,12 @@ function renderFs(data) {
2116
2563
  row.addEventListener('click', () => fsOpenEntry(row.dataset.name, row.dataset.type)))
2117
2564
  }
2118
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
+
2119
2572
  function fsOpenEntry(name, type) {
2120
2573
  if (!name) return
2121
2574
  const p = fsJoin(state.fs.path, name)
@@ -2537,6 +2990,142 @@ async function loadLocalVersion() {
2537
2990
  $('update-desc').textContent = state.localVersion ? t('update.currentV', { version: state.localVersion }) : t('update.noVersion')
2538
2991
  }
2539
2992
 
2993
+ /* ---------------- 远程公告 ----------------
2994
+ * 与 update.json 放在同一台服务器上,格式见 README/发布说明。
2995
+ * 公告只读取文本并用 textContent/转义后的换行渲染,不执行服务端下发的 HTML/脚本。
2996
+ */
2997
+ const ANNOUNCEMENTS_KEY = 'seenAnnouncementsV1'
2998
+ const ANNOUNCEMENT_HISTORY_KEY = 'announcementHistoryV1'
2999
+ function readSeenAnnouncements() {
3000
+ try {
3001
+ const value = JSON.parse(LS.get(ANNOUNCEMENTS_KEY, '{}'))
3002
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
3003
+ } catch { return {} }
3004
+ }
3005
+ function markAnnouncementSeen(id) {
3006
+ if (!id) return
3007
+ const seen = readSeenAnnouncements()
3008
+ seen[id] = Date.now()
3009
+ const keys = Object.keys(seen)
3010
+ if (keys.length > 100) {
3011
+ keys.sort((a, b) => Number(seen[a]) - Number(seen[b]))
3012
+ for (const key of keys.slice(0, keys.length - 100)) delete seen[key]
3013
+ }
3014
+ LS.set(ANNOUNCEMENTS_KEY, JSON.stringify(seen))
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
+ }
3050
+ function announcementVersionMatch(item) {
3051
+ const min = String(item.minVersion || item.minAppVersion || '').trim()
3052
+ const max = String(item.maxVersion || item.maxAppVersion || '').trim()
3053
+ if (!state.localVersion) return false
3054
+ if (min && cmpVersion(state.localVersion, min) < 0) return false
3055
+ if (max && cmpVersion(state.localVersion, max) > 0) return false
3056
+ return true
3057
+ }
3058
+ function normalizeAnnouncement(item, base) {
3059
+ if (!item || typeof item !== 'object') return null
3060
+ const id = String(item.id || '').trim().slice(0, 120)
3061
+ const title = String(item.title || '').trim().slice(0, 160)
3062
+ const content = String(item.content ?? item.body ?? '').trim().slice(0, 20000)
3063
+ if (!id || !title || !content || !announcementVersionMatch(item)) return null
3064
+ const now = Date.now()
3065
+ const startsAt = Date.parse(item.publishedAt || item.startsAt || '')
3066
+ const expiresAt = Date.parse(item.expiresAt || '')
3067
+ if (Number.isFinite(startsAt) && startsAt > now) return null
3068
+ if (Number.isFinite(expiresAt) && expiresAt <= now) return null
3069
+ let actionUrl = String(item.actionUrl || item.url || '').trim()
3070
+ if (actionUrl) {
3071
+ try {
3072
+ const parsed = new URL(actionUrl, base + '/')
3073
+ if (!['http:', 'https:'].includes(parsed.protocol)) actionUrl = ''
3074
+ else actionUrl = parsed.href
3075
+ } catch { actionUrl = '' }
3076
+ }
3077
+ return {
3078
+ id, title, content, actionUrl,
3079
+ actionText: String(item.actionText || '').trim().slice(0, 80),
3080
+ publishedAt: Number.isFinite(startsAt) ? startsAt : 0,
3081
+ force: item.force === true
3082
+ }
3083
+ }
3084
+ function openAnnouncementModal(item) {
3085
+ state.announcement = item
3086
+ $('announcement-title').textContent = item.title
3087
+ $('announcement-content').innerHTML = esc(item.content).replace(/\r?\n/g, '<br>')
3088
+ const action = $('announcement-action')
3089
+ if (item.actionUrl) {
3090
+ action.href = item.actionUrl
3091
+ action.textContent = item.actionText || t('announcement.open')
3092
+ action.classList.remove('hidden')
3093
+ } else {
3094
+ action.removeAttribute('href')
3095
+ action.textContent = ''
3096
+ action.classList.add('hidden')
3097
+ }
3098
+ $('announcement-later').classList.toggle('hidden', item.force)
3099
+ $('modal-announcement').classList.remove('hidden')
3100
+ }
3101
+ function closeAnnouncement(markSeen) {
3102
+ if (markSeen && state.announcement) markAnnouncementSeen(state.announcement.id)
3103
+ state.announcement = null
3104
+ $('modal-announcement').classList.add('hidden')
3105
+ }
3106
+ async function checkAnnouncements() {
3107
+ const base = updateBase()
3108
+ if (!base || !state.localVersion) return false
3109
+ try {
3110
+ const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(8000) : undefined
3111
+ const url = base + '/announcements.json?t=' + Date.now()
3112
+ const res = signal ? await fetch(url, { cache: 'no-store', signal }) : await fetch(url, { cache: 'no-store' })
3113
+ if (!res.ok) return false
3114
+ const raw = await res.text()
3115
+ if (raw.length > 512 * 1024) return false
3116
+ const data = JSON.parse(raw)
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)
3120
+ const seen = readSeenAnnouncements()
3121
+ const items = normalized.filter(item => !seen[item.id])
3122
+ .sort((a, b) => b.publishedAt - a.publishedAt)
3123
+ if (!items.length) return false
3124
+ openAnnouncementModal(items[0])
3125
+ return true
3126
+ } catch { return false }
3127
+ }
3128
+
2540
3129
  /* ---------------- 更新内容弹窗 ---------------- */
2541
3130
  const NOTES_KEY = 'seenNotesVersion'
2542
3131
  let notesVersion = ''
@@ -2993,8 +3582,84 @@ function updateConn() {
2993
3582
  }
2994
3583
 
2995
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
+ }
2996
3592
  el.style.height = 'auto'
2997
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 })
2998
3663
  }
2999
3664
 
3000
3665
  /* ---------------- 初始化 ---------------- */
@@ -3220,6 +3885,7 @@ function bindUi() {
3220
3885
  renderUpdateExpandBtn()
3221
3886
  renderServers()
3222
3887
  renderSessions()
3888
+ renderWorkbench()
3223
3889
  renderPending(); renderQueue(); renderJobs()
3224
3890
  updateConn()
3225
3891
  if (state.current) { renderSessionTitle(); renderSessionSub(); renderSessionCards(); renderHistory(true) }
@@ -3245,11 +3911,74 @@ function bindUi() {
3245
3911
  // 底部导航
3246
3912
  document.querySelectorAll('.nav-btn').forEach(b =>
3247
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
+ })
3248
3927
  // 会话列表点击
3249
3928
  $('session-list').addEventListener('click', (e) => {
3929
+ if (swipeSuppressClickUntil > Date.now()) { swipeSuppressClickUntil = 0; return }
3930
+ if (e.target.closest('[data-archived-toggle]')) {
3931
+ LS.set('showArchivedV1', LS.get('showArchivedV1', '0') === '1' ? '0' : '1')
3932
+ renderSessions()
3933
+ return
3934
+ }
3935
+ const archive = e.target.closest('[data-archive-session]')
3936
+ if (archive) {
3937
+ e.stopPropagation()
3938
+ archiveSession(archive.dataset.archiveSession)
3939
+ return
3940
+ }
3941
+ const swipeRow = e.target.closest('[data-session-swipe]')
3942
+ if (swipeRow?.classList.contains('revealed')) {
3943
+ swipeRow.classList.remove('revealed')
3944
+ return
3945
+ }
3250
3946
  const card = e.target.closest('[data-id]')
3251
3947
  if (card) openSession(card.dataset.id)
3252
3948
  })
3949
+ bindSessionSwipe()
3950
+ $('wb-toggle').addEventListener('click', () => {
3951
+ if (!state.wb?.bound) return
3952
+ state.wbOpen = !state.wbOpen
3953
+ renderWorkbench()
3954
+ })
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
+ }
3962
+ const newButton = e.target.closest('[data-wb-new]')
3963
+ if (newButton) {
3964
+ safeRpc('session.create', { workspaceId: newButton.dataset.wbNew }, t('home.createFailed')).then(async v => {
3965
+ if (!v?.sessionId) return
3966
+ toast(t('home.created'), 'ok')
3967
+ await refreshSessions()
3968
+ openSession(v.sessionId)
3969
+ })
3970
+ return
3971
+ }
3972
+ const head = e.target.closest('.wb-project-head')
3973
+ if (head) {
3974
+ const project = head.closest('[data-wb-project]')
3975
+ const id = project?.dataset.wbProject
3976
+ if (id) { state.wbOpenProjects[id] = !state.wbOpenProjects[id]; renderWorkbench() }
3977
+ return
3978
+ }
3979
+ const session = e.target.closest('[data-wb-session]')
3980
+ if (session) openSession(session.dataset.wbSession)
3981
+ })
3253
3982
  $('btn-back').addEventListener('click', closeSession)
3254
3983
  $('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
3255
3984
  $('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
@@ -3288,10 +4017,37 @@ function bindUi() {
3288
4017
  })
3289
4018
  $('btn-cancel').addEventListener('click', cancelSession)
3290
4019
  $('btn-send').addEventListener('click', sendMessage)
4020
+ $('btn-fs-send').addEventListener('click', sendMessage)
3291
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
+ })
3292
4042
  $('composer-menu').addEventListener('click', async (e) => {
3293
4043
  const chip = e.target.closest('[data-cmd]')
3294
4044
  if (chip) {
4045
+ if (chip.dataset.cmd === '/permission') {
4046
+ const submenu = $('permission-submenu')
4047
+ submenu?.classList.toggle('hidden')
4048
+ return
4049
+ }
4050
+ $('permission-submenu')?.classList.add('hidden')
3295
4051
  const input = $('composer-input')
3296
4052
  input.value = chip.dataset.cmd + ' '
3297
4053
  input.focus()
@@ -3299,6 +4055,15 @@ function bindUi() {
3299
4055
  hideComposerMenu()
3300
4056
  return
3301
4057
  }
4058
+ const perm = e.target.closest('[data-perm]')
4059
+ if (perm) {
4060
+ const input = $('composer-input')
4061
+ input.value = '/permission ' + perm.dataset.perm + ' '
4062
+ input.focus()
4063
+ autosize(input)
4064
+ hideComposerMenu()
4065
+ return
4066
+ }
3302
4067
  const preset = e.target.closest('[data-preset]')
3303
4068
  if (preset) {
3304
4069
  const found = readPresets().find(x => x.id === preset.dataset.preset)
@@ -3314,8 +4079,13 @@ function bindUi() {
3314
4079
  $('btn-model-refresh').addEventListener('click', loadSessionModels)
3315
4080
  const input = $('composer-input')
3316
4081
  input.addEventListener('input', () => autosize(input))
4082
+ $('btn-fs-toggle').addEventListener('click', () => setComposerFullscreen(!$('composer-wrap').classList.contains('fs')))
4083
+ bindComposerFullscreenGesture()
4084
+ updateComposerFullscreenButton()
3317
4085
  input.addEventListener('keydown', (e) => {
3318
- if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage() }
4086
+ if (e.key !== 'Enter' || e.isComposing) return
4087
+ if (isMobileDevice() && mobileEnterAction() !== 'send') return
4088
+ if (!e.shiftKey) { e.preventDefault(); sendMessage() }
3319
4089
  })
3320
4090
 
3321
4091
  // 审批
@@ -3337,6 +4107,18 @@ function bindUi() {
3337
4107
  $('workspace-create').addEventListener('click', createWorkspace)
3338
4108
  $('workspace-name').addEventListener('keydown', (e) => { if (e.key === 'Enter') createWorkspace() })
3339
4109
  $('modal-workspace').addEventListener('click', (e) => { if (e.target === $('modal-workspace')) closeWorkspaceModal() })
4110
+ $('archive-cancel').addEventListener('click', closeArchiveConfirm)
4111
+ $('archive-confirm').addEventListener('click', confirmArchiveSession)
4112
+ $('modal-archive').addEventListener('click', (e) => { if (e.target === $('modal-archive')) closeArchiveConfirm() })
4113
+ $('announcement-later').addEventListener('click', () => closeAnnouncement(false))
4114
+ $('announcement-confirm').addEventListener('click', () => closeAnnouncement(true))
4115
+ $('modal-announcement').addEventListener('click', (e) => {
4116
+ if (e.target === $('modal-announcement') && !state.announcement?.force) closeAnnouncement(false)
4117
+ })
4118
+ $('announcement-history-close').addEventListener('click', closeAnnouncementHistory)
4119
+ $('modal-announcement-history').addEventListener('click', (e) => {
4120
+ if (e.target === $('modal-announcement-history')) closeAnnouncementHistory()
4121
+ })
3340
4122
  // 设置
3341
4123
  $('view-settings').addEventListener('click', (e) => {
3342
4124
  const group = e.target.closest('[data-settings-group]')
@@ -3373,10 +4155,16 @@ function bindUi() {
3373
4155
  $('btn-update-expand').addEventListener('click', toggleUpdateExpand)
3374
4156
  $('btn-reset').addEventListener('click', () => {
3375
4157
  if (!confirm(t('settings.confirmReset'))) return
3376
- LS.del('token'); LS.del('notify'); LS.del('server')
4158
+ LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction'); LS.del(ANNOUNCEMENTS_KEY); LS.del(ANNOUNCEMENT_HISTORY_KEY)
3377
4159
  if (bgBridge()?.saveBackgroundConfig) saveBgConfig(false)
3378
4160
  location.reload()
3379
4161
  })
4162
+ $('mobile-enter-action').value = mobileEnterAction()
4163
+ $('mobile-enter-action').addEventListener('change', (e) => {
4164
+ const action = e.target.value === 'send' ? 'send' : 'newline'
4165
+ LS.set('mobileEnterAction', action)
4166
+ toast(t(action === 'send' ? 'settings.mobileEnterSend' : 'settings.mobileEnterNewline'), 'ok')
4167
+ })
3380
4168
  $('opt-notify').checked = LS.get('notify', '0') === '1'
3381
4169
  $('opt-notify').addEventListener('change', async (e) => {
3382
4170
  if (e.target.checked) {
@@ -3411,6 +4199,7 @@ function bindUi() {
3411
4199
  LS.set('peakRemind', e.target.checked ? '1' : '0')
3412
4200
  })
3413
4201
  $('btn-test-notify').addEventListener('click', sendTestNotification)
4202
+ $('btn-announcement-history').addEventListener('click', openAnnouncementHistory)
3414
4203
  // 已开启则启动时重新调度, 防止系统清理后丢失
3415
4204
  if (peakRemindOn() && CAP?.isNativePlatform?.()) schedulePeakReminders()
3416
4205
  applyBgConfigFromNative()
@@ -3506,10 +4295,11 @@ async function boot() {
3506
4295
  bindNativeBack()
3507
4296
  bindNativeLinks()
3508
4297
  applyNativeInsets()
4298
+ showView('view-activity')
3509
4299
  updateConn()
3510
- loadLocalVersion()
4300
+ await loadLocalVersion()
3511
4301
  if (!state.token) {
3512
- showView('view-settings')
4302
+ showView('view-activity')
3513
4303
  $('token-desc').textContent = t('token.notSetHint')
3514
4304
  } else {
3515
4305
  // 多服务器: 启动时静默测速一次, 选最快的连接(同源页面也参与比较)
@@ -3519,9 +4309,12 @@ async function boot() {
3519
4309
  const host = await safeRpc('host.describe', {}, '')
3520
4310
  if (host) { state.hostInfo = host; $('host-desc').textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions }) }
3521
4311
  loadDshControl()
3522
- // 启动后自动检查一次更新(静默)
3523
- setTimeout(() => checkUpdate(true), 4000)
3524
4312
  }
4313
+ // 公告与更新共用当前服务器;公告优先,避免启动时两个弹窗重叠。
4314
+ setTimeout(async () => {
4315
+ const shown = await checkAnnouncements()
4316
+ if (!shown && state.token) checkUpdate(true)
4317
+ }, 4000)
3525
4318
  renderPending()
3526
4319
  }
3527
4320