dsh-remote-plugin 0.6.15 → 0.6.16
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/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +112 -7
- package/package.json +1 -1
- package/public/admin.html +46 -1
- package/public/admin.js +79 -9
- package/public/announcements.json +33 -0
- package/public/app.js +850 -35
- package/public/index.html +95 -7
- package/public/styles.css +76 -0
- package/public/update.json +8 -8
- package/public/version.json +1 -1
package/public/app.js
CHANGED
|
@@ -79,6 +79,8 @@ const state = {
|
|
|
79
79
|
queueSteering: {}, // sessionId:itemId -> pending steer request
|
|
80
80
|
sessionTurnTimes: {}, // sessionId -> 本轮开始/结束时间,避免中间事件推动排序
|
|
81
81
|
jobs: {}, // sessionId -> jobs
|
|
82
|
+
sessionActivity: new Set(), // 已发送消息或已执行命令的会话
|
|
83
|
+
pendingPrompts: new Set(), // 正在提交消息的会话
|
|
82
84
|
history: emptyHistory(),
|
|
83
85
|
errCount: 0,
|
|
84
86
|
streamInfo: {
|
|
@@ -91,6 +93,9 @@ const state = {
|
|
|
91
93
|
fs: { path: null, initial: null, loaded: false, upload: null, workspaceId: LS.get('fsWorkspaceIdV1', ''), preview: null },
|
|
92
94
|
composerImages: [], // 当前草稿中的图片附件:只在发送成功后释放
|
|
93
95
|
models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
|
|
96
|
+
modelSettings: { status: 'idle', error: '', writable: false, hasDocument: false, providers: [], namespaces: [], credentials: {} },
|
|
97
|
+
modelEditor: null,
|
|
98
|
+
asrTest: { running: false, status: 'idle', meta: null, summary: null, events: [] },
|
|
94
99
|
wb: null,
|
|
95
100
|
wbProjects: [],
|
|
96
101
|
wbArchived: [],
|
|
@@ -1357,6 +1362,23 @@ async function refreshSessions() {
|
|
|
1357
1362
|
refreshWorkbench()
|
|
1358
1363
|
}
|
|
1359
1364
|
|
|
1365
|
+
function removeLocalSessionRecord(sessionId) {
|
|
1366
|
+
if (!sessionId) return
|
|
1367
|
+
state.sessions = state.sessions.filter(session => session?.sessionId !== sessionId)
|
|
1368
|
+
state.byId.delete(sessionId)
|
|
1369
|
+
state.pendingProjections.delete(sessionId)
|
|
1370
|
+
state.sessionActivity.delete(sessionId)
|
|
1371
|
+
state.pendingPrompts.delete(sessionId)
|
|
1372
|
+
delete state.queues[sessionId]
|
|
1373
|
+
delete state.jobs[sessionId]
|
|
1374
|
+
const historyCache = readHistoryCache()
|
|
1375
|
+
if (Object.prototype.hasOwnProperty.call(historyCache, sessionId)) {
|
|
1376
|
+
delete historyCache[sessionId]
|
|
1377
|
+
writeHistoryCache(historyCache)
|
|
1378
|
+
}
|
|
1379
|
+
cacheWrite(CACHE.sessions, state.sessions.slice(0, 80))
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1360
1382
|
function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
|
|
1361
1383
|
function hydrateSessionProjections(sessionId, projections) {
|
|
1362
1384
|
const s = state.byId.get(sessionId)
|
|
@@ -1849,17 +1871,49 @@ async function openSession(id) {
|
|
|
1849
1871
|
refreshSessions()
|
|
1850
1872
|
}
|
|
1851
1873
|
|
|
1852
|
-
function
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1874
|
+
function sessionHasPendingActivity(sessionId, session) {
|
|
1875
|
+
return !!session?.running
|
|
1876
|
+
|| state.sessionActivity.has(sessionId)
|
|
1877
|
+
|| state.pendingPrompts.has(sessionId)
|
|
1878
|
+
|| (state.queues[sessionId] || []).some(item => item?.placement !== 'context')
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
async function shouldDiscardEmptySession(sessionId) {
|
|
1882
|
+
const session = state.byId.get(sessionId)
|
|
1883
|
+
if (!session || sessionHasPendingActivity(sessionId, session)) return false
|
|
1884
|
+
const deadline = Date.now() + 3500
|
|
1885
|
+
while (state.current === sessionId && state.history.loading && Date.now() < deadline) {
|
|
1886
|
+
await new Promise(resolve => setTimeout(resolve, 25))
|
|
1887
|
+
}
|
|
1888
|
+
if (state.current !== sessionId || sessionHasPendingActivity(sessionId, session)) return false
|
|
1889
|
+
return isEmptySessionHistory(state.history)
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
async function closeSession() {
|
|
1893
|
+
if (closeSession.pending) return closeSession.pending
|
|
1894
|
+
const sessionId = state.current
|
|
1895
|
+
if (!sessionId) return
|
|
1896
|
+
const task = (async () => {
|
|
1897
|
+
const discard = await shouldDiscardEmptySession(sessionId)
|
|
1898
|
+
if (state.current !== sessionId) return
|
|
1899
|
+
state.current = null
|
|
1900
|
+
if (discard) removeLocalSessionRecord(sessionId)
|
|
1901
|
+
setComposerFullscreen(false)
|
|
1902
|
+
clearComposerImages()
|
|
1903
|
+
setSessionRecovery('idle')
|
|
1904
|
+
$('btn-rename-session').classList.add('hidden')
|
|
1905
|
+
$('btn-archive-session').classList.add('hidden')
|
|
1906
|
+
state.history = emptyHistory()
|
|
1907
|
+
document.body.classList.remove('in-session')
|
|
1908
|
+
hideComposerMenu()
|
|
1909
|
+
renderSessions()
|
|
1910
|
+
renderWorkbench()
|
|
1911
|
+
showView('view-home')
|
|
1912
|
+
})()
|
|
1913
|
+
closeSession.pending = task
|
|
1914
|
+
try { await task } finally {
|
|
1915
|
+
if (closeSession.pending === task) closeSession.pending = null
|
|
1916
|
+
}
|
|
1863
1917
|
}
|
|
1864
1918
|
|
|
1865
1919
|
/* Android 手势返回/实体返回: 注册后系统不再直接杀 App, 由这里接管导航 */
|
|
@@ -1871,7 +1925,7 @@ function bindNativeBack() {
|
|
|
1871
1925
|
if (customSelectCurrent) { closeCustomSelect(); return }
|
|
1872
1926
|
const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
|
|
1873
1927
|
if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else if (openModal.id === 'modal-archive') closeArchiveConfirm(); else if (openModal.id === 'modal-rename') closeRenameSession(); else if (openModal.id === 'modal-app-version-warning') closeAppVersionWarning(false); else if (openModal.id === 'modal-scan-live') closeLiveScan(''); else openModal.classList.add('hidden'); return } // 先关弹窗
|
|
1874
|
-
if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
|
|
1928
|
+
if (document.body.classList.contains('in-session')) { void closeSession(); return } // 会话页 → 回主页
|
|
1875
1929
|
if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
|
|
1876
1930
|
if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
|
|
1877
1931
|
showView('view-home'); return
|
|
@@ -1922,11 +1976,19 @@ const HISTORY_MAX_VISIBLE = 5000 // 已加载的可显示事件上限(消息/
|
|
|
1922
1976
|
function emptyHistory() {
|
|
1923
1977
|
return {
|
|
1924
1978
|
visible: [], seqs: new Set(), minSeq: Infinity,
|
|
1925
|
-
hasMore: false, loading: false, renderStart: 0, renderEnd: 0,
|
|
1979
|
+
hasMore: false, loading: false, loaded: false, renderStart: 0, renderEnd: 0,
|
|
1926
1980
|
partialReasoning: new Map()
|
|
1927
1981
|
}
|
|
1928
1982
|
}
|
|
1929
1983
|
|
|
1984
|
+
function sessionHistoryHasContent(history) {
|
|
1985
|
+
return !!history && ((history.visible?.length || 0) > 0 || (history.partialReasoning?.size || 0) > 0)
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
function isEmptySessionHistory(history) {
|
|
1989
|
+
return history?.loaded === true && !sessionHistoryHasContent(history)
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1930
1992
|
function reasoningStreamKey(data, index) {
|
|
1931
1993
|
return `${data?.turn ?? '?'}:${data?.step ?? '?'}:${index ?? '?'}`
|
|
1932
1994
|
}
|
|
@@ -2042,6 +2104,7 @@ function restoreCachedHistory() {
|
|
|
2042
2104
|
h.visible.push(e)
|
|
2043
2105
|
}
|
|
2044
2106
|
h.visible.sort((a, b) => a.seq - b.seq)
|
|
2107
|
+
h.loaded = true
|
|
2045
2108
|
state.history = h
|
|
2046
2109
|
$('history-hint').textContent = t('history.offlineCache', { n: h.visible.length })
|
|
2047
2110
|
renderHistory(true)
|
|
@@ -2051,7 +2114,8 @@ function restoreCachedHistory() {
|
|
|
2051
2114
|
async function loadHistory(reset) {
|
|
2052
2115
|
const id = state.current
|
|
2053
2116
|
if (!id || state.history.loading) return
|
|
2054
|
-
|
|
2117
|
+
const history = state.history
|
|
2118
|
+
history.loading = true
|
|
2055
2119
|
if (reset) setSessionRecovery('loading')
|
|
2056
2120
|
const moreBtn = $('history-more')
|
|
2057
2121
|
if (moreBtn) moreBtn.classList.add('hidden')
|
|
@@ -2062,7 +2126,8 @@ async function loadHistory(reset) {
|
|
|
2062
2126
|
try {
|
|
2063
2127
|
v = await rpc('session.history', payload)
|
|
2064
2128
|
} catch (e) {
|
|
2065
|
-
state.history
|
|
2129
|
+
if (state.current !== id || state.history !== history) return
|
|
2130
|
+
history.loading = false
|
|
2066
2131
|
if (e.message === 'AUTH') { authFailure(); return }
|
|
2067
2132
|
if (restoreCachedHistory()) {
|
|
2068
2133
|
setSessionRecovery('cached', e.message)
|
|
@@ -2082,7 +2147,9 @@ async function loadHistory(reset) {
|
|
|
2082
2147
|
return
|
|
2083
2148
|
}
|
|
2084
2149
|
|
|
2150
|
+
if (state.current !== id || state.history !== history) return
|
|
2085
2151
|
hydrateSessionProjections(id, v.projections)
|
|
2152
|
+
history.loaded = true
|
|
2086
2153
|
const incoming = v.events || []
|
|
2087
2154
|
let added = 0
|
|
2088
2155
|
if (reset) state.history.partialReasoning.clear()
|
|
@@ -2103,7 +2170,7 @@ async function loadHistory(reset) {
|
|
|
2103
2170
|
state.history.visible.sort((a, b) => a.seq - b.seq)
|
|
2104
2171
|
trimVisible()
|
|
2105
2172
|
state.history.hasMore = !!v.hasMore
|
|
2106
|
-
|
|
2173
|
+
history.loading = false
|
|
2107
2174
|
setSessionRecovery('ready')
|
|
2108
2175
|
renderSessionTitle(); renderSessionSub(); renderSessionCards()
|
|
2109
2176
|
try {
|
|
@@ -2601,33 +2668,48 @@ async function sendSessionText(text) {
|
|
|
2601
2668
|
async function sendSessionContent(text, images) {
|
|
2602
2669
|
const clean = String(text || '').trim()
|
|
2603
2670
|
if ((!clean && !images.length) || !state.current) return false
|
|
2604
|
-
|
|
2671
|
+
const sessionId = state.current
|
|
2672
|
+
if (images.length === 0 && clean && await runSlashCommand(clean)) {
|
|
2673
|
+
state.sessionActivity.add(sessionId)
|
|
2674
|
+
return true
|
|
2675
|
+
}
|
|
2605
2676
|
const buttons = [$('btn-send'), $('btn-fs-send')].filter(Boolean)
|
|
2606
2677
|
buttons.forEach(button => { button.disabled = true })
|
|
2678
|
+
state.pendingPrompts.add(sessionId)
|
|
2607
2679
|
try {
|
|
2608
2680
|
const content = [...await encodeComposerImagesFor(images)]
|
|
2609
2681
|
if (clean) content.push({ type: 'text', text: clean })
|
|
2610
2682
|
setSessionRecovery('resuming')
|
|
2611
2683
|
const v = await safeRpc('session.prompt', {
|
|
2612
|
-
sessionId
|
|
2684
|
+
sessionId,
|
|
2613
2685
|
mode: 'queue',
|
|
2614
2686
|
content
|
|
2615
2687
|
}, t('send.failed'))
|
|
2616
2688
|
if (v?.accepted) {
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2689
|
+
state.sessionActivity.add(sessionId)
|
|
2690
|
+
if (state.current === sessionId) {
|
|
2691
|
+
setSessionRecovery('ready')
|
|
2692
|
+
noteSessionTurnTime(sessionId, Date.now())
|
|
2693
|
+
renderSessions()
|
|
2694
|
+
toast(images.length ? t('send.imageSent') : (clean.startsWith('/') ? t('send.commandSent') : t('send.sent')), 'ok')
|
|
2695
|
+
}
|
|
2696
|
+
return true
|
|
2697
|
+
}
|
|
2698
|
+
if (v?.command?.text) {
|
|
2699
|
+
state.sessionActivity.add(sessionId)
|
|
2700
|
+
if (state.current === sessionId) toast(t('send.commandExecuted'), 'ok')
|
|
2621
2701
|
return true
|
|
2622
2702
|
}
|
|
2623
|
-
if (
|
|
2624
|
-
setSessionRecovery('error')
|
|
2703
|
+
if (state.current === sessionId) setSessionRecovery('error')
|
|
2625
2704
|
return false
|
|
2626
2705
|
} catch (e) {
|
|
2627
|
-
|
|
2628
|
-
|
|
2706
|
+
if (state.current === sessionId) {
|
|
2707
|
+
setSessionRecovery('error', e?.message)
|
|
2708
|
+
toast(t('composer.imageReadFailed', { msg: e?.message || e }), 'err')
|
|
2709
|
+
}
|
|
2629
2710
|
return false
|
|
2630
2711
|
} finally {
|
|
2712
|
+
state.pendingPrompts.delete(sessionId)
|
|
2631
2713
|
buttons.forEach(button => { button.disabled = false })
|
|
2632
2714
|
}
|
|
2633
2715
|
}
|
|
@@ -2890,7 +2972,7 @@ async function confirmArchiveSession() {
|
|
|
2890
2972
|
closeArchiveConfirm()
|
|
2891
2973
|
toast(t('session.archived'), 'ok')
|
|
2892
2974
|
await refreshSessions()
|
|
2893
|
-
if (state.current === sessionId) closeSession()
|
|
2975
|
+
if (state.current === sessionId) void closeSession()
|
|
2894
2976
|
} finally {
|
|
2895
2977
|
button.disabled = false
|
|
2896
2978
|
}
|
|
@@ -4611,6 +4693,716 @@ async function restorePeakReminders() {
|
|
|
4611
4693
|
if (peakRemindOn() && legacyCleaned) await schedulePeakReminders({ legacyCleaned: true })
|
|
4612
4694
|
}
|
|
4613
4695
|
|
|
4696
|
+
/* ---------------- 功能测试 / Android ASR ---------------- */
|
|
4697
|
+
function asrTestBridge() { return window.NativeAsrTest }
|
|
4698
|
+
|
|
4699
|
+
function emptyAsrTest() {
|
|
4700
|
+
return { running: false, status: 'idle', meta: null, summary: null, events: [], lastError: '' }
|
|
4701
|
+
}
|
|
4702
|
+
|
|
4703
|
+
function asrTestEvent(event) {
|
|
4704
|
+
if (!event || typeof event !== 'object') return
|
|
4705
|
+
const current = state.asrTest
|
|
4706
|
+
const data = event.data && typeof event.data === 'object' ? event.data : {}
|
|
4707
|
+
if (event.type === 'meta') current.meta = data
|
|
4708
|
+
if (event.type === 'summary') {
|
|
4709
|
+
current.summary = data
|
|
4710
|
+
current.running = false
|
|
4711
|
+
}
|
|
4712
|
+
if (event.type === 'status') {
|
|
4713
|
+
current.status = String(data.status || 'unknown')
|
|
4714
|
+
if (current.status === 'listening' || current.status === 'starting' || current.status === 'restarting') current.running = true
|
|
4715
|
+
if (['stopped', 'unsupported', 'permission-denied'].includes(current.status)) current.running = false
|
|
4716
|
+
}
|
|
4717
|
+
if (event.type === 'error') current.lastError = String(data.name || data.message || 'error')
|
|
4718
|
+
current.events.push({ type: event.type, atMs: Number(event.atMs) || 0, data })
|
|
4719
|
+
if (current.events.length > 500) current.events.splice(0, current.events.length - 500)
|
|
4720
|
+
renderAsrTest()
|
|
4721
|
+
}
|
|
4722
|
+
window.__dshAsrEvent = asrTestEvent
|
|
4723
|
+
|
|
4724
|
+
function asrTestStatusText(status) {
|
|
4725
|
+
const labels = {
|
|
4726
|
+
idle: t('settings.asrTestNativeOnly'),
|
|
4727
|
+
starting: t('settings.asrTestStarted'),
|
|
4728
|
+
listening: t('settings.asrTestStarted'),
|
|
4729
|
+
restarting: t('settings.asrTestRestarting'),
|
|
4730
|
+
'permission-requesting': t('settings.asrTestPermission'),
|
|
4731
|
+
'permission-denied': t('settings.asrTestPermissionDenied'),
|
|
4732
|
+
'permission-error': t('settings.asrTestPermissionError'),
|
|
4733
|
+
unsupported: t('settings.asrTestUnavailable'),
|
|
4734
|
+
busy: t('settings.asrTestBusy'),
|
|
4735
|
+
stopped: t('settings.asrTestStopped')
|
|
4736
|
+
}
|
|
4737
|
+
return labels[status] || t('settings.asrTestStatus', { status })
|
|
4738
|
+
}
|
|
4739
|
+
|
|
4740
|
+
function asrTestLogLines() {
|
|
4741
|
+
const current = state.asrTest
|
|
4742
|
+
const lines = []
|
|
4743
|
+
for (const event of current.events) {
|
|
4744
|
+
const data = event.data || {}
|
|
4745
|
+
const at = `${event.atMs}ms`
|
|
4746
|
+
if (event.type === 'meta') {
|
|
4747
|
+
lines.push(`[${at}] meta brand=${data.brand || '—'} manufacturer=${data.manufacturer || '—'} model=${data.model || '—'} Android=${data.androidVersion || '—'} API=${data.apiLevel || '—'}`)
|
|
4748
|
+
lines.push(`[${at}] recordAudioPermission=${data.recordAudioPermission ?? 'unknown'} recordAudioAppOp=${data.recordAudioAppOp || 'unknown'} microphoneMuted=${data.microphoneMuted ?? 'unknown'}`)
|
|
4749
|
+
lines.push(`[${at}] recognitionAvailable=${data.recognitionAvailable === true} onDeviceAvailable=${data.onDeviceAvailable === true} path=${data.networkPath || '—'}`)
|
|
4750
|
+
for (const service of data.recognitionServices || []) lines.push(`[${at}] service ${service.packageName || '—'} / ${service.serviceName || '—'} xiaomiLike=${service.xiaomiLike === true}`)
|
|
4751
|
+
} else if (event.type === 'status') {
|
|
4752
|
+
lines.push(`[${at}] status=${data.status || '—'} session=${data.session ?? '—'} reason=${data.reason || '—'} ${data.message || ''}`.trim())
|
|
4753
|
+
} else if (event.type === 'partial' || event.type === 'final') {
|
|
4754
|
+
lines.push(`[${at}] ${event.type}#${data.count ?? '—'} session=${data.session ?? '—'} +${data.elapsedMs ?? '—'}ms: ${data.text || '(empty)'}`)
|
|
4755
|
+
} else if (event.type === 'callback') {
|
|
4756
|
+
lines.push(`[${at}] callback=${data.name || '—'} session=${data.session ?? '—'} +${data.elapsedMs ?? '—'}ms${data.bytes >= 0 ? ` bytes=${data.bytes}` : ''}`)
|
|
4757
|
+
} else if (event.type === 'error') {
|
|
4758
|
+
lines.push(`[${at}] error=${data.name || '—'} code=${data.code ?? '—'} session=${data.session ?? '—'} ${data.message || ''}`.trim())
|
|
4759
|
+
} else if (event.type === 'summary') {
|
|
4760
|
+
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 ?? '—'}`)
|
|
4761
|
+
}
|
|
4762
|
+
}
|
|
4763
|
+
return lines
|
|
4764
|
+
}
|
|
4765
|
+
|
|
4766
|
+
function asrTestReport() {
|
|
4767
|
+
const current = state.asrTest
|
|
4768
|
+
const meta = current.meta || {}
|
|
4769
|
+
const summary = current.summary || {}
|
|
4770
|
+
const lines = [
|
|
4771
|
+
'DSH Remote Android ASR 测试报告',
|
|
4772
|
+
`生成时间: ${new Date().toISOString()}`,
|
|
4773
|
+
`设备: ${meta.brand || '—'} / ${meta.manufacturer || '—'} / ${meta.model || '—'}`,
|
|
4774
|
+
`Android: ${meta.androidVersion || '—'} (API ${meta.apiLevel || '—'})`,
|
|
4775
|
+
`识别可用: ${meta.recognitionAvailable === true ? 'yes' : meta.recognitionAvailable === false ? 'no' : 'unknown'}`,
|
|
4776
|
+
`端侧识别可用: ${meta.onDeviceAvailable === true ? 'yes' : meta.onDeviceAvailable === false ? 'no' : 'unknown'}`,
|
|
4777
|
+
`路径: ${meta.networkPath || 'system-default-recognition-service'}`,
|
|
4778
|
+
`测试结束原因: ${summary.reason || current.status || '—'}`,
|
|
4779
|
+
`总时长: ${summary.durationMs ?? '—'}ms`,
|
|
4780
|
+
`session: ${summary.sessionCount ?? '—'} / 重建: ${summary.restartCount ?? '—'} / partial: ${summary.partialCount ?? '—'} / final: ${summary.finalCount ?? '—'} / errors: ${summary.errorCount ?? '—'}`,
|
|
4781
|
+
'',
|
|
4782
|
+
'事件日志:',
|
|
4783
|
+
...asrTestLogLines()
|
|
4784
|
+
]
|
|
4785
|
+
return lines.join('\n')
|
|
4786
|
+
}
|
|
4787
|
+
|
|
4788
|
+
function renderAsrTest() {
|
|
4789
|
+
const start = $('btn-asr-test-start')
|
|
4790
|
+
const stop = $('btn-asr-test-stop')
|
|
4791
|
+
const copy = $('btn-asr-test-copy')
|
|
4792
|
+
const permission = $('btn-asr-test-permission')
|
|
4793
|
+
const engine = $('btn-asr-test-engine')
|
|
4794
|
+
const status = $('asr-test-status')
|
|
4795
|
+
const summary = $('asr-test-summary')
|
|
4796
|
+
const log = $('asr-test-log')
|
|
4797
|
+
if (!start || !stop || !copy || !permission || !engine || !status || !summary || !log) return
|
|
4798
|
+
const current = state.asrTest
|
|
4799
|
+
const native = !!(CAP?.isNativePlatform?.() && asrTestBridge()?.startAsrTest)
|
|
4800
|
+
start.disabled = current.running || !native
|
|
4801
|
+
stop.disabled = !current.running || !native
|
|
4802
|
+
copy.disabled = !current.events.length
|
|
4803
|
+
const permissionError = current.status === 'permission-error' || current.status === 'permission-denied' || current.summary?.reason === 'permission-error'
|
|
4804
|
+
status.className = 'feature-test-status ' + (permissionError || current.status === 'unsupported' ? 'error' : current.status === 'stopped' ? 'ok' : 'muted')
|
|
4805
|
+
status.textContent = native ? (permissionError ? t('settings.asrTestPermissionError') : asrTestStatusText(current.status)) : t('settings.asrTestWebUnsupported')
|
|
4806
|
+
permission.classList.toggle('hidden', !native || !permissionError)
|
|
4807
|
+
engine.classList.toggle('hidden', !native || !permissionError)
|
|
4808
|
+
const meta = current.meta || {}
|
|
4809
|
+
const s = current.summary
|
|
4810
|
+
summary.textContent = [
|
|
4811
|
+
meta.model ? `${t('settings.asrTestMeta')}: ${meta.brand || '—'} / ${meta.manufacturer || '—'} / ${meta.model}` : '',
|
|
4812
|
+
s ? `${t('settings.asrTestSummary')}: ${t('settings.asrTestStatus', { status: s.reason || 'done' })} · session ${s.sessionCount ?? '—'} · partial ${s.partialCount ?? '—'} · final ${s.finalCount ?? '—'} · error ${s.errorCount ?? '—'}` : ''
|
|
4813
|
+
].filter(Boolean).join('\n')
|
|
4814
|
+
log.textContent = current.events.length ? asrTestLogLines().join('\n') : t('settings.asrTestLogEmpty')
|
|
4815
|
+
log.scrollTop = log.scrollHeight
|
|
4816
|
+
}
|
|
4817
|
+
|
|
4818
|
+
function clearAsrTest() {
|
|
4819
|
+
if (state.asrTest.running) return toast(t('settings.asrTestBusy'), 'err')
|
|
4820
|
+
state.asrTest = emptyAsrTest()
|
|
4821
|
+
renderAsrTest()
|
|
4822
|
+
}
|
|
4823
|
+
|
|
4824
|
+
async function startAsrTest() {
|
|
4825
|
+
const native = asrTestBridge()
|
|
4826
|
+
if (!CAP?.isNativePlatform?.() || !native?.startAsrTest) return toast(t('settings.asrTestWebUnsupported'), 'err')
|
|
4827
|
+
if (state.asrTest.running) return toast(t('settings.asrTestBusy'), 'err')
|
|
4828
|
+
if (!confirm(t('settings.asrTestConsent'))) return
|
|
4829
|
+
state.asrTest = { ...emptyAsrTest(), running: true, status: 'starting' }
|
|
4830
|
+
renderAsrTest()
|
|
4831
|
+
try {
|
|
4832
|
+
if (native.startAsrTest() === false) throw new Error(t('settings.asrTestUnavailable'))
|
|
4833
|
+
} catch (error) {
|
|
4834
|
+
state.asrTest.running = false
|
|
4835
|
+
state.asrTest.status = 'error'
|
|
4836
|
+
state.asrTest.lastError = error?.message || String(error)
|
|
4837
|
+
renderAsrTest()
|
|
4838
|
+
toast(state.asrTest.lastError, 'err')
|
|
4839
|
+
}
|
|
4840
|
+
}
|
|
4841
|
+
|
|
4842
|
+
function stopAsrTest() {
|
|
4843
|
+
try { asrTestBridge()?.stopAsrTest?.() } catch {}
|
|
4844
|
+
}
|
|
4845
|
+
|
|
4846
|
+
function openAsrPermissionSettings() {
|
|
4847
|
+
try {
|
|
4848
|
+
if (asrTestBridge()?.openAsrPermissionSettings?.() === false) throw new Error('permission settings unavailable')
|
|
4849
|
+
} catch (error) {
|
|
4850
|
+
toast(error?.message || String(error), 'err')
|
|
4851
|
+
}
|
|
4852
|
+
}
|
|
4853
|
+
|
|
4854
|
+
function openAsrEngineSettings() {
|
|
4855
|
+
try {
|
|
4856
|
+
if (asrTestBridge()?.openAsrEngineSettings?.() === false) throw new Error('voice engine settings unavailable')
|
|
4857
|
+
} catch (error) {
|
|
4858
|
+
toast(error?.message || String(error), 'err')
|
|
4859
|
+
}
|
|
4860
|
+
}
|
|
4861
|
+
|
|
4862
|
+
async function copyAsrTestLog() {
|
|
4863
|
+
const ok = await copyText(asrTestReport())
|
|
4864
|
+
toast(t(ok ? 'settings.asrTestCopyOk' : 'settings.asrTestCopyFailed'), ok ? 'ok' : 'err')
|
|
4865
|
+
}
|
|
4866
|
+
|
|
4867
|
+
/* ---------------- 模型设置 ---------------- */
|
|
4868
|
+
const MODEL_SETTINGS_FIELDS = ['baseURL', 'api', 'apiKeyEnv', 'displayName', 'models']
|
|
4869
|
+
const MODEL_REASONING_LIMIT = 12
|
|
4870
|
+
const MODEL_REASONING_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/
|
|
4871
|
+
|
|
4872
|
+
function modelValueAt(value, path = []) {
|
|
4873
|
+
let current = value
|
|
4874
|
+
for (const part of path) {
|
|
4875
|
+
if (current === null || typeof current !== 'object') return undefined
|
|
4876
|
+
current = current[part]
|
|
4877
|
+
}
|
|
4878
|
+
return current
|
|
4879
|
+
}
|
|
4880
|
+
|
|
4881
|
+
function cloneModelValue(value) {
|
|
4882
|
+
if (value === undefined) return undefined
|
|
4883
|
+
try { return structuredClone(value) } catch {}
|
|
4884
|
+
try { return JSON.parse(JSON.stringify(value)) } catch { return value }
|
|
4885
|
+
}
|
|
4886
|
+
|
|
4887
|
+
function modelObjectAt(value, path = []) {
|
|
4888
|
+
const result = modelValueAt(value, path)
|
|
4889
|
+
return result && typeof result === 'object' && !Array.isArray(result) ? cloneModelValue(result) : {}
|
|
4890
|
+
}
|
|
4891
|
+
|
|
4892
|
+
function modelKeyRefFor(provider, namespace, path) {
|
|
4893
|
+
const effective = modelObjectAt(namespace?.value, path)
|
|
4894
|
+
const user = modelObjectAt(namespace?.user, path)
|
|
4895
|
+
const named = typeof user.apiKeyEnv === 'string' && user.apiKeyEnv.trim()
|
|
4896
|
+
? user.apiKeyEnv.trim()
|
|
4897
|
+
: typeof effective.apiKeyEnv === 'string' && effective.apiKeyEnv.trim()
|
|
4898
|
+
? effective.apiKeyEnv.trim()
|
|
4899
|
+
: ''
|
|
4900
|
+
if (named) return named
|
|
4901
|
+
if (namespace?.ns === 'llm-deepseek') return 'DEEPSEEK_API_KEY'
|
|
4902
|
+
if (namespace?.ns === 'llm-pi-ai') return provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_') + '_API_KEY'
|
|
4903
|
+
return ''
|
|
4904
|
+
}
|
|
4905
|
+
|
|
4906
|
+
function modelProfileName(row) {
|
|
4907
|
+
const display = String(row.displayName || row.provider || '')
|
|
4908
|
+
return display === row.provider ? display : `${display} (${row.provider})`
|
|
4909
|
+
}
|
|
4910
|
+
|
|
4911
|
+
function modelSettingsNamespace(ns) {
|
|
4912
|
+
return state.modelSettings.namespaces.find(item => item.ns === ns) || null
|
|
4913
|
+
}
|
|
4914
|
+
|
|
4915
|
+
function modelSettingsRow(provider) {
|
|
4916
|
+
return state.modelSettings.providers.find(row => row.provider === provider) || null
|
|
4917
|
+
}
|
|
4918
|
+
|
|
4919
|
+
function modelSettingsPathChanged(before, after, key) {
|
|
4920
|
+
return JSON.stringify(before?.[key]) !== JSON.stringify(after?.[key])
|
|
4921
|
+
}
|
|
4922
|
+
|
|
4923
|
+
function modelCatalogRows(value) {
|
|
4924
|
+
if (!Array.isArray(value)) return []
|
|
4925
|
+
return value
|
|
4926
|
+
.filter(model => model && typeof model === 'object' && !Array.isArray(model))
|
|
4927
|
+
.map(model => cloneModelValue(model))
|
|
4928
|
+
}
|
|
4929
|
+
|
|
4930
|
+
function modelReasoningRows(model) {
|
|
4931
|
+
const reasoning = modelObjectAt(model, ['reasoning'])
|
|
4932
|
+
const raw = Array.isArray(reasoning.efforts) && reasoning.efforts.length
|
|
4933
|
+
? reasoning.efforts
|
|
4934
|
+
: (Array.isArray(model?.reasoningEfforts) ? model.reasoningEfforts : [])
|
|
4935
|
+
return raw.map(item => {
|
|
4936
|
+
const value = typeof item === 'string' ? { id: item } : modelObjectAt(item)
|
|
4937
|
+
return {
|
|
4938
|
+
id: typeof value.id === 'string' ? value.id.trim() : '',
|
|
4939
|
+
name: typeof value.name === 'string' ? value.name.trim() : '',
|
|
4940
|
+
description: typeof value.description === 'string' ? value.description.trim() : ''
|
|
4941
|
+
}
|
|
4942
|
+
})
|
|
4943
|
+
}
|
|
4944
|
+
|
|
4945
|
+
function withModelReasoning(model, rows, defaultEffort = '') {
|
|
4946
|
+
const next = cloneModelValue(model) || {}
|
|
4947
|
+
delete next.reasoningEfforts
|
|
4948
|
+
const reasoning = modelObjectAt(next, ['reasoning'])
|
|
4949
|
+
const efforts = rows.map(row => {
|
|
4950
|
+
const value = { id: String(row.id || '').trim() }
|
|
4951
|
+
if (String(row.name || '').trim()) value.name = String(row.name).trim()
|
|
4952
|
+
if (String(row.description || '').trim()) value.description = String(row.description).trim()
|
|
4953
|
+
return value
|
|
4954
|
+
})
|
|
4955
|
+
if (efforts.length) {
|
|
4956
|
+
reasoning.efforts = efforts
|
|
4957
|
+
const selected = efforts.some(row => row.id === defaultEffort) ? defaultEffort : ''
|
|
4958
|
+
if (selected) reasoning.defaultEffort = selected
|
|
4959
|
+
else delete reasoning.defaultEffort
|
|
4960
|
+
next.reasoning = reasoning
|
|
4961
|
+
} else {
|
|
4962
|
+
delete reasoning.efforts
|
|
4963
|
+
delete reasoning.defaultEffort
|
|
4964
|
+
if (Object.keys(reasoning).length) next.reasoning = reasoning
|
|
4965
|
+
else delete next.reasoning
|
|
4966
|
+
}
|
|
4967
|
+
return next
|
|
4968
|
+
}
|
|
4969
|
+
|
|
4970
|
+
function modelReasoningError(models) {
|
|
4971
|
+
for (const model of models) {
|
|
4972
|
+
const rows = modelReasoningRows(model)
|
|
4973
|
+
if (rows.length > MODEL_REASONING_LIMIT) return t('settings.modelReasoningLimit', { count: MODEL_REASONING_LIMIT })
|
|
4974
|
+
const ids = new Set()
|
|
4975
|
+
for (const row of rows) {
|
|
4976
|
+
if (!row.id) return t('settings.modelReasoningIdRequired')
|
|
4977
|
+
if (!MODEL_REASONING_ID_RE.test(row.id)) return t('settings.modelReasoningInvalid')
|
|
4978
|
+
if (ids.has(row.id)) return t('settings.modelReasoningDuplicate')
|
|
4979
|
+
ids.add(row.id)
|
|
4980
|
+
}
|
|
4981
|
+
const defaultEffort = modelObjectAt(model, ['reasoning']).defaultEffort
|
|
4982
|
+
if (defaultEffort && !ids.has(defaultEffort)) return t('settings.modelReasoningDefaultInvalid')
|
|
4983
|
+
}
|
|
4984
|
+
return ''
|
|
4985
|
+
}
|
|
4986
|
+
|
|
4987
|
+
async function loadModelSettings(force = false) {
|
|
4988
|
+
if (!state.token) {
|
|
4989
|
+
state.modelSettings = { ...state.modelSettings, status: 'error', error: t('token.notSetHint') }
|
|
4990
|
+
renderModelSettings()
|
|
4991
|
+
return
|
|
4992
|
+
}
|
|
4993
|
+
if (state.modelSettings.status === 'loading') return
|
|
4994
|
+
if (!force && state.modelSettings.status === 'ready') return
|
|
4995
|
+
state.modelSettings = { ...state.modelSettings, status: 'loading', error: '' }
|
|
4996
|
+
renderModelSettings()
|
|
4997
|
+
try {
|
|
4998
|
+
const [providersValue, settingsValue] = await Promise.all([
|
|
4999
|
+
rpc('llm.providers', {}),
|
|
5000
|
+
rpc('settings.describe', {})
|
|
5001
|
+
])
|
|
5002
|
+
const namespaces = Array.isArray(settingsValue?.namespaces) ? settingsValue.namespaces : []
|
|
5003
|
+
const providers = Array.isArray(providersValue?.providers) ? providersValue.providers : []
|
|
5004
|
+
const rows = providers.map(entry => {
|
|
5005
|
+
const settingsPath = Array.isArray(entry.settingsPath) ? entry.settingsPath : []
|
|
5006
|
+
const namespace = namespaces.find(item => item.ns === entry.settingsNs) || null
|
|
5007
|
+
const effective = modelObjectAt(namespace?.value, settingsPath)
|
|
5008
|
+
const keyRef = modelKeyRefFor(entry.provider, namespace, settingsPath)
|
|
5009
|
+
return {
|
|
5010
|
+
...entry,
|
|
5011
|
+
settingsPath,
|
|
5012
|
+
keyRef,
|
|
5013
|
+
namespace,
|
|
5014
|
+
configured: namespace !== null && (settingsPath.length === 0 || modelValueAt(namespace.value, settingsPath) !== undefined),
|
|
5015
|
+
effective,
|
|
5016
|
+
credential: null
|
|
5017
|
+
}
|
|
5018
|
+
})
|
|
5019
|
+
const refs = [...new Set(rows.map(row => row.keyRef).filter(Boolean))]
|
|
5020
|
+
let credentials = {}
|
|
5021
|
+
if (refs.length > 0) {
|
|
5022
|
+
const value = await rpc('credentials.describe', { refs })
|
|
5023
|
+
credentials = value?.credentials && typeof value.credentials === 'object' ? value.credentials : {}
|
|
5024
|
+
}
|
|
5025
|
+
state.modelSettings = {
|
|
5026
|
+
status: 'ready',
|
|
5027
|
+
error: '',
|
|
5028
|
+
writable: settingsValue?.writable === true,
|
|
5029
|
+
hasDocument: settingsValue?.hasDocument === true,
|
|
5030
|
+
providers: rows.map(row => ({ ...row, credential: row.keyRef ? credentials[row.keyRef] || null : null })),
|
|
5031
|
+
namespaces,
|
|
5032
|
+
credentials
|
|
5033
|
+
}
|
|
5034
|
+
state.modelEditor = null
|
|
5035
|
+
} catch (error) {
|
|
5036
|
+
state.modelSettings = { ...state.modelSettings, status: 'error', error: error?.message || String(error) }
|
|
5037
|
+
}
|
|
5038
|
+
renderModelSettings()
|
|
5039
|
+
}
|
|
5040
|
+
|
|
5041
|
+
function renderModelSettings() {
|
|
5042
|
+
const status = $('model-settings-status')
|
|
5043
|
+
const list = $('model-settings-list')
|
|
5044
|
+
if (!status || !list) return
|
|
5045
|
+
const current = state.modelSettings
|
|
5046
|
+
if (current.status === 'loading') {
|
|
5047
|
+
status.className = 'model-settings-status muted'
|
|
5048
|
+
status.textContent = t('settings.modelLoading')
|
|
5049
|
+
list.innerHTML = ''
|
|
5050
|
+
return
|
|
5051
|
+
}
|
|
5052
|
+
if (current.status === 'error') {
|
|
5053
|
+
status.className = 'model-settings-status error'
|
|
5054
|
+
status.textContent = t('settings.modelUnavailable', { msg: current.error || t('err.dshError') })
|
|
5055
|
+
list.innerHTML = ''
|
|
5056
|
+
return
|
|
5057
|
+
}
|
|
5058
|
+
if (!current.providers.length) {
|
|
5059
|
+
status.className = 'model-settings-status muted'
|
|
5060
|
+
status.textContent = t('settings.modelEmpty')
|
|
5061
|
+
list.innerHTML = ''
|
|
5062
|
+
return
|
|
5063
|
+
}
|
|
5064
|
+
status.className = 'model-settings-status ' + (current.writable ? 'muted' : 'model-readonly')
|
|
5065
|
+
status.textContent = current.writable ? t('settings.modelIntro') : t('settings.modelReadOnly')
|
|
5066
|
+
list.innerHTML = current.providers.map(renderModelProviderCard).join('')
|
|
5067
|
+
}
|
|
5068
|
+
|
|
5069
|
+
function renderModelProviderCard(row) {
|
|
5070
|
+
const editor = state.modelEditor?.provider === row.provider ? renderModelEditor() : ''
|
|
5071
|
+
const credentialConfigured = row.credential?.configured === true
|
|
5072
|
+
const dot = row.keyRef ? (credentialConfigured ? 'configured' : '') : 'unknown'
|
|
5073
|
+
const stateLabel = row.keyRef
|
|
5074
|
+
? (credentialConfigured ? t('settings.modelConfigured') : t('settings.modelMissing'))
|
|
5075
|
+
: t('settings.modelConfigured')
|
|
5076
|
+
return `<article class="model-provider-card" data-model-provider-card="${esc(row.provider)}">
|
|
5077
|
+
<div class="model-provider-head">
|
|
5078
|
+
<div class="model-provider-identity">
|
|
5079
|
+
<span class="model-provider-dot ${dot}" title="${esc(stateLabel)}" aria-label="${esc(stateLabel)}"></span>
|
|
5080
|
+
<span class="model-provider-name">${esc(row.displayName || row.provider)}</span>
|
|
5081
|
+
<code class="model-provider-route">${esc(row.provider)}</code>
|
|
5082
|
+
</div>
|
|
5083
|
+
<button class="mini-btn" type="button" data-model-action="edit" data-model-provider="${esc(row.provider)}">${esc(t('settings.modelEdit'))}</button>
|
|
5084
|
+
</div>
|
|
5085
|
+
${editor}
|
|
5086
|
+
</article>`
|
|
5087
|
+
}
|
|
5088
|
+
|
|
5089
|
+
function renderModelReasoningEditor(model, index, readOnly) {
|
|
5090
|
+
const rows = modelReasoningRows(model)
|
|
5091
|
+
const reasoning = modelObjectAt(model, ['reasoning'])
|
|
5092
|
+
const defaultEffort = typeof reasoning.defaultEffort === 'string' ? reasoning.defaultEffort : ''
|
|
5093
|
+
const effortRows = rows.length
|
|
5094
|
+
? rows.map((row, effortIndex) => `<div class="model-reasoning-entry">
|
|
5095
|
+
<input class="model-input" data-model-field="reasoning-id" data-model-index="${index}" data-reasoning-index="${effortIndex}" value="${esc(row.id)}" placeholder="${esc(t('settings.modelReasoningId'))}" aria-label="${esc(t('settings.modelReasoningId'))} ${effortIndex + 1}" ${readOnly ? 'disabled' : ''}>
|
|
5096
|
+
<input class="model-input" data-model-field="reasoning-name" data-model-index="${index}" data-reasoning-index="${effortIndex}" value="${esc(row.name)}" placeholder="${esc(t('settings.modelReasoningName'))}" aria-label="${esc(t('settings.modelReasoningName'))} ${effortIndex + 1}" ${readOnly ? 'disabled' : ''}>
|
|
5097
|
+
<input class="model-input" data-model-field="reasoning-description" data-model-index="${index}" data-reasoning-index="${effortIndex}" value="${esc(row.description)}" placeholder="${esc(t('settings.modelReasoningDescription'))}" aria-label="${esc(t('settings.modelReasoningDescription'))} ${effortIndex + 1}" ${readOnly ? 'disabled' : ''}>
|
|
5098
|
+
<button class="model-entry-remove" type="button" data-model-action="remove-reasoning" data-model-index="${index}" data-reasoning-index="${effortIndex}" aria-label="${esc(t('settings.modelReasoningRemove'))}" title="${esc(t('settings.modelReasoningRemove'))}" ${readOnly ? 'disabled' : ''}>×</button>
|
|
5099
|
+
</div>`).join('')
|
|
5100
|
+
: `<div class="model-empty">${esc(t('settings.modelReasoningEmpty'))}</div>`
|
|
5101
|
+
const defaultOptions = rows.filter(row => row.id).map(row => `<option value="${esc(row.id)}" ${defaultEffort === row.id ? 'selected' : ''}>${esc(row.name || row.id)}</option>`).join('')
|
|
5102
|
+
return `<div class="model-reasoning" data-model-reasoning-editor="${index}">
|
|
5103
|
+
<div class="model-reasoning-head">
|
|
5104
|
+
<div><div class="model-catalog-title">${esc(t('settings.modelReasoning'))}</div><div class="model-catalog-hint">${esc(t('settings.modelReasoningHint'))}</div></div>
|
|
5105
|
+
<div class="model-catalog-actions"><button class="mini-btn" type="button" data-model-action="add-reasoning" data-model-index="${index}" ${readOnly || rows.length >= MODEL_REASONING_LIMIT ? 'disabled' : ''}>${esc(t('settings.modelReasoningAdd'))}</button><button class="mini-btn" type="button" data-model-action="clear-reasoning" data-model-index="${index}" ${readOnly || !rows.length ? 'disabled' : ''}>${esc(t('settings.modelReasoningClear'))}</button></div>
|
|
5106
|
+
</div>
|
|
5107
|
+
<div class="model-reasoning-list">${effortRows}</div>
|
|
5108
|
+
<label class="model-reasoning-default"><span>${esc(t('settings.modelReasoningDefault'))}</span><select class="model-input" data-model-field="reasoning-default" data-model-index="${index}" ${readOnly || !rows.length ? 'disabled' : ''}><option value="" ${defaultEffort ? '' : 'selected'}>${esc(t('settings.modelReasoningProviderDefault'))}</option>${defaultOptions}</select></label>
|
|
5109
|
+
</div>`
|
|
5110
|
+
}
|
|
5111
|
+
|
|
5112
|
+
function renderModelEditor() {
|
|
5113
|
+
const editor = state.modelEditor
|
|
5114
|
+
if (!editor) return ''
|
|
5115
|
+
const readOnly = !state.modelSettings.writable || editor.busy
|
|
5116
|
+
const keyPlaceholder = editor.keyConfigured && !editor.clearKey
|
|
5117
|
+
? t('settings.modelApiKeyStored')
|
|
5118
|
+
: t('settings.modelApiKeyPlaceholder')
|
|
5119
|
+
const models = editor.models || []
|
|
5120
|
+
const modelList = models.length
|
|
5121
|
+
? models.map((model, index) => `<div class="model-entry-card">
|
|
5122
|
+
<div class="model-entry">
|
|
5123
|
+
<input class="model-input" data-model-field="model-id" data-model-index="${index}" value="${esc(model.id || '')}" placeholder="${esc(t('settings.modelId'))}" aria-label="${esc(t('settings.modelId'))} ${index + 1}" ${readOnly ? 'disabled' : ''}>
|
|
5124
|
+
<input class="model-input model-name-input" data-model-field="model-name" data-model-index="${index}" value="${esc(model.name || '')}" placeholder="${esc(t('settings.modelName'))}" aria-label="${esc(t('settings.modelName'))} ${index + 1}" ${readOnly ? 'disabled' : ''}>
|
|
5125
|
+
<button class="model-entry-remove" type="button" data-model-action="remove-model" data-model-index="${index}" aria-label="${esc(t('settings.modelRemove'))}" title="${esc(t('settings.modelRemove'))}" ${readOnly ? 'disabled' : ''}>×</button>
|
|
5126
|
+
</div>
|
|
5127
|
+
${renderModelReasoningEditor(model, index, readOnly)}
|
|
5128
|
+
</div>`).join('')
|
|
5129
|
+
: `<div class="model-empty">${esc(t('settings.modelNoModels'))}</div>`
|
|
5130
|
+
const discovery = editor.discovered?.length
|
|
5131
|
+
? `<div class="model-discovery">
|
|
5132
|
+
<div class="model-discovery-head"><span>${esc(t('settings.modelCandidates'))}</span><button class="mini-btn" type="button" data-model-action="select-all">${esc(editor.discoverySelected.size === editor.discovered.length ? t('settings.modelSelectNone') : t('settings.modelSelectAll'))}</button></div>
|
|
5133
|
+
<div class="model-discovery-list">${editor.discovered.map((model, index) => `<label class="model-discovery-row"><input type="checkbox" data-model-candidate="${esc(model.id)}" ${editor.discoverySelected.has(model.id) ? 'checked' : ''}><code>${esc(model.id)}${model.name && model.name !== model.id ? ` · ${esc(model.name)}` : ''}</code></label>`).join('')}</div>
|
|
5134
|
+
<button class="mini-btn" type="button" data-model-action="add-selected" ${readOnly ? 'disabled' : ''}>${esc(t('settings.modelAddSelected'))}</button>
|
|
5135
|
+
</div>`
|
|
5136
|
+
: ''
|
|
5137
|
+
const effectText = editor.applies === 'restart' ? t('settings.modelRestart') : t('settings.modelLive')
|
|
5138
|
+
return `<div class="model-editor">
|
|
5139
|
+
<div class="model-editor-title"><strong>${esc(editor.displayName || editor.provider)}</strong><code>${esc(editor.provider)}</code></div>
|
|
5140
|
+
<div class="model-field">
|
|
5141
|
+
<label for="model-api-key-${esc(editor.provider)}">${esc(t('settings.modelApiKey'))}</label>
|
|
5142
|
+
<input id="model-api-key-${esc(editor.provider)}" class="model-input" type="password" autocomplete="off" data-model-field="apiKey" value="${esc(editor.keyDraft || '')}" placeholder="${esc(keyPlaceholder)}" ${readOnly || editor.keyWritable === false ? 'disabled' : ''}>
|
|
5143
|
+
${editor.keyConfigured && editor.keyWritable !== false ? `<button class="mini-btn" type="button" data-model-action="clear-key" ${readOnly ? 'disabled' : ''}>${esc(t('settings.modelClearKey'))}</button>` : ''}
|
|
5144
|
+
</div>
|
|
5145
|
+
<div class="model-inline">
|
|
5146
|
+
<div class="model-field"><label for="model-base-url-${esc(editor.provider)}">${esc(t('settings.modelBaseUrl'))}</label><input id="model-base-url-${esc(editor.provider)}" class="model-input" type="url" data-model-field="baseURL" value="${esc(editor.baseURL || '')}" placeholder="${esc(t('settings.modelBaseUrlPlaceholder'))}" ${readOnly ? 'disabled' : ''}></div>
|
|
5147
|
+
${editor.api ? `<div class="model-field"><span class="model-field-label">${esc(t('settings.modelProtocol'))}</span><input class="model-input" data-model-field="api" value="${esc(editor.api)}" ${readOnly ? 'disabled' : ''}></div>` : ''}
|
|
5148
|
+
</div>
|
|
5149
|
+
<div class="model-catalog">
|
|
5150
|
+
<div class="model-catalog-head"><div><div class="model-catalog-title">${esc(t('settings.modelCatalog'))}</div><div class="model-catalog-hint">${esc(t('settings.modelCatalogHint'))}</div></div><div class="model-catalog-actions"><button class="mini-btn" type="button" data-model-action="discover" ${readOnly || editor.busy ? 'disabled' : ''}>${esc(editor.busy ? t('settings.modelFetching') : t('settings.modelFetch'))}</button><button class="mini-btn" type="button" data-model-action="add-model" ${readOnly ? 'disabled' : ''}>${esc(t('settings.modelAdd'))}</button></div></div>
|
|
5151
|
+
<div class="model-list">${modelList}</div>
|
|
5152
|
+
${discovery}
|
|
5153
|
+
</div>
|
|
5154
|
+
${editor.error ? `<p class="model-editor-error">${esc(editor.error)}</p>` : ''}
|
|
5155
|
+
<div class="model-editor-actions"><span class="model-catalog-hint">${esc(effectText)}</span><button class="mini-btn" type="button" data-model-action="cancel">${esc(t('settings.modelCancel'))}</button><button class="mini-btn primary" type="button" data-model-action="save" ${readOnly ? 'disabled' : ''}>${esc(editor.busy ? t('settings.modelSaving') : t('settings.modelSave'))}</button></div>
|
|
5156
|
+
</div>`
|
|
5157
|
+
}
|
|
5158
|
+
|
|
5159
|
+
function openModelEditor(provider) {
|
|
5160
|
+
const row = modelSettingsRow(provider)
|
|
5161
|
+
const namespace = row?.namespace
|
|
5162
|
+
if (!row || !namespace) return
|
|
5163
|
+
const effective = modelObjectAt(namespace.value, row.settingsPath)
|
|
5164
|
+
const user = modelObjectAt(namespace.user, row.settingsPath)
|
|
5165
|
+
state.modelEditor = {
|
|
5166
|
+
provider: row.provider,
|
|
5167
|
+
displayName: row.displayName,
|
|
5168
|
+
settingsNs: row.settingsNs,
|
|
5169
|
+
settingsPath: row.settingsPath,
|
|
5170
|
+
namespace,
|
|
5171
|
+
userProfile: user,
|
|
5172
|
+
effectiveProfile: effective,
|
|
5173
|
+
keyRef: row.keyRef || '',
|
|
5174
|
+
keyConfigured: row.credential?.configured === true,
|
|
5175
|
+
keyWritable: row.credential?.writable !== false,
|
|
5176
|
+
baseURL: typeof (user.baseURL ?? effective.baseURL) === 'string' ? (user.baseURL ?? effective.baseURL) : '',
|
|
5177
|
+
initialBaseURL: typeof user.baseURL === 'string' ? user.baseURL : '',
|
|
5178
|
+
api: typeof (user.api ?? effective.api) === 'string' ? (user.api ?? effective.api) : '',
|
|
5179
|
+
initialApi: typeof user.api === 'string' ? user.api : '',
|
|
5180
|
+
models: modelCatalogRows(user.models ?? effective.models),
|
|
5181
|
+
modelsDirty: false,
|
|
5182
|
+
baseURLDirty: false,
|
|
5183
|
+
apiDirty: false,
|
|
5184
|
+
keyDraft: '',
|
|
5185
|
+
clearKey: false,
|
|
5186
|
+
discovered: [],
|
|
5187
|
+
discoverySelected: new Set(),
|
|
5188
|
+
applies: namespace.applies,
|
|
5189
|
+
busy: false,
|
|
5190
|
+
error: ''
|
|
5191
|
+
}
|
|
5192
|
+
renderModelSettings()
|
|
5193
|
+
}
|
|
5194
|
+
|
|
5195
|
+
function collectModelEditorForm() {
|
|
5196
|
+
const editor = state.modelEditor
|
|
5197
|
+
const root = $('model-settings-list')
|
|
5198
|
+
if (!editor || !root) return
|
|
5199
|
+
const base = root.querySelector('[data-model-field="baseURL"]')
|
|
5200
|
+
const api = root.querySelector('[data-model-field="api"]')
|
|
5201
|
+
const key = root.querySelector('[data-model-field="apiKey"]')
|
|
5202
|
+
if (base) editor.baseURL = base.value.trim()
|
|
5203
|
+
if (api) editor.api = api.value.trim()
|
|
5204
|
+
if (key) editor.keyDraft = key.value
|
|
5205
|
+
root.querySelectorAll('[data-model-field="model-id"]').forEach(input => {
|
|
5206
|
+
const index = Number(input.dataset.modelIndex)
|
|
5207
|
+
if (editor.models[index]) editor.models[index].id = input.value.trim()
|
|
5208
|
+
})
|
|
5209
|
+
root.querySelectorAll('[data-model-field="model-name"]').forEach(input => {
|
|
5210
|
+
const index = Number(input.dataset.modelIndex)
|
|
5211
|
+
if (editor.models[index]) {
|
|
5212
|
+
const value = input.value.trim()
|
|
5213
|
+
if (value) editor.models[index].name = value
|
|
5214
|
+
else delete editor.models[index].name
|
|
5215
|
+
}
|
|
5216
|
+
})
|
|
5217
|
+
editor.models.forEach((model, index) => {
|
|
5218
|
+
const section = root.querySelector(`[data-model-reasoning-editor="${index}"]`)
|
|
5219
|
+
if (!section) return
|
|
5220
|
+
const rows = [...section.querySelectorAll('[data-model-field="reasoning-id"]')].map((input, effortIndex) => ({
|
|
5221
|
+
id: input.value.trim(),
|
|
5222
|
+
name: section.querySelector(`[data-model-field="reasoning-name"][data-reasoning-index="${effortIndex}"]`)?.value.trim() || '',
|
|
5223
|
+
description: section.querySelector(`[data-model-field="reasoning-description"][data-reasoning-index="${effortIndex}"]`)?.value.trim() || ''
|
|
5224
|
+
}))
|
|
5225
|
+
const defaultEffort = section.querySelector('[data-model-field="reasoning-default"]')?.value || ''
|
|
5226
|
+
editor.models[index] = withModelReasoning(model, rows, defaultEffort)
|
|
5227
|
+
})
|
|
5228
|
+
}
|
|
5229
|
+
|
|
5230
|
+
function setModelEditorError(message) {
|
|
5231
|
+
if (!state.modelEditor) return
|
|
5232
|
+
state.modelEditor.error = message || ''
|
|
5233
|
+
renderModelSettings()
|
|
5234
|
+
}
|
|
5235
|
+
|
|
5236
|
+
async function discoverModelSettings() {
|
|
5237
|
+
const editor = state.modelEditor
|
|
5238
|
+
if (!editor || editor.busy) return
|
|
5239
|
+
collectModelEditorForm()
|
|
5240
|
+
editor.busy = true
|
|
5241
|
+
editor.error = ''
|
|
5242
|
+
renderModelSettings()
|
|
5243
|
+
try {
|
|
5244
|
+
const payload = { settingsNs: editor.settingsNs }
|
|
5245
|
+
if (editor.provider) payload.provider = editor.provider
|
|
5246
|
+
if (editor.baseURL) payload.baseURL = editor.baseURL
|
|
5247
|
+
if (editor.api) payload.api = editor.api
|
|
5248
|
+
if (editor.keyDraft.trim()) payload.apiKey = editor.keyDraft.trim()
|
|
5249
|
+
const value = await rpc('llm.discoverModels', payload)
|
|
5250
|
+
const found = Array.isArray(value?.models) ? value.models.filter(model => model && typeof model.id === 'string' && model.id.trim()) : []
|
|
5251
|
+
if (!found.length) throw new Error(t('settings.modelFetchEmpty'))
|
|
5252
|
+
const known = new Set(editor.models.map(model => model.id))
|
|
5253
|
+
editor.discovered = found
|
|
5254
|
+
editor.discoverySelected = new Set(found.filter(model => !known.has(model.id)).map(model => model.id))
|
|
5255
|
+
} catch (error) {
|
|
5256
|
+
editor.error = t('settings.modelFetchFailed', { msg: error?.message || String(error) })
|
|
5257
|
+
} finally {
|
|
5258
|
+
editor.busy = false
|
|
5259
|
+
}
|
|
5260
|
+
renderModelSettings()
|
|
5261
|
+
}
|
|
5262
|
+
|
|
5263
|
+
function addDiscoveredModels() {
|
|
5264
|
+
const editor = state.modelEditor
|
|
5265
|
+
if (!editor) return
|
|
5266
|
+
collectModelEditorForm()
|
|
5267
|
+
const known = new Set(editor.models.map(model => model.id))
|
|
5268
|
+
for (const candidate of editor.discovered || []) {
|
|
5269
|
+
if (!editor.discoverySelected.has(candidate.id) || known.has(candidate.id)) continue
|
|
5270
|
+
editor.models.push({ id: candidate.id, ...(candidate.name ? { name: candidate.name } : {}), ...(candidate.contextWindow ? { contextWindow: candidate.contextWindow } : {}), ...(candidate.maxTokens ? { maxTokens: candidate.maxTokens } : {}) })
|
|
5271
|
+
known.add(candidate.id)
|
|
5272
|
+
}
|
|
5273
|
+
editor.modelsDirty = true
|
|
5274
|
+
editor.discovered = []
|
|
5275
|
+
editor.discoverySelected = new Set()
|
|
5276
|
+
renderModelSettings()
|
|
5277
|
+
}
|
|
5278
|
+
|
|
5279
|
+
async function saveModelEditor() {
|
|
5280
|
+
const editor = state.modelEditor
|
|
5281
|
+
if (!editor || editor.busy) return
|
|
5282
|
+
collectModelEditorForm()
|
|
5283
|
+
if (!state.modelSettings.writable) return setModelEditorError(t('settings.modelReadOnly'))
|
|
5284
|
+
const models = editor.models || []
|
|
5285
|
+
if (editor.modelsDirty && models.some(model => !String(model.id || '').trim())) return setModelEditorError(t('settings.modelIdRequired'))
|
|
5286
|
+
const reasoningError = editor.modelsDirty ? modelReasoningError(models) : ''
|
|
5287
|
+
if (reasoningError) return setModelEditorError(reasoningError)
|
|
5288
|
+
if (editor.keyDraft.trim() && !editor.keyRef) return setModelEditorError(t('settings.modelSaveFailed', { msg: t('settings.modelApiKey') }))
|
|
5289
|
+
editor.busy = true
|
|
5290
|
+
editor.error = ''
|
|
5291
|
+
renderModelSettings()
|
|
5292
|
+
try {
|
|
5293
|
+
const before = editor.userProfile || {}
|
|
5294
|
+
const after = { ...before }
|
|
5295
|
+
if (editor.baseURLDirty) {
|
|
5296
|
+
if (editor.baseURL) after.baseURL = editor.baseURL
|
|
5297
|
+
else delete after.baseURL
|
|
5298
|
+
}
|
|
5299
|
+
if (editor.apiDirty) {
|
|
5300
|
+
if (editor.api) after.api = editor.api
|
|
5301
|
+
else delete after.api
|
|
5302
|
+
}
|
|
5303
|
+
if (editor.modelsDirty) after.models = models.map(model => cloneModelValue(model))
|
|
5304
|
+
if (editor.settingsNs === 'llm-pi-ai' && editor.keyDraft.trim() && !after.apiKeyEnv) after.apiKeyEnv = editor.keyRef
|
|
5305
|
+
const ops = MODEL_SETTINGS_FIELDS.flatMap(key => {
|
|
5306
|
+
if (!modelSettingsPathChanged(before, after, key)) return []
|
|
5307
|
+
const path = [...editor.settingsPath, key]
|
|
5308
|
+
return after[key] === undefined ? [{ op: 'unset', path }] : [{ op: 'set', path, value: after[key] }]
|
|
5309
|
+
})
|
|
5310
|
+
if (ops.length) {
|
|
5311
|
+
const value = await rpc('settings.mutate', { ns: editor.settingsNs, ops, expectedRevision: editor.namespace.revision })
|
|
5312
|
+
editor.namespace = value
|
|
5313
|
+
}
|
|
5314
|
+
if (editor.keyDraft.trim()) {
|
|
5315
|
+
await rpc('credentials.set', { ref: editor.keyRef, value: editor.keyDraft.trim() })
|
|
5316
|
+
} else if (editor.clearKey && editor.keyConfigured && editor.keyRef) {
|
|
5317
|
+
await rpc('credentials.unset', { ref: editor.keyRef })
|
|
5318
|
+
}
|
|
5319
|
+
state.modelEditor = null
|
|
5320
|
+
await loadModelSettings(true)
|
|
5321
|
+
toast(t('settings.modelSaved'), 'ok')
|
|
5322
|
+
} catch (error) {
|
|
5323
|
+
editor.error = t('settings.modelSaveFailed', { msg: error?.message || String(error) })
|
|
5324
|
+
} finally {
|
|
5325
|
+
if (state.modelEditor === editor) {
|
|
5326
|
+
editor.busy = false
|
|
5327
|
+
renderModelSettings()
|
|
5328
|
+
}
|
|
5329
|
+
}
|
|
5330
|
+
}
|
|
5331
|
+
|
|
5332
|
+
function handleModelSettingsClick(event) {
|
|
5333
|
+
const action = event.target.closest('[data-model-action]')
|
|
5334
|
+
if (!action) return
|
|
5335
|
+
const type = action.dataset.modelAction
|
|
5336
|
+
if (type === 'edit') return openModelEditor(action.dataset.modelProvider)
|
|
5337
|
+
if (type === 'cancel') { state.modelEditor = null; renderModelSettings(); return }
|
|
5338
|
+
if (type === 'save') return void saveModelEditor()
|
|
5339
|
+
const editor = state.modelEditor
|
|
5340
|
+
if (!editor) return
|
|
5341
|
+
if (type === 'add-model') {
|
|
5342
|
+
collectModelEditorForm(); editor.models.push({ id: '' }); editor.modelsDirty = true; renderModelSettings(); return
|
|
5343
|
+
}
|
|
5344
|
+
if (type === 'remove-model') {
|
|
5345
|
+
collectModelEditorForm(); editor.models.splice(Number(action.dataset.modelIndex), 1); editor.modelsDirty = true; renderModelSettings(); return
|
|
5346
|
+
}
|
|
5347
|
+
if (type === 'add-reasoning') {
|
|
5348
|
+
collectModelEditorForm()
|
|
5349
|
+
const index = Number(action.dataset.modelIndex)
|
|
5350
|
+
const model = editor.models[index]
|
|
5351
|
+
if (!model) return
|
|
5352
|
+
const rows = modelReasoningRows(model)
|
|
5353
|
+
if (rows.length >= MODEL_REASONING_LIMIT) return setModelEditorError(t('settings.modelReasoningLimit', { count: MODEL_REASONING_LIMIT }))
|
|
5354
|
+
rows.push({ id: '', name: '', description: '' })
|
|
5355
|
+
editor.models[index] = withModelReasoning(model, rows, modelObjectAt(model, ['reasoning']).defaultEffort || '')
|
|
5356
|
+
editor.modelsDirty = true
|
|
5357
|
+
renderModelSettings()
|
|
5358
|
+
return
|
|
5359
|
+
}
|
|
5360
|
+
if (type === 'remove-reasoning') {
|
|
5361
|
+
collectModelEditorForm()
|
|
5362
|
+
const index = Number(action.dataset.modelIndex)
|
|
5363
|
+
const effortIndex = Number(action.dataset.reasoningIndex)
|
|
5364
|
+
const model = editor.models[index]
|
|
5365
|
+
if (!model) return
|
|
5366
|
+
const rows = modelReasoningRows(model)
|
|
5367
|
+
rows.splice(effortIndex, 1)
|
|
5368
|
+
editor.models[index] = withModelReasoning(model, rows, modelObjectAt(model, ['reasoning']).defaultEffort || '')
|
|
5369
|
+
editor.modelsDirty = true
|
|
5370
|
+
renderModelSettings()
|
|
5371
|
+
return
|
|
5372
|
+
}
|
|
5373
|
+
if (type === 'clear-reasoning') {
|
|
5374
|
+
collectModelEditorForm()
|
|
5375
|
+
const index = Number(action.dataset.modelIndex)
|
|
5376
|
+
const model = editor.models[index]
|
|
5377
|
+
if (!model) return
|
|
5378
|
+
editor.models[index] = withModelReasoning(model, [], '')
|
|
5379
|
+
editor.modelsDirty = true
|
|
5380
|
+
renderModelSettings()
|
|
5381
|
+
return
|
|
5382
|
+
}
|
|
5383
|
+
if (type === 'clear-key') { collectModelEditorForm(); editor.keyDraft = ''; editor.clearKey = true; renderModelSettings(); return }
|
|
5384
|
+
if (type === 'discover') return void discoverModelSettings()
|
|
5385
|
+
if (type === 'add-selected') return addDiscoveredModels()
|
|
5386
|
+
if (type === 'select-all') {
|
|
5387
|
+
const ids = (editor.discovered || []).map(model => model.id)
|
|
5388
|
+
editor.discoverySelected = editor.discoverySelected.size === ids.length ? new Set() : new Set(ids)
|
|
5389
|
+
renderModelSettings()
|
|
5390
|
+
}
|
|
5391
|
+
}
|
|
5392
|
+
|
|
5393
|
+
function handleModelSettingsInput(event) {
|
|
5394
|
+
const editor = state.modelEditor
|
|
5395
|
+
if (!editor) return
|
|
5396
|
+
const field = event.target.dataset.modelField
|
|
5397
|
+
if (field === 'baseURL') editor.baseURLDirty = true
|
|
5398
|
+
if (field === 'api') editor.apiDirty = true
|
|
5399
|
+
if (field === 'model-id' || field === 'model-name' || field?.startsWith('reasoning-')) editor.modelsDirty = true
|
|
5400
|
+
}
|
|
5401
|
+
|
|
5402
|
+
async function openModelConfigDocument() {
|
|
5403
|
+
const value = await safeRpc('settings.openDocument', {}, t('settings.modelOpenConfigFailed'))
|
|
5404
|
+
if (value?.opened) toast(t('settings.modelOpenedConfig'), 'ok')
|
|
5405
|
+
}
|
|
4614
5406
|
/* ---------------- 视图切换 ---------------- */
|
|
4615
5407
|
function showView(id) {
|
|
4616
5408
|
for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-stats', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
|
|
@@ -4627,7 +5419,7 @@ function showView(id) {
|
|
|
4627
5419
|
if (id === 'view-settings') showSettingsHome()
|
|
4628
5420
|
}
|
|
4629
5421
|
|
|
4630
|
-
const SETTINGS_GROUPS = ['general', 'servers', 'notify', 'theme', 'about']
|
|
5422
|
+
const SETTINGS_GROUPS = ['general', 'model', 'tests', 'servers', 'notify', 'theme', 'about']
|
|
4631
5423
|
function showSettingsHome() {
|
|
4632
5424
|
const home = $('settings-home')
|
|
4633
5425
|
if (!home) return
|
|
@@ -4641,6 +5433,7 @@ function showSettingsPage(name) {
|
|
|
4641
5433
|
home.classList.add('hidden')
|
|
4642
5434
|
for (const g of SETTINGS_GROUPS) $('settings-page-' + g)?.classList.toggle('hidden', g !== name)
|
|
4643
5435
|
window.scrollTo(0, 0)
|
|
5436
|
+
if (name === 'model') void loadModelSettings()
|
|
4644
5437
|
}
|
|
4645
5438
|
|
|
4646
5439
|
function updateConn() {
|
|
@@ -4765,19 +5558,24 @@ function bindComposerFullscreenGesture() {
|
|
|
4765
5558
|
}
|
|
4766
5559
|
|
|
4767
5560
|
/* ---------------- 初始化 ---------------- */
|
|
4768
|
-
/** 解析 dshremote://pair?token=..&server=..
|
|
5561
|
+
/** 解析 dshremote://pair?token=..&server=.. 配对二维码;server 可重复以携带多个主机地址。 */
|
|
4769
5562
|
function applyPairUrl(url) {
|
|
4770
5563
|
try {
|
|
4771
5564
|
const u = new URL(String(url).trim())
|
|
4772
5565
|
if (u.protocol !== 'dshremote:' || u.hostname !== 'pair') return false
|
|
4773
5566
|
const tok = (u.searchParams.get('token') || '').trim()
|
|
4774
|
-
const
|
|
4775
|
-
|
|
5567
|
+
const servers = [...new Set(u.searchParams.getAll('server')
|
|
5568
|
+
.map(value => value.trim().replace(/\/+$/, ''))
|
|
5569
|
+
.filter(value => /^https?:\/\//i.test(value)))]
|
|
5570
|
+
if (!tok || !servers.length) return false
|
|
4776
5571
|
state.token = tok
|
|
4777
5572
|
LS.set('token', tok)
|
|
4778
|
-
state.server =
|
|
4779
|
-
|
|
4780
|
-
|
|
5573
|
+
state.server = servers[0]
|
|
5574
|
+
for (let i = servers.length - 1; i >= 0; i--) {
|
|
5575
|
+
const server = servers[i]
|
|
5576
|
+
if (!state.servers.some(s => s.url === server)) {
|
|
5577
|
+
state.servers.unshift({ id: newServerId(), url: server, note: '', group: state.activeGroup })
|
|
5578
|
+
}
|
|
4781
5579
|
}
|
|
4782
5580
|
saveServers()
|
|
4783
5581
|
renderServers()
|
|
@@ -5091,6 +5889,10 @@ function dshControlFailureText(value) {
|
|
|
5091
5889
|
INVALID_SERVICE: 'settings.dshErrorInvalidService',
|
|
5092
5890
|
SYSTEMCTL_NOT_FOUND: 'settings.dshErrorSystemctlNotFound',
|
|
5093
5891
|
SYSTEMD_UNAVAILABLE: 'settings.dshErrorSystemdUnavailable',
|
|
5892
|
+
SERVICE_CONTROL_NOT_FOUND: 'settings.dshErrorServiceControlNotFound',
|
|
5893
|
+
SERVICE_DISABLED: 'settings.dshErrorServiceDisabled',
|
|
5894
|
+
SERVICE_STOP_TIMEOUT: 'settings.dshErrorServiceStopTimeout',
|
|
5895
|
+
STATUS_PARSE_FAILED: 'settings.dshErrorStatusParseFailed',
|
|
5094
5896
|
PERMISSION_DENIED: 'settings.dshErrorPermissionDenied',
|
|
5095
5897
|
COMMAND_TIMEOUT: 'settings.dshErrorCommandTimeout',
|
|
5096
5898
|
COMMAND_FAILED: 'settings.dshErrorCommandFailed',
|
|
@@ -5325,6 +6127,8 @@ function bindUi() {
|
|
|
5325
6127
|
renderAnnouncementBoard()
|
|
5326
6128
|
renderPending(); renderQueue(); renderJobs()
|
|
5327
6129
|
updateConn()
|
|
6130
|
+
if (state.modelSettings.status === 'ready' || state.modelSettings.status === 'error' || state.modelSettings.status === 'loading') renderModelSettings()
|
|
6131
|
+
renderAsrTest()
|
|
5328
6132
|
if (state.current) { renderSessionTitle(); renderSessionSub(); renderSessionCards(); renderHistory(true) }
|
|
5329
6133
|
else renderModelMenu()
|
|
5330
6134
|
loadLocalVersion()
|
|
@@ -5418,7 +6222,7 @@ function bindUi() {
|
|
|
5418
6222
|
const session = e.target.closest('[data-wb-session]')
|
|
5419
6223
|
if (session) openSession(session.dataset.wbSession)
|
|
5420
6224
|
})
|
|
5421
|
-
$('btn-back').addEventListener('click', closeSession)
|
|
6225
|
+
$('btn-back').addEventListener('click', () => { void closeSession() })
|
|
5422
6226
|
$('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
|
|
5423
6227
|
$('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
|
|
5424
6228
|
$('btn-refresh').addEventListener('click', () => { toast(t('common.refreshing')); openStreams(); refreshAll() })
|
|
@@ -5625,6 +6429,17 @@ function bindUi() {
|
|
|
5625
6429
|
if (group) { showSettingsPage(group.dataset.settingsGroup); return }
|
|
5626
6430
|
if (e.target.closest('[data-settings-back]')) { showSettingsHome(); return }
|
|
5627
6431
|
})
|
|
6432
|
+
$('btn-model-settings-refresh')?.addEventListener('click', () => loadModelSettings(true))
|
|
6433
|
+
$('btn-model-settings-open')?.addEventListener('click', openModelConfigDocument)
|
|
6434
|
+
$('model-settings-list')?.addEventListener('click', handleModelSettingsClick)
|
|
6435
|
+
$('model-settings-list')?.addEventListener('input', handleModelSettingsInput)
|
|
6436
|
+
$('btn-asr-test-start')?.addEventListener('click', startAsrTest)
|
|
6437
|
+
$('btn-asr-test-stop')?.addEventListener('click', stopAsrTest)
|
|
6438
|
+
$('btn-asr-test-copy')?.addEventListener('click', copyAsrTestLog)
|
|
6439
|
+
$('btn-asr-test-clear')?.addEventListener('click', clearAsrTest)
|
|
6440
|
+
$('btn-asr-test-permission')?.addEventListener('click', openAsrPermissionSettings)
|
|
6441
|
+
$('btn-asr-test-engine')?.addEventListener('click', openAsrEngineSettings)
|
|
6442
|
+
renderAsrTest()
|
|
5628
6443
|
$('btn-scan-camera').addEventListener('click', () => scanPair('CAMERA'))
|
|
5629
6444
|
$('btn-scan-gallery').addEventListener('click', () => scanPair('PHOTOS'))
|
|
5630
6445
|
$('scan-live-cancel')?.addEventListener('click', () => closeLiveScan(''))
|