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.
@@ -60,6 +60,7 @@ const state = {
60
60
  streamMode: 'ws', // 'ws' | 'poll'
61
61
  pollSeq: { mux: 0, host: 0 },
62
62
  fs: { path: null, initial: null, loaded: false },
63
+ models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
63
64
  view: 'sessions'
64
65
  }
65
66
  const streams = {}
@@ -96,6 +97,206 @@ function toast(text, kind = '') {
96
97
  toast._t = setTimeout(() => el.classList.add('hidden'), 2600)
97
98
  }
98
99
 
100
+ /* ---------------- 预设提示词 ---------------- */
101
+ const PRESETS_KEY = 'dshPromptPresets'
102
+ function readPresets() {
103
+ try {
104
+ const v = JSON.parse(LS.get(PRESETS_KEY, '[]') || '[]')
105
+ return Array.isArray(v) ? v.filter(p => p && typeof p.id === 'string' && typeof p.name === 'string' && typeof p.text === 'string') : []
106
+ } catch { return [] }
107
+ }
108
+ function renderPresetMenuDesktop() {
109
+ const btn = $('btn-preset')
110
+ const menu = $('preset-menu')
111
+ if (!btn || !menu) return
112
+ const list = readPresets()
113
+ btn.style.display = ''
114
+ menu.classList.add('hidden')
115
+ if (!list.length) {
116
+ menu.innerHTML = `<div class="ds-empty">${esc(t('ds.presetsEmpty'))}</div><div class="ds-empty ds-presets-guide">${esc(t('ds.presetsGuide'))}</div>`
117
+ return
118
+ }
119
+ menu.innerHTML = list.map(p => `<button class="ds-feedback-item" data-ds-preset="${esc(p.id)}">${esc(p.name)}</button>`).join('')
120
+ }
121
+ const PRESET_NAME_MAX = 20
122
+ const PRESET_TEXT_MAX = 2000
123
+ const PRESET_LIMIT = 20
124
+ function renderPresetSummary() {
125
+ const home = $('preset-count-home')
126
+ const modal = $('preset-count')
127
+ const n = readPresets().length
128
+ if (home) home.textContent = `· ${n}/${PRESET_LIMIT}`
129
+ if (modal) modal.textContent = `${n}/${PRESET_LIMIT}`
130
+ }
131
+ function writePresets(list) {
132
+ LS.set(PRESETS_KEY, JSON.stringify(list))
133
+ renderPresetSummary()
134
+ renderPresets()
135
+ renderPresetMenuDesktop()
136
+ }
137
+ function renderPresets() {
138
+ const box = $('preset-list')
139
+ if (!box) return
140
+ const list = readPresets()
141
+ renderPresetSummary()
142
+ if (!list.length) {
143
+ box.innerHTML = `<div class="ds-preset-empty">${esc(t('presets.empty'))}</div>`
144
+ return
145
+ }
146
+ box.innerHTML = list.map(p => `<div class="ds-preset-row">
147
+ <div class="ds-preset-main"><div class="ds-preset-name">${esc(p.name)}</div><div class="ds-preset-preview">${esc((p.text || '').slice(0, 60))}</div></div>
148
+ <button class="ds-mini-btn" data-preset-edit="${esc(p.id)}">${t('presets.edit')}</button>
149
+ <button class="ds-mini-btn" data-preset-del="${esc(p.id)}">${t('presets.delete')}</button>
150
+ </div>`).join('')
151
+ box.querySelectorAll('[data-preset-edit]').forEach(b => b.addEventListener('click', () => editPreset(b.dataset.presetEdit)))
152
+ box.querySelectorAll('[data-preset-del]').forEach(b => b.addEventListener('click', () => deletePreset(b.dataset.presetDel)))
153
+ }
154
+ function promptPreset(id) {
155
+ const list = readPresets()
156
+ const existing = id ? list.find(p => p.id === id) : null
157
+ const name = prompt(t('presets.namePrompt'), existing?.name || '')
158
+ if (name == null) return
159
+ const text = prompt(t('presets.textPrompt'), existing?.text || '')
160
+ if (text == null) return
161
+ const n = (name || '').trim()
162
+ if (!n) return toast(t('presets.nameEmpty'), 'err')
163
+ if (n.length > PRESET_NAME_MAX) return toast(t('presets.nameTooLong'), 'err')
164
+ if (text.length > PRESET_TEXT_MAX) return toast(t('presets.textTooLong'), 'err')
165
+ if (existing) {
166
+ existing.name = n
167
+ existing.text = text
168
+ } else {
169
+ if (list.length >= PRESET_LIMIT) return toast(t('presets.limit'), 'err')
170
+ list.push({ id: 'p' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), name: n, text })
171
+ }
172
+ writePresets(list)
173
+ toast(existing ? t('presets.saved') : t('presets.added'), 'ok')
174
+ }
175
+ function addPreset() { promptPreset(null) }
176
+ function editPreset(id) { promptPreset(id) }
177
+ function deletePreset(id) {
178
+ const list = readPresets()
179
+ const p = list.find(x => x.id === id)
180
+ if (!p) return
181
+ if (!confirm(t('presets.confirmDelete', { name: p.name }))) return
182
+ writePresets(list.filter(x => x.id !== id))
183
+ toast(t('presets.deleted'), 'ok')
184
+ }
185
+ function openPresetModal() {
186
+ renderPresets()
187
+ const modal = $('modal-presets')
188
+ if (modal) modal.classList.remove('hidden')
189
+ }
190
+ function closePresetModal() {
191
+ const modal = $('modal-presets')
192
+ if (modal) modal.classList.add('hidden')
193
+ }
194
+ function togglePresetMenuDesktop() {
195
+ const menu = $('preset-menu')
196
+ if (!menu) return
197
+ menu.classList.toggle('hidden')
198
+ }
199
+ function toggleCmdMenuDesktop() {
200
+ const menu = $('cmd-menu')
201
+ if (!menu) return
202
+ menu.classList.toggle('hidden')
203
+ }
204
+
205
+ /* ---------------- 模型 / 思考深度 ---------------- */
206
+ function toggleModelMenuDesktop() {
207
+ const menu = $('model-menu')
208
+ if (!menu) return
209
+ const willOpen = menu.classList.contains('hidden')
210
+ menu.classList.toggle('hidden', !willOpen)
211
+ if (willOpen && !state.models.loaded && !state.models.loading) loadSessionModels()
212
+ }
213
+
214
+ async function loadSessionModels() {
215
+ if (!state.current || state.models.loading) return
216
+ state.models.loading = true
217
+ renderModelMenu()
218
+ try {
219
+ const v = await rpc('session.models', { sessionId: state.current })
220
+ state.models.groups = v.groups || []
221
+ state.models.current = v.current || null
222
+ state.models.failures = v.failures || []
223
+ state.models.loaded = true
224
+ } catch (e) {
225
+ if (e.message === 'AUTH') { toast(t('ds.toastAuth'), 'err'); state.models.loading = false; renderModelMenu(); return }
226
+ toast(t('models.loadFailed', { msg: e.message }), 'err')
227
+ }
228
+ state.models.loading = false
229
+ renderModelMenu()
230
+ }
231
+
232
+ function renderModelMenu() {
233
+ const box = $('model-menu')
234
+ if (!box) return
235
+ if (state.models.loading) { box.innerHTML = `<div class="ds-model-head">${t('menu.modelTitle')}</div><span>${t('models.loading')}</span>`; return }
236
+ const groups = state.models.groups || []
237
+ if (!groups.length) {
238
+ box.innerHTML = `<div class="ds-model-head">${t('menu.modelTitle')}</div><span>${(state.models.failures || []).map(f => f.name + ' ' + t('models.unavailable')).join(';') || t('models.none')}</span>`
239
+ return
240
+ }
241
+ const cur = state.models.current
242
+ box.innerHTML = `<div class="ds-model-head">${t('menu.modelTitle')}</div>` + groups.map(g => `
243
+ <div class="ds-model-group">
244
+ <div class="ds-model-provider">${esc(g.name || g.id)}</div>
245
+ <div class="ds-model-chips">${(g.models || []).map(m => {
246
+ const isCur = cur && cur.provider === g.id && cur.model === m.id
247
+ return `<button class="ds-model-chip ${isCur ? 'current' : ''}" data-model="${esc(m.id)}" data-provider="${esc(g.id)}">${esc(m.name || m.id)}</button>`
248
+ }).join('')}</div>
249
+ </div>`).join('') + `<div class="ds-model-effort-group" id="model-effort-group"><div class="ds-model-provider">${t('menu.effortTitle')}</div><div class="ds-model-chips" id="model-efforts"></div></div>`
250
+ box.querySelectorAll('[data-model]').forEach(btn =>
251
+ btn.addEventListener('click', () => selectSessionModel(btn.dataset.provider, btn.dataset.model)))
252
+ renderEffortMenu()
253
+ }
254
+
255
+ function renderEffortMenu() {
256
+ const group = $('model-effort-group')
257
+ const box = $('model-efforts')
258
+ if (!group || !box) return
259
+ const cur = state.models.current
260
+ const provider = (state.models.groups || []).find(g => g.id === cur?.provider)
261
+ const model = (provider?.models || []).find(m => m.id === cur?.model)
262
+ const efforts = model?.reasoning?.efforts || []
263
+ group.classList.toggle('hidden', !efforts.length)
264
+ box.innerHTML = efforts.map(e => {
265
+ const isCur = cur?.reasoningEffort === e.id || (!cur?.reasoningEffort && e.id === model.reasoning.defaultEffort)
266
+ return `<button class="ds-model-chip ${isCur ? 'current' : ''}" data-effort="${esc(e.id)}" title="${esc(e.description || '')}">${esc(e.name || e.id)}</button>`
267
+ }).join('')
268
+ box.querySelectorAll('[data-effort]').forEach(btn =>
269
+ btn.addEventListener('click', () => selectSessionEffort(btn.dataset.effort)))
270
+ }
271
+
272
+ async function selectSessionEffort(effortId) {
273
+ const cur = state.models.current
274
+ if (!state.current || !cur) return
275
+ const v = await safeRpc('session.selectModel', {
276
+ sessionId: state.current, provider: cur.provider, model: cur.model, reasoningEffort: effortId
277
+ }, t('models.effortFailed'))
278
+ if (v?.selected) {
279
+ state.models.current = v.selected
280
+ renderEffortMenu()
281
+ toast(t('models.effortSwitched', { effort: effortId }), 'ok')
282
+ }
283
+ }
284
+
285
+ async function selectSessionModel(provider, modelId) {
286
+ if (!state.current) return
287
+ const group = (state.models.groups || []).find(g => g.id === provider)
288
+ const model = (group?.models || []).find(m => m.id === modelId)
289
+ const payload = { sessionId: state.current, provider, model: modelId }
290
+ const effort = model?.reasoning?.defaultEffort || model?.reasoning?.efforts?.[0]?.id
291
+ if (effort) payload.reasoningEffort = effort
292
+ const v = await safeRpc('session.selectModel', payload, t('models.switchFailed'))
293
+ if (v?.selected) {
294
+ state.models.current = v.selected
295
+ renderModelMenu()
296
+ toast(t('models.switched', { model: v.selected.model }), 'ok')
297
+ }
298
+ }
299
+
99
300
  /* ---------------- 反馈 ---------------- */
