dsh-remote-plugin 0.5.9 → 0.6.1

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.
@@ -57,10 +57,14 @@ const state = {
57
57
  questionModal: null,
58
58
  streamsOk: { mux: false, host: false },
59
59
  errCount: 0,
60
+ streamMode: 'ws', // 'ws' | 'poll'
61
+ pollSeq: { mux: 0, host: 0 },
60
62
  fs: { path: null, initial: null, loaded: false },
61
63
  view: 'sessions'
62
64
  }
63
65
  const streams = {}
66
+ let pollTimer = null
67
+ let wsRetryTimer = null
64
68
 
65
69
  function esc(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])) }
66
70
  function short(id) { return '…' + String(id).slice(-8) }
@@ -92,6 +96,35 @@ function toast(text, kind = '') {
92
96
  toast._t = setTimeout(() => el.classList.add('hidden'), 2600)
93
97
  }
94
98
 
99
+ /* ---------------- 预设提示词 ---------------- */
100
+ const PRESETS_KEY = 'dshPromptPresets'
101
+ function readPresets() {
102
+ try {
103
+ const v = JSON.parse(LS.get(PRESETS_KEY, '[]') || '[]')
104
+ return Array.isArray(v) ? v.filter(p => p && typeof p.id === 'string' && typeof p.name === 'string' && typeof p.text === 'string') : []
105
+ } catch { return [] }
106
+ }
107
+ function renderPresetMenuDesktop() {
108
+ const btn = $('btn-preset')
109
+ const menu = $('preset-menu')
110
+ if (!btn || !menu) return
111
+ const list = readPresets()
112
+ btn.style.display = list.length ? '' : 'none'
113
+ menu.classList.add('hidden')
114
+ menu.innerHTML = list.map(p => `<button class="ds-feedback-item" data-ds-preset="${esc(p.id)}">${esc(p.name)}</button>`).join('')
115
+ }
116
+ function togglePresetMenuDesktop() {
117
+ const menu = $('preset-menu')
118
+ if (!menu) return
119
+ if (!readPresets().length) return
120
+ menu.classList.toggle('hidden')
121
+ }
122
+ function toggleCmdMenuDesktop() {
123
+ const menu = $('cmd-menu')
124
+ if (!menu) return
125
+ menu.classList.toggle('hidden')
126
+ }
127
+
95
128
  /* ---------------- 反馈 ---------------- */
