dsh-remote-plugin 0.6.1 → 0.6.3

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.
Binary file
package/gateway.cjs CHANGED
@@ -1653,10 +1653,20 @@ function proxyApi(req, res, url) {
1653
1653
  }
1654
1654
 
1655
1655
  // ---------- 其它 ----------
1656
- function serveHealth(res) {
1656
+ async function serveHealth(res) {
1657
+ let upstreamOk = false
1658
+ try {
1659
+ const ctrl = new AbortController()
1660
+ const timer = setTimeout(() => ctrl.abort(), 2000)
1661
+ const probe = await fetch(UPSTREAM.origin + '/healthz', { signal: ctrl.signal, cache: 'no-store' })
1662
+ clearTimeout(timer)
1663
+ upstreamOk = probe.ok
1664
+ } catch {
1665
+ upstreamOk = false
1666
+ }
1657
1667
  cors(res)
1658
1668
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
1659
- res.end(JSON.stringify({ ok: true, service: 'dsh-remote', version: VERSION, upstream: UPSTREAM.origin }))
1669
+ res.end(JSON.stringify({ ok: true, service: 'dsh-remote', version: VERSION, pid: process.pid, upstream: UPSTREAM.origin, upstreamOk }))
1660
1670
  }
1661
1671
 
1662
1672
  function lanAddresses() {
package/index.mjs CHANGED
@@ -5,8 +5,8 @@
5
5
  * 网关不可用时回退到插件模式主机状态
6
6
  * 浏览器侧入口由 client half 注册在 DSH 原生侧边栏(见 client.js)。
7
7
  */
8
- import { spawn } from 'node:child_process'
9
- import { createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs'
8
+ import { execFileSync, spawn } from 'node:child_process'
9
+ import { appendFileSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs'
10
10
  import { stat } from 'node:fs/promises'
11
11
  import { homedir, hostname, networkInterfaces } from 'node:os'
12
12
  import { dirname, extname, normalize, resolve } from 'node:path'
@@ -128,9 +128,59 @@ function runExit(cmd, args) {
128
128
 
129
129
  async function gatewayRunning() {
130
130
  try {
131
- const res = await fetch(`${GATEWAY_BASE}/health`, { signal: AbortSignal.timeout(800) })
132
- return res.ok
131
+ const res = await fetch(`${GATEWAY_BASE}/health`, { signal: AbortSignal.timeout(3000) })
132
+ if (!res.ok) return { running: false }
133
+ const data = await res.json().catch(() => ({}))
134
+ return {
135
+ running: true,
136
+ pid: Number(data.pid) || 0,
137
+ upstream: typeof data.upstream === 'string' ? data.upstream : '',
138
+ upstreamOk: data.upstreamOk === true,
139
+ }
140
+ } catch {
141
+ return { running: false }
142
+ }
143
+ }
144
+
145
+ function gatewayPidFile() { return `${homedir()}/.dsh-remote/plugin-gateway.pid` }
146
+
147
+ function readGatewayPid() {
148
+ try {
149
+ const pid = Number(readFileSync(gatewayPidFile(), 'utf8').trim())
150
+ return Number.isFinite(pid) && pid > 0 ? pid : 0
133
151
  } catch {
152
+ return 0
153
+ }
154
+ }
155
+
156
+ function writeGatewayPid(pid) {
157
+ try {
158
+ mkdirSync(`${homedir()}/.dsh-remote`, { recursive: true })
159
+ writeFileSync(gatewayPidFile(), String(pid) + '\n')
160
+ } catch {}
161
+ }
162
+
163
+ function logGateway(msg) {
164
+ try {
165
+ mkdirSync(`${homedir()}/.dsh-remote`, { recursive: true })
166
+ appendFileSync(`${homedir()}/.dsh-remote/plugin-gateway.log`, `[${new Date().toISOString()}] ${msg}\n`)
167
+ } catch {}
168
+ }
169
+
170
+ async function killGateway(health) {
171
+ const pid = (health && Number(health.pid)) || readGatewayPid()
172
+ if (!pid) return false
173
+ try {
174
+ if (process.platform === 'win32') {
175
+ await runExit('taskkill', ['/F', '/PID', String(pid)])
176
+ } else {
177
+ process.kill(pid)
178
+ }
179
+ logGateway('已停止旧网关 PID=' + pid)
180
+ await sleep(300)
181
+ return true
182
+ } catch (e) {
183
+ logGateway('停止旧网关失败 PID=' + pid + ' ' + (e?.message || String(e)))
134
184
  return false
135
185
  }
136
186
  }
@@ -157,7 +207,9 @@ function setGatewayEnabled(on) {
157
207
 
158
208
  /** 启动随插件分发的 gateway.cjs; 已运行则直接返回。 */
159
209
  async function startGateway() {
160
- if (await gatewayRunning()) {
210
+ const upstream = `http://${dshListen.host}:${dshListen.port}`
211
+ const health = await gatewayRunning()
212
+ if (health.running) {
161
213
  setGatewayEnabled(true)
162
214
  return { ok: true, running: true, started: false }
163
215
  }
@@ -166,6 +218,7 @@ async function startGateway() {
166
218
  return { ok: false, running: false, error: '插件包缺少 gateway.cjs, 请升级插件' }
167
219
  }
168
220
  const port = process.env.DSH_REMOTE_GATEWAY_PORT || '8787'
221
+ logGateway('启动网关, 上游: ' + upstream)
169
222
 
170
223
  // 首选 systemd-run: 网关成为独立 user 单元, DSH 重启/升级不会连带杀掉它
171
224
  let sysd = false
@@ -173,10 +226,16 @@ async function startGateway() {
173
226
  await runExit('systemctl', ['--user', 'reset-failed', 'dsh-remote-gateway'])
174
227
  sysd = (await runExit('systemd-run', [
175
228
  '--user', '--unit=dsh-remote-gateway', '--service-type=exec',
176
- '--setenv=PORT=' + port, '--setenv=HOST=0.0.0.0',
229
+ '--setenv=PORT=' + port, '--setenv=HOST=0.0.0.0', '--setenv=DSH_UPSTREAM=' + upstream,
177
230
  '--', process.execPath, script,
178
231
  ])) === 0
179
232
  } catch {}
233
+ if (sysd) {
234
+ try {
235
+ const pid = Number(execFileSync('systemctl', ['--user', 'show', '-p', 'MainPID', '--value', 'dsh-remote-gateway'], { encoding: 'utf8' }).trim())
236
+ if (Number.isFinite(pid) && pid > 1) writeGatewayPid(pid)
237
+ } catch {}
238
+ }
180
239
 
181
240
  // 无 systemd 的机器回退: detached 子进程
182
241
  if (!sysd) {
@@ -188,14 +247,17 @@ async function startGateway() {
188
247
  cwd: dirname(script),
189
248
  detached: true,
190
249
  stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'],
191
- env: { ...process.env, PORT: port },
250
+ env: { ...process.env, PORT: port, DSH_UPSTREAM: upstream },
192
251
  })
193
252
  child.unref()
253
+ writeGatewayPid(child.pid)
194
254
  }
195
255
  // 最多等 4 秒; 超过可能是端口冲突或首次初始化, 前端稍后刷新即可
196
256
  for (let i = 0; i < 16; i++) {
197
257
  await sleep(250)
198
- if (await gatewayRunning()) {
258
+ const h = await gatewayRunning()
259
+ if (h.running) {
260
+ if (h.pid) writeGatewayPid(h.pid)
199
261
  setGatewayEnabled(true)
200
262
  return { ok: true, running: true, started: true }
201
263
  }
@@ -210,9 +272,24 @@ function ensureGateway() {
210
272
  if (ensurePromise) return ensurePromise
211
273
  ensurePromise = (async () => {
212
274
  try {
213
- if (await gatewayRunning()) return true
214
- const out = await startGateway()
215
- return !!out.running
275
+ const health = await gatewayRunning()
276
+ if (!health.running) {
277
+ const out = await startGateway()
278
+ return !!out.running
279
+ }
280
+ const upstream = `http://${dshListen.host}:${dshListen.port}`
281
+ const oldUpstream = health.upstream || ''
282
+ if (health.upstreamOk === false || (oldUpstream && oldUpstream !== upstream) || (!oldUpstream && upstream)) {
283
+ logGateway(`网关上游需刷新: 旧=${oldUpstream || '?'} 新=${upstream}`)
284
+ await killGateway(health)
285
+ for (let i = 0; i < 10; i++) {
286
+ if (!(await gatewayRunning()).running) break
287
+ await sleep(200)
288
+ }
289
+ const out = await startGateway()
290
+ return !!out.running
291
+ }
292
+ return true
216
293
  } finally {
217
294
  setTimeout(() => { ensurePromise = null }, 4000)
218
295
  }
@@ -417,7 +494,8 @@ async function serveStatic(req, res, ctx) {
417
494
  // 本地网关开关(仅插件内嵌页使用): GET 状态 / POST {action:'start'|'stop'}
418
495
  if (pathname === `${MOUNT}/admin/api/gateway`) {
419
496
  if (req.method === 'GET') {
420
- sendJson(res, 200, { ok: true, running: await gatewayRunning() })
497
+ const h = await gatewayRunning()
498
+ sendJson(res, 200, { ok: true, running: h.running, upstream: h.upstream || '', upstreamOk: h.upstreamOk === true })
421
499
  return
422
500
  }
423
501
  if (req.method === 'POST') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
package/public/app.js CHANGED
@@ -2324,6 +2324,21 @@ function renderNotesPages(items) {
2324
2324
  box.scrollLeft = 0
2325
2325
  updateNotesPage()
2326
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
+ }
2327
2342
  function updateNotesPage() {
2328
2343
  const box = $('notes-pages')
2329
2344
  const pageEl = $('notes-page')
@@ -2338,7 +2353,21 @@ function scrollNotes(dir) {
2338
2353
  if (box) box.scrollBy({ left: dir * box.clientWidth, behavior: 'smooth' })
2339
2354
  }
2340
2355
  function openNotesModal(info) {
2341
- if (!info?.version || String(info.version).includes('-rc')) return
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
2342
2371
  if (LS.get(NOTES_KEY) === info.version) return
2343
2372
  const items = splitNotes(info.notes)
2344
2373
  if (!items.length) return
@@ -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
 
@@ -35,7 +35,7 @@
35
35
  <span class="ds-feedback-ico"><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="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg></span>
36
36
  <span class="ds-feedback-body"><span class="ds-feedback-name" data-i18n="ds.feedbackWrite">写反馈</span><span class="ds-feedback-desc" data-i18n="ds.feedbackWriteDesc">App 内直接提交</span></span>
37
37
  </button>
38
- <a class="ds-feedback-item primary" href="https://github.com/Blank-not-black/dsh-Remote/issues" target="_blank" rel="noopener" role="menuitem">
38
+ <a class="ds-feedback-item" href="https://github.com/Blank-not-black/dsh-Remote/issues" target="_blank" rel="noopener" role="menuitem">
39
39
  <span class="ds-feedback-ico"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8a8.01 8.01 0 0 0 5.47 7.59c.4.08.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg></span>
40
40
  <span class="ds-feedback-body"><span class="ds-feedback-name">GitHub Issues</span><span class="ds-feedback-desc" data-i18n="ds.feedbackGithubDesc">反馈 bug / 提建议</span></span>
41
41
  </a>
@@ -77,25 +77,36 @@
77
77
 
78
78
  <!-- 会话详情 -->
79
79
  <section id="view-chat" class="ds-view">
80
+ <div id="session-cards" class="ds-session-cards"></div>
80
81
  <div id="history" class="ds-history" aria-live="polite"></div>
81
82
  <div class="ds-composer">
82
83
  <textarea id="composer" rows="1" data-i18n-placeholder="ds.composerPlaceholder" placeholder="输入消息…"></textarea>
83
- <div style="position:relative">
84
- <button id="btn-cmd" class="ds-btn" data-i18n="ds.commands">指令</button>
85
- <div id="cmd-menu" class="ds-group-menu hidden" style="width:280px; left:auto; right:0; top:auto; bottom:calc(100% + 8px)">
86
- <button class="ds-feedback-item" data-ds-cmd="/compact" data-i18n="ds.cmdCompact">/compact 压缩对话历史</button>
87
- <button class="ds-feedback-item" data-ds-cmd="/export" data-i18n="ds.cmdExport">/export 导出会话日志 ZIP</button>
88
- <button class="ds-feedback-item" data-ds-cmd="/feedback" data-i18n="ds.cmdFeedback">/feedback 反馈当前会话</button>
89
- <button class="ds-feedback-item" data-ds-cmd="/goal" data-i18n="ds.cmdGoal">/goal 设置/查看任务目标</button>
90
- <button class="ds-feedback-item" data-ds-cmd="/permission" data-i18n="ds.cmdPermission">/permission 切换权限预设</button>
91
- <button class="ds-feedback-item" data-ds-cmd="/plan" data-i18n="ds.cmdPlan">/plan 进入/退出计划模式</button>
84
+ <div class="ds-composer-actions">
85
+ <div class="ds-composer-left">
86
+ <div style="position:relative">
87
+ <button id="btn-cmd" class="ds-btn" data-i18n="ds.commands">指令</button>
88
+ <div id="cmd-menu" class="ds-group-menu hidden" style="width:280px; left:0; right:auto; top:auto; bottom:calc(100% + 8px)">
89
+ <button class="ds-feedback-item" data-ds-cmd="/compact" data-i18n="ds.cmdCompact">/compact 压缩对话历史</button>
90
+ <button class="ds-feedback-item" data-ds-cmd="/export" data-i18n="ds.cmdExport">/export 导出会话日志 ZIP</button>
91
+ <button class="ds-feedback-item" data-ds-cmd="/feedback" data-i18n="ds.cmdFeedback">/feedback 反馈当前会话</button>
92
+ <button class="ds-feedback-item" data-ds-cmd="/goal" data-i18n="ds.cmdGoal">/goal 设置/查看任务目标</button>
93
+ <button class="ds-feedback-item" data-ds-cmd="/permission" data-i18n="ds.cmdPermission">/permission 切换权限预设</button>
94
+ <button class="ds-feedback-item" data-ds-cmd="/plan" data-i18n="ds.cmdPlan">/plan 进入/退出计划模式</button>
95
+ </div>
96
+ </div>
97
+ <div style="position:relative">
98
+ <button id="btn-preset" class="ds-btn" data-i18n="ds.presets">预设</button>
99
+ <div id="preset-menu" class="ds-group-menu hidden" style="width:260px; left:0; right:auto; top:auto; bottom:calc(100% + 8px)"></div>
100
+ </div>
101
+ <div style="position:relative">
102
+ <button id="btn-model" class="ds-btn" data-i18n="ds.models">模型</button>
103
+ <div id="model-menu" class="ds-group-menu hidden" style="width:360px; left:0; right:auto; top:auto; bottom:calc(100% + 8px)"></div>
104
+ </div>
105
+ </div>
106
+ <div class="ds-composer-right">
107
+ <button id="btn-send" class="ds-btn primary ds-send-btn" data-i18n-aria="ds.send" data-i18n-title="ds.send" aria-label="发送">↑</button>
92
108
  </div>
93
109
  </div>
94
- <div style="position:relative">
95
- <button id="btn-preset" class="ds-btn" data-i18n="ds.presets">预设</button>
96
- <div id="preset-menu" class="ds-group-menu hidden" style="width:260px; left:auto; right:0; top:auto; bottom:calc(100% + 8px)"></div>
97
- </div>
98
- <button id="btn-send" class="ds-btn primary" data-i18n="ds.send">发送</button>
99
110
  </div>
100
111
  </section>
101
112
 
@@ -117,23 +128,19 @@
117
128
  <div class="ds-settings">
118
129
  <div class="ds-setting-row" data-settings-group="general" style="cursor:pointer">
119
130
  <div><div class="ds-setting-name" data-i18n="ds.groupGeneral">通用</div><div class="ds-setting-desc" data-i18n="ds.groupGeneralDesc">工具调用、预设提示词</div></div>
120
- <span class="ds-feedback-item" style="font-size:18px">›</span>
131
+ <span class="ds-setting-arrow" aria-hidden="true">›</span>
121
132
  </div>
122
133
  <div class="ds-setting-row" data-settings-group="servers" style="cursor:pointer">
123
134
  <div><div class="ds-setting-name" data-i18n="ds.groupServers">服务器</div><div class="ds-setting-desc" data-i18n="ds.groupServersDesc">服务器地址</div></div>
124
- <span class="ds-feedback-item" style="font-size:18px">›</span>
125
- </div>
126
- <div class="ds-setting-row" data-settings-group="notify" style="cursor:pointer">
127
- <div><div class="ds-setting-name" data-i18n="ds.groupNotify">通知</div><div class="ds-setting-desc" data-i18n="ds.groupNotifyDesc">通知与提醒</div></div>
128
- <span class="ds-feedback-item" style="font-size:18px">›</span>
135
+ <span class="ds-setting-arrow" aria-hidden="true">›</span>
129
136
  </div>
130
137
  <div class="ds-setting-row" data-settings-group="theme" style="cursor:pointer">
131
138
  <div><div class="ds-setting-name" data-i18n="ds.groupTheme">皮肤</div><div class="ds-setting-desc" data-i18n="ds.groupThemeDesc">配色主题</div></div>
132
- <span class="ds-feedback-item" style="font-size:18px">›</span>
139
+ <span class="ds-setting-arrow" aria-hidden="true">›</span>
133
140
  </div>
134
141
  <div class="ds-setting-row" data-settings-group="about" style="cursor:pointer">
135
142
  <div><div class="ds-setting-name" data-i18n="ds.groupAbout">关于</div><div class="ds-setting-desc" data-i18n="ds.groupAboutDesc">版本信息</div></div>
136
- <span class="ds-feedback-item" style="font-size:18px">›</span>
143
+ <span class="ds-setting-arrow" aria-hidden="true">›</span>
137
144
  </div>
138
145
  </div>
139
146
  <div class="ds-settings">
@@ -145,14 +152,6 @@
145
152
  <div><div class="ds-setting-name" data-i18n="ds.langTitle">语言 / Language</div></div>
146
153
  <button id="btn-lang" class="ds-btn">EN</button>
147
154
  </div>
148
- <div class="ds-setting-row">
149
- <div><div class="ds-setting-name" data-i18n="ds.feedbackTitle">反馈渠道</div><div class="ds-setting-desc" data-i18n="ds.feedbackDesc">GitHub / Gitee / B站:反馈 bug、提建议、唠嗑</div></div>
150
- </div>
151
- <div class="ds-feedback-links">
152
- <a class="ds-btn" href="https://github.com/Blank-not-black/dsh-Remote" target="_blank" rel="noopener">GitHub</a>
153
- <a class="ds-btn" href="https://gitee.com/Blankneverfails/dsh-Remote" target="_blank" rel="noopener">Gitee</a>
154
- <a class="ds-btn" href="https://space.bilibili.com/419009275/dynamic" target="_blank" rel="noopener">B站</a>
155
- </div>
156
155
  </div>
157
156
  </div>
158
157
 
@@ -162,7 +161,8 @@
162
161
  <div class="ds-setting-name" data-i18n="ds.groupGeneral">通用</div>
163
162
  </div>
164
163
  <div class="ds-setting-row">
165
- <div><div class="ds-setting-name" data-i18n="ds.emptyGroup">暂无设置项</div></div>
164
+ <div><div class="ds-setting-name" data-i18n="presets.title">预设提示词</div><div class="ds-setting-desc"><span data-i18n="presets.desc">常用提示词,对话界面一键插入(最多 20 条)</span> <span id="preset-count-home"></span></div></div>
165
+ <button id="btn-manage-presets" class="ds-btn" data-i18n="presets.manage">管理</button>
166
166
  </div>
167
167
  </div>
168
168
 
@@ -190,16 +190,6 @@
190
190
  </div>
191
191
  </div>
192
192
 
193
- <div id="settings-page-notify" class="ds-settings hidden">
194
- <div class="ds-setting-row" data-settings-back style="cursor:pointer">
195
- <button class="ds-btn" data-i18n="ds.back">‹ 返回</button>
196
- <div class="ds-setting-name" data-i18n="ds.groupNotify">通知</div>
197
- </div>
198
- <div class="ds-setting-row">
199
- <div><div class="ds-setting-name" data-i18n="ds.emptyGroup">暂无设置项</div></div>
200
- </div>
201
- </div>
202
-
203
193
  <div id="settings-page-theme" class="ds-settings hidden">
204
194
  <div class="ds-setting-row" data-settings-back style="cursor:pointer">
205
195
  <button class="ds-btn" data-i18n="ds.back">‹ 返回</button>
@@ -218,7 +208,7 @@
218
208
  </div>
219
209
  <div class="ds-setting-row" id="btn-donate-about" style="cursor:pointer">
220
210
  <div><div class="ds-setting-name" data-i18n="ds.donate">赞赏支持</div><div class="ds-setting-desc" data-i18n="ds.donateDesc">如果 dsh-remote 帮到了你,欢迎赞赏支持开发 ☕</div></div>
221
- <span class="ds-feedback-item" style="font-size:18px">›</span>
211
+ <span class="ds-setting-arrow" aria-hidden="true">›</span>
222
212
  </div>
223
213
  </div>
224
214
  </section>
@@ -276,6 +266,23 @@
276
266
  </div>
277
267
  </div>
278
268
 
269
+ <!-- 预设提示词管理模态 -->
270
+ <div id="modal-presets" class="ds-modal hidden">
271
+ <div class="ds-modal-card">
272
+ <div class="ds-modal-title" data-i18n="presets.title">预设提示词</div>
273
+ <div class="ds-modal-body">
274
+ <div class="ds-preset-toolbar">
275
+ <span id="preset-count" class="ds-preset-count"></span>
276
+ <button id="btn-preset-add" class="ds-btn" data-i18n="presets.add">新增</button>
277
+ </div>
278
+ <div id="preset-list" class="ds-preset-list"></div>
279
+ </div>
280
+ <div class="ds-modal-actions">
281
+ <button id="btn-presets-close" class="ds-btn" data-i18n="ds.close">关闭</button>
282
+ </div>
283
+ </div>
284
+ </div>
285
+
279
286
  <!-- 赞赏支持模态 -->
280
287
  <div id="modal-donate" class="ds-modal hidden">
281
288
  <div class="ds-modal-card">
@@ -328,7 +335,7 @@
328
335
  'ds.groupTheme': '皮肤', 'ds.groupThemeDesc': '配色主题',
329
336
  'ds.groupAbout': '关于', 'ds.groupAboutDesc': '版本信息',
330
337
  'ds.donate': '赞赏支持', 'ds.donateDesc': '如果 dsh-remote 帮到了你,欢迎赞赏支持开发 ☕', 'ds.donateThanks': '感谢你的支持 ☕', 'ds.donateClose': '关闭',
331
- 'ds.notesTitle': '更新内容', 'ds.notesClose': '关闭', 'ds.notesPage': '{current}/{total}',
338
+ 'ds.notesTitle': '更新内容', 'ds.notesClose': '关闭', 'ds.notesPage': '版本号 {current}/{total}',
332
339
  'ds.back': '‹ 返回', 'ds.emptyGroup': '暂无设置项',
333
340
  'ds.serversTitle': '服务器(分组)', 'ds.speedTest': '测速', 'ds.currentGroup': '当前组',
334
341
  'ds.addGroup': '+ 组', 'ds.addServer': '添加', 'ds.tokenTitle': '访问令牌', 'ds.copyToken': '复制',
@@ -388,6 +395,24 @@
388
395
  'ds.questionOptions': '选项', 'ds.questionCustom': '自定义回答',
389
396
  'ds.questionNeedAnswer': '请选择一个选项或填写自定义回答', 'ds.questionSubmitted': '已提交回答',
390
397
  'ds.questionFrom': '来自 {server}',
398
+ 'ds.models': '模型', 'ds.close': '关闭',
399
+ 'ds.presetsEmpty': '暂时未设置快捷提示词', 'ds.presetsGuide': '去设置 → 通用 → 预设提示词 管理添加',
400
+ 'presets.title': '预设提示词', 'presets.desc': '常用提示词,对话界面一键插入(最多 20 条)', 'presets.manage': '管理',
401
+ 'presets.add': '新增', 'presets.edit': '编辑', 'presets.delete': '删除', 'presets.empty': '暂无预设',
402
+ 'presets.menuTitle': '预设', 'presets.namePrompt': '预设名称(≤20 字):', 'presets.textPrompt': '预设内容(≤2000 字):',
403
+ 'presets.nameEmpty': '名称不能为空', 'presets.nameTooLong': '名称不能超过 20 字', 'presets.textTooLong': '内容不能超过 2000 字', 'presets.limit': '最多 20 条预设', 'presets.added': '预设已添加',
404
+ 'presets.confirmDelete': '删除预设「{name}」?', 'presets.saved': '预设已保存', 'presets.deleted': '预设已删除',
405
+ 'goal.title': '目标', 'goal.pause': '暂停', 'goal.resume': '继续', 'goal.complete': '完成', 'goal.edit': '改目标', 'goal.clear': '清除',
406
+ 'goal.none': '当前会话没有目标', 'goal.confirmClear': '清除当前目标?(不会删除会话)', 'goal.confirmComplete': '将目标标记为完成?',
407
+ 'goal.actionFailed': '目标操作失败', 'goal.actionSubmitted': '目标操作已提交',
408
+ 'goal.cannotEmpty': '目标不能为空', 'goal.updateFailed': '更新失败', 'goal.updated': '目标已更新', 'goal.editPrompt': '修改目标:',
409
+ 'todos.title': '任务清单',
410
+ 'subagent.title': '子代理', 'subagent.diagnostic': '诊断项', 'subagent.running': '· 运行中', 'subagent.interrupt': '中断',
411
+ 'subagent.confirmInterrupt': '中断这个子代理当前回合?', 'subagent.interruptFailed': '中断失败', 'subagent.interruptSubmitted': '中断请求已提交',
412
+ 'models.loading': '模型加载中…', 'models.loadFailed': '模型列表加载失败:{msg}', 'models.unavailable': '不可用', 'models.none': '没有可用模型',
413
+ 'models.switchFailed': '切换模型失败', 'models.switched': '已切换模型:{model}',
414
+ 'models.effortFailed': '切换思考深度失败', 'models.effortSwitched': '思考深度:{effort}',
415
+ 'menu.modelTitle': '模型切换', 'menu.effortTitle': '思考深度',
391
416
  },
392
417
  en: {
393
418
  'ds.repo': 'GitHub repo', 'ds.newSession': '+ New session', 'ds.sessions': 'Sessions',
@@ -404,7 +429,7 @@
404
429
  'ds.groupTheme': 'Theme', 'ds.groupThemeDesc': 'Color themes',
405
430
  'ds.groupAbout': 'About', 'ds.groupAboutDesc': 'Version info',
406
431
  'ds.donate': 'Support', 'ds.donateDesc': 'If dsh-remote has been helpful to you, feel free to support development with a coffee ☕', 'ds.donateThanks': 'Thanks for your support ☕', 'ds.donateClose': 'Close',
407
- 'ds.notesTitle': 'What\'s New', 'ds.notesClose': 'Close', 'ds.notesPage': '{current}/{total}',
432
+ 'ds.notesTitle': 'What\'s New', 'ds.notesClose': 'Close', 'ds.notesPage': 'Version {current}/{total}',
408
433
  'ds.back': '‹ Back', 'ds.emptyGroup': 'No settings here',
409
434
  'ds.serversTitle': 'Servers (groups)', 'ds.speedTest': 'Test', 'ds.currentGroup': 'Current group',
410
435
  'ds.addGroup': '+ Group', 'ds.addServer': 'Add', 'ds.tokenTitle': 'Access token', 'ds.copyToken': 'Copy',
@@ -464,6 +489,24 @@
464
489
  'ds.questionOptions': 'Options', 'ds.questionCustom': 'Custom answer',
465
490
  'ds.questionNeedAnswer': 'Choose an option or write a custom answer', 'ds.questionSubmitted': 'Answer submitted',
466
491
  'ds.questionFrom': 'From {server}',
492
+ 'ds.models': 'Models', 'ds.close': 'Close',
493
+ 'ds.presetsEmpty': 'No quick prompts yet', 'ds.presetsGuide': 'Go to Settings → General → Prompt presets to add one',
494
+ 'presets.title': 'Prompt presets', 'presets.desc': 'Common prompts, insert into the composer in one tap (max 20)', 'presets.manage': 'Manage',
495
+ 'presets.add': 'Add', 'presets.edit': 'Edit', 'presets.delete': 'Delete', 'presets.empty': 'No presets yet',
496
+ 'presets.menuTitle': 'Presets', 'presets.namePrompt': 'Preset name (≤20 chars):', 'presets.textPrompt': 'Preset content (≤2000 chars):',
497
+ 'presets.nameEmpty': 'Name cannot be empty', 'presets.nameTooLong': 'Name must be ≤20 characters', 'presets.textTooLong': 'Content must be ≤2000 characters', 'presets.limit': 'At most 20 presets', 'presets.added': 'Preset added',
498
+ 'presets.confirmDelete': 'Delete preset "{name}"?', 'presets.saved': 'Preset saved', 'presets.deleted': 'Preset deleted',
499
+ 'goal.title': 'Goal', 'goal.pause': 'Pause', 'goal.resume': 'Resume', 'goal.complete': 'Complete', 'goal.edit': 'Edit goal', 'goal.clear': 'Clear',
500
+ 'goal.none': 'This session has no goal', 'goal.confirmClear': 'Clear the current goal? (the session stays)', 'goal.confirmComplete': 'Mark the goal as completed?',
501
+ 'goal.actionFailed': 'Goal action failed', 'goal.actionSubmitted': 'Goal action submitted',
502
+ 'goal.cannotEmpty': 'Goal cannot be empty', 'goal.updateFailed': 'Update failed', 'goal.updated': 'Goal updated', 'goal.editPrompt': 'Edit goal:',
503
+ 'todos.title': 'Todos',
504
+ 'subagent.title': 'Subagents', 'subagent.diagnostic': 'Diagnostic', 'subagent.running': '· running', 'subagent.interrupt': 'Interrupt',
505
+ 'subagent.confirmInterrupt': 'Interrupt this subagent\'s current turn?', 'subagent.interruptFailed': 'Interrupt failed', 'subagent.interruptSubmitted': 'Interrupt requested',
506
+ 'models.loading': 'Loading models…', 'models.loadFailed': 'Failed to load models: {msg}', 'models.unavailable': 'unavailable', 'models.none': 'No models available',
507
+ 'models.switchFailed': 'Model switch failed', 'models.switched': 'Switched model: {model}',
508
+ 'models.effortFailed': 'Failed to switch reasoning effort', 'models.effortSwitched': 'Reasoning effort: {effort}',
509
+ 'menu.modelTitle': 'Model', 'menu.effortTitle': 'Reasoning effort',
467
510
  }
468
511
  }
469
512
  </script>
@@ -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 = {}
@@ -109,14 +110,90 @@ function renderPresetMenuDesktop() {
109
110
  const menu = $('preset-menu')
110
111
  if (!btn || !menu) return
111
112
  const list = readPresets()
112
- btn.style.display = list.length ? '' : 'none'
113
+ btn.style.display = ''
113
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
+ }
114
119
  menu.innerHTML = list.map(p => `<button class="ds-feedback-item" data-ds-preset="${esc(p.id)}">${esc(p.name)}</button>`).join('')
115
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
+ }
116
194
  function togglePresetMenuDesktop() {
117
195
  const menu = $('preset-menu')
118
196
  if (!menu) return
119
- if (!readPresets().length) return
120
197
  menu.classList.toggle('hidden')
121
198
  }
122
199
  function toggleCmdMenuDesktop() {
@@ -125,6 +202,101 @@ function toggleCmdMenuDesktop() {
125
202
  menu.classList.toggle('hidden')
126
203
  }
127
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
+
128
300
  /* ---------------- 反馈 ---------------- */
129
301
  const FEEDBACK_LINKS = {
130
302
  githubIssues: 'https://github.com/Blank-not-black/dsh-Remote/issues',
@@ -172,6 +344,31 @@ const NOTES_KEY = 'seenNotesVersion'
172
344
  let notesVersion = ''
173
345
  let notesPages = []
174
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
+ }
175
372
  function splitNotes(notes) {
176
373
  return String(notes || '').split(/[;;]/).map(s => s.trim()).filter(Boolean)
177
374
  }
@@ -186,6 +383,21 @@ function renderNotesPages(items) {
186
383
  box.scrollLeft = 0
187
384
  updateNotesPage()
188
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
+ }
189
401
  function updateNotesPage() {
190
402
  const box = $('notes-pages')
191
403
  const pageEl = $('notes-page')
@@ -200,7 +412,23 @@ function scrollNotes(dir) {
200
412
  if (box) box.scrollBy({ left: dir * box.clientWidth, behavior: 'smooth' })
201
413
  }
202
414
  function openNotesModal(info) {
203
- if (!info?.version || String(info.version).includes('-rc')) return
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
204
432
  if (LS.get(NOTES_KEY) === info.version) return
205
433
  const items = splitNotes(info.notes)
206
434
  if (!items.length) return
@@ -724,11 +952,23 @@ function applyProjection(sessionId, key, value, seq) {
724
952
  s.projections.values[key] = value
725
953
  s.projections.asOfSeq = Math.max(s.projections.asOfSeq || 0, seq || 0)
726
954
  }
727
- if (state.current === sessionId) renderSessions()
955
+ if (state.current === sessionId) {
956
+ renderSessions()
957
+ if (['goal', 'todos'].includes(key)) renderSessionCards()
958
+ }
728
959
  if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) refreshSessions()
729
960
  }
730
961
  function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
731
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
+ }
732
972
  function onSessionEvent(sessionId, event) {
733
973
  if (state.current === sessionId && event) {
734
974
  const h = state.history
@@ -739,6 +979,7 @@ function onSessionEvent(sessionId, event) {
739
979
  h.visible.sort((a, b) => a.seq - b.seq)
740
980
  renderHistory()
741
981
  }
982
+ if (String(event.type || '').startsWith('goal/') || String(event.type || '').startsWith('todo/')) renderSessionCards()
742
983
  }
743
984
  }
744
985
 
@@ -767,15 +1008,19 @@ function renderSessions() {
767
1008
  async function openSession(id) {
768
1009
  state.current = id
769
1010
  state.history = { seqs: new Set(), visible: [], hasMore: false, loading: false, minSeq: Infinity }
1011
+ state.models = { loaded: false, loading: false, groups: [], current: null, failures: [] }
770
1012
  showView('view-chat')
771
1013
  $('ds-title').textContent = titleOf(state.byId.get(id)) || t('ds.sessions')
772
1014
  $('history').innerHTML = `<div class="ds-empty">${t('ds.historyLoading')}</div>`
773
1015
  renderSessions()
1016
+ renderSessionCards()
774
1017
  await loadHistory()
775
1018
  }
776
1019
  function closeSession() {
777
1020
  state.current = null
778
1021
  state.history = { seqs: new Set(), visible: [], hasMore: false, loading: false, minSeq: Infinity }
1022
+ const cards = $('session-cards')
1023
+ if (cards) cards.innerHTML = ''
779
1024
  showView('view-sessions')
780
1025
  }
781
1026
  async function loadHistory() {
@@ -866,6 +1111,95 @@ function renderHistory() {
866
1111
  box.innerHTML = items.map(eventHtml).join('') || `<div class="ds-empty">${t('ds.historyEmpty')}</div>`
867
1112
  box.scrollTop = box.scrollHeight
868
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
+
869
1203
  async function runSlashCommand(text) {
870
1204
  const clean = String(text || '').trim()
871
1205
  if (!clean.startsWith('/') || !state.current) return false
@@ -1120,7 +1454,7 @@ function showView(id) {
1120
1454
  if (id === 'view-settings') showSettingsHome()
1121
1455
  }
1122
1456
 
1123
- const SETTINGS_GROUPS = ['general', 'servers', 'notify', 'theme', 'about']
1457
+ const SETTINGS_GROUPS = ['general', 'servers', 'theme', 'about']
1124
1458
  function showSettingsHome() {
1125
1459
  const home = $('settings-home')
1126
1460
  if (!home) return
@@ -1132,6 +1466,7 @@ function showSettingsPage(name) {
1132
1466
  if (!home || !SETTINGS_GROUPS.includes(name)) return
1133
1467
  home.classList.add('hidden')
1134
1468
  for (const g of SETTINGS_GROUPS) $('settings-page-' + g)?.classList.toggle('hidden', g !== name)
1469
+ if (name === 'general') renderPresetSummary()
1135
1470
  }
1136
1471
  function updateConn() {
1137
1472
  const el = $('conn-badge')
@@ -1173,6 +1508,7 @@ function bindUi() {
1173
1508
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage() }
1174
1509
  })
1175
1510
  renderPresetMenuDesktop()
1511
+ renderPresetSummary()
1176
1512
  $('btn-cmd').addEventListener('click', (e) => { e.stopPropagation(); toggleCmdMenuDesktop() })
1177
1513
  $('cmd-menu').addEventListener('click', (e) => {
1178
1514
  const item = e.target.closest('[data-ds-cmd]')
@@ -1196,9 +1532,14 @@ function bindUi() {
1196
1532
  $('preset-menu').classList.add('hidden')
1197
1533
  }
1198
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
+ })
1199
1539
  document.addEventListener('click', (e) => {
1200
1540
  if (!e.target.closest('#preset-menu') && !e.target.closest('#btn-preset')) $('preset-menu')?.classList.add('hidden')
1201
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')
1202
1543
  })
1203
1544
  $('btn-stats-top').addEventListener('click', toggleStatsDrawer)
1204
1545
  $('stats-drawer-close').addEventListener('click', toggleStatsDrawer)
@@ -1247,6 +1588,10 @@ function bindUi() {
1247
1588
  if (group) { showSettingsPage(group.dataset.settingsGroup); return }
1248
1589
  if (e.target.closest('[data-settings-back]')) { showSettingsHome(); return }
1249
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() })
1250
1595
  $('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
1251
1596
  $('btn-server-add').addEventListener('click', addServer)
1252
1597
  $('btn-group-add').addEventListener('click', addGroup)
package/public/index.html CHANGED
@@ -592,7 +592,7 @@
592
592
  'update.corrupted': '下载文件损坏,请重试', 'update.serverFileMissing': '服务器上还没有对应版本的文件,请稍后再试',
593
593
  'update.installUnsupported': '当前版本不支持 App 内安装,已转浏览器下载',
594
594
  'update.expand': '展开', 'update.collapse': '收起',
595
- 'notes.title': '更新内容', 'notes.close': '关闭', 'notes.page': '{current}/{total}',
595
+ 'notes.title': '更新内容', 'notes.close': '关闭', 'notes.page': '版本号 {current}/{total}',
596
596
  'scan.imageLoadFailed': '图片加载失败', 'scan.decodeUnsupported': '当前设备不支持图片解码',
597
597
  'scan.browserHint': '浏览器请打开主机管理页,用手机相机扫码', 'scan.unsupported': '当前 App 版本不支持拍照扫码,请先更新 App',
598
598
  'scan.permissionDenied': '未授予相机权限',
@@ -771,7 +771,7 @@
771
771
  'update.corrupted': 'Downloaded file is corrupted, please retry', 'update.serverFileMissing': 'The file for this version is not on the server yet, please try again later',
772
772
  'update.installUnsupported': 'This version cannot install in-app, opening browser download',
773
773
  'update.expand': 'Expand', 'update.collapse': 'Collapse',
774
- 'notes.title': 'What\'s New', 'notes.close': 'Close', 'notes.page': '{current}/{total}',
774
+ 'notes.title': 'What\'s New', 'notes.close': 'Close', 'notes.page': 'Version {current}/{total}',
775
775
  'scan.imageLoadFailed': 'Image failed to load', 'scan.decodeUnsupported': 'This device cannot decode images',
776
776
  'scan.browserHint': 'In a browser, open the host admin page and scan with your phone camera', 'scan.unsupported': 'This app version cannot scan, please update first',
777
777
  'scan.permissionDenied': 'Camera permission not granted',
@@ -1,7 +1,17 @@
1
1
  {
2
- "version": "0.6.1",
2
+ "version": "0.6.3",
3
3
  "apkUrl": "dsh-remote.apk",
4
- "sha256": "ee8292f6689ec539377a15da6b2f08a3c05fa943354a9107c0a3c5c596f0a1a9",
5
- "releasedAt": "2026-08-18T17:20:46.562Z",
6
- "notes": "admin 管理页新增赞赏入口;设置关于行去掉多余箭头;新版本首次打开弹“更新内容”(横向分页);过滤 system-reminder 伪用户消息;赞赏码三入口(README + 设置关于 + WebUI 主页按钮);斜杠命令桥接修复与 debug 定位(executed 状态可观测);命令菜单全命令化并点击填入输入框;斜杠命令改插件桥接执行;客户端斜杠命令真实执行;会话历史秒开 + RPC 20s 超时兜底;后台轮询只统计 AI 新消息;新增任务结束通知;设置界面五分组;修复 goal 完成态隐藏与会话切换残留"
4
+ "sha256": "e658387db7cee1e0b5392fad3719056b463d40ce55b5aac0d313d797c07438f2",
5
+ "releasedAt": "2026-08-19T03:20:39.386Z",
6
+ "notes": "修复 DSH 重启后网关 upstream 端口不刷新(Issue #1):/health 增加 upstream/upstreamOk/pid 探测;插件 ensureGateway 检测上游变化或不可达时自动杀旧网关并按新 DSH_UPSTREAM 重启;启动/刷新写插件日志与 PID 文件。",
7
+ "history": [
8
+ {
9
+ "version": "0.6.3",
10
+ "notes": "修复 DSH 重启后网关 upstream 端口不刷新(Issue #1):/health 增加 upstream/upstreamOk/pid 探测;插件 ensureGateway 检测上游变化或不可达时自动杀旧网关并按新 DSH_UPSTREAM 重启;启动/刷新写插件日志与 PID 文件。"
11
+ },
12
+ {
13
+ "version": "0.6.2",
14
+ "notes": "桌面端 WebUI:设置页清理与输入区饱满化;新增会话功能(goal/todo/subagent/模型切换/思考深度);预设提示词管理与空状态引导;更新弹窗改为按正式版逐版展示历史(App/桌面端);移除通知分组;聊天宽度约 680px、输入框按钮对齐。"
15
+ }
16
+ ]
7
17
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.6.1"
2
+ "version": "0.6.3"
3
3
  }