dsh-remote-plugin 0.6.0 → 0.6.2

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
@@ -269,11 +269,15 @@ function renderStats(days) {
269
269
  }).join('')
270
270
  }
271
271
  async function rpc(method, payload = {}) {
272
- const res = await fetch(apiUrl('/api/' + method), {
272
+ const opts = {
273
273
  method: 'POST',
274
274
  headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
275
275
  body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
276
- })
276
+ }
277
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
278
+ opts.signal = AbortSignal.timeout(20000)
279
+ }
280
+ const res = await fetch(apiUrl('/api/' + method), opts)
277
281
  if (res.status === 401) throw new Error('AUTH')
278
282
  if (!res.ok) throw new Error('HTTP ' + res.status)
279
283
  const full = await res.json()
@@ -451,6 +455,7 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
451
455
  if (srv) state.groupActive[state.activeGroup] = srv.id
452
456
  }
453
457
  saveServers()
458
+ syncBgConfig()
454
459
  if (!silent) {
455
460
  if (chosen) toast(t('speed.switched', { url: chosen, ms: Number.isFinite(ms) ? ms : 0 }), 'ok')
456
461
  else toast(t('speed.switchedOrigin'), 'ok')
@@ -976,6 +981,10 @@ function applyProjection(sessionId, key, value, seq) {
976
981
  else renderSessions()
977
982
  }function titleOf(s) { return proj(s, 'title') || short(s.sessionId) }
978
983
  function short(id) { return '…' + String(id).slice(-8) }
984
+ const GOAL_TERMINAL_PHASES = new Set(['complete', 'cleared'])
985
+ function isGoalTerminal(goal) {
986
+ return !!goal && GOAL_TERMINAL_PHASES.has(goal.phase)
987
+ }
979
988
  function goalOf(s) {
980
989
  const p = proj(s, 'goal')
981
990
  if (!p) return null
@@ -1028,8 +1037,10 @@ async function openSession(id) {
1028
1037
  state.history = emptyHistory()
1029
1038
  document.body.classList.add('in-session')
1030
1039
  showView('view-session')
1040
+ $('session-cards').innerHTML = ''
1031
1041
  renderSessionTitle(); renderSessionSub(); updateCancelBtn(); updateSessionStatus()
1032
1042
  $('history').innerHTML = '<div class="empty">' + t('history.loading') + '</div>'
1043
+ restoreCachedHistory()
1033
1044
  await loadHistory(true)
1034
1045
  renderSessionCards()
1035
1046
  refreshSessions()
@@ -1049,7 +1060,7 @@ function bindNativeBack() {
1049
1060
  try {
1050
1061
  CAP.Plugins?.App?.addListener?.('backButton', () => {
1051
1062
  const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
1052
- if (openModal) { openModal.classList.add('hidden'); return } // 先关弹窗
1063
+ if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else openModal.classList.add('hidden'); return } // 先关弹窗
1053
1064
  if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
1054
1065
  if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
1055
1066
  if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
@@ -1192,8 +1203,12 @@ async function loadHistory(reset) {
1192
1203
  trimVisible()
1193
1204
  state.history.hasMore = !!v.hasMore
1194
1205
  state.history.loading = false
1195
- if (reset) renderHistory(true)
1196
- else if (added) renderHistory(false, 'keep')
1206
+ try {
1207
+ if (reset) renderHistory(true)
1208
+ else if (added) renderHistory(false, 'keep')
1209
+ } catch (e) {
1210
+ console.error('renderHistory failed', e)
1211
+ }
1197
1212
  if (moreBtn) moreBtn.classList.toggle('hidden', !state.history.hasMore)
1198
1213
  $('history-hint').textContent = state.history.visible.length ? t('history.count', { n: state.history.visible.length }) : ''
1199
1214
  scheduleHistoryCacheSave()
@@ -1338,6 +1353,13 @@ function shouldShowEvent(type) {
1338
1353
  if (INTERESTING_EVENTS.has(type)) return true
1339
1354
  return false
1340
1355
  }
1356
+ function systemReminderText(blocks) {
1357
+ if (!Array.isArray(blocks)) return ''
1358
+ return blocks
1359
+ .filter(b => b && typeof b === 'object' && b.type === 'text' && String(b.text ?? '').trimStart().startsWith('<system-reminder>'))
1360
+ .map(b => String(b.text ?? ''))
1361
+ .join('\n')
1362
+ }
1341
1363
  function eventHtml(entry, ctx = {}) {
1342
1364
  const seq = entry.seq
1343
1365
  const ev = entry.event || {}
@@ -1350,7 +1372,12 @@ function eventHtml(entry, ctx = {}) {
1350
1372
  const msg = data.message || {}
1351
1373
  const role = data.role || msg.role || (type.startsWith('user') ? 'user' : 'assistant')
1352
1374
  const blocks = msg.content || data.content || []
1353
- 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>`
1375
+ const sysText = type === 'user/message' ? systemReminderText(blocks) : ''
1376
+ if (sysText) {
1377
+ inner = `<details class="event" data-seq="${seq}"><summary>${esc(t('event.systemReminder'))}</summary><pre>${esc(truncate(sysText, 400))}</pre></details>`
1378
+ } else {
1379
+ 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>`
1380
+ }
1354
1381
  } else if (type === 'tool/call') {
1355
1382
  const name = data.name || data.toolName || t('tool.default')
1356
1383
  const step = (data.turn != null ? ` · turn ${data.turn}` : '') + (data.step != null ? `.${data.step}` : '')
@@ -1436,12 +1463,13 @@ async function renderSessionCards() {
1436
1463
  const box = $('session-cards')
1437
1464
  const statsBox = $('stats-body')
1438
1465
  if (!s) { box.innerHTML = ''; if (statsBox) statsBox.innerHTML = ''; return }
1439
- if (statsBox) statsBox.innerHTML = statsHtml(s)
1466
+ if (statsBox) { try { statsBox.innerHTML = statsHtml(s) } catch {} }
1467
+ box.innerHTML = ''
1440
1468
  const goal = goalOf(s)
1441
1469
  const todos = proj(s, 'todos')
1442
1470
  let html = ''
1443
1471
 
1444
- if (goal) {
1472
+ if (goal && !isGoalTerminal(goal)) {
1445
1473
  html += `<div class="card"><div class="card-title">${t('goal.title')}</div>
1446
1474
  <div class="goal-obj">${esc(goal.objective || '')}</div>
1447
1475
  <div class="goal-phase">phase: ${esc(goal.phase || '?')} · revision ${goal.revision ?? '?'}</div>
@@ -1476,6 +1504,16 @@ async function renderSessionCards() {
1476
1504
  }
1477
1505
  }
1478
1506
 
1507
+ function setGoalPhaseLocal(phase) {
1508
+ const s = state.byId.get(state.current)
1509
+ const p = s && proj(s, 'goal')
1510
+ const goal = p && typeof p === 'object' && p.goal && typeof p.goal === 'object' ? p.goal : p
1511
+ if (!goal) return
1512
+ goal.phase = phase
1513
+ renderSessions()
1514
+ renderSessionCards()
1515
+ }
1516
+
1479
1517
  async function goalAction(kind) {
1480
1518
  const s = state.byId.get(state.current)
1481
1519
  const goal = goalOf(s)
@@ -1488,6 +1526,8 @@ async function goalAction(kind) {
1488
1526
  if (kind === 'clear' && !confirm(t('goal.confirmClear'))) return
1489
1527
  if (kind === 'complete' && !confirm(t('goal.confirmComplete'))) return
1490
1528
  await safeRpc(method, { sessionId: state.current, ref }, t('goal.actionFailed'))
1529
+ if (kind === 'complete') setGoalPhaseLocal('complete')
1530
+ if (kind === 'clear') setGoalPhaseLocal('cleared')
1491
1531
  toast(t('goal.actionSubmitted'), 'ok')
1492
1532
  scheduleRefresh()
1493
1533
  }
@@ -1500,9 +1540,34 @@ async function interruptSubagent(childId) {
1500
1540
  }
1501
1541
 
1502
1542
  /* ---------------- 发送 / 取消 / 快捷菜单 ---------------- */
1543
+ async function runSlashCommand(text) {
1544
+ const clean = String(text || '').trim()
1545
+ if (!clean.startsWith('/') || !state.current) return false
1546
+ try {
1547
+ const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
1548
+ ? AbortSignal.timeout(20000)
1549
+ : undefined
1550
+ const res = await fetch(apiUrl('/remote/api/command'), {
1551
+ method: 'POST',
1552
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
1553
+ body: JSON.stringify({ sessionId: state.current, line: clean }),
1554
+ ...(signal ? { signal } : {})
1555
+ })
1556
+ if (res.status === 401) { authFailure(); return true }
1557
+ if (!res.ok) return false
1558
+ const data = await res.json().catch(() => null)
1559
+ if (data?.ok === false) { toast(data.message || t('send.failed'), 'err'); return true }
1560
+ if (data?.ok && data.executed === true) { toast(t('send.commandExecuted'), 'ok'); return true }
1561
+ } catch (e) {
1562
+ console.error('slash command bridge failed', e)
1563
+ }
1564
+ return false
1565
+ }
1566
+
1503
1567
  async function sendSessionText(text) {
1504
1568
  const clean = String(text || '').trim()
1505
1569
  if (!clean || !state.current) return false
1570
+ if (await runSlashCommand(clean)) return true
1506
1571
  $('btn-send').disabled = true
1507
1572
  const v = await safeRpc('session.prompt', {
1508
1573
  sessionId: state.current,
@@ -2240,6 +2305,83 @@ async function loadLocalVersion() {
2240
2305
  $('update-desc').textContent = state.localVersion ? t('update.currentV', { version: state.localVersion }) : t('update.noVersion')
2241
2306
  }
2242
2307
 
2308
+ /* ---------------- 更新内容弹窗 ---------------- */
2309
+ const NOTES_KEY = 'seenNotesVersion'
2310
+ let notesVersion = ''
2311
+ let notesPages = []
2312
+ let notesPage = 0
2313
+ function splitNotes(notes) {
2314
+ return String(notes || '').split(/[;;]/).map(s => s.trim()).filter(Boolean)
2315
+ }
2316
+ function renderNotesPages(items) {
2317
+ const box = $('notes-pages')
2318
+ if (!box) return
2319
+ const pages = []
2320
+ for (let i = 0; i < items.length; i += 3) pages.push(items.slice(i, i + 3))
2321
+ notesPages = pages
2322
+ notesPage = 0
2323
+ box.innerHTML = pages.map(page => `<div class="notes-page" style="flex:0 0 100%;scroll-snap-align:start;box-sizing:border-box;min-width:0;">${page.map(item => `<div class="notes-item" style="padding:6px 0;line-height:1.5;">${esc(item)}</div>`).join('')}</div>`).join('')
2324
+ box.scrollLeft = 0
2325
+ updateNotesPage()
2326
+ }
2327
+ function renderNotesVersionPages(entries) {
2328
+ const box = $('notes-pages')
2329
+ if (!box) return
2330
+ notesPages = entries
2331
+ notesPage = 0
2332
+ box.innerHTML = entries.map(entry => {
2333
+ const items = splitNotes(entry.notes)
2334
+ return `<div class="notes-page" style="flex:0 0 100%;scroll-snap-align:start;box-sizing:border-box;min-width:0;">
2335
+ <div class="notes-version-title" style="font-weight:700;margin-bottom:6px;opacity:.9;">v${esc(entry.version)}</div>
2336
+ ${items.length ? items.map(item => `<div class="notes-item" style="padding:6px 0;line-height:1.5;">${esc(item)}</div>`).join('') : `<div class="notes-item" style="padding:6px 0;line-height:1.5;">${esc(entry.notes || '')}</div>`}
2337
+ </div>`
2338
+ }).join('')
2339
+ box.scrollLeft = 0
2340
+ updateNotesPage()
2341
+ }
2342
+ function updateNotesPage() {
2343
+ const box = $('notes-pages')
2344
+ const pageEl = $('notes-page')
2345
+ if (!box || !pageEl) return
2346
+ const total = notesPages.length || 1
2347
+ const idx = Math.min(Math.max(0, Math.round(box.scrollLeft / Math.max(1, box.clientWidth))), total - 1)
2348
+ notesPage = idx
2349
+ pageEl.textContent = t('notes.page', { current: idx + 1, total })
2350
+ }
2351
+ function scrollNotes(dir) {
2352
+ const box = $('notes-pages')
2353
+ if (box) box.scrollBy({ left: dir * box.clientWidth, behavior: 'smooth' })
2354
+ }
2355
+ function openNotesModal(info) {
2356
+ if (!info?.version) return
2357
+ const history = Array.isArray(info.history) ? info.history.filter(h => h && typeof h.version === 'string' && typeof h.notes === 'string' && !String(h.version).includes('-rc')) : []
2358
+ const latestStable = history[0]?.version || info.version
2359
+ if (history.length) {
2360
+ const entries = history.filter(h => cmpVersion(h.version, state.localVersion) > 0)
2361
+ if (!entries.length) return
2362
+ if (LS.get(NOTES_KEY) === latestStable) return
2363
+ notesVersion = latestStable
2364
+ const vEl = $('notes-version')
2365
+ if (vEl) vEl.textContent = 'v' + latestStable
2366
+ renderNotesVersionPages(entries.reverse())
2367
+ $('modal-notes').classList.remove('hidden')
2368
+ return
2369
+ }
2370
+ if (String(info.version).includes('-rc')) return
2371
+ if (LS.get(NOTES_KEY) === info.version) return
2372
+ const items = splitNotes(info.notes)
2373
+ if (!items.length) return
2374
+ notesVersion = info.version
2375
+ const vEl = $('notes-version')
2376
+ if (vEl) vEl.textContent = 'v' + info.version
2377
+ renderNotesPages(items)
2378
+ $('modal-notes').classList.remove('hidden')
2379
+ }
2380
+ function closeNotesModal() {
2381
+ $('modal-notes').classList.add('hidden')
2382
+ if (notesVersion) { LS.set(NOTES_KEY, notesVersion); notesVersion = '' }
2383
+ }
2384
+
2243
2385
  async function checkUpdate(silent) {
2244
2386
  if (!state.localVersion) {
2245
2387
  $('update-desc').textContent = t('update.noVersion')
@@ -2259,6 +2401,7 @@ async function checkUpdate(silent) {
2259
2401
  const res = await fetch(base + '/update.json?t=' + Date.now() + '&local=' + encodeURIComponent(state.localVersion))
2260
2402
  if (!res.ok) throw new Error('HTTP ' + res.status)
2261
2403
  const info = await res.json()
2404
+ openNotesModal(info)
2262
2405
  if (info.version && cmpVersion(info.version, state.localVersion) > 0) {
2263
2406
  state.updateInfo = info
2264
2407
  const hasNotes = !!(info.notes && String(info.notes).trim())
@@ -2401,6 +2544,108 @@ function notify(title, body) {
2401
2544
  } catch {}
2402
2545
  }
2403
2546
 
2547
+ /* ---------------- 后台轮询(Android 前台服务) ---------------- */
2548
+ function bgBridge() { return window.NativeBackground }
2549
+ function bgBase() { return (state.server || location.origin || '').replace(/\/+$/, '') }
2550
+ function applyBgConfigFromNative() {
2551
+ const b = bgBridge()
2552
+ if (!b?.getBackgroundConfig) return
2553
+ try {
2554
+ const cfg = JSON.parse(b.getBackgroundConfig() || '{}')
2555
+ $('opt-bg-poll').checked = !!cfg.enabled
2556
+ const v = String(cfg.intervalMin ?? 1)
2557
+ const opts = Array.from($('bg-interval')?.options || [])
2558
+ if (opts.some(o => o.value === v)) $('bg-interval').value = v
2559
+ if ($('opt-task-done')) $('opt-task-done').checked = cfg.notifyTaskDone !== false
2560
+ $('bg-auth-status')?.classList.toggle('hidden', !cfg.loginExpired)
2561
+ } catch {}
2562
+ }
2563
+ function saveBgConfig(enabled) {
2564
+ const b = bgBridge()
2565
+ if (!b?.saveBackgroundConfig) return false
2566
+ const base = bgBase()
2567
+ const intervalMin = parseFloat($('bg-interval')?.value || '1') || 1
2568
+ const notifyTaskDone = $('opt-task-done')?.checked !== false
2569
+ b.saveBackgroundConfig(JSON.stringify({ enabled, intervalMin, base, token: state.token || '', notifyTaskDone }))
2570
+ if (enabled) $('bg-auth-status')?.classList.add('hidden')
2571
+ return true
2572
+ }
2573
+ function syncBgConfig() {
2574
+ if ($('opt-bg-poll')?.checked && state.token) saveBgConfig(true)
2575
+ }
2576
+
2577
+ /* ---------------- 预设提示词 ---------------- */
2578
+ const PRESETS_KEY = 'dshPromptPresets'
2579
+ const PRESET_NAME_MAX = 20
2580
+ const PRESET_TEXT_MAX = 2000
2581
+ const PRESET_LIMIT = 20
2582
+ function readPresets() {
2583
+ try {
2584
+ const v = JSON.parse(LS.get(PRESETS_KEY, '[]') || '[]')
2585
+ return Array.isArray(v) ? v.filter(p => p && typeof p.id === 'string' && typeof p.name === 'string' && typeof p.text === 'string') : []
2586
+ } catch { return [] }
2587
+ }
2588
+ function writePresets(list) {
2589
+ LS.set(PRESETS_KEY, JSON.stringify(list))
2590
+ renderPresets()
2591
+ renderPresetMenu()
2592
+ }
2593
+ function renderPresets() {
2594
+ const box = $('preset-list')
2595
+ if (!box) return
2596
+ const list = readPresets()
2597
+ if (!list.length) {
2598
+ box.innerHTML = `<div class="server-empty">${esc(t('presets.empty'))}</div>`
2599
+ return
2600
+ }
2601
+ box.innerHTML = list.map(p => `<div class="server-row">
2602
+ <div class="server-main"><div class="server-note">${esc(p.name)}</div><div class="server-url">${esc((p.text || '').slice(0, 60))}</div></div>
2603
+ <button class="mini-btn" data-preset-edit="${esc(p.id)}">${t('presets.edit')}</button>
2604
+ <button class="mini-btn" data-preset-del="${esc(p.id)}">${t('presets.delete')}</button>
2605
+ </div>`).join('')
2606
+ box.querySelectorAll('[data-preset-edit]').forEach(b => b.addEventListener('click', () => editPreset(b.dataset.presetEdit)))
2607
+ box.querySelectorAll('[data-preset-del]').forEach(b => b.addEventListener('click', () => deletePreset(b.dataset.presetDel)))
2608
+ }
2609
+ function renderPresetMenu() {
2610
+ const group = $('preset-menu-group')
2611
+ const listBox = $('preset-menu-list')
2612
+ if (!group || !listBox) return
2613
+ const list = readPresets()
2614
+ group.classList.toggle('hidden', !list.length)
2615
+ listBox.innerHTML = list.map(p => `<button class="menu-chip" data-preset="${esc(p.id)}">${esc(p.name)}</button>`).join('')
2616
+ }
2617
+ function promptPreset(id) {
2618
+ const list = readPresets()
2619
+ const existing = id ? list.find(p => p.id === id) : null
2620
+ const name = prompt(t('presets.namePrompt'), existing?.name || '')
2621
+ if (name == null) return
2622
+ const text = prompt(t('presets.textPrompt'), existing?.text || '')
2623
+ if (text == null) return
2624
+ const n = (name || '').trim()
2625
+ if (!n) return toast(t('presets.nameEmpty'), 'err')
2626
+ if (n.length > PRESET_NAME_MAX) return toast(t('presets.nameTooLong'), 'err')
2627
+ if (text.length > PRESET_TEXT_MAX) return toast(t('presets.textTooLong'), 'err')
2628
+ if (existing) {
2629
+ existing.name = n
2630
+ existing.text = text
2631
+ } else {
2632
+ if (list.length >= PRESET_LIMIT) return toast(t('presets.limit'), 'err')
2633
+ list.push({ id: 'p' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), name: n, text })
2634
+ }
2635
+ writePresets(list)
2636
+ toast(existing ? t('presets.saved') : t('presets.added'), 'ok')
2637
+ }
2638
+ function addPreset() { promptPreset(null) }
2639
+ function editPreset(id) { promptPreset(id) }
2640
+ function deletePreset(id) {
2641
+ const list = readPresets()
2642
+ const p = list.find(x => x.id === id)
2643
+ if (!p) return
2644
+ if (!confirm(t('presets.confirmDelete', { name: p.name }))) return
2645
+ writePresets(list.filter(x => x.id !== id))
2646
+ toast(t('presets.deleted'), 'ok')
2647
+ }
2648
+
2404
2649
  /* ---------------- 峰谷计费提醒(每天 9/12/14/18 点本地通知) ---------------- */
2405
2650
  const PEAK_REMIND_NOTIFS = [
2406
2651
  { id: 8801, hour: 9, periodKey: 'peak0912', enterKey: 'enterPeak' },
@@ -2446,6 +2691,23 @@ function showView(id) {
2446
2691
  window.scrollTo(0, 0)
2447
2692
  if (id === 'view-files' && !state.fs.loaded) loadFs(null, { silent: true })
2448
2693
  if (id === 'view-stats') loadStats()
2694
+ if (id === 'view-settings') showSettingsHome()
2695
+ }
2696
+
2697
+ const SETTINGS_GROUPS = ['general', 'servers', 'notify', 'theme', 'about']
2698
+ function showSettingsHome() {
2699
+ const home = $('settings-home')
2700
+ if (!home) return
2701
+ home.classList.remove('hidden')
2702
+ for (const name of SETTINGS_GROUPS) $('settings-page-' + name)?.classList.add('hidden')
2703
+ window.scrollTo(0, 0)
2704
+ }
2705
+ function showSettingsPage(name) {
2706
+ const home = $('settings-home')
2707
+ if (!home || !SETTINGS_GROUPS.includes(name)) return
2708
+ home.classList.add('hidden')
2709
+ for (const g of SETTINGS_GROUPS) $('settings-page-' + g)?.classList.toggle('hidden', g !== name)
2710
+ window.scrollTo(0, 0)
2449
2711
  }
2450
2712
 
2451
2713
  function updateConn() {
@@ -2489,6 +2751,7 @@ function applyPairUrl(url) {
2489
2751
  saveServers()
2490
2752
  renderServers()
2491
2753
  $('token-desc').textContent = t('token.savedScan')
2754
+ syncBgConfig()
2492
2755
  return true
2493
2756
  } catch {
2494
2757
  return false
@@ -2629,6 +2892,11 @@ function openThemePanel() {
2629
2892
  $('modal-theme').classList.remove('hidden')
2630
2893
  }
2631
2894
 
2895
+ function openDonateModal() {
2896
+ const m = $('modal-donate')
2897
+ if (m) m.classList.remove('hidden')
2898
+ }
2899
+
2632
2900
  function bindUi() {
2633
2901
  renderLangBtn()
2634
2902
  renderThemeBtn()
@@ -2649,6 +2917,17 @@ function bindUi() {
2649
2917
  })
2650
2918
  $('btn-theme').addEventListener('click', openThemePanel)
2651
2919
  $('theme-close').addEventListener('click', () => $('modal-theme').classList.add('hidden'))
2920
+ $('btn-donate').addEventListener('click', openDonateModal)
2921
+ $('donate-close').addEventListener('click', () => $('modal-donate').classList.add('hidden'))
2922
+ document.addEventListener('click', (e) => {
2923
+ if (e.target.closest('[data-donate-open]')) openDonateModal()
2924
+ })
2925
+ // 更新内容弹窗
2926
+ $('notes-close').addEventListener('click', closeNotesModal)
2927
+ $('notes-prev').addEventListener('click', () => scrollNotes(-1))
2928
+ $('notes-next').addEventListener('click', () => scrollNotes(1))
2929
+ $('notes-pages').addEventListener('scroll', updateNotesPage)
2930
+ $('modal-notes').addEventListener('click', (e) => { if (e.target === $('modal-notes')) closeNotesModal() })
2652
2931
  renderServers()
2653
2932
  // 底部导航
2654
2933
  document.querySelectorAll('.nav-btn').forEach(b =>
@@ -2691,9 +2970,27 @@ function bindUi() {
2691
2970
  $('btn-cancel').addEventListener('click', cancelSession)
2692
2971
  $('btn-send').addEventListener('click', sendMessage)
2693
2972
  $('btn-plus').addEventListener('click', toggleComposerMenu)
2694
- $('composer-menu').addEventListener('click', (e) => {
2973
+ $('composer-menu').addEventListener('click', async (e) => {
2695
2974
  const chip = e.target.closest('[data-cmd]')
2696
- if (chip) { hideComposerMenu(); sendSessionText(chip.dataset.cmd) }
2975
+ if (chip) {
2976
+ const input = $('composer-input')
2977
+ input.value = chip.dataset.cmd + ' '
2978
+ input.focus()
2979
+ autosize(input)
2980
+ hideComposerMenu()
2981
+ return
2982
+ }
2983
+ const preset = e.target.closest('[data-preset]')
2984
+ if (preset) {
2985
+ const found = readPresets().find(x => x.id === preset.dataset.preset)
2986
+ if (found) {
2987
+ const input = $('composer-input')
2988
+ input.value = found.text
2989
+ input.focus()
2990
+ autosize(input)
2991
+ }
2992
+ hideComposerMenu()
2993
+ }
2697
2994
  })
2698
2995
  $('btn-model-refresh').addEventListener('click', loadSessionModels)
2699
2996
  const input = $('composer-input')
@@ -2718,10 +3015,15 @@ function bindUi() {
2718
3015
  $('goal-close').addEventListener('click', () => $('modal-goal').classList.add('hidden'))
2719
3016
  $('goal-edit').addEventListener('click', submitGoalEdit)
2720
3017
  // 设置
3018
+ $('view-settings').addEventListener('click', (e) => {
3019
+ const group = e.target.closest('[data-settings-group]')
3020
+ if (group) { showSettingsPage(group.dataset.settingsGroup); return }
3021
+ if (e.target.closest('[data-settings-back]')) { showSettingsHome(); return }
3022
+ })
2721
3023
  $('btn-scan-pair').addEventListener('click', scanPair)
2722
3024
  $('btn-change-token').addEventListener('click', () => {
2723
3025
  const input = prompt(t('token.prompt'), state.token)
2724
- if (input && input.trim()) { state.token = input.trim(); LS.set('token', input.trim()); $('token-desc').textContent = t('token.saved'); toast(t('token.savedReconnect'), 'ok'); openStreams(); refreshAll() }
3026
+ if (input && input.trim()) { state.token = input.trim(); LS.set('token', input.trim()); $('token-desc').textContent = t('token.saved'); toast(t('token.savedReconnect'), 'ok'); openStreams(); refreshAll(); syncBgConfig() }
2725
3027
  })
2726
3028
  $('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
2727
3029
  $('btn-server-add').addEventListener('click', addServer)
@@ -2746,6 +3048,7 @@ function bindUi() {
2746
3048
  $('btn-reset').addEventListener('click', () => {
2747
3049
  if (!confirm(t('settings.confirmReset'))) return
2748
3050
  LS.del('token'); LS.del('notify'); LS.del('server')
3051
+ if (bgBridge()?.saveBackgroundConfig) saveBgConfig(false)
2749
3052
  location.reload()
2750
3053
  })
2751
3054
  $('opt-notify').checked = LS.get('notify', '0') === '1'
@@ -2775,6 +3078,35 @@ function bindUi() {
2775
3078
  })
2776
3079
  // 已开启则启动时重新调度, 防止系统清理后丢失
2777
3080
  if (peakRemindOn() && CAP?.isNativePlatform?.()) schedulePeakReminders()
3081
+ applyBgConfigFromNative()
3082
+ $('opt-bg-poll').addEventListener('change', async (e) => {
3083
+ if (e.target.checked) {
3084
+ if (!CAP?.isNativePlatform?.() || !bgBridge()?.saveBackgroundConfig) {
3085
+ e.target.checked = false
3086
+ return toast(t('settings.bgNativeOnly'), 'err')
3087
+ }
3088
+ if (!state.token) {
3089
+ e.target.checked = false
3090
+ return toast(t('settings.bgNeedToken'), 'err')
3091
+ }
3092
+ const ok = await ensureNotify()
3093
+ if (!ok) { e.target.checked = false; return toast(t('settings.notifyDenied')) }
3094
+ saveBgConfig(true)
3095
+ toast(t('settings.bgOn'), 'ok')
3096
+ } else {
3097
+ saveBgConfig(false)
3098
+ toast(t('settings.bgOff'), 'ok')
3099
+ }
3100
+ })
3101
+ $('bg-interval').addEventListener('change', () => {
3102
+ if ($('opt-bg-poll')?.checked) saveBgConfig(true)
3103
+ })
3104
+ $('opt-task-done')?.addEventListener('change', () => {
3105
+ if (bgBridge()?.saveBackgroundConfig) saveBgConfig($('opt-bg-poll')?.checked)
3106
+ })
3107
+ renderPresets()
3108
+ renderPresetMenu()
3109
+ $('btn-preset-add').addEventListener('click', addPreset)
2778
3110
  $('opt-tools').checked = LS.get('showTools', '1') !== '0'
2779
3111
  $('opt-tools').addEventListener('change', (e) => {
2780
3112
  LS.set('showTools', e.target.checked ? '1' : '0')
@@ -1,5 +1,6 @@
1
1
  /* DSH Remote 桌面端专用样式 · 零依赖 · 只引用 --dsr-* 皮肤变量 */
2
2
  html, body { height: 100%; margin: 0; overflow: hidden; overscroll-behavior: none; background: var(--dsr-bg); color: var(--dsr-text); }
3
+ .hidden { display: none !important; }
3
4
  .ds-app { position: fixed; inset: 0; display: flex; overflow: hidden; }
4
5
  .ds-sidebar { width: 280px; flex: none; display: flex; flex-direction: column; border-right: 1px solid var(--dsr-line); background: var(--dsr-panel); }
5
6
  .ds-brand { display: flex; align-items: center; gap: 8px; padding: 14px 14px 10px; font-weight: 700; font-size: 15px; }
@@ -52,12 +53,10 @@ a.ds-btn { text-decoration: none; }
52
53
  font: inherit; text-align: left; cursor: pointer; text-decoration: none;
53
54
  }
54
55
  .ds-feedback-item:hover, .ds-feedback-item:focus-visible { background: var(--dsr-bg); outline: none; }
55
- .ds-feedback-item.primary { background: var(--dsr-accent-soft); border: 1px solid var(--dsr-accent-line); }
56
56
  .ds-feedback-ico {
57
57
  width: 32px; height: 32px; border-radius: 9px; flex-shrink: 0;
58
58
  display: grid; place-items: center; background: var(--dsr-panel); border: 1px solid var(--dsr-line); color: var(--dsr-accent-strong);
59
59
  }
60
- .ds-feedback-item.primary .ds-feedback-ico { background: var(--dsr-accent-strong); border-color: var(--dsr-accent-strong); color: var(--dsr-on-accent); }
61
60
  .ds-feedback-ico svg { width: 16px; height: 16px; fill: currentColor; }
62
61
  .ds-feedback-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
63
62
  .ds-feedback-name { font-size: 13px; font-weight: 600; }
@@ -100,16 +99,47 @@ a.ds-btn { text-decoration: none; }
100
99
  /* 会话视图 */
101
100
  #view-chat { padding: 0; }
102
101
  .ds-history { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 18px 22px; display: flex; flex-direction: column; gap: 10px; }
103
- .ds-msg { max-width: 78%; padding: 9px 12px; border-radius: 12px; font-size: 13.5px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; }
102
+ .ds-msg { max-width: min(78%, 680px); width: fit-content; box-sizing: border-box; padding: 9px 12px; border-radius: 12px; font-size: 13.5px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
104
103
  .ds-msg.user { align-self: flex-end; background: var(--dsr-accent-soft); border: 1px solid var(--dsr-accent-line); color: var(--dsr-accent-strong); }
105
104
  .ds-msg.assistant { align-self: flex-start; background: var(--dsr-panel); border: 1px solid var(--dsr-line); color: var(--dsr-text); }
106
105
  .ds-msg .role { font-size: 10px; color: var(--dsr-muted); margin-bottom: 3px; }
107
- .ds-tool { align-self: flex-start; max-width: 78%; background: var(--dsr-bg-2); border: 1px solid var(--dsr-line); border-radius: 10px; padding: 8px 11px; font-size: 12px; }
106
+ .ds-tool { align-self: flex-start; max-width: min(78%, 680px); width: fit-content; box-sizing: border-box; background: var(--dsr-bg-2); border: 1px solid var(--dsr-line); border-radius: 10px; padding: 8px 11px; font-size: 12px; }
108
107
  .ds-tool summary { cursor: pointer; color: var(--dsr-muted); }
109
108
  .ds-tool pre { margin: 6px 0 0; white-space: pre-wrap; word-break: break-all; font-size: 11px; color: var(--dsr-text); }
110
109
  .ds-empty { color: var(--dsr-muted); text-align: center; padding: 30px 0; font-size: 13px; }
111
- .ds-composer { flex: none; display: flex; gap: 10px; padding: 12px 16px; border-top: 1px solid var(--dsr-line); background: var(--dsr-panel); }
112
- .ds-composer textarea { flex: 1; min-width: 0; resize: none; background: var(--dsr-bg-2); color: var(--dsr-text); border: 1px solid var(--dsr-line); border-radius: 11px; padding: 10px 12px; font: inherit; font-size: 13.5px; outline: none; max-height: 160px; }
110
+ .ds-presets-guide { padding: 2px 8px 10px; font-size: 11px; line-height: 1.5; }
111
+ .ds-session-cards { flex: none; max-height: 220px; overflow-y: auto; padding: 10px 22px 0; display: flex; flex-direction: column; gap: 8px; }
112
+ .ds-session-cards:empty { display: none; }
113
+ .ds-card { background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: 12px; padding: 10px 12px; }
114
+ .ds-card-title { font-size: 11px; font-weight: 700; color: var(--dsr-muted); letter-spacing: .6px; margin-bottom: 6px; text-transform: uppercase; }
115
+ .ds-goal-obj { font-size: 13px; line-height: 1.55; word-break: break-word; }
116
+ .ds-goal-phase { font-size: 11px; color: var(--dsr-muted); margin-top: 3px; }
117
+ .ds-goal-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
118
+ .ds-mini-btn { display: inline-flex; align-items: center; justify-content: center; min-height: 26px; padding: 2px 9px; border-radius: 7px; background: var(--dsr-bg-2); border: 1px solid var(--dsr-line); color: var(--dsr-text); font: inherit; font-size: 11.5px; cursor: pointer; }
119
+ .ds-mini-btn:hover { filter: brightness(1.08); }
120
+ .ds-todo-row { display: flex; align-items: flex-start; gap: 8px; font-size: 12.5px; line-height: 1.5; padding: 2px 0; }
121
+ .ds-pill { flex: none; font-size: 10.5px; line-height: 16px; padding: 0 7px; border-radius: 999px; background: var(--dsr-bg-2); border: 1px solid var(--dsr-line); color: var(--dsr-muted); }
122
+ .ds-pill.active { background: var(--dsr-accent-soft); border-color: var(--dsr-accent-line); color: var(--dsr-accent-strong); }
123
+ .ds-pill.done { background: var(--dsr-success-soft); border-color: var(--dsr-success-line); color: var(--dsr-success); }
124
+ .ds-card-row { display: flex; align-items: center; gap: 10px; font-size: 12.5px; padding: 3px 0; }
125
+ .ds-card-k { flex: 1; min-width: 0; word-break: break-word; }
126
+ .ds-card-v { color: var(--dsr-muted); font-size: 11.5px; white-space: nowrap; }
127
+ .ds-model-head { font-size: 11px; font-weight: 700; color: var(--dsr-muted); letter-spacing: .6px; padding: 4px 6px 2px; }
128
+ .ds-model-group { padding: 2px 0 4px; }
129
+ .ds-model-provider { font-size: 11px; color: var(--dsr-muted); padding: 2px 6px; }
130
+ .ds-model-chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 6px 4px; }
131
+ .ds-model-chip { display: inline-flex; align-items: center; min-height: 26px; padding: 2px 9px; border-radius: 999px; background: var(--dsr-bg-2); border: 1px solid var(--dsr-line); color: var(--dsr-text); font: inherit; font-size: 11.5px; cursor: pointer; white-space: nowrap; }
132
+ .ds-model-chip:hover { filter: brightness(1.08); }
133
+ .ds-model-chip.current { background: var(--dsr-accent-soft); border-color: var(--dsr-accent-line); color: var(--dsr-accent-strong); font-weight: 600; }
134
+ .ds-model-effort-group { padding-top: 2px; border-top: 1px solid var(--dsr-divider); margin-top: 2px; }
135
+ .ds-model-effort-group.hidden { display: none; }
136
+ .ds-composer { flex: none; display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--dsr-line); background: var(--dsr-panel); }
137
+ .ds-composer textarea { width: 100%; min-width: 0; resize: none; background: var(--dsr-bg-2); color: var(--dsr-text); border: 1px solid var(--dsr-line); border-radius: 12px; padding: 10px 14px; font: inherit; font-size: 13.5px; line-height: 1.5; outline: none; min-height: 44px; max-height: 120px; box-sizing: border-box; }
138
+ .ds-composer-actions { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
139
+ .ds-composer-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
140
+ .ds-composer-right { display: flex; align-items: center; flex: none; }
141
+ .ds-composer .ds-btn { min-height: 34px; box-sizing: border-box; }
142
+ .ds-send-btn { width: 36px; height: 36px; min-height: 36px; padding: 0; border-radius: 50%; background: var(--dsr-accent-strong); border-color: var(--dsr-accent-strong); color: var(--dsr-on-accent); font-size: 18px; line-height: 1; justify-content: center; }
113
143
 
114
144
  /* 文件传输 */
115
145
  .ds-fs-bar { display: flex; align-items: center; gap: 8px; margin: 8px 0; }
@@ -122,12 +152,26 @@ a.ds-btn { text-decoration: none; }
122
152
  .ds-fs-size { color: var(--dsr-muted); font-size: 12px; white-space: nowrap; }
123
153
 
124
154
  /* 设置 */
155
+ #view-settings { overflow-y: auto; }
125
156
  .ds-settings { max-width: 720px; display: flex; flex-direction: column; gap: 10px; }
126
- .ds-setting-row { display: flex; align-items: center; gap: 12px; background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: 12px; padding: 11px 13px; }
157
+ .ds-setting-row { display: flex; align-items: center; gap: 10px; background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: 12px; padding: 10px 14px; }
127
158
  .ds-setting-row > div:first-child { flex: 1; min-width: 0; }
159
+ .ds-setting-arrow,
160
+ #settings-home .ds-setting-row .ds-btn {
161
+ display: inline-flex; align-items: center; justify-content: center;
162
+ min-height: 32px; padding: 0 10px; border-radius: 8px;
163
+ background: transparent; border: 1px solid transparent; color: var(--dsr-muted);
164
+ font-size: 12.5px; cursor: pointer; white-space: nowrap; text-decoration: none;
165
+ }
166
+ .ds-setting-arrow { font-size: 16px; padding: 0 12px; }
167
+ .ds-setting-arrow:hover,
168
+ #settings-home .ds-setting-row .ds-btn:hover,
169
+ #settings-home .ds-setting-row .ds-btn:focus-visible {
170
+ background: var(--dsr-bg-2); border-color: var(--dsr-line); color: var(--dsr-text);
171
+ }
128
172
  .ds-setting-name { font-size: 13.5px; font-weight: 600; }
129
173
  .ds-setting-desc { font-size: 12px; color: var(--dsr-muted); margin-top: 2px; word-break: break-all; }
130
- .ds-feedback-links { display: flex; gap: 8px; flex-wrap: wrap; padding: 0 13px; }
174
+ #token-desc { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
131
175
  .ds-group-bar { display: flex; align-items: center; gap: 10px; padding: 2px 0; }
132
176
  .ds-group-bar > label { font-size: 12px; color: var(--dsr-muted); white-space: nowrap; }
133
177
  .ds-group-select { flex: 1; min-width: 0; position: relative; }
@@ -222,6 +266,16 @@ a.ds-btn { text-decoration: none; }
222
266
  .ds-q-option .muted { font-size: 11px; color: var(--dsr-muted); }
223
267
  .ds-modal-body textarea { width: 100%; box-sizing: border-box; margin-top: 6px; background: var(--dsr-bg-2); color: var(--dsr-text); border: 1px solid var(--dsr-line); border-radius: 9px; padding: 7px 9px; font: inherit; font-size: 13px; outline: none; }
224
268
 
269
+ /* 预设提示词管理 */
270
+ .ds-preset-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
271
+ .ds-preset-count { font-size: 12px; color: var(--dsr-muted); }
272
+ .ds-preset-list { display: flex; flex-direction: column; gap: 8px; }
273
+ .ds-preset-row { display: flex; align-items: center; gap: 8px; background: var(--dsr-bg-2); border: 1px solid var(--dsr-line); border-radius: 10px; padding: 8px 10px; }
274
+ .ds-preset-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
275
+ .ds-preset-name { font-size: 13px; font-weight: 600; word-break: break-word; }
276
+ .ds-preset-preview { font-size: 11.5px; color: var(--dsr-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
277
+ .ds-preset-empty { font-size: 12.5px; color: var(--dsr-muted); text-align: center; padding: 18px 0; }
278
+
225
279
  .ds-toast { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%); z-index: 150; background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: 10px; padding: 9px 15px; font-size: 13px; box-shadow: 0 8px 24px rgba(0,0,0,.3); }
226
280
  .ds-toast.hidden { display: none; }
227
281