100
301
  const FEEDBACK_LINKS = {
101
302
  githubIssues: 'https://github.com/Blank-not-black/dsh-Remote/issues',
@@ -134,6 +335,120 @@ function openFeedbackModal() {
134
335
  setTimeout(() => $('fb-msg').focus(), 50)
135
336
  }
136
337
  function closeFeedbackModal() { $('modal-feedback').classList.add('hidden') }
338
+ function openDonateModal() {
339
+ const m = $('modal-donate')
340
+ if (m) m.classList.remove('hidden')
341
+ }
342
+ /* ---------------- 更新内容弹窗 ---------------- */
343
+ const NOTES_KEY = 'seenNotesVersion'
344
+ let notesVersion = ''
345
+ let notesPages = []
346
+ let notesPage = 0
347
+ function parseVersion(v) {
348
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v || '').trim())
349
+ if (!m) return { core: [0, 0, 0], pre: null }
350
+ return { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null }
351
+ }
352
+ function cmpVersion(a, b) {
353
+ const pa = parseVersion(a), pb = parseVersion(b)
354
+ for (let i = 0; i < 3; i++) {
355
+ const d = pa.core[i] - pb.core[i]
356
+ if (d) return d
357
+ }
358
+ if (!pa.pre && !pb.pre) return 0
359
+ if (!pa.pre) return 1
360
+ if (!pb.pre) return -1
361
+ const sa = String(pa.pre).split('.'), sb = String(pb.pre).split('.')
362
+ for (let i = 0; i < Math.max(sa.length, sb.length); i++) {
363
+ const x = sa[i] ?? '', y = sb[i] ?? ''
364
+ if (x === y) continue
365
+ const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y)
366
+ if (nx && ny) { const d = Number(x) - Number(y); if (d) return d }
367
+ else if (nx !== ny) return nx ? -1 : 1
368
+ else { const d = x.localeCompare(y); if (d) return d }
369
+ }
370
+ return 0
371
+ }
372
+ function splitNotes(notes) {
373
+ return String(notes || '').split(/[;;]/).map(s => s.trim()).filter(Boolean)
374
+ }
375
+ function renderNotesPages(items) {
376
+ const box = $('notes-pages')
377
+ if (!box) return
378
+ const pages = []
379
+ for (let i = 0; i < items.length; i += 3) pages.push(items.slice(i, i + 3))
380
+ notesPages = pages
381
+ notesPage = 0
382
+ 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('')
383
+ box.scrollLeft = 0
384
+ updateNotesPage()
385
+ }
386
+ function renderNotesVersionPages(entries) {
387
+ const box = $('notes-pages')
388
+ if (!box) return
389
+ notesPages = entries
390
+ notesPage = 0
391
+ box.innerHTML = entries.map(entry => {
392
+ const items = splitNotes(entry.notes)
393
+ return `<div class="notes-page" style="flex:0 0 100%;scroll-snap-align:start;box-sizing:border-box;min-width:0;">
394
+ <div class="notes-version-title" style="font-weight:700;margin-bottom:6px;opacity:.9;">v${esc(entry.version)}</div>
395
+ ${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>`}
396
+ </div>`
397
+ }).join('')
398
+ box.scrollLeft = 0
399
+ updateNotesPage()
400
+ }
401
+ function updateNotesPage() {
402
+ const box = $('notes-pages')
403
+ const pageEl = $('notes-page')
404
+ if (!box || !pageEl) return
405
+ const total = notesPages.length || 1
406
+ const idx = Math.min(Math.max(0, Math.round(box.scrollLeft / Math.max(1, box.clientWidth))), total - 1)
407
+ notesPage = idx
408
+ pageEl.textContent = t('ds.notesPage', { current: idx + 1, total })
409
+ }
410
+ function scrollNotes(dir) {
411
+ const box = $('notes-pages')
412
+ if (box) box.scrollBy({ left: dir * box.clientWidth, behavior: 'smooth' })
413
+ }
414
+ function openNotesModal(info) {
415
+ if (!info?.version) return
416
+ const history = Array.isArray(info.history) ? info.history.filter(h => h && typeof h.version === 'string' && typeof h.notes === 'string' && !String(h.version).includes('-rc')) : []
417
+ const latestStable = history[0]?.version || info.version
418
+ if (history.length) {
419
+ const seen = LS.get(NOTES_KEY, '')
420
+ let entries
421
+ if (seen) entries = history.filter(h => cmpVersion(h.version, seen) > 0)
422
+ else entries = history.slice(0, 3)
423
+ if (!entries.length) return
424
+ notesVersion = latestStable
425
+ const vEl = $('notes-version')
426
+ if (vEl) vEl.textContent = 'v' + latestStable
427
+ renderNotesVersionPages(entries.slice().reverse())
428
+ $('modal-notes').classList.remove('hidden')
429
+ return
430
+ }
431
+ if (String(info.version).includes('-rc')) return
432
+ if (LS.get(NOTES_KEY) === info.version) return
433
+ const items = splitNotes(info.notes)
434
+ if (!items.length) return
435
+ notesVersion = info.version
436
+ const vEl = $('notes-version')
437
+ if (vEl) vEl.textContent = 'v' + info.version
438
+ renderNotesPages(items)
439
+ $('modal-notes').classList.remove('hidden')
440
+ }
441
+ function closeNotesModal() {
442
+ $('modal-notes').classList.add('hidden')
443
+ if (notesVersion) { LS.set(NOTES_KEY, notesVersion); notesVersion = '' }
444
+ }
445
+ async function checkNotesOnStart() {
446
+ try {
447
+ const res = await fetch('../update.json?t=' + Date.now())
448
+ if (!res.ok) return
449
+ openNotesModal(await res.json())
450
+ } catch {}
451
+ }
137
452
  async function submitFeedback() {
138
453
  const type = document.querySelector('#fb-chips .ds-fb-chip.current')?.dataset.fbType || 'bug'
139
454
  const message = $('fb-msg').value.trim()
@@ -637,11 +952,23 @@ function applyProjection(sessionId, key, value, seq) {
637
952
  s.projections.values[key] = value
638
953
  s.projections.asOfSeq = Math.max(s.projections.asOfSeq || 0, seq || 0)
639
954
  }
640
- if (state.current === sessionId) renderSessions()
955
+ if (state.current === sessionId) {
956
+ renderSessions()
957
+ if (['goal', 'todos'].includes(key)) renderSessionCards()
958
+ }
641
959
  if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) refreshSessions()
642
960
  }
643
961
  function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
644
962
  function titleOf(s) { return proj(s, 'title') || short(s.sessionId) }
963
+ const GOAL_TERMINAL_PHASES = new Set(['complete', 'cleared'])
964
+ function isGoalTerminal(goal) {
965
+ return !!goal && GOAL_TERMINAL_PHASES.has(goal.phase)
966
+ }
967
+ function goalOf(s) {
968
+ const p = proj(s, 'goal')
969
+ if (!p) return null
970
+ return p.goal && typeof p.goal === 'object' ? p.goal : p
971
+ }
645
972
  function onSessionEvent(sessionId, event) {
646
973
  if (state.current === sessionId && event) {
647
974
  const h = state.history
@@ -652,6 +979,7 @@ function onSessionEvent(sessionId, event) {
652
979
  h.visible.sort((a, b) => a.seq - b.seq)
653
980
  renderHistory()
654
981
  }
982
+ if (String(event.type || '').startsWith('goal/') || String(event.type || '').startsWith('todo/')) renderSessionCards()
655
983
  }
656
984
  }
657
985
 
@@ -680,15 +1008,19 @@ function renderSessions() {
680
1008
  async function openSession(id) {
681
1009
  state.current = id
682
1010
  state.history = { seqs: new Set(), visible: [], hasMore: false, loading: false, minSeq: Infinity }
1011
+ state.models = { loaded: false, loading: false, groups: [], current: null, failures: [] }
683
1012
  showView('view-chat')
684
1013
  $('ds-title').textContent = titleOf(state.byId.get(id)) || t('ds.sessions')
685
1014
  $('history').innerHTML = `<div class="ds-empty">${t('ds.historyLoading')}</div>`
686
1015
  renderSessions()
1016
+ renderSessionCards()
687
1017
  await loadHistory()
688
1018
  }
689
1019
  function closeSession() {
690
1020
  state.current = null
691
1021
  state.history = { seqs: new Set(), visible: [], hasMore: false, loading: false, minSeq: Infinity }
1022
+ const cards = $('session-cards')
1023
+ if (cards) cards.innerHTML = ''
692
1024
  showView('view-sessions')
693
1025
  }
694
1026
  async function loadHistory() {
@@ -735,6 +1067,13 @@ function blockHtml(b) {
735
1067
  if (b.type === 'image') return `<div>🖼</div>`
736
1068
  return ''
737
1069
  }
1070
+ function systemReminderText(blocks) {
1071
+ if (!Array.isArray(blocks)) return ''
1072
+ return blocks
1073
+ .filter(b => b && typeof b === 'object' && b.type === 'text' && String(b.text ?? '').trimStart().startsWith('<system-reminder>'))
1074
+ .map(b => String(b.text ?? ''))
1075
+ .join('\n')
1076
+ }
738
1077
  function eventHtml(entry) {
739
1078
  const ev = entry.event || {}
740
1079
  const data = ev.data || {}
@@ -744,6 +1083,11 @@ function eventHtml(entry) {
744
1083
  const msg = data.message || {}
745
1084
  const role = data.role || msg.role || (type.startsWith('user') ? 'user' : 'assistant')
746
1085
  const blocks = msg.content || data.content || []
1086
+ const sysText = type === 'user/message' ? systemReminderText(blocks) : ''
1087
+ if (sysText) {
1088
+ const shown = sysText.length > 400 ? sysText.slice(0, 400) + '…' : sysText
1089
+ return `<details class="event ds-tool"><summary>${esc(t('ds.eventSystemReminder'))}</summary><pre>${esc(shown)}</pre></details>`
1090
+ }
747
1091
  const text = blocks.map(blockHtml).join('')
748
1092
  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>`
749
1093
  }
@@ -767,10 +1111,124 @@ function renderHistory() {
767
1111
  box.innerHTML = items.map(eventHtml).join('') || `<div class="ds-empty">${t('ds.historyEmpty')}</div>`
768
1112
  box.scrollTop = box.scrollHeight
769
1113
  }
1114
+
1115
+ /* ---------------- 会话信息卡(goal / todo / 子代理) ---------------- */
1116
+ async function renderSessionCards() {
1117
+ const box = $('session-cards')
1118
+ const s = state.byId.get(state.current)
1119
+ if (!box) return
1120
+ if (!s) { box.innerHTML = ''; return }
1121
+ const goal = goalOf(s)
1122
+ const todos = proj(s, 'todos')
1123
+ let html = ''
1124
+ if (goal && !isGoalTerminal(goal)) {
1125
+ html += `<div class="ds-card ds-goal-card"><div class="ds-card-title">${t('goal.title')}</div>
1126
+ <div class="ds-goal-obj">${esc(goal.objective || '')}</div>
1127
+ <div class="ds-goal-phase">phase: ${esc(goal.phase || '?')} · revision ${goal.revision ?? '?'}</div>
1128
+ <div class="ds-goal-actions">
1129
+ ${goal.phase === 'active' ? `<button class="ds-mini-btn" data-goal="pause">${t('goal.pause')}</button>` : `<button class="ds-mini-btn" data-goal="resume">${t('goal.resume')}</button>`}
1130
+ <button class="ds-mini-btn" data-goal="complete">${t('goal.complete')}</button>
1131
+ <button class="ds-mini-btn" data-goal="edit">${t('goal.edit')}</button>
1132
+ <button class="ds-mini-btn" data-goal="clear">${t('goal.clear')}</button>
1133
+ </div></div>`
1134
+ }
1135
+ if (todos?.items?.length) {
1136
+ html += `<div class="ds-card"><div class="ds-card-title">${t('todos.title')}</div>${todos.items.map(item =>
1137
+ `<div class="ds-todo-row"><span class="ds-pill ${item.status === 'completed' ? 'done' : item.status === 'in_progress' ? 'active' : ''}">${esc(item.status || 'pending')}</span><span>${esc(item.content || '')}</span></div>`
1138
+ ).join('')}</div>`
1139
+ }
1140
+ box.innerHTML = html
1141
+ box.querySelectorAll('[data-goal]').forEach(btn =>
1142
+ btn.addEventListener('click', () => goalAction(btn.dataset.goal)))
1143
+
1144
+ const sub = await safeRpc('subagent.list', { parentSessionId: state.current }, '')
1145
+ if (sub?.entries?.length) {
1146
+ const rows = sub.entries.map(e => {
1147
+ if (e.kind === 'diagnostic') return `<div class="ds-card-row"><span class="ds-card-k">${t('subagent.diagnostic')}</span><span class="ds-card-v">${esc(e.reason)}</span></div>`
1148
+ const label = e.label || short(e.id)
1149
+ const running = e.activity === 'running'
1150
+ return `<div class="ds-card-row"><span class="ds-card-k">${running ? '▶ ' : ''}${esc(label)}</span><span class="ds-card-v">${esc(e.mode)} ${running ? t('subagent.running') : ''}${e.mode === 'continuable' && running ? ` <button class="ds-mini-btn" data-sub-interrupt="${esc(e.id)}">${t('subagent.interrupt')}</button>` : ''}</span></div>`
1151
+ }).join('')
1152
+ box.insertAdjacentHTML('beforeend', `<div class="ds-card"><div class="ds-card-title">${t('subagent.title')}</div>${rows}</div>`)
1153
+ box.querySelectorAll('[data-sub-interrupt]').forEach(btn =>
1154
+ btn.addEventListener('click', () => interruptSubagent(btn.dataset.subInterrupt)))
1155
+ }
1156
+ }
1157
+
1158
+ function setGoalPhaseLocal(phase) {
1159
+ const s = state.byId.get(state.current)
1160
+ const p = s && proj(s, 'goal')
1161
+ const goal = p && typeof p === 'object' && p.goal && typeof p.goal === 'object' ? p.goal : p
1162
+ if (!goal) return
1163
+ goal.phase = phase
1164
+ renderSessions()
1165
+ renderSessionCards()
1166
+ }
1167
+
1168
+ async function goalAction(kind) {
1169
+ const s = state.byId.get(state.current)
1170
+ const goal = goalOf(s)
1171
+ if (!goal) return toast(t('goal.none'), 'err')
1172
+ const ref = { id: goal.id, revision: goal.revision }
1173
+ if (kind === 'edit') {
1174
+ const objective = prompt(t('goal.editPrompt'), goal.objective || '')
1175
+ if (objective === null) return
1176
+ if (!objective.trim()) return toast(t('goal.cannotEmpty'), 'err')
1177
+ await safeRpc('goal.edit', { sessionId: state.current, ref, objective: objective.trim() }, t('goal.updateFailed'))
1178
+ toast(t('goal.updated'), 'ok')
1179
+ refreshSessions()
1180
+ renderSessionCards()
1181
+ return
1182
+ }
1183
+ const map = { pause: 'goal.pause', resume: 'goal.resume', complete: 'goal.complete', clear: 'goal.clear' }
1184
+ const method = map[kind]
1185
+ if (!method) return
1186
+ if (kind === 'clear' && !confirm(t('goal.confirmClear'))) return
1187
+ if (kind === 'complete' && !confirm(t('goal.confirmComplete'))) return
1188
+ await safeRpc(method, { sessionId: state.current, ref }, t('goal.actionFailed'))
1189
+ if (kind === 'complete') setGoalPhaseLocal('complete')
1190
+ if (kind === 'clear') setGoalPhaseLocal('cleared')
1191
+ toast(t('goal.actionSubmitted'), 'ok')
1192
+ refreshSessions()
1193
+ renderSessionCards()
1194
+ }
1195
+
1196
+ async function interruptSubagent(childId) {
1197
+ if (!confirm(t('subagent.confirmInterrupt'))) return
1198
+ await safeRpc('subagent.interrupt', { parentSessionId: state.current, childSessionId: childId, mode: 'continuable' }, t('subagent.interruptFailed'))
1199
+ toast(t('subagent.interruptSubmitted'), 'ok')
1200
+ setTimeout(renderSessionCards, 600)
1201
+ }
1202
+
1203
+ async function runSlashCommand(text) {
1204
+ const clean = String(text || '').trim()
1205
+ if (!clean.startsWith('/') || !state.current) return false
1206
+ try {
1207
+ const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
1208
+ ? AbortSignal.timeout(20000)
1209
+ : undefined
1210
+ const res = await fetch(apiUrl('/remote/api/command'), {
1211
+ method: 'POST',
1212
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' },
1213
+ body: JSON.stringify({ sessionId: state.current, line: clean }),
1214
+ ...(signal ? { signal } : {})
1215
+ })
1216
+ if (res.status === 401) { toast(t('ds.toastAuth'), 'err'); return true }
1217
+ if (!res.ok) return false
1218
+ const data = await res.json().catch(() => null)
1219
+ if (data?.ok === false) return true
1220
+ return data?.ok === true && data.executed === true
1221
+ } catch (e) {
1222
+ console.error('slash command bridge failed', e)
1223
+ }
1224
+ return false
1225
+ }
1226
+
770
1227
  async function sendMessage() {
771
1228
  const input = $('composer')
772
1229
  const text = input.value.trim()
773
1230
  if (!text || !state.current) return
1231
+ if (await runSlashCommand(text)) { input.value = ''; return }
774
1232
  input.value = ''
775
1233
  const v = await safeRpc('session.prompt', { sessionId: state.current, text }, '')
776
1234
  if (v) toast(t('ds.toastSent'), 'ok')
@@ -993,6 +1451,22 @@ function showView(id) {
993
1451
  if (id === 'view-chat') { const s = state.byId.get(state.current); $('ds-title').textContent = s ? titleOf(s) : t('ds.sessions') }
994
1452
  else $('ds-title').textContent = t(titles[id])
995
1453
  if (id === 'view-files' && !state.fs.loaded) loadFs(null, true)
1454
+ if (id === 'view-settings') showSettingsHome()
1455
+ }
1456
+
1457
+ const SETTINGS_GROUPS = ['general', 'servers', 'theme', 'about']
1458
+ function showSettingsHome() {
1459
+ const home = $('settings-home')
1460
+ if (!home) return
1461
+ home.classList.remove('hidden')
1462
+ for (const name of SETTINGS_GROUPS) $('settings-page-' + name)?.classList.add('hidden')
1463
+ }
1464
+ function showSettingsPage(name) {
1465
+ const home = $('settings-home')
1466
+ if (!home || !SETTINGS_GROUPS.includes(name)) return
1467
+ home.classList.add('hidden')
1468
+ for (const g of SETTINGS_GROUPS) $('settings-page-' + g)?.classList.toggle('hidden', g !== name)
1469
+ if (name === 'general') renderPresetSummary()
996
1470
  }
997
1471
  function updateConn() {
998
1472
  const el = $('conn-badge')
@@ -1033,8 +1507,52 @@ function bindUi() {
1033
1507
  $('composer').addEventListener('keydown', (e) => {
1034
1508
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage() }
1035
1509
  })
1510
+ renderPresetMenuDesktop()
1511
+ renderPresetSummary()
1512
+ $('btn-cmd').addEventListener('click', (e) => { e.stopPropagation(); toggleCmdMenuDesktop() })
1513
+ $('cmd-menu').addEventListener('click', (e) => {
1514
+ const item = e.target.closest('[data-ds-cmd]')
1515
+ if (item) {
1516
+ const input = $('composer')
1517
+ input.value = item.dataset.dsCmd + ' '
1518
+ input.focus()
1519
+ $('cmd-menu').classList.add('hidden')
1520
+ }
1521
+ })
1522
+ $('btn-preset').addEventListener('click', (e) => { e.stopPropagation(); togglePresetMenuDesktop() })
1523
+ $('preset-menu').addEventListener('click', (e) => {
1524
+ const item = e.target.closest('[data-ds-preset]')
1525
+ if (item) {
1526
+ const found = readPresets().find(x => x.id === item.dataset.dsPreset)
1527
+ if (found) {
1528
+ const input = $('composer')
1529
+ input.value = found.text
1530
+ input.focus()
1531
+ }
1532
+ $('preset-menu').classList.add('hidden')
1533
+ }
1534
+ })
1535
+ $('btn-model').addEventListener('click', (e) => { e.stopPropagation(); toggleModelMenuDesktop() })
1536
+ $('model-menu').addEventListener('click', (e) => {
1537
+ if (e.target.closest('[data-model]') || e.target.closest('[data-effort]')) e.stopPropagation()
1538
+ })
1539
+ document.addEventListener('click', (e) => {
1540
+ if (!e.target.closest('#preset-menu') && !e.target.closest('#btn-preset')) $('preset-menu')?.classList.add('hidden')
1541
+ if (!e.target.closest('#cmd-menu') && !e.target.closest('#btn-cmd')) $('cmd-menu')?.classList.add('hidden')
1542
+ if (!e.target.closest('#model-menu') && !e.target.closest('#btn-model')) $('model-menu')?.classList.add('hidden')
1543
+ })
1036
1544
  $('btn-stats-top').addEventListener('click', toggleStatsDrawer)
1037
1545
  $('stats-drawer-close').addEventListener('click', toggleStatsDrawer)
1546
+ // 赞赏支持
1547
+ $('btn-donate').addEventListener('click', openDonateModal)
1548
+ $('btn-donate-about').addEventListener('click', openDonateModal)
1549
+ $('btn-donate-close').addEventListener('click', () => $('modal-donate').classList.add('hidden'))
1550
+ // 更新内容弹窗
1551
+ $('notes-close').addEventListener('click', closeNotesModal)
1552
+ $('notes-prev').addEventListener('click', () => scrollNotes(-1))
1553
+ $('notes-next').addEventListener('click', () => scrollNotes(1))
1554
+ $('notes-pages').addEventListener('scroll', updateNotesPage)
1555
+ $('modal-notes').addEventListener('click', (e) => { if (e.target === $('modal-notes')) closeNotesModal() })
1038
1556
  // 反馈
1039
1557
  $('btn-feedback').addEventListener('click', (e) => { e.stopPropagation(); toggleFeedbackMenu() })
1040
1558
  $('feedback-menu').addEventListener('click', (e) => {
@@ -1065,6 +1583,15 @@ function bindUi() {
1065
1583
  })
1066
1584
  $('stats-chart').addEventListener('mouseleave', hideTip)
1067
1585
 
1586
+ $('view-settings').addEventListener('click', (e) => {
1587
+ const group = e.target.closest('[data-settings-group]')
1588
+ if (group) { showSettingsPage(group.dataset.settingsGroup); return }
1589
+ if (e.target.closest('[data-settings-back]')) { showSettingsHome(); return }
1590
+ })
1591
+ $('btn-manage-presets').addEventListener('click', openPresetModal)
1592
+ $('btn-preset-add').addEventListener('click', addPreset)
1593
+ $('btn-presets-close').addEventListener('click', closePresetModal)
1594
+ $('modal-presets').addEventListener('click', (e) => { if (e.target === $('modal-presets')) closePresetModal() })
1068
1595
  $('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
1069
1596
  $('btn-server-add').addEventListener('click', addServer)
1070
1597
  $('btn-group-add').addEventListener('click', addGroup)
@@ -1105,6 +1632,7 @@ async function start() {
1105
1632
  $('token-desc').textContent = state.token ? '● ' + state.token.slice(0, 12) + '…' : t('ds.toastAuth')
1106
1633
  bindUi()
1107
1634
  updateConn()
1635
+ checkNotesOnStart()
1108
1636
  if (state.token) {
1109
1637
  if (state.servers.length) await selectFastestServer({ silent: true, reconnect: false })
1110
1638
  openStreams()
Binary file