96
129
  const FEEDBACK_LINKS = {
97
130
  githubIssues: 'https://github.com/Blank-not-black/dsh-Remote/issues',
@@ -130,6 +163,64 @@ function openFeedbackModal() {
130
163
  setTimeout(() => $('fb-msg').focus(), 50)
131
164
  }
132
165
  function closeFeedbackModal() { $('modal-feedback').classList.add('hidden') }
166
+ function openDonateModal() {
167
+ const m = $('modal-donate')
168
+ if (m) m.classList.remove('hidden')
169
+ }
170
+ /* ---------------- 更新内容弹窗 ---------------- */
171
+ const NOTES_KEY = 'seenNotesVersion'
172
+ let notesVersion = ''
173
+ let notesPages = []
174
+ let notesPage = 0
175
+ function splitNotes(notes) {
176
+ return String(notes || '').split(/[;;]/).map(s => s.trim()).filter(Boolean)
177
+ }
178
+ function renderNotesPages(items) {
179
+ const box = $('notes-pages')
180
+ if (!box) return
181
+ const pages = []
182
+ for (let i = 0; i < items.length; i += 3) pages.push(items.slice(i, i + 3))
183
+ notesPages = pages
184
+ notesPage = 0
185
+ 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('')
186
+ box.scrollLeft = 0
187
+ updateNotesPage()
188
+ }
189
+ function updateNotesPage() {
190
+ const box = $('notes-pages')
191
+ const pageEl = $('notes-page')
192
+ if (!box || !pageEl) return
193
+ const total = notesPages.length || 1
194
+ const idx = Math.min(Math.max(0, Math.round(box.scrollLeft / Math.max(1, box.clientWidth))), total - 1)
195
+ notesPage = idx
196
+ pageEl.textContent = t('ds.notesPage', { current: idx + 1, total })
197
+ }
198
+ function scrollNotes(dir) {
199
+ const box = $('notes-pages')
200
+ if (box) box.scrollBy({ left: dir * box.clientWidth, behavior: 'smooth' })
201
+ }
202
+ function openNotesModal(info) {
203
+ if (!info?.version || String(info.version).includes('-rc')) return
204
+ if (LS.get(NOTES_KEY) === info.version) return
205
+ const items = splitNotes(info.notes)
206
+ if (!items.length) return
207
+ notesVersion = info.version
208
+ const vEl = $('notes-version')
209
+ if (vEl) vEl.textContent = 'v' + info.version
210
+ renderNotesPages(items)
211
+ $('modal-notes').classList.remove('hidden')
212
+ }
213
+ function closeNotesModal() {
214
+ $('modal-notes').classList.add('hidden')
215
+ if (notesVersion) { LS.set(NOTES_KEY, notesVersion); notesVersion = '' }
216
+ }
217
+ async function checkNotesOnStart() {
218
+ try {
219
+ const res = await fetch('../update.json?t=' + Date.now())
220
+ if (!res.ok) return
221
+ openNotesModal(await res.json())
222
+ } catch {}
223
+ }
133
224
  async function submitFeedback() {
134
225
  const type = document.querySelector('#fb-chips .ds-fb-chip.current')?.dataset.fbType || 'bug'
135
226
  const message = $('fb-msg').value.trim()
@@ -483,9 +574,11 @@ function deleteGroup(name) {
483
574
  if (state.token) selectFastestServer({ silent: true })
484
575
  }
485
576
 
486
- /* ---------------- 事件流 ---------------- */
577
+ /* ---------------- 事件流 (WebSocket + 轮询降级) ---------------- */
487
578
  function openStreams() {
488
579
  if (!state.token) return
580
+ if (state.streamMode === 'poll') stopPolling()
581
+ state.streamMode = 'ws'
489
582
  openStream('mux', onMuxFrame, true)
490
583
  openStream('host', onHostFrame, false)
491
584
  }
@@ -500,12 +593,14 @@ function openStream(kind, handler, refreshOnOpen) {
500
593
  ws.onopen = () => {
501
594
  state.streamsOk[kind] = true
502
595
  state.errCount = 0
596
+ if (state.streamMode === 'poll') { stopPolling(); state.streamMode = 'ws' }
503
597
  updateConn()
504
598
  if (kind === 'mux') { state.approvals = []; state.questions = []; renderNotifStack() }
505
599
  if (refreshOnOpen) refreshSessions()
506
600
  }
507
601
  ws.onmessage = (msg) => {
508
602
  state.streamsOk[kind] = true
603
+ state.errCount = 0
509
604
  updateConn()
510
605
  try { handler(JSON.parse(msg.data)) } catch {}
511
606
  }
@@ -513,11 +608,84 @@ function openStream(kind, handler, refreshOnOpen) {
513
608
  state.streamsOk[kind] = false
514
609
  state.errCount++
515
610
  updateConn()
611
+ if (state.streamMode === 'poll') return
612
+ if (state.errCount >= 3) { enterPollMode(); return }
516
613
  if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
517
614
  if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), 1200)
518
615
  }
519
616
  ws.onerror = () => { try { ws.close() } catch {} }
520
617
  }
