dsh-remote-plugin 0.6.23 → 0.6.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -104,7 +104,6 @@ const state = {
104
104
  models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
105
105
  modelSettings: { status: 'idle', error: '', writable: false, hasDocument: false, providers: [], namespaces: [], credentials: {} },
106
106
  modelEditor: null,
107
- asrTest: { running: false, status: 'idle', meta: null, summary: null, events: [] },
108
107
  wb: null,
109
108
  wbProjects: [],
110
109
  wbArchived: [],
@@ -1566,6 +1565,10 @@ window.addEventListener('online', () => {
1566
1565
  function onMuxFrame(full) {
1567
1566
  const f = full.payload
1568
1567
  if (!f) return
1568
+ if (f.type === 'session/reasoning') {
1569
+ if (f.sessionId === state.current) { applyReasoningBaseline(f.partialReasoning); scheduleReasoningRender() }
1570
+ return
1571
+ }
1569
1572
  if (f.type === 'session/event') return onSessionEvent(f.sessionId, f.event)
1570
1573
  if (f.type === 'session/subscribed') return
1571
1574
  if (f.type === 'approval/requested') {
@@ -1837,7 +1840,17 @@ function resyncAfterStreamOpen() {
1837
1840
  state.lastStreamResyncAt = Date.now()
1838
1841
  void refreshAll().then(() => resyncCurrentSession())
1839
1842
  }
1840
- function titleOf(s) { return proj(s, 'title') || (s?.sessionId ? short(s.sessionId) : t('session.unknown')) }
1843
+ function sessionTitleValue(s) {
1844
+ const value = proj(s, 'title', '')
1845
+ return value == null ? '' : String(value).trim()
1846
+ }
1847
+ function hasSessionTitle(s) { return !!sessionTitleValue(s) }
1848
+ function titleOf(s) { return sessionTitleValue(s) || (s?.sessionId ? t('session.untitled') : t('session.unknown')) }
1849
+ function sessionLabelOf(s) {
1850
+ const title = titleOf(s)
1851
+ if (!s?.sessionId || hasSessionTitle(s)) return title
1852
+ return `${title} · ${String(s.sessionId).slice(-8)}`
1853
+ }
1841
1854
  function short(id) { return '…' + String(id).slice(-8) }
1842
1855
  function isTopLevelSession(session) {
1843
1856
  return !!session && !session.parentSessionId && session.origin !== 'subagent'
@@ -2110,7 +2123,7 @@ function renderWorkbench() {
2110
2123
  <div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
2111
2124
  <button class="wb-session" type="button" data-wb-session="${esc(s.sessionId)}" data-motion-key="${esc(s.sessionId)}">
2112
2125
  <span class="wb-session-drag-handle" data-reorder-handle aria-hidden="true">⠿</span>
2113
- <span class="wb-session-title">${esc(titleOf(s))}</span>
2126
+ <span class="wb-session-title">${esc(sessionLabelOf(s))}</span>
2114
2127
  <span class="wb-session-meta">${s.running ? esc(t('sessions.running')) : esc(fmtTime(sessionSortTime(s)))}</span>
2115
2128
  </button>
2116
2129
  <button type="button" class="sc-archive-btn" data-archive-session="${esc(s.sessionId)}">${esc(t('session.archive'))}</button>
@@ -2202,7 +2215,7 @@ function renderSessions() {
2202
2215
  const renderSession = s => {
2203
2216
  const workspace = sessionWorkspaceLabel(s)
2204
2217
  const workspaceTitle = sessionWorkspaceName(s)
2205
- const title = titleOf(s)
2218
+ const title = sessionLabelOf(s)
2206
2219
  const goal = goalOf(s)
2207
2220
  const pending = (state.approvals.some(a => a.sessionId === s.sessionId) || state.questions.some(q => q.sessionId === s.sessionId)) ? 'pending' : ''
2208
2221
  const queueN = (state.queues[s.sessionId] || []).filter(i => i.placement === 'queued').length
@@ -2275,7 +2288,31 @@ function renderSessions() {
2275
2288
  }
2276
2289
 
2277
2290
  /* ---------------- 会话详情 ---------------- */
2291
+ const emptySessionCleanup = new Set()
2292
+ async function archiveEmptySessionOnLeave(sessionId) {
2293
+ const base = state.server
2294
+ const protectedSession = () => state.server !== base || state.current !== sessionId
2295
+ || !state.byId.has(sessionId) || state.byId.get(sessionId)?.running
2296
+ || state.sessionActivity?.has(sessionId) || state.pendingPrompts?.has(sessionId)
2297
+ || (state.queues[sessionId] || []).length > 0
2298
+ || (state.composerImages || []).length > 0 || !!$('composer-input')?.value?.trim()
2299
+ if (!sessionId || emptySessionCleanup.has(sessionId) || protectedSession()) return false
2300
+ emptySessionCleanup.add(sessionId)
2301
+ try {
2302
+ const history = await rpc('session.history', { sessionId, maxMessages: 1 })
2303
+ if (protectedSession() || !Array.isArray(history?.events) || history.events.some(item => !['permission/preset', 'sandbox/mode', 'approval/policy'].includes(item?.event?.type || item?.type))
2304
+ || history.hasMore || (history.partialReasoning || []).length) return false
2305
+ const result = await rpc('workspace.archiveSession', { sessionId })
2306
+ if (!Array.isArray(result?.archivedSessionIds) || !result.archivedSessionIds.includes(sessionId)) return false
2307
+ if (state.server !== base) return false
2308
+ await refreshSessions()
2309
+ return true
2310
+ } catch { return false } // 离线、历史未知或归档失败时保留会话。
2311
+ finally { emptySessionCleanup.delete(sessionId) }
2312
+ }
2313
+
2278
2314
  async function openSession(id) {
2315
+ if (state.current && state.current !== id) await archiveEmptySessionOnLeave(state.current)
2279
2316
  state.current = id
2280
2317
  setSessionRecovery('loading')
2281
2318
  state.history = emptyHistory()
@@ -2318,7 +2355,7 @@ async function closeSession() {
2318
2355
  const sessionId = state.current
2319
2356
  if (!sessionId) return
2320
2357
  const task = (async () => {
2321
- const discard = await shouldDiscardEmptySession(sessionId)
2358
+ const discard = await shouldDiscardEmptySession(sessionId) && await archiveEmptySessionOnLeave(sessionId)
2322
2359
  if (state.current !== sessionId) return
2323
2360
  state.current = null
2324
2361
  renderSessionPending()
@@ -2432,6 +2469,13 @@ function reasoningStreamKey(data, index) {
2432
2469
  * DSH 的实时思考以 assistant/chunk 下发,历史尾页可能把增量压成
2433
2470
  * reasoning-chunks。最终 assistant/message 到达后再由正式消息接管展示。
2434
2471
  */
2472
+ function applyReasoningBaseline(items) {
2473
+ if (!Array.isArray(items)) return
2474
+ state.history.reasoningVersion = (state.history.reasoningVersion || 0) + 1
2475
+ state.history.partialReasoning.clear()
2476
+ for (const item of items) state.history.partialReasoning.set(reasoningStreamKey(item, item.index), item)
2477
+ }
2478
+
2435
2479
  function applyReasoningStreamEvent(event) {
2436
2480
  const h = state.history
2437
2481
  const data = event?.data || {}
@@ -2460,7 +2504,7 @@ function applyReasoningStreamEvent(event) {
2460
2504
  item.text += Array.isArray(data.texts) ? data.texts.join('') : String(data.text || '')
2461
2505
  h.partialReasoning.set(key, item)
2462
2506
  changed = true
2463
- } else if (event?.type === 'assistant/message') {
2507
+ } else if (event?.type === 'assistant/message' || event?.type === 'assistant/attempt') {
2464
2508
  for (const [key, item] of h.partialReasoning) {
2465
2509
  if (item.turn === data.turn && item.step === data.step) {
2466
2510
  h.partialReasoning.delete(key)
@@ -2550,6 +2594,7 @@ async function loadHistory(reset) {
2550
2594
  const id = state.current
2551
2595
  if (!id || state.history.loading) return
2552
2596
  const history = state.history
2597
+ const reasoningVersion = history.reasoningVersion || 0
2553
2598
  history.loading = true
2554
2599
  if (reset) setSessionRecovery('loading')
2555
2600
  const moreBtn = $('history-more')
@@ -2583,6 +2628,7 @@ async function loadHistory(reset) {
2583
2628
  }
2584
2629
 
2585
2630
  if (state.current !== id || state.history !== history) return
2631
+ const liveReasoning = (history.reasoningVersion || 0) !== reasoningVersion ? new Map(history.partialReasoning) : null
2586
2632
  hydrateSessionProjections(id, v.projections)
2587
2633
  history.loaded = true
2588
2634
  const incoming = v.events || []
@@ -2600,6 +2646,8 @@ async function loadHistory(reset) {
2600
2646
  added++
2601
2647
  }
2602
2648
  // 向前翻页游标 = 本页最旧的 raw seq(即使它本身被过滤)
2649
+ if (liveReasoning) history.partialReasoning = liveReasoning
2650
+ else applyReasoningBaseline(v.partialReasoning)
2603
2651
  const firstSeq = incoming[0]?.event?.seq
2604
2652
  if (firstSeq != null) state.history.minSeq = Math.min(state.history.minSeq, firstSeq)
2605
2653
  state.history.visible.sort((a, b) => a.seq - b.seq)
@@ -2840,6 +2888,7 @@ function blockHtml(b) {
2840
2888
  if ((b.type === 'tool-call' || b.type === 'tool-result') && LS.get('showTools', '1') === '0') return ''
2841
2889
  switch (b.type) {
2842
2890
  case 'text': return `<div class="md">${window.mdToHtml ? window.mdToHtml(b.text ?? '') : esc(b.text ?? '')}</div>`
2891
+ case 'file': return `<div class="tool">📎 ${esc(b.attachment?.name || b.name || 'file')} <small>${esc(b.attachment?.bytes ?? b.bytes ?? '')} bytes</small></div>`
2843
2892
  case 'image': return `<img alt="${t('block.image')}" src="data:${esc(b.mediaType || 'image/png')};base64,${esc(b.data || '')}">`
2844
2893
  case 'thinking':
2845
2894
  case 'reasoning':
@@ -3410,7 +3459,7 @@ function renameSession(sessionId = state.current) {
3410
3459
  const session = state.byId.get(sessionId)
3411
3460
  if (!session) return
3412
3461
  renamePendingSessionId = sessionId
3413
- $('rename-session-input').value = titleOf(session)
3462
+ $('rename-session-input').value = sessionTitleValue(session)
3414
3463
  $('modal-rename').classList.remove('hidden')
3415
3464
  setTimeout(() => { $('rename-session-input').focus(); $('rename-session-input').select() }, 40)
3416
3465
  }
@@ -3601,7 +3650,7 @@ function renderOverview() {
3601
3650
  ]
3602
3651
  $('overview-attention-count').textContent = pending.length ? t('overview.pendingCount', { n: pending.length }) : '—'
3603
3652
  $('overview-attention-list').innerHTML = pending.length ? pending.slice(0, 3).map(({ kind, item }) => {
3604
- const title = titleOf(state.byId.get(item.sessionId))
3653
+ const title = sessionLabelOf(state.byId.get(item.sessionId))
3605
3654
  if (kind === 'approval') return `<div class="overview-attention-item" data-overview-approval="${esc(item.approvalId)}">
3606
3655
  <span class="overview-item-mark">⌁</span><span class="overview-item-copy"><span class="overview-item-title">${esc(item.toolName || t('tool.default'))}</span><span class="overview-item-desc">${esc(approvalDetail(item))} · ${esc(title)}</span></span>
3607
3656
  <span class="overview-item-actions"><button type="button" class="mini-btn" data-overview-approve="1">${t('pending.allow')}</button><button type="button" class="mini-btn" data-overview-approve="0">${t('pending.reject')}</button></span>
@@ -3650,7 +3699,7 @@ function renderOverview() {
3650
3699
  $('overview-connection-mode').textContent = state.token ? t(state.streamMode === 'poll' ? 'overview.poll' : 'overview.ws') : '—'
3651
3700
  $('overview-active-count').textContent = running ? t('overview.activeCount', { n: running }) : ''
3652
3701
  $('overview-session-list').innerHTML = sessions.length ? sessions.map(s => `<button type="button" class="overview-session-item ${s.running ? 'running' : ''}" data-overview-session="${esc(s.sessionId)}">
3653
- <span class="overview-item-mark">${s.running ? '●' : '○'}</span><span class="overview-item-copy"><span class="overview-item-title">${esc(titleOf(s))}</span><span class="overview-item-desc">${s.running ? esc(t('sessions.running')) + ' · ' : ''}${esc(fmtTime(sessionSortTime(s)))}</span></span><span class="overview-item-arrow">›</span>
3702
+ <span class="overview-item-mark">${s.running ? '●' : '○'}</span><span class="overview-item-copy"><span class="overview-item-title">${esc(sessionLabelOf(s))}</span><span class="overview-item-desc">${s.running ? esc(t('sessions.running')) + ' · ' : ''}${esc(fmtTime(sessionSortTime(s)))}</span></span><span class="overview-item-arrow">›</span>
3654
3703
  </button>`).join('') : `<div class="overview-empty">${t('overview.noSession')}</div>`
3655
3704
  $('overview-session-list').querySelectorAll('[data-overview-session]').forEach(btn => btn.addEventListener('click', () => openSession(btn.dataset.overviewSession)))
3656
3705
  }
@@ -4153,9 +4202,11 @@ async function openFsPreview(pathValue, name) {
4153
4202
  $('file-preview-loading').classList.add('hidden')
4154
4203
  $('file-preview-source').textContent = data.content || ''
4155
4204
  const markdown = data.extension === '.md' || data.extension === '.markdown'
4156
- $('file-preview-tabs').classList.toggle('hidden', !markdown)
4205
+ const htmlPreview = (data.extension === '.html' || data.extension === '.htm') && !!window.DshGenUi
4206
+ $('file-preview-tabs').classList.toggle('hidden', !markdown && !htmlPreview)
4157
4207
  if (markdown) $('file-preview-rendered').innerHTML = window.mdToHtml(data.content || '')
4158
- showFsPreviewMode(markdown ? 'rendered' : 'source')
4208
+ if (htmlPreview) $('file-preview-rendered').innerHTML = window.DshGenUi.html(data.content || '')
4209
+ showFsPreviewMode(markdown || htmlPreview ? 'rendered' : 'source')
4159
4210
  } catch (e) {
4160
4211
  if (generation !== fsPreviewGeneration) return
4161
4212
  $('file-preview-loading').textContent = e.message || t('fs.previewFailed', { msg: t('fs.networkError') })
@@ -5101,77 +5152,123 @@ async function sha256Hex(buffer) {
5101
5152
  return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('')
5102
5153
  }
5103
5154
 
5104
- /**
5105
- * 下载 APK 并用 update.json 的 sha256 校验。
5106
- * 返回 { ok, skipped } 或 { ok:false, status | corrupted | network }。
5107
- * 老产物没有 sha256 时跳过校验;crypto.subtle 不可用也跳过(不阻塞老 WebView)。
5108
- */
5155
+ let updateDownloadBusy = false
5156
+ let updateDownloadTimer = null
5157
+ let updateDownloadSample = null
5158
+
5159
+ function updateDownloadProgress(value) {
5160
+ const box = $('update-download-progress')
5161
+ if (!box) return
5162
+ const received = Math.max(0, Number(value.received) || 0)
5163
+ const total = Math.max(0, Number(value.total) || 0)
5164
+ const now = performance.now()
5165
+ if (!updateDownloadSample || received < updateDownloadSample.received) updateDownloadSample = { time: now, received, speed: 0 }
5166
+ const elapsed = now - updateDownloadSample.time
5167
+ if (elapsed >= 800) updateDownloadSample = { time: now, received, speed: Math.max(0, received - updateDownloadSample.received) * 1000 / elapsed }
5168
+ const format = n => n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${(n / 1024).toFixed(1)} KB`
5169
+ const bar = $('update-download-bar')
5170
+ box.classList.remove('hidden')
5171
+ if (total > 0) bar.value = Math.min(100, received * 100 / total)
5172
+ else bar.removeAttribute('value')
5173
+ const phase = value.phase || 'downloading'
5174
+ $('update-download-status').textContent = phase === 'error' ? t('update.downloadFailed', { msg: value.error || t('fs.networkError') }) : t(`update.phase.${phase}`)
5175
+ $('update-download-detail').textContent = `${total > 0 ? `${Math.min(100, received * 100 / total).toFixed(0)}% · ` : ''}${format(received)}${total > 0 ? ` / ${format(total)}` : ''} · ${format(phase === 'downloading' ? updateDownloadSample.speed : 0)}/s`
5176
+ }
5177
+
5178
+ function finishUpdateDownload() {
5179
+ updateDownloadBusy = false
5180
+ clearInterval(updateDownloadTimer)
5181
+ updateDownloadTimer = null
5182
+ $('btn-download-update').disabled = false
5183
+ }
5184
+
5109
5185
  async function verifyUpdateApk(info, url) {
5110
- const expected = String(info.sha256 || '').trim().toLowerCase()
5111
- if (!expected || !/^[0-9a-f]{64}$/.test(expected)) return { ok: true, skipped: true }
5112
- let res
5113
- try {
5114
- const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(120000) : undefined
5115
- res = signal ? await fetch(url, { signal }) : await fetch(url)
5116
- } catch (err) {
5117
- return { ok: false, network: true, msg: err?.message || '' }
5118
- }
5119
- if (!res.ok) return { ok: false, status: res.status }
5120
- let buf
5121
- try {
5122
- buf = await res.arrayBuffer()
5123
- } catch (err) {
5124
- return { ok: false, network: true, msg: err?.message || '' }
5125
- }
5126
- let actual
5186
+ const controller = new AbortController()
5187
+ let timer
5188
+ let received = 0, total = 0, phase = 'downloading'
5189
+ const progressTimer = setInterval(() => updateDownloadProgress({ phase, received, total }), 500)
5190
+ const resetTimeout = () => { clearTimeout(timer); timer = setTimeout(() => controller.abort(), 60000) }
5127
5191
  try {
5128
- actual = await sha256Hex(buf)
5129
- } catch {
5130
- return { ok: true, skipped: true }
5131
- }
5132
- if (actual.toLowerCase() !== expected) return { ok: false, corrupted: true }
5133
- return { ok: true, skipped: false }
5192
+ resetTimeout()
5193
+ const res = await fetch(url, { signal: controller.signal })
5194
+ if (!res.ok) return { ok: false, status: res.status }
5195
+ total = Number(res.headers.get('content-length')) || 0
5196
+ const chunks = []
5197
+ if (res.body?.getReader) {
5198
+ const reader = res.body.getReader()
5199
+ for (;;) {
5200
+ const { value, done } = await reader.read()
5201
+ if (done) break
5202
+ chunks.push(value)
5203
+ received += value.byteLength
5204
+ resetTimeout()
5205
+ updateDownloadProgress({ phase: 'downloading', received, total })
5206
+ }
5207
+ } else {
5208
+ chunks.push(new Uint8Array(await res.arrayBuffer()))
5209
+ received = chunks[0].byteLength
5210
+ }
5211
+ if (total > 0 && total !== received) return { ok: false, corrupted: true }
5212
+ const blob = new Blob(chunks, { type: 'application/vnd.android.package-archive' })
5213
+ phase = 'verifying'
5214
+ updateDownloadProgress({ phase, received, total: total || received })
5215
+ const expected = String(info.sha256 || '').trim().toLowerCase()
5216
+ if (expected) {
5217
+ if (!/^[0-9a-f]{64}$/.test(expected)) return { ok: false, corrupted: true }
5218
+ const actual = await sha256Hex(await blob.arrayBuffer())
5219
+ if (actual !== expected) return { ok: false, corrupted: true }
5220
+ }
5221
+ return { ok: true, blob }
5222
+ } catch (err) { return { ok: false, network: true, msg: err?.message || '' } }
5223
+ finally { clearTimeout(timer); clearInterval(progressTimer) }
5134
5224
  }
5135
5225
 
5136
5226
  async function downloadUpdate() {
5137
5227
  const info = state.updateInfo
5138
- if (!info) return
5139
- const base = updateBase()
5140
- let url
5141
- try { url = new URL(info.apkUrl || 'dsh-remote.apk', base + '/').href }
5142
- catch { url = base + '/' + (info.apkUrl || 'dsh-remote.apk') }
5143
-
5144
- // 先下载校验再交给原生/浏览器安装;校验失败不进入安装
5145
- const verify = await verifyUpdateApk(info, url)
5146
- if (!verify.ok) {
5147
- if (verify.corrupted) {
5148
- toast(t('update.corrupted'), 'err')
5149
- } else if (verify.status) {
5150
- toast(t('update.serverFileMissing'), 'err')
5151
- } else {
5152
- toast(t('update.downloadFailed', { msg: verify.msg || t('fs.networkError') }), 'err')
5153
- }
5228
+ if (!info || updateDownloadBusy) return
5229
+ const url = new URL(info.apkUrl || 'dsh-remote.apk', updateBase() + '/').href
5230
+ updateDownloadBusy = true
5231
+ updateDownloadSample = null
5232
+ $('btn-download-update').disabled = true
5233
+ updateDownloadProgress({ phase: 'downloading', received: 0, total: 0 })
5234
+ if (CAP?.isNativePlatform?.() && window.NativeUpdate?.downloadVerifiedAndInstall && window.NativeUpdate?.getDownloadStatus) {
5235
+ try {
5236
+ if (!window.NativeUpdate.downloadVerifiedAndInstall(url, String(info.sha256 || '').trim())) throw new Error(t('update.busy'))
5237
+ const poll = () => {
5238
+ try {
5239
+ const value = JSON.parse(window.NativeUpdate.getDownloadStatus())
5240
+ updateDownloadProgress(value)
5241
+ if (['complete', 'error'].includes(value.phase)) finishUpdateDownload()
5242
+ } catch (error) { updateDownloadProgress({ phase: 'error', error: error.message }); finishUpdateDownload() }
5243
+ }
5244
+ updateDownloadTimer = setInterval(poll, 500)
5245
+ poll()
5246
+ } catch (error) { updateDownloadProgress({ phase: 'error', error: error.message }); finishUpdateDownload() }
5154
5247
  return
5155
5248
  }
5156
-
5157
- if (CAP?.isNativePlatform?.()) {
5158
- // Android WebView 原生桥(不依赖 Capacitor 插件路由)
5159
- if (window.NativeUpdate?.downloadAndInstall) {
5160
- try {
5161
- window.NativeUpdate.downloadAndInstall(url)
5162
- toast(t('update.downloadStarted'), 'ok')
5163
- } catch (e) {
5164
- toast(t('update.downloadFailed', { msg: e?.message || '' }), 'err')
5165
- }
5166
- return
5249
+ try {
5250
+ const result = await verifyUpdateApk(info, url)
5251
+ if (!result.ok) throw new Error(result.corrupted ? t('update.corrupted') : result.status ? t('update.serverFileMissing') : result.msg || t('fs.networkError'))
5252
+ if (CAP?.isNativePlatform?.() && window.NativeUpdate?.downloadAndInstall) {
5253
+ // 旧壳不具备进度桥;安装本次新版后即可使用单次下载和完整进度。
5254
+ window.NativeUpdate.downloadAndInstall(url)
5255
+ updateDownloadProgress({ phase: 'legacy', received: 0, total: 0 })
5256
+ } else {
5257
+ const objectUrl = URL.createObjectURL(result.blob)
5258
+ const link = document.createElement('a')
5259
+ link.href = objectUrl
5260
+ link.download = 'dsh-remote.apk'
5261
+ document.body.appendChild(link)
5262
+ link.click()
5263
+ link.remove()
5264
+ setTimeout(() => URL.revokeObjectURL(objectUrl), 60000)
5265
+ updateDownloadProgress({ phase: 'complete', received: result.blob.size, total: result.blob.size })
5167
5266
  }
5168
- // 兜底: 旧版 App 没有原生桥时用浏览器下载
5169
- toast(t('update.installUnsupported'), 'err')
5170
- }
5171
- // 浏览器: 直接触发下载
5172
- location.href = url
5267
+ } catch (error) { updateDownloadProgress({ phase: 'error', error: error.message }) }
5268
+ finally { finishUpdateDownload() }
5173
5269
  }
5174
5270
 
5271
+
5175
5272
  /* ---------------- 通知 ---------------- */
5176
5273
  const CAP = window.Capacitor || null
5177
5274
  async function ensureNotify() {
@@ -5389,177 +5486,6 @@ async function restorePeakReminders() {
5389
5486
  if (peakRemindOn() && legacyCleaned) await schedulePeakReminders({ legacyCleaned: true })
5390
5487
  }
5391
5488
 
5392
- /* ---------------- 功能测试 / Android ASR ---------------- */
5393
- function asrTestBridge() { return window.NativeAsrTest }
5394
-
5395
- function emptyAsrTest() {
5396
- return { running: false, status: 'idle', meta: null, summary: null, events: [], lastError: '' }
5397
- }
5398
-
5399
- function asrTestEvent(event) {
5400
- if (!event || typeof event !== 'object') return
5401
- const current = state.asrTest
5402
- const data = event.data && typeof event.data === 'object' ? event.data : {}
5403
- if (event.type === 'meta') current.meta = data
5404
- if (event.type === 'summary') {
5405
- current.summary = data
5406
- current.running = false
5407
- }
5408
- if (event.type === 'status') {
5409
- current.status = String(data.status || 'unknown')
5410
- if (current.status === 'listening' || current.status === 'starting' || current.status === 'restarting') current.running = true
5411
- if (['stopped', 'unsupported', 'permission-denied'].includes(current.status)) current.running = false
5412
- }
5413
- if (event.type === 'error') current.lastError = String(data.name || data.message || 'error')
5414
- current.events.push({ type: event.type, atMs: Number(event.atMs) || 0, data })
5415
- if (current.events.length > 500) current.events.splice(0, current.events.length - 500)
5416
- renderAsrTest()
5417
- }
5418
- window.__dshAsrEvent = asrTestEvent
5419
-
5420
- function asrTestStatusText(status) {
5421
- const labels = {
5422
- idle: t('settings.asrTestNativeOnly'),
5423
- starting: t('settings.asrTestStarted'),
5424
- listening: t('settings.asrTestStarted'),
5425
- restarting: t('settings.asrTestRestarting'),
5426
- 'permission-requesting': t('settings.asrTestPermission'),
5427
- 'permission-denied': t('settings.asrTestPermissionDenied'),
5428
- 'permission-error': t('settings.asrTestPermissionError'),
5429
- unsupported: t('settings.asrTestUnavailable'),
5430
- busy: t('settings.asrTestBusy'),
5431
- stopped: t('settings.asrTestStopped')
5432
- }
5433
- return labels[status] || t('settings.asrTestStatus', { status })
5434
- }
5435
-
5436
- function asrTestLogLines() {
5437
- const current = state.asrTest
5438
- const lines = []
5439
- for (const event of current.events) {
5440
- const data = event.data || {}
5441
- const at = `${event.atMs}ms`
5442
- if (event.type === 'meta') {
5443
- lines.push(`[${at}] meta brand=${data.brand || '—'} manufacturer=${data.manufacturer || '—'} model=${data.model || '—'} Android=${data.androidVersion || '—'} API=${data.apiLevel || '—'}`)
5444
- lines.push(`[${at}] recordAudioPermission=${data.recordAudioPermission ?? 'unknown'} recordAudioAppOp=${data.recordAudioAppOp || 'unknown'} microphoneMuted=${data.microphoneMuted ?? 'unknown'}`)
5445
- lines.push(`[${at}] recognitionAvailable=${data.recognitionAvailable === true} onDeviceAvailable=${data.onDeviceAvailable === true} path=${data.networkPath || '—'}`)
5446
- for (const service of data.recognitionServices || []) lines.push(`[${at}] service ${service.packageName || '—'} / ${service.serviceName || '—'} xiaomiLike=${service.xiaomiLike === true}`)
5447
- } else if (event.type === 'status') {
5448
- lines.push(`[${at}] status=${data.status || '—'} session=${data.session ?? '—'} reason=${data.reason || '—'} ${data.message || ''}`.trim())
5449
- } else if (event.type === 'partial' || event.type === 'final') {
5450
- lines.push(`[${at}] ${event.type}#${data.count ?? '—'} session=${data.session ?? '—'} +${data.elapsedMs ?? '—'}ms: ${data.text || '(empty)'}`)
5451
- } else if (event.type === 'callback') {
5452
- lines.push(`[${at}] callback=${data.name || '—'} session=${data.session ?? '—'} +${data.elapsedMs ?? '—'}ms${data.bytes >= 0 ? ` bytes=${data.bytes}` : ''}`)
5453
- } else if (event.type === 'error') {
5454
- lines.push(`[${at}] error=${data.name || '—'} code=${data.code ?? '—'} session=${data.session ?? '—'} ${data.message || ''}`.trim())
5455
- } else if (event.type === 'summary') {
5456
- lines.push(`[${at}] summary reason=${data.reason || '—'} duration=${data.durationMs ?? '—'}ms sessions=${data.sessionCount ?? '—'} restarts=${data.restartCount ?? '—'} partial=${data.partialCount ?? '—'} final=${data.finalCount ?? '—'} errors=${data.errorCount ?? '—'}`)
5457
- }
5458
- }
5459
- return lines
5460
- }
5461
-
5462
- function asrTestReport() {
5463
- const current = state.asrTest
5464
- const meta = current.meta || {}
5465
- const summary = current.summary || {}
5466
- const lines = [
5467
- 'DSH Remote Android ASR 测试报告',
5468
- `生成时间: ${new Date().toISOString()}`,
5469
- `设备: ${meta.brand || '—'} / ${meta.manufacturer || '—'} / ${meta.model || '—'}`,
5470
- `Android: ${meta.androidVersion || '—'} (API ${meta.apiLevel || '—'})`,
5471
- `识别可用: ${meta.recognitionAvailable === true ? 'yes' : meta.recognitionAvailable === false ? 'no' : 'unknown'}`,
5472
- `端侧识别可用: ${meta.onDeviceAvailable === true ? 'yes' : meta.onDeviceAvailable === false ? 'no' : 'unknown'}`,
5473
- `路径: ${meta.networkPath || 'system-default-recognition-service'}`,
5474
- `测试结束原因: ${summary.reason || current.status || '—'}`,
5475
- `总时长: ${summary.durationMs ?? '—'}ms`,
5476
- `session: ${summary.sessionCount ?? '—'} / 重建: ${summary.restartCount ?? '—'} / partial: ${summary.partialCount ?? '—'} / final: ${summary.finalCount ?? '—'} / errors: ${summary.errorCount ?? '—'}`,
5477
- '',
5478
- '事件日志:',
5479
- ...asrTestLogLines()
5480
- ]
5481
- return lines.join('\n')
5482
- }
5483
-
5484
- function renderAsrTest() {
5485
- const start = $('btn-asr-test-start')
5486
- const stop = $('btn-asr-test-stop')
5487
- const copy = $('btn-asr-test-copy')
5488
- const permission = $('btn-asr-test-permission')
5489
- const engine = $('btn-asr-test-engine')
5490
- const status = $('asr-test-status')
5491
- const summary = $('asr-test-summary')
5492
- const log = $('asr-test-log')
5493
- if (!start || !stop || !copy || !permission || !engine || !status || !summary || !log) return
5494
- const current = state.asrTest
5495
- const native = !!(CAP?.isNativePlatform?.() && asrTestBridge()?.startAsrTest)
5496
- start.disabled = current.running || !native
5497
- stop.disabled = !current.running || !native
5498
- copy.disabled = !current.events.length
5499
- const permissionError = current.status === 'permission-error' || current.status === 'permission-denied' || current.summary?.reason === 'permission-error'
5500
- status.className = 'feature-test-status ' + (permissionError || current.status === 'unsupported' ? 'error' : current.status === 'stopped' ? 'ok' : 'muted')
5501
- status.textContent = native ? (permissionError ? t('settings.asrTestPermissionError') : asrTestStatusText(current.status)) : t('settings.asrTestWebUnsupported')
5502
- permission.classList.toggle('hidden', !native || !permissionError)
5503
- engine.classList.toggle('hidden', !native || !permissionError)
5504
- const meta = current.meta || {}
5505
- const s = current.summary
5506
- summary.textContent = [
5507
- meta.model ? `${t('settings.asrTestMeta')}: ${meta.brand || '—'} / ${meta.manufacturer || '—'} / ${meta.model}` : '',
5508
- s ? `${t('settings.asrTestSummary')}: ${t('settings.asrTestStatus', { status: s.reason || 'done' })} · session ${s.sessionCount ?? '—'} · partial ${s.partialCount ?? '—'} · final ${s.finalCount ?? '—'} · error ${s.errorCount ?? '—'}` : ''
5509
- ].filter(Boolean).join('\n')
5510
- log.textContent = current.events.length ? asrTestLogLines().join('\n') : t('settings.asrTestLogEmpty')
5511
- log.scrollTop = log.scrollHeight
5512
- }
5513
-
5514
- function clearAsrTest() {
5515
- if (state.asrTest.running) return toast(t('settings.asrTestBusy'), 'err')
5516
- state.asrTest = emptyAsrTest()
5517
- renderAsrTest()
5518
- }
5519
-
5520
- async function startAsrTest() {
5521
- const native = asrTestBridge()
5522
- if (!CAP?.isNativePlatform?.() || !native?.startAsrTest) return toast(t('settings.asrTestWebUnsupported'), 'err')
5523
- if (state.asrTest.running) return toast(t('settings.asrTestBusy'), 'err')
5524
- if (!confirm(t('settings.asrTestConsent'))) return
5525
- state.asrTest = { ...emptyAsrTest(), running: true, status: 'starting' }
5526
- renderAsrTest()
5527
- try {
5528
- if (native.startAsrTest() === false) throw new Error(t('settings.asrTestUnavailable'))
5529
- } catch (error) {
5530
- state.asrTest.running = false
5531
- state.asrTest.status = 'error'
5532
- state.asrTest.lastError = error?.message || String(error)
5533
- renderAsrTest()
5534
- toast(state.asrTest.lastError, 'err')
5535
- }
5536
- }
5537
-
5538
- function stopAsrTest() {
5539
- try { asrTestBridge()?.stopAsrTest?.() } catch {}
5540
- }
5541
-
5542
- function openAsrPermissionSettings() {
5543
- try {
5544
- if (asrTestBridge()?.openAsrPermissionSettings?.() === false) throw new Error('permission settings unavailable')
5545
- } catch (error) {
5546
- toast(error?.message || String(error), 'err')
5547
- }
5548
- }
5549
-
5550
- function openAsrEngineSettings() {
5551
- try {
5552
- if (asrTestBridge()?.openAsrEngineSettings?.() === false) throw new Error('voice engine settings unavailable')
5553
- } catch (error) {
5554
- toast(error?.message || String(error), 'err')
5555
- }
5556
- }
5557
-
5558
- async function copyAsrTestLog() {
5559
- const ok = await copyText(asrTestReport())
5560
- toast(t(ok ? 'settings.asrTestCopyOk' : 'settings.asrTestCopyFailed'), ok ? 'ok' : 'err')
5561
- }
5562
-
5563
5489
  /* ---------------- 模型设置 ---------------- */
5564
5490
  const MODEL_SETTINGS_FIELDS = ['baseURL', 'api', 'apiKeyEnv', 'displayName', 'models']
5565
5491
  const MODEL_REASONING_LIMIT = 12
@@ -6101,6 +6027,7 @@ async function openModelConfigDocument() {
6101
6027
  }
6102
6028
  /* ---------------- 视图切换 ---------------- */
6103
6029
  function showView(id) {
6030
+ if (id !== 'view-session' && document.body.classList.contains('in-session') && state.current) void archiveEmptySessionOnLeave(state.current)
6104
6031
  for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-stats', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
6105
6032
  // 离开会话页必须清掉 in-session, 否则其他页面顶栏被 body 样式隐藏
6106
6033
  document.body.classList.toggle('in-session', id === 'view-session')
@@ -6846,7 +6773,6 @@ function bindUi() {
6846
6773
  renderPending(); renderQueue(); renderJobs()
6847
6774
  updateConn()
6848
6775
  if (state.modelSettings.status === 'ready' || state.modelSettings.status === 'error' || state.modelSettings.status === 'loading') renderModelSettings()
6849
- renderAsrTest()
6850
6776
  if (state.current) { renderSessionTitle(); renderSessionSub(); renderSessionCards(); renderHistory(true) }
6851
6777
  else renderModelMenu()
6852
6778
  loadLocalVersion()
@@ -7164,13 +7090,6 @@ function bindUi() {
7164
7090
  $('btn-model-settings-open')?.addEventListener('click', openModelConfigDocument)
7165
7091
  $('model-settings-list')?.addEventListener('click', handleModelSettingsClick)
7166
7092
  $('model-settings-list')?.addEventListener('input', handleModelSettingsInput)
7167
- $('btn-asr-test-start')?.addEventListener('click', startAsrTest)
7168
- $('btn-asr-test-stop')?.addEventListener('click', stopAsrTest)
7169
- $('btn-asr-test-copy')?.addEventListener('click', copyAsrTestLog)
7170
- $('btn-asr-test-clear')?.addEventListener('click', clearAsrTest)
7171
- $('btn-asr-test-permission')?.addEventListener('click', openAsrPermissionSettings)
7172
- $('btn-asr-test-engine')?.addEventListener('click', openAsrEngineSettings)
7173
- renderAsrTest()
7174
7093
  $('btn-scan-camera').addEventListener('click', () => scanPair('CAMERA'))
7175
7094
  $('btn-scan-gallery').addEventListener('click', () => scanPair('PHOTOS'))
7176
7095
  $('scan-live-cancel')?.addEventListener('click', () => closeLiveScan(''))
@@ -528,6 +528,13 @@ button.ds-overview-attention-item, button.ds-overview-session-item { cursor:poin
528
528
  .ds-workspace-create-location code { min-width: 0; flex: 1; padding: 7px 9px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsr-text); background: var(--dsr-bg-2); border: 1px solid var(--dsr-line); border-radius: 9px; }
529
529
  .ds-workspace-name { width: 100%; box-sizing: border-box; background: var(--dsr-bg-2); color: var(--dsr-text); border: 1px solid var(--dsr-line); border-radius: 9px; padding: 8px 10px; font: inherit; outline: none; }
530
530
  .ds-workspace-name:focus { border-color: var(--dsr-accent-line); }
531
+ .ds-new-session-desc { margin: 0; color: var(--dsr-muted); line-height: 1.55; }
532
+ .ds-new-session-label { font-size: 12px; color: var(--dsr-muted); }
533
+ .ds-new-session-select { width: 100%; box-sizing: border-box; background: var(--dsr-bg-2); color: var(--dsr-text); border: 1px solid var(--dsr-line); border-radius: 9px; padding: 8px 10px; font: inherit; outline: none; }
534
+ .ds-new-session-select:focus { border-color: var(--dsr-accent-line); }
535
+ .ds-new-session-details { display: flex; flex-direction: column; gap: 4px; min-width: 0; padding: 8px 10px; background: var(--dsr-bg-2); border: 1px solid var(--dsr-line); border-radius: 9px; }
536
+ .ds-new-session-name { font-size: 13px; font-weight: 600; }
537
+ .ds-new-session-path { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsr-muted); font-size: 11.5px; }
531
538
  .ds-modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
532
539
  .ds-q-item { border: 1px solid var(--dsr-line); border-radius: 10px; padding: 9px 11px; }
533
540
  .ds-q-text { font-size: 13.5px; margin-bottom: 6px; }