dsh-remote-plugin 0.6.15 → 0.6.17
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 +199 -15
- package/index.mjs +21 -3
- package/package.json +1 -1
- package/public/admin.html +53 -3
- package/public/admin.js +163 -19
- package/public/announcements.json +33 -0
- package/public/app.js +1040 -61
- package/public/desktop/desktop.css +1 -0
- package/public/desktop/desktop.html +4 -3
- package/public/desktop/desktop.js +167 -20
- package/public/index.html +95 -7
- package/public/styles.css +76 -0
- package/public/update.json +12 -12
- 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: {
|
|
@@ -88,9 +90,12 @@ const state = {
|
|
|
88
90
|
streamMode: 'ws', // 'ws' | 'poll'
|
|
89
91
|
pollSeq: { mux: 0, host: 0 },
|
|
90
92
|
refreshTimer: null,
|
|
91
|
-
fs: { path: null, initial: null, loaded: false, upload: null, workspaceId: LS.get('fsWorkspaceIdV1', ''), preview: null },
|
|
93
|
+
fs: { path: null, initial: null, loaded: false, upload: null, workspaceId: LS.get('fsWorkspaceIdV1', ''), roots: [], rootIndex: 0, 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: [],
|
|
@@ -531,6 +536,55 @@ async function safeRpc(method, payload, errText) {
|
|
|
531
536
|
}
|
|
532
537
|
}
|
|
533
538
|
|
|
539
|
+
let hostDescribePromise = null
|
|
540
|
+
let hostDescribeRetryTimer = null
|
|
541
|
+
let hostDescribeFailures = 0
|
|
542
|
+
|
|
543
|
+
function scheduleHostDescribeRetry() {
|
|
544
|
+
if (!state.token || hostDescribeRetryTimer) return
|
|
545
|
+
const delays = [2000, 5000, 15000, 30000, 60000]
|
|
546
|
+
const delay = delays[Math.min(Math.max(0, hostDescribeFailures - 1), delays.length - 1)]
|
|
547
|
+
hostDescribeRetryTimer = setTimeout(() => {
|
|
548
|
+
hostDescribeRetryTimer = null
|
|
549
|
+
void refreshHostDescription()
|
|
550
|
+
}, delay)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function refreshHostDescription({ notify = false } = {}) {
|
|
554
|
+
if (!state.token) return null
|
|
555
|
+
if (hostDescribePromise) return hostDescribePromise
|
|
556
|
+
const server = state.server
|
|
557
|
+
hostDescribePromise = (async () => {
|
|
558
|
+
try {
|
|
559
|
+
const host = await rpc('host.describe', {}, 5000)
|
|
560
|
+
if (server !== state.server) return null
|
|
561
|
+
state.hostInfo = host
|
|
562
|
+
const health = activeGatewayHealth()
|
|
563
|
+
if (health) health.upstreamReachable = true
|
|
564
|
+
hostDescribeFailures = 0
|
|
565
|
+
if (hostDescribeRetryTimer) clearTimeout(hostDescribeRetryTimer)
|
|
566
|
+
hostDescribeRetryTimer = null
|
|
567
|
+
const desc = $('host-desc')
|
|
568
|
+
if (desc && host) desc.textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions })
|
|
569
|
+
renderOverview()
|
|
570
|
+
return host
|
|
571
|
+
} catch (error) {
|
|
572
|
+
if (server !== state.server) return null
|
|
573
|
+
if (error.message === 'AUTH') authFailure()
|
|
574
|
+
else {
|
|
575
|
+
hostDescribeFailures++
|
|
576
|
+
scheduleHostDescribeRetry()
|
|
577
|
+
if (notify) toast(`${t('settings.probeFailed')}:${error.message}`, 'err')
|
|
578
|
+
}
|
|
579
|
+
return null
|
|
580
|
+
} finally {
|
|
581
|
+
hostDescribePromise = null
|
|
582
|
+
if (server !== state.server) scheduleHostDescribeRetry()
|
|
583
|
+
}
|
|
584
|
+
})()
|
|
585
|
+
return hostDescribePromise
|
|
586
|
+
}
|
|
587
|
+
|
|
534
588
|
function authFailure() {
|
|
535
589
|
toast(t('err.accessDenied'), 'err')
|
|
536
590
|
showView('view-settings')
|
|
@@ -635,7 +689,10 @@ async function pingServer(base) {
|
|
|
635
689
|
const res = await fetch(u + '/health?t=' + Date.now(), { signal: ctrl.signal, cache: 'no-store' })
|
|
636
690
|
if (!res.ok) return Infinity
|
|
637
691
|
const health = await res.json().catch(() => null)
|
|
638
|
-
if (health && typeof health === 'object')
|
|
692
|
+
if (health && typeof health === 'object') {
|
|
693
|
+
state.gatewayHealth[u] = health
|
|
694
|
+
renderOverview()
|
|
695
|
+
}
|
|
639
696
|
return Math.round(performance.now() - t0)
|
|
640
697
|
} catch {
|
|
641
698
|
return Infinity
|
|
@@ -645,12 +702,22 @@ async function pingServer(base) {
|
|
|
645
702
|
}
|
|
646
703
|
|
|
647
704
|
function activeGatewayCapability(name) {
|
|
648
|
-
const
|
|
649
|
-
const capabilities = state.gatewayHealth[key]?.capabilities
|
|
705
|
+
const capabilities = activeGatewayHealth()?.capabilities
|
|
650
706
|
if (!capabilities || !Object.prototype.hasOwnProperty.call(capabilities, name)) return null
|
|
651
707
|
return Number(capabilities[name]) > 0
|
|
652
708
|
}
|
|
653
709
|
|
|
710
|
+
function activeGatewayHealth() {
|
|
711
|
+
const key = String(state.server || location.origin || '').replace(/\/+$/, '')
|
|
712
|
+
return state.gatewayHealth[key] || null
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function dshReachable() {
|
|
716
|
+
const health = activeGatewayHealth()
|
|
717
|
+
if (health && typeof health.upstreamReachable === 'boolean') return health.upstreamReachable
|
|
718
|
+
return !!state.hostInfo
|
|
719
|
+
}
|
|
720
|
+
|
|
654
721
|
async function selectFastestServer({ silent = false, reconnect = true } = {}) {
|
|
655
722
|
if (state.selectingServer) return null
|
|
656
723
|
state.selectingServer = true
|
|
@@ -682,6 +749,11 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
|
|
|
682
749
|
|
|
683
750
|
renderServers()
|
|
684
751
|
if (chosen !== state.server) {
|
|
752
|
+
state.hostInfo = null
|
|
753
|
+
hostDescribeFailures = 0
|
|
754
|
+
if (hostDescribeRetryTimer) clearTimeout(hostDescribeRetryTimer)
|
|
755
|
+
hostDescribeRetryTimer = null
|
|
756
|
+
resetFsForServer()
|
|
685
757
|
state.server = chosen
|
|
686
758
|
if (state.autoSelect[state.activeGroup] !== false && best) {
|
|
687
759
|
const srv = state.servers.find(s => s.url === best)
|
|
@@ -804,6 +876,7 @@ function renderServers() {
|
|
|
804
876
|
// 手动模式: 点击条目 = 选中该服务器连接
|
|
805
877
|
state.groupActive[group] = id
|
|
806
878
|
state.activeGroup = group
|
|
879
|
+
if (state.server !== s.url) resetFsForServer()
|
|
807
880
|
state.server = s.url
|
|
808
881
|
saveServers()
|
|
809
882
|
renderServers()
|
|
@@ -858,10 +931,14 @@ function editServer(id) {
|
|
|
858
931
|
const group = prompt(t('servers.promptEditGroup'), s.group || '默认')
|
|
859
932
|
if (group === null) return
|
|
860
933
|
const wasActive = state.server === s.url
|
|
934
|
+
const changedActiveUrl = wasActive && state.server !== raw
|
|
861
935
|
s.url = raw
|
|
862
936
|
s.note = note.trim()
|
|
863
937
|
s.group = ensureGroup(group.trim() || '默认')
|
|
864
|
-
if (wasActive)
|
|
938
|
+
if (wasActive) {
|
|
939
|
+
if (changedActiveUrl) resetFsForServer()
|
|
940
|
+
state.server = raw
|
|
941
|
+
}
|
|
865
942
|
saveServers()
|
|
866
943
|
renderServers()
|
|
867
944
|
toast(t('servers.edited'), 'ok')
|
|
@@ -1085,7 +1162,11 @@ function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
|
|
|
1085
1162
|
renderPending()
|
|
1086
1163
|
}
|
|
1087
1164
|
if (refreshOnOpen) refreshAll()
|
|
1088
|
-
if (allStreamsOpen())
|
|
1165
|
+
if (allStreamsOpen()) {
|
|
1166
|
+
resyncAfterStreamOpen()
|
|
1167
|
+
void pingServer(state.server || location.origin)
|
|
1168
|
+
void refreshHostDescription()
|
|
1169
|
+
}
|
|
1089
1170
|
}
|
|
1090
1171
|
ws.onmessage = (msg) => {
|
|
1091
1172
|
if (!streamIsCurrent(kind, ws, generation)) return
|
|
@@ -1357,6 +1438,23 @@ async function refreshSessions() {
|
|
|
1357
1438
|
refreshWorkbench()
|
|
1358
1439
|
}
|
|
1359
1440
|
|
|
1441
|
+
function removeLocalSessionRecord(sessionId) {
|
|
1442
|
+
if (!sessionId) return
|
|
1443
|
+
state.sessions = state.sessions.filter(session => session?.sessionId !== sessionId)
|
|
1444
|
+
state.byId.delete(sessionId)
|
|
1445
|
+
state.pendingProjections.delete(sessionId)
|
|
1446
|
+
state.sessionActivity.delete(sessionId)
|
|
1447
|
+
state.pendingPrompts.delete(sessionId)
|
|
1448
|
+
delete state.queues[sessionId]
|
|
1449
|
+
delete state.jobs[sessionId]
|
|
1450
|
+
const historyCache = readHistoryCache()
|
|
1451
|
+
if (Object.prototype.hasOwnProperty.call(historyCache, sessionId)) {
|
|
1452
|
+
delete historyCache[sessionId]
|
|
1453
|
+
writeHistoryCache(historyCache)
|
|
1454
|
+
}
|
|
1455
|
+
cacheWrite(CACHE.sessions, state.sessions.slice(0, 80))
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1360
1458
|
function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
|
|
1361
1459
|
function hydrateSessionProjections(sessionId, projections) {
|
|
1362
1460
|
const s = state.byId.get(sessionId)
|
|
@@ -1551,7 +1649,13 @@ function workspaceOptionLabel(workspace) {
|
|
|
1551
1649
|
function workspaceOptionsHtml({ all = false, ungrouped = false, root = false, selected = '' } = {}) {
|
|
1552
1650
|
const rows = []
|
|
1553
1651
|
if (all) rows.push({ id: '', label: t('workspace.all') })
|
|
1554
|
-
if (root)
|
|
1652
|
+
if (root) {
|
|
1653
|
+
const roots = state.fs.roots.length ? state.fs.roots : ['']
|
|
1654
|
+
roots.forEach((rootPath, index) => rows.push({
|
|
1655
|
+
id: index === 0 ? '' : `__fs_root__:${index}`,
|
|
1656
|
+
label: rootPath && roots.length > 1 ? `${t('fs.root')} — ${rootPath}` : t('fs.root'),
|
|
1657
|
+
}))
|
|
1658
|
+
}
|
|
1555
1659
|
for (const workspace of workspaceItems()) rows.push({ id: workspace.workspaceId, label: workspaceOptionLabel(workspace) })
|
|
1556
1660
|
if (ungrouped) rows.push({ id: WORKSPACE_UNGROUPED, label: t('workspace.ungrouped') })
|
|
1557
1661
|
return rows.map(row => `<option value="${esc(row.id)}"${row.id === selected ? ' selected' : ''}>${esc(row.label)}</option>`).join('')
|
|
@@ -1579,7 +1683,8 @@ function renderWorkspaceNavigation() {
|
|
|
1579
1683
|
}
|
|
1580
1684
|
const fsSelect = $('fs-workspace')
|
|
1581
1685
|
if (fsSelect) {
|
|
1582
|
-
|
|
1686
|
+
const selected = state.fs.workspaceId || (state.fs.rootIndex > 0 ? `__fs_root__:${state.fs.rootIndex}` : '')
|
|
1687
|
+
fsSelect.innerHTML = workspaceOptionsHtml({ root: true, selected })
|
|
1583
1688
|
syncCustomSelect(fsSelect)
|
|
1584
1689
|
}
|
|
1585
1690
|
if ($('modal-new-session') && !$('modal-new-session').classList.contains('hidden')) renderNewSessionWorkspace()
|
|
@@ -1849,17 +1954,49 @@ async function openSession(id) {
|
|
|
1849
1954
|
refreshSessions()
|
|
1850
1955
|
}
|
|
1851
1956
|
|
|
1852
|
-
function
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1957
|
+
function sessionHasPendingActivity(sessionId, session) {
|
|
1958
|
+
return !!session?.running
|
|
1959
|
+
|| state.sessionActivity.has(sessionId)
|
|
1960
|
+
|| state.pendingPrompts.has(sessionId)
|
|
1961
|
+
|| (state.queues[sessionId] || []).some(item => item?.placement !== 'context')
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
async function shouldDiscardEmptySession(sessionId) {
|
|
1965
|
+
const session = state.byId.get(sessionId)
|
|
1966
|
+
if (!session || sessionHasPendingActivity(sessionId, session)) return false
|
|
1967
|
+
const deadline = Date.now() + 3500
|
|
1968
|
+
while (state.current === sessionId && state.history.loading && Date.now() < deadline) {
|
|
1969
|
+
await new Promise(resolve => setTimeout(resolve, 25))
|
|
1970
|
+
}
|
|
1971
|
+
if (state.current !== sessionId || sessionHasPendingActivity(sessionId, session)) return false
|
|
1972
|
+
return isEmptySessionHistory(state.history)
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
async function closeSession() {
|
|
1976
|
+
if (closeSession.pending) return closeSession.pending
|
|
1977
|
+
const sessionId = state.current
|
|
1978
|
+
if (!sessionId) return
|
|
1979
|
+
const task = (async () => {
|
|
1980
|
+
const discard = await shouldDiscardEmptySession(sessionId)
|
|
1981
|
+
if (state.current !== sessionId) return
|
|
1982
|
+
state.current = null
|
|
1983
|
+
if (discard) removeLocalSessionRecord(sessionId)
|
|
1984
|
+
setComposerFullscreen(false)
|
|
1985
|
+
clearComposerImages()
|
|
1986
|
+
setSessionRecovery('idle')
|
|
1987
|
+
$('btn-rename-session').classList.add('hidden')
|
|
1988
|
+
$('btn-archive-session').classList.add('hidden')
|
|
1989
|
+
state.history = emptyHistory()
|
|
1990
|
+
document.body.classList.remove('in-session')
|
|
1991
|
+
hideComposerMenu()
|
|
1992
|
+
renderSessions()
|
|
1993
|
+
renderWorkbench()
|
|
1994
|
+
showView('view-home')
|
|
1995
|
+
})()
|
|
1996
|
+
closeSession.pending = task
|
|
1997
|
+
try { await task } finally {
|
|
1998
|
+
if (closeSession.pending === task) closeSession.pending = null
|
|
1999
|
+
}
|
|
1863
2000
|
}
|
|
1864
2001
|
|
|
1865
2002
|
/* Android 手势返回/实体返回: 注册后系统不再直接杀 App, 由这里接管导航 */
|
|
@@ -1871,7 +2008,7 @@ function bindNativeBack() {
|
|
|
1871
2008
|
if (customSelectCurrent) { closeCustomSelect(); return }
|
|
1872
2009
|
const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
|
|
1873
2010
|
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 } // 会话页 → 回主页
|
|
2011
|
+
if (document.body.classList.contains('in-session')) { void closeSession(); return } // 会话页 → 回主页
|
|
1875
2012
|
if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
|
|
1876
2013
|
if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
|
|
1877
2014
|
showView('view-home'); return
|
|
@@ -1922,11 +2059,19 @@ const HISTORY_MAX_VISIBLE = 5000 // 已加载的可显示事件上限(消息/
|
|
|
1922
2059
|
function emptyHistory() {
|
|
1923
2060
|
return {
|
|
1924
2061
|
visible: [], seqs: new Set(), minSeq: Infinity,
|
|
1925
|
-
hasMore: false, loading: false, renderStart: 0, renderEnd: 0,
|
|
2062
|
+
hasMore: false, loading: false, loaded: false, renderStart: 0, renderEnd: 0,
|
|
1926
2063
|
partialReasoning: new Map()
|
|
1927
2064
|
}
|
|
1928
2065
|
}
|
|
1929
2066
|
|
|
2067
|
+
function sessionHistoryHasContent(history) {
|
|
2068
|
+
return !!history && ((history.visible?.length || 0) > 0 || (history.partialReasoning?.size || 0) > 0)
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
function isEmptySessionHistory(history) {
|
|
2072
|
+
return history?.loaded === true && !sessionHistoryHasContent(history)
|
|
2073
|
+
}
|
|
2074
|
+
|
|
1930
2075
|
function reasoningStreamKey(data, index) {
|
|
1931
2076
|
return `${data?.turn ?? '?'}:${data?.step ?? '?'}:${index ?? '?'}`
|
|
1932
2077
|
}
|
|
@@ -2042,6 +2187,7 @@ function restoreCachedHistory() {
|
|
|
2042
2187
|
h.visible.push(e)
|
|
2043
2188
|
}
|
|
2044
2189
|
h.visible.sort((a, b) => a.seq - b.seq)
|
|
2190
|
+
h.loaded = true
|
|
2045
2191
|
state.history = h
|
|
2046
2192
|
$('history-hint').textContent = t('history.offlineCache', { n: h.visible.length })
|
|
2047
2193
|
renderHistory(true)
|
|
@@ -2051,7 +2197,8 @@ function restoreCachedHistory() {
|
|
|
2051
2197
|
async function loadHistory(reset) {
|
|
2052
2198
|
const id = state.current
|
|
2053
2199
|
if (!id || state.history.loading) return
|
|
2054
|
-
|
|
2200
|
+
const history = state.history
|
|
2201
|
+
history.loading = true
|
|
2055
2202
|
if (reset) setSessionRecovery('loading')
|
|
2056
2203
|
const moreBtn = $('history-more')
|
|
2057
2204
|
if (moreBtn) moreBtn.classList.add('hidden')
|
|
@@ -2062,7 +2209,8 @@ async function loadHistory(reset) {
|
|
|
2062
2209
|
try {
|
|
2063
2210
|
v = await rpc('session.history', payload)
|
|
2064
2211
|
} catch (e) {
|
|
2065
|
-
state.history
|
|
2212
|
+
if (state.current !== id || state.history !== history) return
|
|
2213
|
+
history.loading = false
|
|
2066
2214
|
if (e.message === 'AUTH') { authFailure(); return }
|
|
2067
2215
|
if (restoreCachedHistory()) {
|
|
2068
2216
|
setSessionRecovery('cached', e.message)
|
|
@@ -2082,7 +2230,9 @@ async function loadHistory(reset) {
|
|
|
2082
2230
|
return
|
|
2083
2231
|
}
|
|
2084
2232
|
|
|
2233
|
+
if (state.current !== id || state.history !== history) return
|
|
2085
2234
|
hydrateSessionProjections(id, v.projections)
|
|
2235
|
+
history.loaded = true
|
|
2086
2236
|
const incoming = v.events || []
|
|
2087
2237
|
let added = 0
|
|
2088
2238
|
if (reset) state.history.partialReasoning.clear()
|
|
@@ -2103,7 +2253,7 @@ async function loadHistory(reset) {
|
|
|
2103
2253
|
state.history.visible.sort((a, b) => a.seq - b.seq)
|
|
2104
2254
|
trimVisible()
|
|
2105
2255
|
state.history.hasMore = !!v.hasMore
|
|
2106
|
-
|
|
2256
|
+
history.loading = false
|
|
2107
2257
|
setSessionRecovery('ready')
|
|
2108
2258
|
renderSessionTitle(); renderSessionSub(); renderSessionCards()
|
|
2109
2259
|
try {
|
|
@@ -2601,33 +2751,48 @@ async function sendSessionText(text) {
|
|
|
2601
2751
|
async function sendSessionContent(text, images) {
|
|
2602
2752
|
const clean = String(text || '').trim()
|
|
2603
2753
|
if ((!clean && !images.length) || !state.current) return false
|
|
2604
|
-
|
|
2754
|
+
const sessionId = state.current
|
|
2755
|
+
if (images.length === 0 && clean && await runSlashCommand(clean)) {
|
|
2756
|
+
state.sessionActivity.add(sessionId)
|
|
2757
|
+
return true
|
|
2758
|
+
}
|
|
2605
2759
|
const buttons = [$('btn-send'), $('btn-fs-send')].filter(Boolean)
|
|
2606
2760
|
buttons.forEach(button => { button.disabled = true })
|
|
2761
|
+
state.pendingPrompts.add(sessionId)
|
|
2607
2762
|
try {
|
|
2608
2763
|
const content = [...await encodeComposerImagesFor(images)]
|
|
2609
2764
|
if (clean) content.push({ type: 'text', text: clean })
|
|
2610
2765
|
setSessionRecovery('resuming')
|
|
2611
2766
|
const v = await safeRpc('session.prompt', {
|
|
2612
|
-
sessionId
|
|
2767
|
+
sessionId,
|
|
2613
2768
|
mode: 'queue',
|
|
2614
2769
|
content
|
|
2615
2770
|
}, t('send.failed'))
|
|
2616
2771
|
if (v?.accepted) {
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2772
|
+
state.sessionActivity.add(sessionId)
|
|
2773
|
+
if (state.current === sessionId) {
|
|
2774
|
+
setSessionRecovery('ready')
|
|
2775
|
+
noteSessionTurnTime(sessionId, Date.now())
|
|
2776
|
+
renderSessions()
|
|
2777
|
+
toast(images.length ? t('send.imageSent') : (clean.startsWith('/') ? t('send.commandSent') : t('send.sent')), 'ok')
|
|
2778
|
+
}
|
|
2779
|
+
return true
|
|
2780
|
+
}
|
|
2781
|
+
if (v?.command?.text) {
|
|
2782
|
+
state.sessionActivity.add(sessionId)
|
|
2783
|
+
if (state.current === sessionId) toast(t('send.commandExecuted'), 'ok')
|
|
2621
2784
|
return true
|
|
2622
2785
|
}
|
|
2623
|
-
if (
|
|
2624
|
-
setSessionRecovery('error')
|
|
2786
|
+
if (state.current === sessionId) setSessionRecovery('error')
|
|
2625
2787
|
return false
|
|
2626
2788
|
} catch (e) {
|
|
2627
|
-
|
|
2628
|
-
|
|
2789
|
+
if (state.current === sessionId) {
|
|
2790
|
+
setSessionRecovery('error', e?.message)
|
|
2791
|
+
toast(t('composer.imageReadFailed', { msg: e?.message || e }), 'err')
|
|
2792
|
+
}
|
|
2629
2793
|
return false
|
|
2630
2794
|
} finally {
|
|
2795
|
+
state.pendingPrompts.delete(sessionId)
|
|
2631
2796
|
buttons.forEach(button => { button.disabled = false })
|
|
2632
2797
|
}
|
|
2633
2798
|
}
|
|
@@ -2890,7 +3055,7 @@ async function confirmArchiveSession() {
|
|
|
2890
3055
|
closeArchiveConfirm()
|
|
2891
3056
|
toast(t('session.archived'), 'ok')
|
|
2892
3057
|
await refreshSessions()
|
|
2893
|
-
if (state.current === sessionId) closeSession()
|
|
3058
|
+
if (state.current === sessionId) void closeSession()
|
|
2894
3059
|
} finally {
|
|
2895
3060
|
button.disabled = false
|
|
2896
3061
|
}
|
|
@@ -2954,7 +3119,7 @@ function renderOverview() {
|
|
|
2954
3119
|
// 独立网关页面默认走同源,此时 state.server 合法地为空;不能因此把
|
|
2955
3120
|
// 已连接网关误报为离线。Capacitor 等非 HTTP 页面仍要求显式服务器。
|
|
2956
3121
|
gateway: !!state.token && (!!state.server || /^https?:$/.test(location.protocol)),
|
|
2957
|
-
dsh:
|
|
3122
|
+
dsh: dshReachable(),
|
|
2958
3123
|
mux: !!state.streamsOk?.mux,
|
|
2959
3124
|
host: !!state.streamsOk?.host
|
|
2960
3125
|
}
|
|
@@ -3198,14 +3363,80 @@ function fsHeaders() {
|
|
|
3198
3363
|
}
|
|
3199
3364
|
|
|
3200
3365
|
function fsJoin(dir, name) {
|
|
3201
|
-
|
|
3366
|
+
const meta = fsPathMeta(dir)
|
|
3367
|
+
if (!meta.value) return String(name || '')
|
|
3368
|
+
return meta.value.endsWith(meta.separator) ? meta.value + name : meta.value + meta.separator + name
|
|
3202
3369
|
}
|
|
3203
3370
|
|
|
3204
3371
|
function fsParent(p) {
|
|
3205
|
-
const
|
|
3206
|
-
|
|
3207
|
-
if (
|
|
3208
|
-
|
|
3372
|
+
const meta = fsPathMeta(p)
|
|
3373
|
+
if (!meta.value) return ''
|
|
3374
|
+
if (meta.root && fsPathEqual(meta.value, meta.root)) return meta.root
|
|
3375
|
+
const idx = meta.value.lastIndexOf(meta.separator)
|
|
3376
|
+
if (idx < 0) return meta.root || ''
|
|
3377
|
+
const parent = meta.value.slice(0, idx)
|
|
3378
|
+
return meta.root && parent.length < meta.root.length ? meta.root : (parent || meta.root)
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
function fsPathMeta(input) {
|
|
3382
|
+
const source = String(input || '').trim()
|
|
3383
|
+
const windows = /^[A-Za-z]:(?:[\\/]|$)/.test(source) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(source) || source.includes('\\')
|
|
3384
|
+
if (!windows) {
|
|
3385
|
+
let value = source.replace(/\/+/g, '/')
|
|
3386
|
+
const root = value.startsWith('/') ? '/' : ''
|
|
3387
|
+
if (root && value.length > root.length) value = value.replace(/\/+$/, '')
|
|
3388
|
+
return { value, root, separator: '/', windows: false }
|
|
3389
|
+
}
|
|
3390
|
+
let value = source.replace(/\//g, '\\')
|
|
3391
|
+
const unc = /^\\{2,}/.test(value)
|
|
3392
|
+
value = unc
|
|
3393
|
+
? '\\\\' + value.replace(/^\\+/, '').replace(/\\+/g, '\\')
|
|
3394
|
+
: value.replace(/\\+/g, '\\')
|
|
3395
|
+
let root = ''
|
|
3396
|
+
if (unc) {
|
|
3397
|
+
const parts = value.slice(2).split('\\').filter(Boolean)
|
|
3398
|
+
root = parts.length >= 2 ? `\\\\${parts[0]}\\${parts[1]}` : value
|
|
3399
|
+
} else {
|
|
3400
|
+
const drive = /^([A-Za-z]:)/.exec(value)
|
|
3401
|
+
if (drive) {
|
|
3402
|
+
root = drive[1] + '\\'
|
|
3403
|
+
if (value === drive[1]) value = root
|
|
3404
|
+
}
|
|
3405
|
+
}
|
|
3406
|
+
if (root && value.length > root.length) value = value.replace(/\\+$/, '')
|
|
3407
|
+
return { value, root, separator: '\\', windows: true }
|
|
3408
|
+
}
|
|
3409
|
+
|
|
3410
|
+
function fsPathKey(value) {
|
|
3411
|
+
const meta = fsPathMeta(value)
|
|
3412
|
+
return meta.windows ? meta.value.toLowerCase() : meta.value
|
|
3413
|
+
}
|
|
3414
|
+
|
|
3415
|
+
function fsPathEqual(left, right) {
|
|
3416
|
+
return fsPathKey(left) === fsPathKey(right)
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3419
|
+
function fsPathInside(candidate, root) {
|
|
3420
|
+
const child = fsPathMeta(candidate)
|
|
3421
|
+
const boundary = fsPathMeta(root)
|
|
3422
|
+
if (!child.value || !boundary.value || child.windows !== boundary.windows) return false
|
|
3423
|
+
const childKey = child.windows ? child.value.toLowerCase() : child.value
|
|
3424
|
+
const rootKey = boundary.windows ? boundary.value.toLowerCase() : boundary.value
|
|
3425
|
+
if (childKey === rootKey) return true
|
|
3426
|
+
const prefix = rootKey.endsWith(boundary.separator) ? rootKey : rootKey + boundary.separator
|
|
3427
|
+
return childKey.startsWith(prefix)
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3430
|
+
function resetFsForServer() {
|
|
3431
|
+
state.fs.path = null
|
|
3432
|
+
state.fs.initial = null
|
|
3433
|
+
state.fs.loaded = false
|
|
3434
|
+
state.fs.upload = null
|
|
3435
|
+
state.fs.preview = null
|
|
3436
|
+
state.fs.roots = []
|
|
3437
|
+
state.fs.rootIndex = 0
|
|
3438
|
+
state.fs.workspaceId = ''
|
|
3439
|
+
LS.del('fsWorkspaceIdV1')
|
|
3209
3440
|
}
|
|
3210
3441
|
|
|
3211
3442
|
const FS_PREVIEW_EXTENSIONS = new Set([
|
|
@@ -3321,7 +3552,14 @@ async function loadFs(dir, { silent = false, resetRoot = false } = {}) {
|
|
|
3321
3552
|
if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error === 'not-found' ? t('fs.notFound') : data.error === 'forbidden' ? t('fs.forbidden') : data.error || ('HTTP ' + res.status))
|
|
3322
3553
|
state.fs.path = data.path
|
|
3323
3554
|
if (!state.fs.initial) state.fs.initial = data.path
|
|
3555
|
+
if (Array.isArray(data.roots) && data.roots.length) state.fs.roots = data.roots.map(value => String(value || '')).filter(Boolean)
|
|
3556
|
+
if (!state.fs.roots.length) state.fs.roots = [data.path]
|
|
3557
|
+
if (!state.fs.workspaceId) {
|
|
3558
|
+
const rootIndex = state.fs.roots.findIndex(root => fsPathInside(data.path, root))
|
|
3559
|
+
if (rootIndex >= 0) state.fs.rootIndex = rootIndex
|
|
3560
|
+
}
|
|
3324
3561
|
state.fs.loaded = true
|
|
3562
|
+
renderWorkspaceNavigation()
|
|
3325
3563
|
renderFs(data)
|
|
3326
3564
|
} catch (e) {
|
|
3327
3565
|
if (e.message === 'AUTH') return
|
|
@@ -3725,11 +3963,12 @@ async function runFsUpload(up) {
|
|
|
3725
3963
|
|
|
3726
3964
|
function fsUp() {
|
|
3727
3965
|
if (!state.fs.path || !state.fs.initial) return
|
|
3728
|
-
|
|
3966
|
+
const parent = fsParent(state.fs.path)
|
|
3967
|
+
if (fsPathEqual(state.fs.path, state.fs.initial) || !fsPathInside(parent, state.fs.initial)) {
|
|
3729
3968
|
toast(t('fs.alreadyRoot'))
|
|
3730
3969
|
return
|
|
3731
3970
|
}
|
|
3732
|
-
loadFs(
|
|
3971
|
+
loadFs(parent)
|
|
3733
3972
|
}
|
|
3734
3973
|
|
|
3735
3974
|
function bindFsPullRefresh() {
|
|
@@ -4611,6 +4850,716 @@ async function restorePeakReminders() {
|
|
|
4611
4850
|
if (peakRemindOn() && legacyCleaned) await schedulePeakReminders({ legacyCleaned: true })
|
|
4612
4851
|
}
|
|
4613
4852
|
|
|
4853
|
+
/* ---------------- 功能测试 / Android ASR ---------------- */
|
|
4854
|
+
function asrTestBridge() { return window.NativeAsrTest }
|
|
4855
|
+
|
|
4856
|
+
function emptyAsrTest() {
|
|
4857
|
+
return { running: false, status: 'idle', meta: null, summary: null, events: [], lastError: '' }
|
|
4858
|
+
}
|
|
4859
|
+
|
|
4860
|
+
function asrTestEvent(event) {
|
|
4861
|
+
if (!event || typeof event !== 'object') return
|
|
4862
|
+
const current = state.asrTest
|
|
4863
|
+
const data = event.data && typeof event.data === 'object' ? event.data : {}
|
|
4864
|
+
if (event.type === 'meta') current.meta = data
|
|
4865
|
+
if (event.type === 'summary') {
|
|
4866
|
+
current.summary = data
|
|
4867
|
+
current.running = false
|
|
4868
|
+
}
|
|
4869
|
+
if (event.type === 'status') {
|
|
4870
|
+
current.status = String(data.status || 'unknown')
|
|
4871
|
+
if (current.status === 'listening' || current.status === 'starting' || current.status === 'restarting') current.running = true
|
|
4872
|
+
if (['stopped', 'unsupported', 'permission-denied'].includes(current.status)) current.running = false
|
|
4873
|
+
}
|
|
4874
|
+
if (event.type === 'error') current.lastError = String(data.name || data.message || 'error')
|
|
4875
|
+
current.events.push({ type: event.type, atMs: Number(event.atMs) || 0, data })
|
|
4876
|
+
if (current.events.length > 500) current.events.splice(0, current.events.length - 500)
|
|
4877
|
+
renderAsrTest()
|
|
4878
|
+
}
|
|
4879
|
+
window.__dshAsrEvent = asrTestEvent
|
|
4880
|
+
|
|
4881
|
+
function asrTestStatusText(status) {
|
|
4882
|
+
const labels = {
|
|
4883
|
+
idle: t('settings.asrTestNativeOnly'),
|
|
4884
|
+
starting: t('settings.asrTestStarted'),
|
|
4885
|
+
listening: t('settings.asrTestStarted'),
|
|
4886
|
+
restarting: t('settings.asrTestRestarting'),
|
|
4887
|
+
'permission-requesting': t('settings.asrTestPermission'),
|
|
4888
|
+
'permission-denied': t('settings.asrTestPermissionDenied'),
|
|
4889
|
+
'permission-error': t('settings.asrTestPermissionError'),
|
|
4890
|
+
unsupported: t('settings.asrTestUnavailable'),
|
|
4891
|
+
busy: t('settings.asrTestBusy'),
|
|
4892
|
+
stopped: t('settings.asrTestStopped')
|
|
4893
|
+
}
|
|
4894
|
+
return labels[status] || t('settings.asrTestStatus', { status })
|
|
4895
|
+
}
|
|
4896
|
+
|
|
4897
|
+
function asrTestLogLines() {
|
|
4898
|
+
const current = state.asrTest
|
|
4899
|
+
const lines = []
|
|
4900
|
+
for (const event of current.events) {
|
|
4901
|
+
const data = event.data || {}
|
|
4902
|
+
const at = `${event.atMs}ms`
|
|
4903
|
+
if (event.type === 'meta') {
|
|
4904
|
+
lines.push(`[${at}] meta brand=${data.brand || '—'} manufacturer=${data.manufacturer || '—'} model=${data.model || '—'} Android=${data.androidVersion || '—'} API=${data.apiLevel || '—'}`)
|
|
4905
|
+
lines.push(`[${at}] recordAudioPermission=${data.recordAudioPermission ?? 'unknown'} recordAudioAppOp=${data.recordAudioAppOp || 'unknown'} microphoneMuted=${data.microphoneMuted ?? 'unknown'}`)
|
|
4906
|
+
lines.push(`[${at}] recognitionAvailable=${data.recognitionAvailable === true} onDeviceAvailable=${data.onDeviceAvailable === true} path=${data.networkPath || '—'}`)
|
|
4907
|
+
for (const service of data.recognitionServices || []) lines.push(`[${at}] service ${service.packageName || '—'} / ${service.serviceName || '—'} xiaomiLike=${service.xiaomiLike === true}`)
|
|
4908
|
+
} else if (event.type === 'status') {
|
|
4909
|
+
lines.push(`[${at}] status=${data.status || '—'} session=${data.session ?? '—'} reason=${data.reason || '—'} ${data.message || ''}`.trim())
|
|
4910
|
+
} else if (event.type === 'partial' || event.type === 'final') {
|
|
4911
|
+
lines.push(`[${at}] ${event.type}#${data.count ?? '—'} session=${data.session ?? '—'} +${data.elapsedMs ?? '—'}ms: ${data.text || '(empty)'}`)
|
|
4912
|
+
} else if (event.type === 'callback') {
|
|
4913
|
+
lines.push(`[${at}] callback=${data.name || '—'} session=${data.session ?? '—'} +${data.elapsedMs ?? '—'}ms${data.bytes >= 0 ? ` bytes=${data.bytes}` : ''}`)
|
|
4914
|
+
} else if (event.type === 'error') {
|
|
4915
|
+
lines.push(`[${at}] error=${data.name || '—'} code=${data.code ?? '—'} session=${data.session ?? '—'} ${data.message || ''}`.trim())
|
|
4916
|
+
} else if (event.type === 'summary') {
|
|
4917
|
+
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 ?? '—'}`)
|
|
4918
|
+
}
|
|
4919
|
+
}
|
|
4920
|
+
return lines
|
|
4921
|
+
}
|
|
4922
|
+
|
|
4923
|
+
function asrTestReport() {
|
|
4924
|
+
const current = state.asrTest
|
|
4925
|
+
const meta = current.meta || {}
|
|
4926
|
+
const summary = current.summary || {}
|
|
4927
|
+
const lines = [
|
|
4928
|
+
'DSH Remote Android ASR 测试报告',
|
|
4929
|
+
`生成时间: ${new Date().toISOString()}`,
|
|
4930
|
+
`设备: ${meta.brand || '—'} / ${meta.manufacturer || '—'} / ${meta.model || '—'}`,
|
|
4931
|
+
`Android: ${meta.androidVersion || '—'} (API ${meta.apiLevel || '—'})`,
|
|
4932
|
+
`识别可用: ${meta.recognitionAvailable === true ? 'yes' : meta.recognitionAvailable === false ? 'no' : 'unknown'}`,
|
|
4933
|
+
`端侧识别可用: ${meta.onDeviceAvailable === true ? 'yes' : meta.onDeviceAvailable === false ? 'no' : 'unknown'}`,
|
|
4934
|
+
`路径: ${meta.networkPath || 'system-default-recognition-service'}`,
|
|
4935
|
+
`测试结束原因: ${summary.reason || current.status || '—'}`,
|
|
4936
|
+
`总时长: ${summary.durationMs ?? '—'}ms`,
|
|
4937
|
+
`session: ${summary.sessionCount ?? '—'} / 重建: ${summary.restartCount ?? '—'} / partial: ${summary.partialCount ?? '—'} / final: ${summary.finalCount ?? '—'} / errors: ${summary.errorCount ?? '—'}`,
|
|
4938
|
+
'',
|
|
4939
|
+
'事件日志:',
|
|
4940
|
+
...asrTestLogLines()
|
|
4941
|
+
]
|
|
4942
|
+
return lines.join('\n')
|
|
4943
|
+
}
|
|
4944
|
+
|
|
4945
|
+
function renderAsrTest() {
|
|
4946
|
+
const start = $('btn-asr-test-start')
|
|
4947
|
+
const stop = $('btn-asr-test-stop')
|
|
4948
|
+
const copy = $('btn-asr-test-copy')
|
|
4949
|
+
const permission = $('btn-asr-test-permission')
|
|
4950
|
+
const engine = $('btn-asr-test-engine')
|
|
4951
|
+
const status = $('asr-test-status')
|
|
4952
|
+
const summary = $('asr-test-summary')
|
|
4953
|
+
const log = $('asr-test-log')
|
|
4954
|
+
if (!start || !stop || !copy || !permission || !engine || !status || !summary || !log) return
|
|
4955
|
+
const current = state.asrTest
|
|
4956
|
+
const native = !!(CAP?.isNativePlatform?.() && asrTestBridge()?.startAsrTest)
|
|
4957
|
+
start.disabled = current.running || !native
|
|
4958
|
+
stop.disabled = !current.running || !native
|
|
4959
|
+
copy.disabled = !current.events.length
|
|
4960
|
+
const permissionError = current.status === 'permission-error' || current.status === 'permission-denied' || current.summary?.reason === 'permission-error'
|
|
4961
|
+
status.className = 'feature-test-status ' + (permissionError || current.status === 'unsupported' ? 'error' : current.status === 'stopped' ? 'ok' : 'muted')
|
|
4962
|
+
status.textContent = native ? (permissionError ? t('settings.asrTestPermissionError') : asrTestStatusText(current.status)) : t('settings.asrTestWebUnsupported')
|
|
4963
|
+
permission.classList.toggle('hidden', !native || !permissionError)
|
|
4964
|
+
engine.classList.toggle('hidden', !native || !permissionError)
|
|
4965
|
+
const meta = current.meta || {}
|
|
4966
|
+
const s = current.summary
|
|
4967
|
+
summary.textContent = [
|
|
4968
|
+
meta.model ? `${t('settings.asrTestMeta')}: ${meta.brand || '—'} / ${meta.manufacturer || '—'} / ${meta.model}` : '',
|
|
4969
|
+
s ? `${t('settings.asrTestSummary')}: ${t('settings.asrTestStatus', { status: s.reason || 'done' })} · session ${s.sessionCount ?? '—'} · partial ${s.partialCount ?? '—'} · final ${s.finalCount ?? '—'} · error ${s.errorCount ?? '—'}` : ''
|
|
4970
|
+
].filter(Boolean).join('\n')
|
|
4971
|
+
log.textContent = current.events.length ? asrTestLogLines().join('\n') : t('settings.asrTestLogEmpty')
|
|
4972
|
+
log.scrollTop = log.scrollHeight
|
|
4973
|
+
}
|
|
4974
|
+
|
|
4975
|
+
function clearAsrTest() {
|
|
4976
|
+
if (state.asrTest.running) return toast(t('settings.asrTestBusy'), 'err')
|
|
4977
|
+
state.asrTest = emptyAsrTest()
|
|
4978
|
+
renderAsrTest()
|
|
4979
|
+
}
|
|
4980
|
+
|
|
4981
|
+
async function startAsrTest() {
|
|
4982
|
+
const native = asrTestBridge()
|
|
4983
|
+
if (!CAP?.isNativePlatform?.() || !native?.startAsrTest) return toast(t('settings.asrTestWebUnsupported'), 'err')
|
|
4984
|
+
if (state.asrTest.running) return toast(t('settings.asrTestBusy'), 'err')
|
|
4985
|
+
if (!confirm(t('settings.asrTestConsent'))) return
|
|
4986
|
+
state.asrTest = { ...emptyAsrTest(), running: true, status: 'starting' }
|
|
4987
|
+
renderAsrTest()
|
|
4988
|
+
try {
|
|
4989
|
+
if (native.startAsrTest() === false) throw new Error(t('settings.asrTestUnavailable'))
|
|
4990
|
+
} catch (error) {
|
|
4991
|
+
state.asrTest.running = false
|
|
4992
|
+
state.asrTest.status = 'error'
|
|
4993
|
+
state.asrTest.lastError = error?.message || String(error)
|
|
4994
|
+
renderAsrTest()
|
|
4995
|
+
toast(state.asrTest.lastError, 'err')
|
|
4996
|
+
}
|
|
4997
|
+
}
|
|
4998
|
+
|
|
4999
|
+
function stopAsrTest() {
|
|
5000
|
+
try { asrTestBridge()?.stopAsrTest?.() } catch {}
|
|
5001
|
+
}
|
|
5002
|
+
|
|
5003
|
+
function openAsrPermissionSettings() {
|
|
5004
|
+
try {
|
|
5005
|
+
if (asrTestBridge()?.openAsrPermissionSettings?.() === false) throw new Error('permission settings unavailable')
|
|
5006
|
+
} catch (error) {
|
|
5007
|
+
toast(error?.message || String(error), 'err')
|
|
5008
|
+
}
|
|
5009
|
+
}
|
|
5010
|
+
|
|
5011
|
+
function openAsrEngineSettings() {
|
|
5012
|
+
try {
|
|
5013
|
+
if (asrTestBridge()?.openAsrEngineSettings?.() === false) throw new Error('voice engine settings unavailable')
|
|
5014
|
+
} catch (error) {
|
|
5015
|
+
toast(error?.message || String(error), 'err')
|
|
5016
|
+
}
|
|
5017
|
+
}
|
|
5018
|
+
|
|
5019
|
+
async function copyAsrTestLog() {
|
|
5020
|
+
const ok = await copyText(asrTestReport())
|
|
5021
|
+
toast(t(ok ? 'settings.asrTestCopyOk' : 'settings.asrTestCopyFailed'), ok ? 'ok' : 'err')
|
|
5022
|
+
}
|
|
5023
|
+
|
|
5024
|
+
/* ---------------- 模型设置 ---------------- */
|
|
5025
|
+
const MODEL_SETTINGS_FIELDS = ['baseURL', 'api', 'apiKeyEnv', 'displayName', 'models']
|
|
5026
|
+
const MODEL_REASONING_LIMIT = 12
|
|
5027
|
+
const MODEL_REASONING_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/
|
|
5028
|
+
|
|
5029
|
+
function modelValueAt(value, path = []) {
|
|
5030
|
+
let current = value
|
|
5031
|
+
for (const part of path) {
|
|
5032
|
+
if (current === null || typeof current !== 'object') return undefined
|
|
5033
|
+
current = current[part]
|
|
5034
|
+
}
|
|
5035
|
+
return current
|
|
5036
|
+
}
|
|
5037
|
+
|
|
5038
|
+
function cloneModelValue(value) {
|
|
5039
|
+
if (value === undefined) return undefined
|
|
5040
|
+
try { return structuredClone(value) } catch {}
|
|
5041
|
+
try { return JSON.parse(JSON.stringify(value)) } catch { return value }
|
|
5042
|
+
}
|
|
5043
|
+
|
|
5044
|
+
function modelObjectAt(value, path = []) {
|
|
5045
|
+
const result = modelValueAt(value, path)
|
|
5046
|
+
return result && typeof result === 'object' && !Array.isArray(result) ? cloneModelValue(result) : {}
|
|
5047
|
+
}
|
|
5048
|
+
|
|
5049
|
+
function modelKeyRefFor(provider, namespace, path) {
|
|
5050
|
+
const effective = modelObjectAt(namespace?.value, path)
|
|
5051
|
+
const user = modelObjectAt(namespace?.user, path)
|
|
5052
|
+
const named = typeof user.apiKeyEnv === 'string' && user.apiKeyEnv.trim()
|
|
5053
|
+
? user.apiKeyEnv.trim()
|
|
5054
|
+
: typeof effective.apiKeyEnv === 'string' && effective.apiKeyEnv.trim()
|
|
5055
|
+
? effective.apiKeyEnv.trim()
|
|
5056
|
+
: ''
|
|
5057
|
+
if (named) return named
|
|
5058
|
+
if (namespace?.ns === 'llm-deepseek') return 'DEEPSEEK_API_KEY'
|
|
5059
|
+
if (namespace?.ns === 'llm-pi-ai') return provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_') + '_API_KEY'
|
|
5060
|
+
return ''
|
|
5061
|
+
}
|
|
5062
|
+
|
|
5063
|
+
function modelProfileName(row) {
|
|
5064
|
+
const display = String(row.displayName || row.provider || '')
|
|
5065
|
+
return display === row.provider ? display : `${display} (${row.provider})`
|
|
5066
|
+
}
|
|
5067
|
+
|
|
5068
|
+
function modelSettingsNamespace(ns) {
|
|
5069
|
+
return state.modelSettings.namespaces.find(item => item.ns === ns) || null
|
|
5070
|
+
}
|
|
5071
|
+
|
|
5072
|
+
function modelSettingsRow(provider) {
|
|
5073
|
+
return state.modelSettings.providers.find(row => row.provider === provider) || null
|
|
5074
|
+
}
|
|
5075
|
+
|
|
5076
|
+
function modelSettingsPathChanged(before, after, key) {
|
|
5077
|
+
return JSON.stringify(before?.[key]) !== JSON.stringify(after?.[key])
|
|
5078
|
+
}
|
|
5079
|
+
|
|
5080
|
+
function modelCatalogRows(value) {
|
|
5081
|
+
if (!Array.isArray(value)) return []
|
|
5082
|
+
return value
|
|
5083
|
+
.filter(model => model && typeof model === 'object' && !Array.isArray(model))
|
|
5084
|
+
.map(model => cloneModelValue(model))
|
|
5085
|
+
}
|
|
5086
|
+
|
|
5087
|
+
function modelReasoningRows(model) {
|
|
5088
|
+
const reasoning = modelObjectAt(model, ['reasoning'])
|
|
5089
|
+
const raw = Array.isArray(reasoning.efforts) && reasoning.efforts.length
|
|
5090
|
+
? reasoning.efforts
|
|
5091
|
+
: (Array.isArray(model?.reasoningEfforts) ? model.reasoningEfforts : [])
|
|
5092
|
+
return raw.map(item => {
|
|
5093
|
+
const value = typeof item === 'string' ? { id: item } : modelObjectAt(item)
|
|
5094
|
+
return {
|
|
5095
|
+
id: typeof value.id === 'string' ? value.id.trim() : '',
|
|
5096
|
+
name: typeof value.name === 'string' ? value.name.trim() : '',
|
|
5097
|
+
description: typeof value.description === 'string' ? value.description.trim() : ''
|
|
5098
|
+
}
|
|
5099
|
+
})
|
|
5100
|
+
}
|
|
5101
|
+
|
|
5102
|
+
function withModelReasoning(model, rows, defaultEffort = '') {
|
|
5103
|
+
const next = cloneModelValue(model) || {}
|
|
5104
|
+
delete next.reasoningEfforts
|
|
5105
|
+
const reasoning = modelObjectAt(next, ['reasoning'])
|
|
5106
|
+
const efforts = rows.map(row => {
|
|
5107
|
+
const value = { id: String(row.id || '').trim() }
|
|
5108
|
+
if (String(row.name || '').trim()) value.name = String(row.name).trim()
|
|
5109
|
+
if (String(row.description || '').trim()) value.description = String(row.description).trim()
|
|
5110
|
+
return value
|
|
5111
|
+
})
|
|
5112
|
+
if (efforts.length) {
|
|
5113
|
+
reasoning.efforts = efforts
|
|
5114
|
+
const selected = efforts.some(row => row.id === defaultEffort) ? defaultEffort : ''
|
|
5115
|
+
if (selected) reasoning.defaultEffort = selected
|
|
5116
|
+
else delete reasoning.defaultEffort
|
|
5117
|
+
next.reasoning = reasoning
|
|
5118
|
+
} else {
|
|
5119
|
+
delete reasoning.efforts
|
|
5120
|
+
delete reasoning.defaultEffort
|
|
5121
|
+
if (Object.keys(reasoning).length) next.reasoning = reasoning
|
|
5122
|
+
else delete next.reasoning
|
|
5123
|
+
}
|
|
5124
|
+
return next
|
|
5125
|
+
}
|
|
5126
|
+
|
|
5127
|
+
function modelReasoningError(models) {
|
|
5128
|
+
for (const model of models) {
|
|
5129
|
+
const rows = modelReasoningRows(model)
|
|
5130
|
+
if (rows.length > MODEL_REASONING_LIMIT) return t('settings.modelReasoningLimit', { count: MODEL_REASONING_LIMIT })
|
|
5131
|
+
const ids = new Set()
|
|
5132
|
+
for (const row of rows) {
|
|
5133
|
+
if (!row.id) return t('settings.modelReasoningIdRequired')
|
|
5134
|
+
if (!MODEL_REASONING_ID_RE.test(row.id)) return t('settings.modelReasoningInvalid')
|
|
5135
|
+
if (ids.has(row.id)) return t('settings.modelReasoningDuplicate')
|
|
5136
|
+
ids.add(row.id)
|
|
5137
|
+
}
|
|
5138
|
+
const defaultEffort = modelObjectAt(model, ['reasoning']).defaultEffort
|
|
5139
|
+
if (defaultEffort && !ids.has(defaultEffort)) return t('settings.modelReasoningDefaultInvalid')
|
|
5140
|
+
}
|
|
5141
|
+
return ''
|
|
5142
|
+
}
|
|
5143
|
+
|
|
5144
|
+
async function loadModelSettings(force = false) {
|
|
5145
|
+
if (!state.token) {
|
|
5146
|
+
state.modelSettings = { ...state.modelSettings, status: 'error', error: t('token.notSetHint') }
|
|
5147
|
+
renderModelSettings()
|
|
5148
|
+
return
|
|
5149
|
+
}
|
|
5150
|
+
if (state.modelSettings.status === 'loading') return
|
|
5151
|
+
if (!force && state.modelSettings.status === 'ready') return
|
|
5152
|
+
state.modelSettings = { ...state.modelSettings, status: 'loading', error: '' }
|
|
5153
|
+
renderModelSettings()
|
|
5154
|
+
try {
|
|
5155
|
+
const [providersValue, settingsValue] = await Promise.all([
|
|
5156
|
+
rpc('llm.providers', {}),
|
|
5157
|
+
rpc('settings.describe', {})
|
|
5158
|
+
])
|
|
5159
|
+
const namespaces = Array.isArray(settingsValue?.namespaces) ? settingsValue.namespaces : []
|
|
5160
|
+
const providers = Array.isArray(providersValue?.providers) ? providersValue.providers : []
|
|
5161
|
+
const rows = providers.map(entry => {
|
|
5162
|
+
const settingsPath = Array.isArray(entry.settingsPath) ? entry.settingsPath : []
|
|
5163
|
+
const namespace = namespaces.find(item => item.ns === entry.settingsNs) || null
|
|
5164
|
+
const effective = modelObjectAt(namespace?.value, settingsPath)
|
|
5165
|
+
const keyRef = modelKeyRefFor(entry.provider, namespace, settingsPath)
|
|
5166
|
+
return {
|
|
5167
|
+
...entry,
|
|
5168
|
+
settingsPath,
|
|
5169
|
+
keyRef,
|
|
5170
|
+
namespace,
|
|
5171
|
+
configured: namespace !== null && (settingsPath.length === 0 || modelValueAt(namespace.value, settingsPath) !== undefined),
|
|
5172
|
+
effective,
|
|
5173
|
+
credential: null
|
|
5174
|
+
}
|
|
5175
|
+
})
|
|
5176
|
+
const refs = [...new Set(rows.map(row => row.keyRef).filter(Boolean))]
|
|
5177
|
+
let credentials = {}
|
|
5178
|
+
if (refs.length > 0) {
|
|
5179
|
+
const value = await rpc('credentials.describe', { refs })
|
|
5180
|
+
credentials = value?.credentials && typeof value.credentials === 'object' ? value.credentials : {}
|
|
5181
|
+
}
|
|
5182
|
+
state.modelSettings = {
|
|
5183
|
+
status: 'ready',
|
|
5184
|
+
error: '',
|
|
5185
|
+
writable: settingsValue?.writable === true,
|
|
5186
|
+
hasDocument: settingsValue?.hasDocument === true,
|
|
5187
|
+
providers: rows.map(row => ({ ...row, credential: row.keyRef ? credentials[row.keyRef] || null : null })),
|
|
5188
|
+
namespaces,
|
|
5189
|
+
credentials
|
|
5190
|
+
}
|
|
5191
|
+
state.modelEditor = null
|
|
5192
|
+
} catch (error) {
|
|
5193
|
+
state.modelSettings = { ...state.modelSettings, status: 'error', error: error?.message || String(error) }
|
|
5194
|
+
}
|
|
5195
|
+
renderModelSettings()
|
|
5196
|
+
}
|
|
5197
|
+
|
|
5198
|
+
function renderModelSettings() {
|
|
5199
|
+
const status = $('model-settings-status')
|
|
5200
|
+
const list = $('model-settings-list')
|
|
5201
|
+
if (!status || !list) return
|
|
5202
|
+
const current = state.modelSettings
|
|
5203
|
+
if (current.status === 'loading') {
|
|
5204
|
+
status.className = 'model-settings-status muted'
|
|
5205
|
+
status.textContent = t('settings.modelLoading')
|
|
5206
|
+
list.innerHTML = ''
|
|
5207
|
+
return
|
|
5208
|
+
}
|
|
5209
|
+
if (current.status === 'error') {
|
|
5210
|
+
status.className = 'model-settings-status error'
|
|
5211
|
+
status.textContent = t('settings.modelUnavailable', { msg: current.error || t('err.dshError') })
|
|
5212
|
+
list.innerHTML = ''
|
|
5213
|
+
return
|
|
5214
|
+
}
|
|
5215
|
+
if (!current.providers.length) {
|
|
5216
|
+
status.className = 'model-settings-status muted'
|
|
5217
|
+
status.textContent = t('settings.modelEmpty')
|
|
5218
|
+
list.innerHTML = ''
|
|
5219
|
+
return
|
|
5220
|
+
}
|
|
5221
|
+
status.className = 'model-settings-status ' + (current.writable ? 'muted' : 'model-readonly')
|
|
5222
|
+
status.textContent = current.writable ? t('settings.modelIntro') : t('settings.modelReadOnly')
|
|
5223
|
+
list.innerHTML = current.providers.map(renderModelProviderCard).join('')
|
|
5224
|
+
}
|
|
5225
|
+
|
|
5226
|
+
function renderModelProviderCard(row) {
|
|
5227
|
+
const editor = state.modelEditor?.provider === row.provider ? renderModelEditor() : ''
|
|
5228
|
+
const credentialConfigured = row.credential?.configured === true
|
|
5229
|
+
const dot = row.keyRef ? (credentialConfigured ? 'configured' : '') : 'unknown'
|
|
5230
|
+
const stateLabel = row.keyRef
|
|
5231
|
+
? (credentialConfigured ? t('settings.modelConfigured') : t('settings.modelMissing'))
|
|
5232
|
+
: t('settings.modelConfigured')
|
|
5233
|
+
return `<article class="model-provider-card" data-model-provider-card="${esc(row.provider)}">
|
|
5234
|
+
<div class="model-provider-head">
|
|
5235
|
+
<div class="model-provider-identity">
|
|
5236
|
+
<span class="model-provider-dot ${dot}" title="${esc(stateLabel)}" aria-label="${esc(stateLabel)}"></span>
|
|
5237
|
+
<span class="model-provider-name">${esc(row.displayName || row.provider)}</span>
|
|
5238
|
+
<code class="model-provider-route">${esc(row.provider)}</code>
|
|
5239
|
+
</div>
|
|
5240
|
+
<button class="mini-btn" type="button" data-model-action="edit" data-model-provider="${esc(row.provider)}">${esc(t('settings.modelEdit'))}</button>
|
|
5241
|
+
</div>
|
|
5242
|
+
${editor}
|
|
5243
|
+
</article>`
|
|
5244
|
+
}
|
|
5245
|
+
|
|
5246
|
+
function renderModelReasoningEditor(model, index, readOnly) {
|
|
5247
|
+
const rows = modelReasoningRows(model)
|
|
5248
|
+
const reasoning = modelObjectAt(model, ['reasoning'])
|
|
5249
|
+
const defaultEffort = typeof reasoning.defaultEffort === 'string' ? reasoning.defaultEffort : ''
|
|
5250
|
+
const effortRows = rows.length
|
|
5251
|
+
? rows.map((row, effortIndex) => `<div class="model-reasoning-entry">
|
|
5252
|
+
<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' : ''}>
|
|
5253
|
+
<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' : ''}>
|
|
5254
|
+
<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' : ''}>
|
|
5255
|
+
<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>
|
|
5256
|
+
</div>`).join('')
|
|
5257
|
+
: `<div class="model-empty">${esc(t('settings.modelReasoningEmpty'))}</div>`
|
|
5258
|
+
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('')
|
|
5259
|
+
return `<div class="model-reasoning" data-model-reasoning-editor="${index}">
|
|
5260
|
+
<div class="model-reasoning-head">
|
|
5261
|
+
<div><div class="model-catalog-title">${esc(t('settings.modelReasoning'))}</div><div class="model-catalog-hint">${esc(t('settings.modelReasoningHint'))}</div></div>
|
|
5262
|
+
<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>
|
|
5263
|
+
</div>
|
|
5264
|
+
<div class="model-reasoning-list">${effortRows}</div>
|
|
5265
|
+
<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>
|
|
5266
|
+
</div>`
|
|
5267
|
+
}
|
|
5268
|
+
|
|
5269
|
+
function renderModelEditor() {
|
|
5270
|
+
const editor = state.modelEditor
|
|
5271
|
+
if (!editor) return ''
|
|
5272
|
+
const readOnly = !state.modelSettings.writable || editor.busy
|
|
5273
|
+
const keyPlaceholder = editor.keyConfigured && !editor.clearKey
|
|
5274
|
+
? t('settings.modelApiKeyStored')
|
|
5275
|
+
: t('settings.modelApiKeyPlaceholder')
|
|
5276
|
+
const models = editor.models || []
|
|
5277
|
+
const modelList = models.length
|
|
5278
|
+
? models.map((model, index) => `<div class="model-entry-card">
|
|
5279
|
+
<div class="model-entry">
|
|
5280
|
+
<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' : ''}>
|
|
5281
|
+
<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' : ''}>
|
|
5282
|
+
<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>
|
|
5283
|
+
</div>
|
|
5284
|
+
${renderModelReasoningEditor(model, index, readOnly)}
|
|
5285
|
+
</div>`).join('')
|
|
5286
|
+
: `<div class="model-empty">${esc(t('settings.modelNoModels'))}</div>`
|
|
5287
|
+
const discovery = editor.discovered?.length
|
|
5288
|
+
? `<div class="model-discovery">
|
|
5289
|
+
<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>
|
|
5290
|
+
<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>
|
|
5291
|
+
<button class="mini-btn" type="button" data-model-action="add-selected" ${readOnly ? 'disabled' : ''}>${esc(t('settings.modelAddSelected'))}</button>
|
|
5292
|
+
</div>`
|
|
5293
|
+
: ''
|
|
5294
|
+
const effectText = editor.applies === 'restart' ? t('settings.modelRestart') : t('settings.modelLive')
|
|
5295
|
+
return `<div class="model-editor">
|
|
5296
|
+
<div class="model-editor-title"><strong>${esc(editor.displayName || editor.provider)}</strong><code>${esc(editor.provider)}</code></div>
|
|
5297
|
+
<div class="model-field">
|
|
5298
|
+
<label for="model-api-key-${esc(editor.provider)}">${esc(t('settings.modelApiKey'))}</label>
|
|
5299
|
+
<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' : ''}>
|
|
5300
|
+
${editor.keyConfigured && editor.keyWritable !== false ? `<button class="mini-btn" type="button" data-model-action="clear-key" ${readOnly ? 'disabled' : ''}>${esc(t('settings.modelClearKey'))}</button>` : ''}
|
|
5301
|
+
</div>
|
|
5302
|
+
<div class="model-inline">
|
|
5303
|
+
<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>
|
|
5304
|
+
${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>` : ''}
|
|
5305
|
+
</div>
|
|
5306
|
+
<div class="model-catalog">
|
|
5307
|
+
<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>
|
|
5308
|
+
<div class="model-list">${modelList}</div>
|
|
5309
|
+
${discovery}
|
|
5310
|
+
</div>
|
|
5311
|
+
${editor.error ? `<p class="model-editor-error">${esc(editor.error)}</p>` : ''}
|
|
5312
|
+
<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>
|
|
5313
|
+
</div>`
|
|
5314
|
+
}
|
|
5315
|
+
|
|
5316
|
+
function openModelEditor(provider) {
|
|
5317
|
+
const row = modelSettingsRow(provider)
|
|
5318
|
+
const namespace = row?.namespace
|
|
5319
|
+
if (!row || !namespace) return
|
|
5320
|
+
const effective = modelObjectAt(namespace.value, row.settingsPath)
|
|
5321
|
+
const user = modelObjectAt(namespace.user, row.settingsPath)
|
|
5322
|
+
state.modelEditor = {
|
|
5323
|
+
provider: row.provider,
|
|
5324
|
+
displayName: row.displayName,
|
|
5325
|
+
settingsNs: row.settingsNs,
|
|
5326
|
+
settingsPath: row.settingsPath,
|
|
5327
|
+
namespace,
|
|
5328
|
+
userProfile: user,
|
|
5329
|
+
effectiveProfile: effective,
|
|
5330
|
+
keyRef: row.keyRef || '',
|
|
5331
|
+
keyConfigured: row.credential?.configured === true,
|
|
5332
|
+
keyWritable: row.credential?.writable !== false,
|
|
5333
|
+
baseURL: typeof (user.baseURL ?? effective.baseURL) === 'string' ? (user.baseURL ?? effective.baseURL) : '',
|
|
5334
|
+
initialBaseURL: typeof user.baseURL === 'string' ? user.baseURL : '',
|
|
5335
|
+
api: typeof (user.api ?? effective.api) === 'string' ? (user.api ?? effective.api) : '',
|
|
5336
|
+
initialApi: typeof user.api === 'string' ? user.api : '',
|
|
5337
|
+
models: modelCatalogRows(user.models ?? effective.models),
|
|
5338
|
+
modelsDirty: false,
|
|
5339
|
+
baseURLDirty: false,
|
|
5340
|
+
apiDirty: false,
|
|
5341
|
+
keyDraft: '',
|
|
5342
|
+
clearKey: false,
|
|
5343
|
+
discovered: [],
|
|
5344
|
+
discoverySelected: new Set(),
|
|
5345
|
+
applies: namespace.applies,
|
|
5346
|
+
busy: false,
|
|
5347
|
+
error: ''
|
|
5348
|
+
}
|
|
5349
|
+
renderModelSettings()
|
|
5350
|
+
}
|
|
5351
|
+
|
|
5352
|
+
function collectModelEditorForm() {
|
|
5353
|
+
const editor = state.modelEditor
|
|
5354
|
+
const root = $('model-settings-list')
|
|
5355
|
+
if (!editor || !root) return
|
|
5356
|
+
const base = root.querySelector('[data-model-field="baseURL"]')
|
|
5357
|
+
const api = root.querySelector('[data-model-field="api"]')
|
|
5358
|
+
const key = root.querySelector('[data-model-field="apiKey"]')
|
|
5359
|
+
if (base) editor.baseURL = base.value.trim()
|
|
5360
|
+
if (api) editor.api = api.value.trim()
|
|
5361
|
+
if (key) editor.keyDraft = key.value
|
|
5362
|
+
root.querySelectorAll('[data-model-field="model-id"]').forEach(input => {
|
|
5363
|
+
const index = Number(input.dataset.modelIndex)
|
|
5364
|
+
if (editor.models[index]) editor.models[index].id = input.value.trim()
|
|
5365
|
+
})
|
|
5366
|
+
root.querySelectorAll('[data-model-field="model-name"]').forEach(input => {
|
|
5367
|
+
const index = Number(input.dataset.modelIndex)
|
|
5368
|
+
if (editor.models[index]) {
|
|
5369
|
+
const value = input.value.trim()
|
|
5370
|
+
if (value) editor.models[index].name = value
|
|
5371
|
+
else delete editor.models[index].name
|
|
5372
|
+
}
|
|
5373
|
+
})
|
|
5374
|
+
editor.models.forEach((model, index) => {
|
|
5375
|
+
const section = root.querySelector(`[data-model-reasoning-editor="${index}"]`)
|
|
5376
|
+
if (!section) return
|
|
5377
|
+
const rows = [...section.querySelectorAll('[data-model-field="reasoning-id"]')].map((input, effortIndex) => ({
|
|
5378
|
+
id: input.value.trim(),
|
|
5379
|
+
name: section.querySelector(`[data-model-field="reasoning-name"][data-reasoning-index="${effortIndex}"]`)?.value.trim() || '',
|
|
5380
|
+
description: section.querySelector(`[data-model-field="reasoning-description"][data-reasoning-index="${effortIndex}"]`)?.value.trim() || ''
|
|
5381
|
+
}))
|
|
5382
|
+
const defaultEffort = section.querySelector('[data-model-field="reasoning-default"]')?.value || ''
|
|
5383
|
+
editor.models[index] = withModelReasoning(model, rows, defaultEffort)
|
|
5384
|
+
})
|
|
5385
|
+
}
|
|
5386
|
+
|
|
5387
|
+
function setModelEditorError(message) {
|
|
5388
|
+
if (!state.modelEditor) return
|
|
5389
|
+
state.modelEditor.error = message || ''
|
|
5390
|
+
renderModelSettings()
|
|
5391
|
+
}
|
|
5392
|
+
|
|
5393
|
+
async function discoverModelSettings() {
|
|
5394
|
+
const editor = state.modelEditor
|
|
5395
|
+
if (!editor || editor.busy) return
|
|
5396
|
+
collectModelEditorForm()
|
|
5397
|
+
editor.busy = true
|
|
5398
|
+
editor.error = ''
|
|
5399
|
+
renderModelSettings()
|
|
5400
|
+
try {
|
|
5401
|
+
const payload = { settingsNs: editor.settingsNs }
|
|
5402
|
+
if (editor.provider) payload.provider = editor.provider
|
|
5403
|
+
if (editor.baseURL) payload.baseURL = editor.baseURL
|
|
5404
|
+
if (editor.api) payload.api = editor.api
|
|
5405
|
+
if (editor.keyDraft.trim()) payload.apiKey = editor.keyDraft.trim()
|
|
5406
|
+
const value = await rpc('llm.discoverModels', payload)
|
|
5407
|
+
const found = Array.isArray(value?.models) ? value.models.filter(model => model && typeof model.id === 'string' && model.id.trim()) : []
|
|
5408
|
+
if (!found.length) throw new Error(t('settings.modelFetchEmpty'))
|
|
5409
|
+
const known = new Set(editor.models.map(model => model.id))
|
|
5410
|
+
editor.discovered = found
|
|
5411
|
+
editor.discoverySelected = new Set(found.filter(model => !known.has(model.id)).map(model => model.id))
|
|
5412
|
+
} catch (error) {
|
|
5413
|
+
editor.error = t('settings.modelFetchFailed', { msg: error?.message || String(error) })
|
|
5414
|
+
} finally {
|
|
5415
|
+
editor.busy = false
|
|
5416
|
+
}
|
|
5417
|
+
renderModelSettings()
|
|
5418
|
+
}
|
|
5419
|
+
|
|
5420
|
+
function addDiscoveredModels() {
|
|
5421
|
+
const editor = state.modelEditor
|
|
5422
|
+
if (!editor) return
|
|
5423
|
+
collectModelEditorForm()
|
|
5424
|
+
const known = new Set(editor.models.map(model => model.id))
|
|
5425
|
+
for (const candidate of editor.discovered || []) {
|
|
5426
|
+
if (!editor.discoverySelected.has(candidate.id) || known.has(candidate.id)) continue
|
|
5427
|
+
editor.models.push({ id: candidate.id, ...(candidate.name ? { name: candidate.name } : {}), ...(candidate.contextWindow ? { contextWindow: candidate.contextWindow } : {}), ...(candidate.maxTokens ? { maxTokens: candidate.maxTokens } : {}) })
|
|
5428
|
+
known.add(candidate.id)
|
|
5429
|
+
}
|
|
5430
|
+
editor.modelsDirty = true
|
|
5431
|
+
editor.discovered = []
|
|
5432
|
+
editor.discoverySelected = new Set()
|
|
5433
|
+
renderModelSettings()
|
|
5434
|
+
}
|
|
5435
|
+
|
|
5436
|
+
async function saveModelEditor() {
|
|
5437
|
+
const editor = state.modelEditor
|
|
5438
|
+
if (!editor || editor.busy) return
|
|
5439
|
+
collectModelEditorForm()
|
|
5440
|
+
if (!state.modelSettings.writable) return setModelEditorError(t('settings.modelReadOnly'))
|
|
5441
|
+
const models = editor.models || []
|
|
5442
|
+
if (editor.modelsDirty && models.some(model => !String(model.id || '').trim())) return setModelEditorError(t('settings.modelIdRequired'))
|
|
5443
|
+
const reasoningError = editor.modelsDirty ? modelReasoningError(models) : ''
|
|
5444
|
+
if (reasoningError) return setModelEditorError(reasoningError)
|
|
5445
|
+
if (editor.keyDraft.trim() && !editor.keyRef) return setModelEditorError(t('settings.modelSaveFailed', { msg: t('settings.modelApiKey') }))
|
|
5446
|
+
editor.busy = true
|
|
5447
|
+
editor.error = ''
|
|
5448
|
+
renderModelSettings()
|
|
5449
|
+
try {
|
|
5450
|
+
const before = editor.userProfile || {}
|
|
5451
|
+
const after = { ...before }
|
|
5452
|
+
if (editor.baseURLDirty) {
|
|
5453
|
+
if (editor.baseURL) after.baseURL = editor.baseURL
|
|
5454
|
+
else delete after.baseURL
|
|
5455
|
+
}
|
|
5456
|
+
if (editor.apiDirty) {
|
|
5457
|
+
if (editor.api) after.api = editor.api
|
|
5458
|
+
else delete after.api
|
|
5459
|
+
}
|
|
5460
|
+
if (editor.modelsDirty) after.models = models.map(model => cloneModelValue(model))
|
|
5461
|
+
if (editor.settingsNs === 'llm-pi-ai' && editor.keyDraft.trim() && !after.apiKeyEnv) after.apiKeyEnv = editor.keyRef
|
|
5462
|
+
const ops = MODEL_SETTINGS_FIELDS.flatMap(key => {
|
|
5463
|
+
if (!modelSettingsPathChanged(before, after, key)) return []
|
|
5464
|
+
const path = [...editor.settingsPath, key]
|
|
5465
|
+
return after[key] === undefined ? [{ op: 'unset', path }] : [{ op: 'set', path, value: after[key] }]
|
|
5466
|
+
})
|
|
5467
|
+
if (ops.length) {
|
|
5468
|
+
const value = await rpc('settings.mutate', { ns: editor.settingsNs, ops, expectedRevision: editor.namespace.revision })
|
|
5469
|
+
editor.namespace = value
|
|
5470
|
+
}
|
|
5471
|
+
if (editor.keyDraft.trim()) {
|
|
5472
|
+
await rpc('credentials.set', { ref: editor.keyRef, value: editor.keyDraft.trim() })
|
|
5473
|
+
} else if (editor.clearKey && editor.keyConfigured && editor.keyRef) {
|
|
5474
|
+
await rpc('credentials.unset', { ref: editor.keyRef })
|
|
5475
|
+
}
|
|
5476
|
+
state.modelEditor = null
|
|
5477
|
+
await loadModelSettings(true)
|
|
5478
|
+
toast(t('settings.modelSaved'), 'ok')
|
|
5479
|
+
} catch (error) {
|
|
5480
|
+
editor.error = t('settings.modelSaveFailed', { msg: error?.message || String(error) })
|
|
5481
|
+
} finally {
|
|
5482
|
+
if (state.modelEditor === editor) {
|
|
5483
|
+
editor.busy = false
|
|
5484
|
+
renderModelSettings()
|
|
5485
|
+
}
|
|
5486
|
+
}
|
|
5487
|
+
}
|
|
5488
|
+
|
|
5489
|
+
function handleModelSettingsClick(event) {
|
|
5490
|
+
const action = event.target.closest('[data-model-action]')
|
|
5491
|
+
if (!action) return
|
|
5492
|
+
const type = action.dataset.modelAction
|
|
5493
|
+
if (type === 'edit') return openModelEditor(action.dataset.modelProvider)
|
|
5494
|
+
if (type === 'cancel') { state.modelEditor = null; renderModelSettings(); return }
|
|
5495
|
+
if (type === 'save') return void saveModelEditor()
|
|
5496
|
+
const editor = state.modelEditor
|
|
5497
|
+
if (!editor) return
|
|
5498
|
+
if (type === 'add-model') {
|
|
5499
|
+
collectModelEditorForm(); editor.models.push({ id: '' }); editor.modelsDirty = true; renderModelSettings(); return
|
|
5500
|
+
}
|
|
5501
|
+
if (type === 'remove-model') {
|
|
5502
|
+
collectModelEditorForm(); editor.models.splice(Number(action.dataset.modelIndex), 1); editor.modelsDirty = true; renderModelSettings(); return
|
|
5503
|
+
}
|
|
5504
|
+
if (type === 'add-reasoning') {
|
|
5505
|
+
collectModelEditorForm()
|
|
5506
|
+
const index = Number(action.dataset.modelIndex)
|
|
5507
|
+
const model = editor.models[index]
|
|
5508
|
+
if (!model) return
|
|
5509
|
+
const rows = modelReasoningRows(model)
|
|
5510
|
+
if (rows.length >= MODEL_REASONING_LIMIT) return setModelEditorError(t('settings.modelReasoningLimit', { count: MODEL_REASONING_LIMIT }))
|
|
5511
|
+
rows.push({ id: '', name: '', description: '' })
|
|
5512
|
+
editor.models[index] = withModelReasoning(model, rows, modelObjectAt(model, ['reasoning']).defaultEffort || '')
|
|
5513
|
+
editor.modelsDirty = true
|
|
5514
|
+
renderModelSettings()
|
|
5515
|
+
return
|
|
5516
|
+
}
|
|
5517
|
+
if (type === 'remove-reasoning') {
|
|
5518
|
+
collectModelEditorForm()
|
|
5519
|
+
const index = Number(action.dataset.modelIndex)
|
|
5520
|
+
const effortIndex = Number(action.dataset.reasoningIndex)
|
|
5521
|
+
const model = editor.models[index]
|
|
5522
|
+
if (!model) return
|
|
5523
|
+
const rows = modelReasoningRows(model)
|
|
5524
|
+
rows.splice(effortIndex, 1)
|
|
5525
|
+
editor.models[index] = withModelReasoning(model, rows, modelObjectAt(model, ['reasoning']).defaultEffort || '')
|
|
5526
|
+
editor.modelsDirty = true
|
|
5527
|
+
renderModelSettings()
|
|
5528
|
+
return
|
|
5529
|
+
}
|
|
5530
|
+
if (type === 'clear-reasoning') {
|
|
5531
|
+
collectModelEditorForm()
|
|
5532
|
+
const index = Number(action.dataset.modelIndex)
|
|
5533
|
+
const model = editor.models[index]
|
|
5534
|
+
if (!model) return
|
|
5535
|
+
editor.models[index] = withModelReasoning(model, [], '')
|
|
5536
|
+
editor.modelsDirty = true
|
|
5537
|
+
renderModelSettings()
|
|
5538
|
+
return
|
|
5539
|
+
}
|
|
5540
|
+
if (type === 'clear-key') { collectModelEditorForm(); editor.keyDraft = ''; editor.clearKey = true; renderModelSettings(); return }
|
|
5541
|
+
if (type === 'discover') return void discoverModelSettings()
|
|
5542
|
+
if (type === 'add-selected') return addDiscoveredModels()
|
|
5543
|
+
if (type === 'select-all') {
|
|
5544
|
+
const ids = (editor.discovered || []).map(model => model.id)
|
|
5545
|
+
editor.discoverySelected = editor.discoverySelected.size === ids.length ? new Set() : new Set(ids)
|
|
5546
|
+
renderModelSettings()
|
|
5547
|
+
}
|
|
5548
|
+
}
|
|
5549
|
+
|
|
5550
|
+
function handleModelSettingsInput(event) {
|
|
5551
|
+
const editor = state.modelEditor
|
|
5552
|
+
if (!editor) return
|
|
5553
|
+
const field = event.target.dataset.modelField
|
|
5554
|
+
if (field === 'baseURL') editor.baseURLDirty = true
|
|
5555
|
+
if (field === 'api') editor.apiDirty = true
|
|
5556
|
+
if (field === 'model-id' || field === 'model-name' || field?.startsWith('reasoning-')) editor.modelsDirty = true
|
|
5557
|
+
}
|
|
5558
|
+
|
|
5559
|
+
async function openModelConfigDocument() {
|
|
5560
|
+
const value = await safeRpc('settings.openDocument', {}, t('settings.modelOpenConfigFailed'))
|
|
5561
|
+
if (value?.opened) toast(t('settings.modelOpenedConfig'), 'ok')
|
|
5562
|
+
}
|
|
4614
5563
|
/* ---------------- 视图切换 ---------------- */
|
|
4615
5564
|
function showView(id) {
|
|
4616
5565
|
for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-stats', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
|
|
@@ -4627,7 +5576,7 @@ function showView(id) {
|
|
|
4627
5576
|
if (id === 'view-settings') showSettingsHome()
|
|
4628
5577
|
}
|
|
4629
5578
|
|
|
4630
|
-
const SETTINGS_GROUPS = ['general', 'servers', 'notify', 'theme', 'about']
|
|
5579
|
+
const SETTINGS_GROUPS = ['general', 'model', 'tests', 'servers', 'notify', 'theme', 'about']
|
|
4631
5580
|
function showSettingsHome() {
|
|
4632
5581
|
const home = $('settings-home')
|
|
4633
5582
|
if (!home) return
|
|
@@ -4641,6 +5590,7 @@ function showSettingsPage(name) {
|
|
|
4641
5590
|
home.classList.add('hidden')
|
|
4642
5591
|
for (const g of SETTINGS_GROUPS) $('settings-page-' + g)?.classList.toggle('hidden', g !== name)
|
|
4643
5592
|
window.scrollTo(0, 0)
|
|
5593
|
+
if (name === 'model') void loadModelSettings()
|
|
4644
5594
|
}
|
|
4645
5595
|
|
|
4646
5596
|
function updateConn() {
|
|
@@ -4765,19 +5715,25 @@ function bindComposerFullscreenGesture() {
|
|
|
4765
5715
|
}
|
|
4766
5716
|
|
|
4767
5717
|
/* ---------------- 初始化 ---------------- */
|
|
4768
|
-
/** 解析 dshremote://pair?token=..&server=..
|
|
5718
|
+
/** 解析 dshremote://pair?token=..&server=.. 配对二维码;server 可重复以携带多个主机地址。 */
|
|
4769
5719
|
function applyPairUrl(url) {
|
|
4770
5720
|
try {
|
|
4771
5721
|
const u = new URL(String(url).trim())
|
|
4772
5722
|
if (u.protocol !== 'dshremote:' || u.hostname !== 'pair') return false
|
|
4773
5723
|
const tok = (u.searchParams.get('token') || '').trim()
|
|
4774
|
-
const
|
|
4775
|
-
|
|
5724
|
+
const servers = [...new Set(u.searchParams.getAll('server')
|
|
5725
|
+
.map(value => value.trim().replace(/\/+$/, ''))
|
|
5726
|
+
.filter(value => /^https?:\/\//i.test(value)))]
|
|
5727
|
+
if (!tok || !servers.length) return false
|
|
4776
5728
|
state.token = tok
|
|
4777
5729
|
LS.set('token', tok)
|
|
4778
|
-
state.server
|
|
4779
|
-
|
|
4780
|
-
|
|
5730
|
+
if (state.server !== servers[0]) resetFsForServer()
|
|
5731
|
+
state.server = servers[0]
|
|
5732
|
+
for (let i = servers.length - 1; i >= 0; i--) {
|
|
5733
|
+
const server = servers[i]
|
|
5734
|
+
if (!state.servers.some(s => s.url === server)) {
|
|
5735
|
+
state.servers.unshift({ id: newServerId(), url: server, note: '', group: state.activeGroup })
|
|
5736
|
+
}
|
|
4781
5737
|
}
|
|
4782
5738
|
saveServers()
|
|
4783
5739
|
renderServers()
|
|
@@ -5091,6 +6047,10 @@ function dshControlFailureText(value) {
|
|
|
5091
6047
|
INVALID_SERVICE: 'settings.dshErrorInvalidService',
|
|
5092
6048
|
SYSTEMCTL_NOT_FOUND: 'settings.dshErrorSystemctlNotFound',
|
|
5093
6049
|
SYSTEMD_UNAVAILABLE: 'settings.dshErrorSystemdUnavailable',
|
|
6050
|
+
SERVICE_CONTROL_NOT_FOUND: 'settings.dshErrorServiceControlNotFound',
|
|
6051
|
+
SERVICE_DISABLED: 'settings.dshErrorServiceDisabled',
|
|
6052
|
+
SERVICE_STOP_TIMEOUT: 'settings.dshErrorServiceStopTimeout',
|
|
6053
|
+
STATUS_PARSE_FAILED: 'settings.dshErrorStatusParseFailed',
|
|
5094
6054
|
PERMISSION_DENIED: 'settings.dshErrorPermissionDenied',
|
|
5095
6055
|
COMMAND_TIMEOUT: 'settings.dshErrorCommandTimeout',
|
|
5096
6056
|
COMMAND_FAILED: 'settings.dshErrorCommandFailed',
|
|
@@ -5201,7 +6161,10 @@ function renderDshControlStatus(value) {
|
|
|
5201
6161
|
async function loadDshControl() {
|
|
5202
6162
|
if (!state.token || !$('dsh-control-desc')) return
|
|
5203
6163
|
if (activeGatewayCapability('dshLifecycle') === false) {
|
|
5204
|
-
renderDshControlStatus({
|
|
6164
|
+
renderDshControlStatus({
|
|
6165
|
+
supported: false,
|
|
6166
|
+
message: activeGatewayHealth()?.dshControl?.message || t('settings.dshUnsupported')
|
|
6167
|
+
})
|
|
5205
6168
|
return
|
|
5206
6169
|
}
|
|
5207
6170
|
try {
|
|
@@ -5325,6 +6288,8 @@ function bindUi() {
|
|
|
5325
6288
|
renderAnnouncementBoard()
|
|
5326
6289
|
renderPending(); renderQueue(); renderJobs()
|
|
5327
6290
|
updateConn()
|
|
6291
|
+
if (state.modelSettings.status === 'ready' || state.modelSettings.status === 'error' || state.modelSettings.status === 'loading') renderModelSettings()
|
|
6292
|
+
renderAsrTest()
|
|
5328
6293
|
if (state.current) { renderSessionTitle(); renderSessionSub(); renderSessionCards(); renderHistory(true) }
|
|
5329
6294
|
else renderModelMenu()
|
|
5330
6295
|
loadLocalVersion()
|
|
@@ -5418,7 +6383,7 @@ function bindUi() {
|
|
|
5418
6383
|
const session = e.target.closest('[data-wb-session]')
|
|
5419
6384
|
if (session) openSession(session.dataset.wbSession)
|
|
5420
6385
|
})
|
|
5421
|
-
$('btn-back').addEventListener('click', closeSession)
|
|
6386
|
+
$('btn-back').addEventListener('click', () => { void closeSession() })
|
|
5422
6387
|
$('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
|
|
5423
6388
|
$('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
|
|
5424
6389
|
$('btn-refresh').addEventListener('click', () => { toast(t('common.refreshing')); openStreams(); refreshAll() })
|
|
@@ -5478,12 +6443,20 @@ function bindUi() {
|
|
|
5478
6443
|
renderSessions()
|
|
5479
6444
|
})
|
|
5480
6445
|
$('fs-workspace').addEventListener('change', (e) => {
|
|
5481
|
-
|
|
6446
|
+
const rootMatch = /^__fs_root__:(\d+)$/.exec(e.target.value)
|
|
6447
|
+
if (rootMatch) {
|
|
6448
|
+
state.fs.rootIndex = Math.min(Number(rootMatch[1]), Math.max(0, state.fs.roots.length - 1))
|
|
6449
|
+
state.fs.workspaceId = ''
|
|
6450
|
+
} else {
|
|
6451
|
+
state.fs.rootIndex = 0
|
|
6452
|
+
state.fs.workspaceId = e.target.value
|
|
6453
|
+
}
|
|
5482
6454
|
if (state.fs.workspaceId) LS.set('fsWorkspaceIdV1', state.fs.workspaceId)
|
|
5483
6455
|
else LS.del('fsWorkspaceIdV1')
|
|
5484
6456
|
state.fs.loaded = false
|
|
5485
6457
|
const workspace = workspaceById(state.fs.workspaceId)
|
|
5486
|
-
|
|
6458
|
+
const rootPath = !workspace && rootMatch ? state.fs.roots[state.fs.rootIndex] : null
|
|
6459
|
+
loadFs(workspace?.path || rootPath || null, { resetRoot: true })
|
|
5487
6460
|
})
|
|
5488
6461
|
$('file-preview-close').addEventListener('click', closeFsPreview)
|
|
5489
6462
|
$('file-preview-done').addEventListener('click', closeFsPreview)
|
|
@@ -5625,6 +6598,17 @@ function bindUi() {
|
|
|
5625
6598
|
if (group) { showSettingsPage(group.dataset.settingsGroup); return }
|
|
5626
6599
|
if (e.target.closest('[data-settings-back]')) { showSettingsHome(); return }
|
|
5627
6600
|
})
|
|
6601
|
+
$('btn-model-settings-refresh')?.addEventListener('click', () => loadModelSettings(true))
|
|
6602
|
+
$('btn-model-settings-open')?.addEventListener('click', openModelConfigDocument)
|
|
6603
|
+
$('model-settings-list')?.addEventListener('click', handleModelSettingsClick)
|
|
6604
|
+
$('model-settings-list')?.addEventListener('input', handleModelSettingsInput)
|
|
6605
|
+
$('btn-asr-test-start')?.addEventListener('click', startAsrTest)
|
|
6606
|
+
$('btn-asr-test-stop')?.addEventListener('click', stopAsrTest)
|
|
6607
|
+
$('btn-asr-test-copy')?.addEventListener('click', copyAsrTestLog)
|
|
6608
|
+
$('btn-asr-test-clear')?.addEventListener('click', clearAsrTest)
|
|
6609
|
+
$('btn-asr-test-permission')?.addEventListener('click', openAsrPermissionSettings)
|
|
6610
|
+
$('btn-asr-test-engine')?.addEventListener('click', openAsrEngineSettings)
|
|
6611
|
+
renderAsrTest()
|
|
5628
6612
|
$('btn-scan-camera').addEventListener('click', () => scanPair('CAMERA'))
|
|
5629
6613
|
$('btn-scan-gallery').addEventListener('click', () => scanPair('PHOTOS'))
|
|
5630
6614
|
$('scan-live-cancel')?.addEventListener('click', () => closeLiveScan(''))
|
|
@@ -5644,11 +6628,7 @@ function bindUi() {
|
|
|
5644
6628
|
if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); addServer() }
|
|
5645
6629
|
})
|
|
5646
6630
|
$('btn-host-describe').addEventListener('click', async () => {
|
|
5647
|
-
|
|
5648
|
-
if (v) {
|
|
5649
|
-
state.hostInfo = v
|
|
5650
|
-
$('host-desc').textContent = t('settings.hostDesc', { version: v.version, cwd: v.cwd, n: v.attachedSessions })
|
|
5651
|
-
}
|
|
6631
|
+
await refreshHostDescription({ notify: true })
|
|
5652
6632
|
})
|
|
5653
6633
|
$('btn-dsh-start')?.addEventListener('click', () => controlDsh('start'))
|
|
5654
6634
|
$('btn-dsh-restart')?.addEventListener('click', () => controlDsh('restart'))
|
|
@@ -5817,8 +6797,7 @@ async function boot() {
|
|
|
5817
6797
|
await maybeWarnAppBehindGateway({ probe: true })
|
|
5818
6798
|
openStreams()
|
|
5819
6799
|
await refreshAll()
|
|
5820
|
-
|
|
5821
|
-
if (host) { state.hostInfo = host; $('host-desc').textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions }) }
|
|
6800
|
+
await refreshHostDescription()
|
|
5822
6801
|
loadDshControl()
|
|
5823
6802
|
}
|
|
5824
6803
|
// 网关从中央 HTTPS 公告源读取并在不可达时回退内置文件。前台每 30 秒检查,
|