618
+
619
+ /* ---------------- 轮询降级模式 ---------------- */
620
+ function enterPollMode() {
621
+ if (state.streamMode === 'poll') return
622
+ state.streamMode = 'poll'
623
+ state.pollSeq = { mux: 0, host: 0 }
624
+ state.streamsOk = { mux: false, host: false }
625
+ try { streams.mux?.close() } catch {}
626
+ try { streams.host?.close() } catch {}
627
+ streams.mux = null
628
+ streams.host = null
629
+ refreshSessions()
630
+ startPolling()
631
+ updateConn()
632
+ }
633
+
634
+ function stopPolling() {
635
+ clearInterval(pollTimer)
636
+ pollTimer = null
637
+ clearTimeout(wsRetryTimer)
638
+ wsRetryTimer = null
639
+ }
640
+
641
+ function startPolling() {
642
+ stopPolling()
643
+ pollTimer = setInterval(pollOnce, 4000)
644
+ wsRetryTimer = setInterval(tryRestoreWs, 30000)
645
+ pollOnce()
646
+ }
647
+
648
+ let pollInFlight = false
649
+ async function pollOnce() {
650
+ if (state.streamMode !== 'poll' || pollInFlight) return
651
+ pollInFlight = true
652
+ try {
653
+ await Promise.all([pollKind('mux'), pollKind('host')])
654
+ } finally {
655
+ pollInFlight = false
656
+ }
657
+ }
658
+
659
+ async function pollKind(kind) {
660
+ if (state.streamMode !== 'poll') return
661
+ const since = state.pollSeq[kind] || 0
662
+ let res
663
+ try {
664
+ const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(5000) : undefined
665
+ const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' }
666
+ res = signal ? await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}`), { signal, headers }) : await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}`), { headers })
667
+ } catch { return }
668
+ if (res.status === 401) { toast(t('ds.toastAuth'), 'err'); return }
669
+ if (!res.ok) return
670
+ let data
671
+ try { data = await res.json() } catch { return }
672
+ if (!data || !Array.isArray(data.events)) return
673
+ if (typeof data.latestSeq === 'number' && data.latestSeq < since) state.pollSeq[kind] = 0
674
+ for (const item of data.events) {
675
+ if (item.seq > (state.pollSeq[kind] || 0)) {
676
+ state.pollSeq[kind] = item.seq
677
+ if (kind === 'mux') onMuxFrame(item.event)
678
+ else onHostFrame(item.event)
679
+ }
680
+ }
681
+ }
682
+
683
+ function tryRestoreWs() {
684
+ if (state.streamMode !== 'poll' || !state.token) return
685
+ // 轮询继续跑,等 WS 真正 onopen 后再切回,避免重连窗口丢事件
686
+ openStream('mux', onMuxFrame, true)
687
+ openStream('host', onHostFrame, false)
688
+ }
521
689
  function onMuxFrame(full) {
522
690
  const f = full.payload
523
691
  if (!f) return
@@ -654,6 +822,13 @@ function blockHtml(b) {
654
822
  if (b.type === 'image') return `<div>🖼</div>`
655
823
  return ''
656
824
  }
825
+ function systemReminderText(blocks) {
826
+ if (!Array.isArray(blocks)) return ''
827
+ return blocks
828
+ .filter(b => b && typeof b === 'object' && b.type === 'text' && String(b.text ?? '').trimStart().startsWith('<system-reminder>'))
829
+ .map(b => String(b.text ?? ''))
830
+ .join('\n')
831
+ }
657
832
  function eventHtml(entry) {
658
833
  const ev = entry.event || {}
659
834
  const data = ev.data || {}
@@ -663,6 +838,11 @@ function eventHtml(entry) {
663
838
  const msg = data.message || {}
664
839
  const role = data.role || msg.role || (type.startsWith('user') ? 'user' : 'assistant')
665
840
  const blocks = msg.content || data.content || []
841
+ const sysText = type === 'user/message' ? systemReminderText(blocks) : ''
842
+ if (sysText) {
843
+ const shown = sysText.length > 400 ? sysText.slice(0, 400) + '…' : sysText
844
+ return `<details class="event ds-tool"><summary>${esc(t('ds.eventSystemReminder'))}</summary><pre>${esc(shown)}</pre></details>`
845
+ }
666
846
  const text = blocks.map(blockHtml).join('')
667
847
  return `<div class="ds-msg ${esc(role)}"><div class="role">${esc(role === 'user' ? t('ds.role.me') : t('ds.role.dsh'))}</div>${text || '<span style="opacity:.6">…</span>'}</div>`
668
848
  }
@@ -686,10 +866,35 @@ function renderHistory() {
686
866
  box.innerHTML = items.map(eventHtml).join('') || `<div class="ds-empty">${t('ds.historyEmpty')}</div>`
687
867
  box.scrollTop = box.scrollHeight
688
868
  }
869
+ async function runSlashCommand(text) {
870
+ const clean = String(text || '').trim()
871
+ if (!clean.startsWith('/') || !state.current) return false
872
+ try {
873
+ const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
874
+ ? AbortSignal.timeout(20000)
875
+ : undefined
876
+ const res = await fetch(apiUrl('/remote/api/command'), {
877
+ method: 'POST',
878
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' },
879
+ body: JSON.stringify({ sessionId: state.current, line: clean }),
880
+ ...(signal ? { signal } : {})
881
+ })
882
+ if (res.status === 401) { toast(t('ds.toastAuth'), 'err'); return true }
883
+ if (!res.ok) return false
884
+ const data = await res.json().catch(() => null)
885
+ if (data?.ok === false) return true
886
+ return data?.ok === true && data.executed === true
887
+ } catch (e) {
888
+ console.error('slash command bridge failed', e)
889
+ }
890
+ return false
891
+ }
892
+
689
893
  async function sendMessage() {
690
894
  const input = $('composer')
691
895
  const text = input.value.trim()
692
896
  if (!text || !state.current) return
897
+ if (await runSlashCommand(text)) { input.value = ''; return }
693
898
  input.value = ''
694
899
  const v = await safeRpc('session.prompt', { sessionId: state.current, text }, '')
695
900
  if (v) toast(t('ds.toastSent'), 'ok')
@@ -912,17 +1117,39 @@ function showView(id) {
912
1117
  if (id === 'view-chat') { const s = state.byId.get(state.current); $('ds-title').textContent = s ? titleOf(s) : t('ds.sessions') }
913
1118
  else $('ds-title').textContent = t(titles[id])
914
1119
  if (id === 'view-files' && !state.fs.loaded) loadFs(null, true)
1120
+ if (id === 'view-settings') showSettingsHome()
1121
+ }
1122
+
1123
+ const SETTINGS_GROUPS = ['general', 'servers', 'notify', 'theme', 'about']
1124
+ function showSettingsHome() {
1125
+ const home = $('settings-home')
1126
+ if (!home) return
1127
+ home.classList.remove('hidden')
1128
+ for (const name of SETTINGS_GROUPS) $('settings-page-' + name)?.classList.add('hidden')
1129
+ }
1130
+ function showSettingsPage(name) {
1131
+ const home = $('settings-home')
1132
+ if (!home || !SETTINGS_GROUPS.includes(name)) return
1133
+ home.classList.add('hidden')
1134
+ for (const g of SETTINGS_GROUPS) $('settings-page-' + g)?.classList.toggle('hidden', g !== name)
915
1135
  }
916
1136
  function updateConn() {
917
1137
  const el = $('conn-badge')
1138
+ const cur = state.servers.find(s => s.url === state.server)
1139
+ const group = cur ? cur.group : state.activeGroup
1140
+ const label = cur ? (cur.note || cur.url) : (state.server || t('ds.origin'))
1141
+ if (state.streamMode === 'poll') {
1142
+ el.textContent = '●'
1143
+ el.className = 'ds-conn off'
1144
+ el.title = t('ds.connPollTitle')
1145
+ $('server-badge').textContent = t('ds.currentServer', { group, url: label })
1146
+ return
1147
+ }
918
1148
  const any = Object.values(state.streamsOk).some(Boolean)
919
1149
  const all = state.streamsOk.mux && state.streamsOk.host
920
1150
  el.textContent = '●'
921
1151
  el.className = 'ds-conn ' + (all ? 'on' : any ? 'ing' : '')
922
1152
  el.title = all ? t('ds.connOn') : any ? t('ds.connIng') : t('ds.connOff')
923
- const cur = state.servers.find(s => s.url === state.server)
924
- const group = cur ? cur.group : state.activeGroup
925
- const label = cur ? (cur.note || cur.url) : (state.server || t('ds.origin'))
926
1153
  $('server-badge').textContent = t('ds.currentServer', { group, url: label })
927
1154
  }
928
1155
 
@@ -945,8 +1172,46 @@ function bindUi() {
945
1172
  $('composer').addEventListener('keydown', (e) => {
946
1173
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage() }
947
1174
  })
1175
+ renderPresetMenuDesktop()
1176
+ $('btn-cmd').addEventListener('click', (e) => { e.stopPropagation(); toggleCmdMenuDesktop() })
1177
+ $('cmd-menu').addEventListener('click', (e) => {
1178
+ const item = e.target.closest('[data-ds-cmd]')
1179
+ if (item) {
1180
+ const input = $('composer')
1181
+ input.value = item.dataset.dsCmd + ' '
1182
+ input.focus()
1183
+ $('cmd-menu').classList.add('hidden')
1184
+ }
1185
+ })
1186
+ $('btn-preset').addEventListener('click', (e) => { e.stopPropagation(); togglePresetMenuDesktop() })
1187
+ $('preset-menu').addEventListener('click', (e) => {
1188
+ const item = e.target.closest('[data-ds-preset]')
1189
+ if (item) {
1190
+ const found = readPresets().find(x => x.id === item.dataset.dsPreset)
1191
+ if (found) {
1192
+ const input = $('composer')
1193
+ input.value = found.text
1194
+ input.focus()
1195
+ }
1196
+ $('preset-menu').classList.add('hidden')
1197
+ }
1198
+ })
1199
+ document.addEventListener('click', (e) => {
1200
+ if (!e.target.closest('#preset-menu') && !e.target.closest('#btn-preset')) $('preset-menu')?.classList.add('hidden')
1201
+ if (!e.target.closest('#cmd-menu') && !e.target.closest('#btn-cmd')) $('cmd-menu')?.classList.add('hidden')
1202
+ })
948
1203
  $('btn-stats-top').addEventListener('click', toggleStatsDrawer)
949
1204
  $('stats-drawer-close').addEventListener('click', toggleStatsDrawer)
1205
+ // 赞赏支持
1206
+ $('btn-donate').addEventListener('click', openDonateModal)
1207
+ $('btn-donate-about').addEventListener('click', openDonateModal)
1208
+ $('btn-donate-close').addEventListener('click', () => $('modal-donate').classList.add('hidden'))
1209
+ // 更新内容弹窗
1210
+ $('notes-close').addEventListener('click', closeNotesModal)
1211
+ $('notes-prev').addEventListener('click', () => scrollNotes(-1))
1212
+ $('notes-next').addEventListener('click', () => scrollNotes(1))
1213
+ $('notes-pages').addEventListener('scroll', updateNotesPage)
1214
+ $('modal-notes').addEventListener('click', (e) => { if (e.target === $('modal-notes')) closeNotesModal() })
950
1215
  // 反馈
951
1216
  $('btn-feedback').addEventListener('click', (e) => { e.stopPropagation(); toggleFeedbackMenu() })
952
1217
  $('feedback-menu').addEventListener('click', (e) => {
@@ -977,6 +1242,11 @@ function bindUi() {
977
1242
  })
978
1243
  $('stats-chart').addEventListener('mouseleave', hideTip)
979
1244
 
1245
+ $('view-settings').addEventListener('click', (e) => {
1246
+ const group = e.target.closest('[data-settings-group]')
1247
+ if (group) { showSettingsPage(group.dataset.settingsGroup); return }
1248
+ if (e.target.closest('[data-settings-back]')) { showSettingsHome(); return }
1249
+ })
980
1250
  $('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
981
1251
  $('btn-server-add').addEventListener('click', addServer)
982
1252
  $('btn-group-add').addEventListener('click', addGroup)
@@ -1017,6 +1287,7 @@ async function start() {
1017
1287
  $('token-desc').textContent = state.token ? '● ' + state.token.slice(0, 12) + '…' : t('ds.toastAuth')
1018
1288
  bindUi()
1019
1289
  updateConn()
1290
+ checkNotesOnStart()
1020
1291
  if (state.token) {
1021
1292
  if (state.servers.length) await selectFastestServer({ silent: true, reconnect: false })
1022
1293
  openStreams()
Binary file