dsh-remote-plugin 0.6.24 → 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') {
@@ -2285,7 +2288,31 @@ function renderSessions() {
2285
2288
  }
2286
2289
 
2287
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
+
2288
2314
  async function openSession(id) {
2315
+ if (state.current && state.current !== id) await archiveEmptySessionOnLeave(state.current)
2289
2316
  state.current = id
2290
2317
  setSessionRecovery('loading')
2291
2318
  state.history = emptyHistory()
@@ -2328,7 +2355,7 @@ async function closeSession() {
2328
2355
  const sessionId = state.current
2329
2356
  if (!sessionId) return
2330
2357
  const task = (async () => {
2331
- const discard = await shouldDiscardEmptySession(sessionId)
2358
+ const discard = await shouldDiscardEmptySession(sessionId) && await archiveEmptySessionOnLeave(sessionId)
2332
2359
  if (state.current !== sessionId) return
2333
2360
  state.current = null
2334
2361
  renderSessionPending()
@@ -2442,6 +2469,13 @@ function reasoningStreamKey(data, index) {
2442
2469
  * DSH 的实时思考以 assistant/chunk 下发,历史尾页可能把增量压成
2443
2470
  * reasoning-chunks。最终 assistant/message 到达后再由正式消息接管展示。
2444
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
+
2445
2479
  function applyReasoningStreamEvent(event) {
2446
2480
  const h = state.history
2447
2481
  const data = event?.data || {}
@@ -2470,7 +2504,7 @@ function applyReasoningStreamEvent(event) {
2470
2504
  item.text += Array.isArray(data.texts) ? data.texts.join('') : String(data.text || '')
2471
2505
  h.partialReasoning.set(key, item)
2472
2506
  changed = true
2473
- } else if (event?.type === 'assistant/message') {
2507
+ } else if (event?.type === 'assistant/message' || event?.type === 'assistant/attempt') {
2474
2508
  for (const [key, item] of h.partialReasoning) {
2475
2509
  if (item.turn === data.turn && item.step === data.step) {
2476
2510
  h.partialReasoning.delete(key)
@@ -2560,6 +2594,7 @@ async function loadHistory(reset) {
2560
2594
  const id = state.current
2561
2595
  if (!id || state.history.loading) return
2562
2596
  const history = state.history
2597
+ const reasoningVersion = history.reasoningVersion || 0
2563
2598
  history.loading = true
2564
2599
  if (reset) setSessionRecovery('loading')
2565
2600
  const moreBtn = $('history-more')
@@ -2593,6 +2628,7 @@ async function loadHistory(reset) {
2593
2628
  }
2594
2629
 
2595
2630
  if (state.current !== id || state.history !== history) return
2631
+ const liveReasoning = (history.reasoningVersion || 0) !== reasoningVersion ? new Map(history.partialReasoning) : null
2596
2632
  hydrateSessionProjections(id, v.projections)
2597
2633
  history.loaded = true
2598
2634
  const incoming = v.events || []
@@ -2610,6 +2646,8 @@ async function loadHistory(reset) {
2610
2646
  added++
2611
2647
  }
2612
2648
  // 向前翻页游标 = 本页最旧的 raw seq(即使它本身被过滤)
2649
+ if (liveReasoning) history.partialReasoning = liveReasoning
2650
+ else applyReasoningBaseline(v.partialReasoning)
2613
2651
  const firstSeq = incoming[0]?.event?.seq
2614
2652
  if (firstSeq != null) state.history.minSeq = Math.min(state.history.minSeq, firstSeq)
2615
2653
  state.history.visible.sort((a, b) => a.seq - b.seq)
@@ -2850,6 +2888,7 @@ function blockHtml(b) {
2850
2888
  if ((b.type === 'tool-call' || b.type === 'tool-result') && LS.get('showTools', '1') === '0') return ''
2851
2889
  switch (b.type) {
2852
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>`
2853
2892
  case 'image': return `<img alt="${t('block.image')}" src="data:${esc(b.mediaType || 'image/png')};base64,${esc(b.data || '')}">`
2854
2893
  case 'thinking':
2855
2894
  case 'reasoning':
@@ -4163,9 +4202,11 @@ async function openFsPreview(pathValue, name) {
4163
4202
  $('file-preview-loading').classList.add('hidden')
4164
4203
  $('file-preview-source').textContent = data.content || ''
4165
4204
  const markdown = data.extension === '.md' || data.extension === '.markdown'
4166
- $('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)
4167
4207
  if (markdown) $('file-preview-rendered').innerHTML = window.mdToHtml(data.content || '')
4168
- showFsPreviewMode(markdown ? 'rendered' : 'source')
4208
+ if (htmlPreview) $('file-preview-rendered').innerHTML = window.DshGenUi.html(data.content || '')
4209
+ showFsPreviewMode(markdown || htmlPreview ? 'rendered' : 'source')
4169
4210
  } catch (e) {
4170
4211
  if (generation !== fsPreviewGeneration) return
4171
4212
  $('file-preview-loading').textContent = e.message || t('fs.previewFailed', { msg: t('fs.networkError') })
@@ -5111,77 +5152,123 @@ async function sha256Hex(buffer) {
5111
5152
  return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('')
5112
5153
  }
5113
5154
 
5114
- /**
5115
- * 下载 APK 并用 update.json 的 sha256 校验。
5116
- * 返回 { ok, skipped } 或 { ok:false, status | corrupted | network }。
5117
- * 老产物没有 sha256 时跳过校验;crypto.subtle 不可用也跳过(不阻塞老 WebView)。
5118
- */
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
+
5119
5185
  async function verifyUpdateApk(info, url) {
5120
- const expected = String(info.sha256 || '').trim().toLowerCase()
5121
- if (!expected || !/^[0-9a-f]{64}$/.test(expected)) return { ok: true, skipped: true }
5122
- let res
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) }
5123
5191
  try {
5124
- const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(120000) : undefined
5125
- res = signal ? await fetch(url, { signal }) : await fetch(url)
5126
- } catch (err) {
5127
- return { ok: false, network: true, msg: err?.message || '' }
5128
- }
5129
- if (!res.ok) return { ok: false, status: res.status }
5130
- let buf
5131
- try {
5132
- buf = await res.arrayBuffer()
5133
- } catch (err) {
5134
- return { ok: false, network: true, msg: err?.message || '' }
5135
- }
5136
- let actual
5137
- try {
5138
- actual = await sha256Hex(buf)
5139
- } catch {
5140
- return { ok: true, skipped: true }
5141
- }
5142
- if (actual.toLowerCase() !== expected) return { ok: false, corrupted: true }
5143
- 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) }
5144
5224
  }
5145
5225
 
5146
5226
  async function downloadUpdate() {
5147
5227
  const info = state.updateInfo
5148
- if (!info) return
5149
- const base = updateBase()
5150
- let url
5151
- try { url = new URL(info.apkUrl || 'dsh-remote.apk', base + '/').href }
5152
- catch { url = base + '/' + (info.apkUrl || 'dsh-remote.apk') }
5153
-
5154
- // 先下载校验再交给原生/浏览器安装;校验失败不进入安装
5155
- const verify = await verifyUpdateApk(info, url)
5156
- if (!verify.ok) {
5157
- if (verify.corrupted) {
5158
- toast(t('update.corrupted'), 'err')
5159
- } else if (verify.status) {
5160
- toast(t('update.serverFileMissing'), 'err')
5161
- } else {
5162
- toast(t('update.downloadFailed', { msg: verify.msg || t('fs.networkError') }), 'err')
5163
- }
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() }
5164
5247
  return
5165
5248
  }
5166
-
5167
- if (CAP?.isNativePlatform?.()) {
5168
- // Android WebView 原生桥(不依赖 Capacitor 插件路由)
5169
- if (window.NativeUpdate?.downloadAndInstall) {
5170
- try {
5171
- window.NativeUpdate.downloadAndInstall(url)
5172
- toast(t('update.downloadStarted'), 'ok')
5173
- } catch (e) {
5174
- toast(t('update.downloadFailed', { msg: e?.message || '' }), 'err')
5175
- }
5176
- 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 })
5177
5266
  }
5178
- // 兜底: 旧版 App 没有原生桥时用浏览器下载
5179
- toast(t('update.installUnsupported'), 'err')
5180
- }
5181
- // 浏览器: 直接触发下载
5182
- location.href = url
5267
+ } catch (error) { updateDownloadProgress({ phase: 'error', error: error.message }) }
5268
+ finally { finishUpdateDownload() }
5183
5269
  }
5184
5270
 
5271
+
5185
5272
  /* ---------------- 通知 ---------------- */
5186
5273
  const CAP = window.Capacitor || null
5187
5274
  async function ensureNotify() {
@@ -5399,177 +5486,6 @@ async function restorePeakReminders() {
5399
5486
  if (peakRemindOn() && legacyCleaned) await schedulePeakReminders({ legacyCleaned: true })
5400
5487
  }
5401
5488
 
5402
- /* ---------------- 功能测试 / Android ASR ---------------- */
5403
- function asrTestBridge() { return window.NativeAsrTest }
5404
-
5405
- function emptyAsrTest() {
5406
- return { running: false, status: 'idle', meta: null, summary: null, events: [], lastError: '' }
5407
- }
5408
-
5409
- function asrTestEvent(event) {
5410
- if (!event || typeof event !== 'object') return
5411
- const current = state.asrTest
5412
- const data = event.data && typeof event.data === 'object' ? event.data : {}
5413
- if (event.type === 'meta') current.meta = data
5414
- if (event.type === 'summary') {
5415
- current.summary = data
5416
- current.running = false
5417
- }
5418
- if (event.type === 'status') {
5419
- current.status = String(data.status || 'unknown')
5420
- if (current.status === 'listening' || current.status === 'starting' || current.status === 'restarting') current.running = true
5421
- if (['stopped', 'unsupported', 'permission-denied'].includes(current.status)) current.running = false
5422
- }
5423
- if (event.type === 'error') current.lastError = String(data.name || data.message || 'error')
5424
- current.events.push({ type: event.type, atMs: Number(event.atMs) || 0, data })
5425
- if (current.events.length > 500) current.events.splice(0, current.events.length - 500)
5426
- renderAsrTest()
5427
- }
5428
- window.__dshAsrEvent = asrTestEvent
5429
-
5430
- function asrTestStatusText(status) {
5431
- const labels = {
5432
- idle: t('settings.asrTestNativeOnly'),
5433
- starting: t('settings.asrTestStarted'),
5434
- listening: t('settings.asrTestStarted'),
5435
- restarting: t('settings.asrTestRestarting'),
5436
- 'permission-requesting': t('settings.asrTestPermission'),
5437
- 'permission-denied': t('settings.asrTestPermissionDenied'),
5438
- 'permission-error': t('settings.asrTestPermissionError'),
5439
- unsupported: t('settings.asrTestUnavailable'),
5440
- busy: t('settings.asrTestBusy'),
5441
- stopped: t('settings.asrTestStopped')
5442
- }
5443
- return labels[status] || t('settings.asrTestStatus', { status })
5444
- }
5445
-
5446
- function asrTestLogLines() {
5447
- const current = state.asrTest
5448
- const lines = []
5449
- for (const event of current.events) {
5450
- const data = event.data || {}
5451
- const at = `${event.atMs}ms`
5452
- if (event.type === 'meta') {
5453
- lines.push(`[${at}] meta brand=${data.brand || '—'} manufacturer=${data.manufacturer || '—'} model=${data.model || '—'} Android=${data.androidVersion || '—'} API=${data.apiLevel || '—'}`)
5454
- lines.push(`[${at}] recordAudioPermission=${data.recordAudioPermission ?? 'unknown'} recordAudioAppOp=${data.recordAudioAppOp || 'unknown'} microphoneMuted=${data.microphoneMuted ?? 'unknown'}`)
5455
- lines.push(`[${at}] recognitionAvailable=${data.recognitionAvailable === true} onDeviceAvailable=${data.onDeviceAvailable === true} path=${data.networkPath || '—'}`)
5456
- for (const service of data.recognitionServices || []) lines.push(`[${at}] service ${service.packageName || '—'} / ${service.serviceName || '—'} xiaomiLike=${service.xiaomiLike === true}`)
5457
- } else if (event.type === 'status') {
5458
- lines.push(`[${at}] status=${data.status || '—'} session=${data.session ?? '—'} reason=${data.reason || '—'} ${data.message || ''}`.trim())
5459
- } else if (event.type === 'partial' || event.type === 'final') {
5460
- lines.push(`[${at}] ${event.type}#${data.count ?? '—'} session=${data.session ?? '—'} +${data.elapsedMs ?? '—'}ms: ${data.text || '(empty)'}`)
5461
- } else if (event.type === 'callback') {
5462
- lines.push(`[${at}] callback=${data.name || '—'} session=${data.session ?? '—'} +${data.elapsedMs ?? '—'}ms${data.bytes >= 0 ? ` bytes=${data.bytes}` : ''}`)
5463
- } else if (event.type === 'error') {
5464
- lines.push(`[${at}] error=${data.name || '—'} code=${data.code ?? '—'} session=${data.session ?? '—'} ${data.message || ''}`.trim())
5465
- } else if (event.type === 'summary') {
5466
- 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 ?? '—'}`)
5467
- }
5468
- }
5469
- return lines
5470
- }
5471
-
5472
- function asrTestReport() {
5473
- const current = state.asrTest
5474
- const meta = current.meta || {}
5475
- const summary = current.summary || {}
5476
- const lines = [
5477
- 'DSH Remote Android ASR 测试报告',
5478
- `生成时间: ${new Date().toISOString()}`,
5479
- `设备: ${meta.brand || '—'} / ${meta.manufacturer || '—'} / ${meta.model || '—'}`,
5480
- `Android: ${meta.androidVersion || '—'} (API ${meta.apiLevel || '—'})`,
5481
- `识别可用: ${meta.recognitionAvailable === true ? 'yes' : meta.recognitionAvailable === false ? 'no' : 'unknown'}`,
5482
- `端侧识别可用: ${meta.onDeviceAvailable === true ? 'yes' : meta.onDeviceAvailable === false ? 'no' : 'unknown'}`,
5483
- `路径: ${meta.networkPath || 'system-default-recognition-service'}`,
5484
- `测试结束原因: ${summary.reason || current.status || '—'}`,
5485
- `总时长: ${summary.durationMs ?? '—'}ms`,
5486
- `session: ${summary.sessionCount ?? '—'} / 重建: ${summary.restartCount ?? '—'} / partial: ${summary.partialCount ?? '—'} / final: ${summary.finalCount ?? '—'} / errors: ${summary.errorCount ?? '—'}`,
5487
- '',
5488
- '事件日志:',
5489
- ...asrTestLogLines()
5490
- ]
5491
- return lines.join('\n')
5492
- }
5493
-
5494
- function renderAsrTest() {
5495
- const start = $('btn-asr-test-start')
5496
- const stop = $('btn-asr-test-stop')
5497
- const copy = $('btn-asr-test-copy')
5498
- const permission = $('btn-asr-test-permission')
5499
- const engine = $('btn-asr-test-engine')
5500
- const status = $('asr-test-status')
5501
- const summary = $('asr-test-summary')
5502
- const log = $('asr-test-log')
5503
- if (!start || !stop || !copy || !permission || !engine || !status || !summary || !log) return
5504
- const current = state.asrTest
5505
- const native = !!(CAP?.isNativePlatform?.() && asrTestBridge()?.startAsrTest)
5506
- start.disabled = current.running || !native
5507
- stop.disabled = !current.running || !native
5508
- copy.disabled = !current.events.length
5509
- const permissionError = current.status === 'permission-error' || current.status === 'permission-denied' || current.summary?.reason === 'permission-error'
5510
- status.className = 'feature-test-status ' + (permissionError || current.status === 'unsupported' ? 'error' : current.status === 'stopped' ? 'ok' : 'muted')
5511
- status.textContent = native ? (permissionError ? t('settings.asrTestPermissionError') : asrTestStatusText(current.status)) : t('settings.asrTestWebUnsupported')
5512
- permission.classList.toggle('hidden', !native || !permissionError)
5513
- engine.classList.toggle('hidden', !native || !permissionError)
5514
- const meta = current.meta || {}
5515
- const s = current.summary
5516
- summary.textContent = [
5517
- meta.model ? `${t('settings.asrTestMeta')}: ${meta.brand || '—'} / ${meta.manufacturer || '—'} / ${meta.model}` : '',
5518
- s ? `${t('settings.asrTestSummary')}: ${t('settings.asrTestStatus', { status: s.reason || 'done' })} · session ${s.sessionCount ?? '—'} · partial ${s.partialCount ?? '—'} · final ${s.finalCount ?? '—'} · error ${s.errorCount ?? '—'}` : ''
5519
- ].filter(Boolean).join('\n')
5520
- log.textContent = current.events.length ? asrTestLogLines().join('\n') : t('settings.asrTestLogEmpty')
5521
- log.scrollTop = log.scrollHeight
5522
- }
5523
-
5524
- function clearAsrTest() {
5525
- if (state.asrTest.running) return toast(t('settings.asrTestBusy'), 'err')
5526
- state.asrTest = emptyAsrTest()
5527
- renderAsrTest()
5528
- }
5529
-
5530
- async function startAsrTest() {
5531
- const native = asrTestBridge()
5532
- if (!CAP?.isNativePlatform?.() || !native?.startAsrTest) return toast(t('settings.asrTestWebUnsupported'), 'err')
5533
- if (state.asrTest.running) return toast(t('settings.asrTestBusy'), 'err')
5534
- if (!confirm(t('settings.asrTestConsent'))) return
5535
- state.asrTest = { ...emptyAsrTest(), running: true, status: 'starting' }
5536
- renderAsrTest()
5537
- try {
5538
- if (native.startAsrTest() === false) throw new Error(t('settings.asrTestUnavailable'))
5539
- } catch (error) {
5540
- state.asrTest.running = false
5541
- state.asrTest.status = 'error'
5542
- state.asrTest.lastError = error?.message || String(error)
5543
- renderAsrTest()
5544
- toast(state.asrTest.lastError, 'err')
5545
- }
5546
- }
5547
-
5548
- function stopAsrTest() {
5549
- try { asrTestBridge()?.stopAsrTest?.() } catch {}
5550
- }
5551
-
5552
- function openAsrPermissionSettings() {
5553
- try {
5554
- if (asrTestBridge()?.openAsrPermissionSettings?.() === false) throw new Error('permission settings unavailable')
5555
- } catch (error) {
5556
- toast(error?.message || String(error), 'err')
5557
- }
5558
- }
5559
-
5560
- function openAsrEngineSettings() {
5561
- try {
5562
- if (asrTestBridge()?.openAsrEngineSettings?.() === false) throw new Error('voice engine settings unavailable')
5563
- } catch (error) {
5564
- toast(error?.message || String(error), 'err')
5565
- }
5566
- }
5567
-
5568
- async function copyAsrTestLog() {
5569
- const ok = await copyText(asrTestReport())
5570
- toast(t(ok ? 'settings.asrTestCopyOk' : 'settings.asrTestCopyFailed'), ok ? 'ok' : 'err')
5571
- }
5572
-
5573
5489
  /* ---------------- 模型设置 ---------------- */
5574
5490
  const MODEL_SETTINGS_FIELDS = ['baseURL', 'api', 'apiKeyEnv', 'displayName', 'models']
5575
5491
  const MODEL_REASONING_LIMIT = 12
@@ -6111,6 +6027,7 @@ async function openModelConfigDocument() {
6111
6027
  }
6112
6028
  /* ---------------- 视图切换 ---------------- */
6113
6029
  function showView(id) {
6030
+ if (id !== 'view-session' && document.body.classList.contains('in-session') && state.current) void archiveEmptySessionOnLeave(state.current)
6114
6031
  for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-stats', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
6115
6032
  // 离开会话页必须清掉 in-session, 否则其他页面顶栏被 body 样式隐藏
6116
6033
  document.body.classList.toggle('in-session', id === 'view-session')
@@ -6856,7 +6773,6 @@ function bindUi() {
6856
6773
  renderPending(); renderQueue(); renderJobs()
6857
6774
  updateConn()
6858
6775
  if (state.modelSettings.status === 'ready' || state.modelSettings.status === 'error' || state.modelSettings.status === 'loading') renderModelSettings()
6859
- renderAsrTest()
6860
6776
  if (state.current) { renderSessionTitle(); renderSessionSub(); renderSessionCards(); renderHistory(true) }
6861
6777
  else renderModelMenu()
6862
6778
  loadLocalVersion()
@@ -7174,13 +7090,6 @@ function bindUi() {
7174
7090
  $('btn-model-settings-open')?.addEventListener('click', openModelConfigDocument)
7175
7091
  $('model-settings-list')?.addEventListener('click', handleModelSettingsClick)
7176
7092
  $('model-settings-list')?.addEventListener('input', handleModelSettingsInput)
7177
- $('btn-asr-test-start')?.addEventListener('click', startAsrTest)
7178
- $('btn-asr-test-stop')?.addEventListener('click', stopAsrTest)
7179
- $('btn-asr-test-copy')?.addEventListener('click', copyAsrTestLog)
7180
- $('btn-asr-test-clear')?.addEventListener('click', clearAsrTest)
7181
- $('btn-asr-test-permission')?.addEventListener('click', openAsrPermissionSettings)
7182
- $('btn-asr-test-engine')?.addEventListener('click', openAsrEngineSettings)
7183
- renderAsrTest()
7184
7093
  $('btn-scan-camera').addEventListener('click', () => scanPair('CAMERA'))
7185
7094
  $('btn-scan-gallery').addEventListener('click', () => scanPair('PHOTOS'))
7186
7095
  $('scan-live-cancel')?.addEventListener('click', () => closeLiveScan(''))
@@ -703,6 +703,8 @@
703
703
  }
704
704
  </script>
705
705
  <script src="i18n.js"></script>
706
+ <link rel="stylesheet" href="../genui.css">
707
+ <script src="../genui.js"></script>
706
708
  <script src="../md.js"></script>
707
709
  <script src="../vendor/gsap/gsap.min.js"></script>
708
710
  <script src="../motion.js"></script>