dsh-remote-plugin 0.6.14 → 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 +314 -30
- 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 +1103 -62
- package/public/desktop/desktop.css +22 -7
- package/public/desktop/desktop.html +24 -4
- package/public/desktop/desktop.js +275 -26
- package/public/index.html +120 -15
- package/public/morphicons-init.js +41 -0
- package/public/motion.js +469 -0
- package/public/plugin.html +4 -1
- package/public/plugin.js +1 -0
- package/public/styles.css +96 -6
- package/public/update.json +12 -12
- package/public/vendor/gsap/NOTICE.md +10 -0
- package/public/vendor/gsap/gsap.min.js +11 -0
- package/public/vendor/morphicons/LICENSE +21 -0
- package/public/vendor/morphicons/README.md +10 -0
- package/public/vendor/morphicons/controller-CXZuwJ_M.js +152 -0
- package/public/vendor/morphicons/dom.js +206 -0
- package/public/vendor/morphicons/element.js +261 -0
- package/public/vendor/morphicons/normalize-CYnN3Npw.js +540 -0
- package/public/vendor/morphicons/spring-CFHloqPP.js +623 -0
- package/public/version.json +1 -1
package/public/app.js
CHANGED
|
@@ -64,6 +64,9 @@ const state = {
|
|
|
64
64
|
workspaceFilter: LS.get('workspaceFilterV1', ''),
|
|
65
65
|
byId: new Map(),
|
|
66
66
|
current: null, // 当前打开的 sessionId
|
|
67
|
+
sessionRecovery: { status: 'idle', error: '' },
|
|
68
|
+
pendingProjections: new Map(),
|
|
69
|
+
lastStreamResyncAt: 0,
|
|
67
70
|
hostInfo: null,
|
|
68
71
|
localVersion: '',
|
|
69
72
|
updateInfo: null,
|
|
@@ -76,6 +79,8 @@ const state = {
|
|
|
76
79
|
queueSteering: {}, // sessionId:itemId -> pending steer request
|
|
77
80
|
sessionTurnTimes: {}, // sessionId -> 本轮开始/结束时间,避免中间事件推动排序
|
|
78
81
|
jobs: {}, // sessionId -> jobs
|
|
82
|
+
sessionActivity: new Set(), // 已发送消息或已执行命令的会话
|
|
83
|
+
pendingPrompts: new Set(), // 正在提交消息的会话
|
|
79
84
|
history: emptyHistory(),
|
|
80
85
|
errCount: 0,
|
|
81
86
|
streamInfo: {
|
|
@@ -88,6 +93,9 @@ const state = {
|
|
|
88
93
|
fs: { path: null, initial: null, loaded: false, upload: null, workspaceId: LS.get('fsWorkspaceIdV1', ''), preview: null },
|
|
89
94
|
composerImages: [], // 当前草稿中的图片附件:只在发送成功后释放
|
|
90
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: [] },
|
|
91
99
|
wb: null,
|
|
92
100
|
wbProjects: [],
|
|
93
101
|
wbArchived: [],
|
|
@@ -1082,6 +1090,7 @@ function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
|
|
|
1082
1090
|
renderPending()
|
|
1083
1091
|
}
|
|
1084
1092
|
if (refreshOnOpen) refreshAll()
|
|
1093
|
+
if (allStreamsOpen()) resyncAfterStreamOpen()
|
|
1085
1094
|
}
|
|
1086
1095
|
ws.onmessage = (msg) => {
|
|
1087
1096
|
if (!streamIsCurrent(kind, ws, generation)) return
|
|
@@ -1168,9 +1177,9 @@ async function pollKind(kind) {
|
|
|
1168
1177
|
const since = state.pollSeq[kind] || 0
|
|
1169
1178
|
let res
|
|
1170
1179
|
try {
|
|
1171
|
-
const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(
|
|
1180
|
+
const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(30000) : undefined
|
|
1172
1181
|
const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }
|
|
1173
|
-
res = signal ? await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}`), { signal, headers }) : await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}`), { headers })
|
|
1182
|
+
res = signal ? await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}&wait=25000`), { signal, headers }) : await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}&wait=25000`), { headers })
|
|
1174
1183
|
} catch { return }
|
|
1175
1184
|
if (res.status === 401) { authFailure(); return }
|
|
1176
1185
|
if (!res.ok) return
|
|
@@ -1188,6 +1197,7 @@ async function pollKind(kind) {
|
|
|
1188
1197
|
renderPending()
|
|
1189
1198
|
}
|
|
1190
1199
|
scheduleRefresh()
|
|
1200
|
+
if (state.current) void resyncCurrentSession()
|
|
1191
1201
|
}
|
|
1192
1202
|
for (const item of data.events) {
|
|
1193
1203
|
if (item.seq > (state.pollSeq[kind] || 0)) {
|
|
@@ -1346,24 +1356,90 @@ async function refreshSessions() {
|
|
|
1346
1356
|
}
|
|
1347
1357
|
state.sessions = v.items || []
|
|
1348
1358
|
state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
|
|
1359
|
+
applyPendingProjections()
|
|
1349
1360
|
cacheWrite(CACHE.sessions, state.sessions.slice(0, 80))
|
|
1350
1361
|
renderSessions()
|
|
1351
1362
|
refreshWorkbench()
|
|
1352
1363
|
}
|
|
1353
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
|
+
|
|
1354
1382
|
function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
|
|
1383
|
+
function hydrateSessionProjections(sessionId, projections) {
|
|
1384
|
+
const s = state.byId.get(sessionId)
|
|
1385
|
+
if (!s || !projections || typeof projections !== 'object') return
|
|
1386
|
+
const incomingSeq = Number(projections.asOfSeq) || 0
|
|
1387
|
+
const current = s.projections || { asOfSeq: 0, values: {} }
|
|
1388
|
+
const currentSeq = Number(current.asOfSeq) || 0
|
|
1389
|
+
if (incomingSeq < currentSeq) return
|
|
1390
|
+
s.projections = {
|
|
1391
|
+
asOfSeq: Math.max(currentSeq, incomingSeq),
|
|
1392
|
+
values: { ...(current.values || {}), ...(projections.values || {}) }
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
function applyPendingProjections() {
|
|
1396
|
+
for (const [sessionId, projections] of state.pendingProjections) {
|
|
1397
|
+
if (!state.byId.has(sessionId)) continue
|
|
1398
|
+
hydrateSessionProjections(sessionId, projections)
|
|
1399
|
+
state.pendingProjections.delete(sessionId)
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1355
1402
|
function applyProjection(sessionId, key, value, seq) {
|
|
1356
1403
|
const s = state.byId.get(sessionId)
|
|
1357
|
-
if (s) {
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1404
|
+
if (!s) {
|
|
1405
|
+
const pending = state.pendingProjections.get(sessionId) || { asOfSeq: 0, values: {} }
|
|
1406
|
+
pending.values[key] = value
|
|
1407
|
+
pending.asOfSeq = Math.max(pending.asOfSeq || 0, seq || 0)
|
|
1408
|
+
state.pendingProjections.set(sessionId, pending)
|
|
1409
|
+
return
|
|
1362
1410
|
}
|
|
1411
|
+
const currentSeq = Number(s.projections?.asOfSeq) || 0
|
|
1412
|
+
if (seq && seq < currentSeq) return
|
|
1413
|
+
s.projections = s.projections || { asOfSeq: 0, values: {} }
|
|
1414
|
+
s.projections.values = s.projections.values || {}
|
|
1415
|
+
s.projections.values[key] = value
|
|
1416
|
+
s.projections.asOfSeq = Math.max(currentSeq, seq || 0)
|
|
1363
1417
|
if (state.current === sessionId) { renderSessionTitle(); renderSessionCards() }
|
|
1364
1418
|
if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) scheduleRefresh()
|
|
1365
1419
|
else renderSessions()
|
|
1366
1420
|
}
|
|
1421
|
+
function setSessionRecovery(status, error = '') {
|
|
1422
|
+
state.sessionRecovery = { status, error: String(error || '') }
|
|
1423
|
+
if (state.current) { renderSessionSub(); updateSessionStatus() }
|
|
1424
|
+
}
|
|
1425
|
+
function recoveryLabel() {
|
|
1426
|
+
const status = state.sessionRecovery.status
|
|
1427
|
+
if (status === 'loading' || status === 'resuming') return t('session.recovering')
|
|
1428
|
+
if (status === 'cached') return t('session.recoveryCached')
|
|
1429
|
+
if (status === 'error') return t('session.recoveryFailed')
|
|
1430
|
+
return ''
|
|
1431
|
+
}
|
|
1432
|
+
function resyncCurrentSession() {
|
|
1433
|
+
if (!state.current) return Promise.resolve()
|
|
1434
|
+
return loadHistory(true).then(() => {
|
|
1435
|
+
if (state.current) { renderSessionCards(); renderSessionSub(); updateCancelBtn(); updateSessionStatus() }
|
|
1436
|
+
})
|
|
1437
|
+
}
|
|
1438
|
+
function resyncAfterStreamOpen() {
|
|
1439
|
+
if (!state.current || Date.now() - state.lastStreamResyncAt < 1200) return
|
|
1440
|
+
state.lastStreamResyncAt = Date.now()
|
|
1441
|
+
void refreshAll().then(() => resyncCurrentSession())
|
|
1442
|
+
}
|
|
1367
1443
|
function titleOf(s) { return proj(s, 'title') || (s?.sessionId ? short(s.sessionId) : t('session.unknown')) }
|
|
1368
1444
|
function short(id) { return '…' + String(id).slice(-8) }
|
|
1369
1445
|
function isTopLevelSession(session) {
|
|
@@ -1415,6 +1491,63 @@ const WORKSPACE_UNGROUPED = '__ungrouped__'
|
|
|
1415
1491
|
function workspaceItems() {
|
|
1416
1492
|
return (state.wbProjects || []).filter(w => w && typeof w.workspaceId === 'string' && w.workspaceId && typeof w.path === 'string' && w.path)
|
|
1417
1493
|
}
|
|
1494
|
+
const WORKBENCH_ORDER_CACHE_KEY = 'workbenchOrderV1'
|
|
1495
|
+
function workbenchOrderScope() { return String(state.server || location.origin || 'default') }
|
|
1496
|
+
function workbenchOrderStore() {
|
|
1497
|
+
const value = cacheRead(WORKBENCH_ORDER_CACHE_KEY, {})
|
|
1498
|
+
if (!value || typeof value !== 'object') return { scopes: {} }
|
|
1499
|
+
if (!value.scopes || typeof value.scopes !== 'object') value.scopes = {}
|
|
1500
|
+
return value
|
|
1501
|
+
}
|
|
1502
|
+
function workbenchOrderScopeValue() {
|
|
1503
|
+
const store = workbenchOrderStore()
|
|
1504
|
+
const key = workbenchOrderScope()
|
|
1505
|
+
if (!store.scopes[key] || typeof store.scopes[key] !== 'object') store.scopes[key] = {}
|
|
1506
|
+
return { store, value: store.scopes[key] }
|
|
1507
|
+
}
|
|
1508
|
+
function orderedItems(items, ids, getId) {
|
|
1509
|
+
const source = Array.isArray(items) ? items : []
|
|
1510
|
+
const byId = new Map(source.map(item => [String(getId(item)), item]))
|
|
1511
|
+
const result = []
|
|
1512
|
+
const used = new Set()
|
|
1513
|
+
for (const id of Array.isArray(ids) ? ids : []) {
|
|
1514
|
+
const key = String(id)
|
|
1515
|
+
const item = byId.get(key)
|
|
1516
|
+
if (item && !used.has(key)) { result.push(item); used.add(key) }
|
|
1517
|
+
}
|
|
1518
|
+
for (const item of source) {
|
|
1519
|
+
const key = String(getId(item))
|
|
1520
|
+
if (!used.has(key)) { result.push(item); used.add(key) }
|
|
1521
|
+
}
|
|
1522
|
+
return result
|
|
1523
|
+
}
|
|
1524
|
+
function orderedWorkspaceItems(items) {
|
|
1525
|
+
const { value } = workbenchOrderScopeValue()
|
|
1526
|
+
return orderedItems(items, value.workspaceIds, item => item.workspaceId)
|
|
1527
|
+
}
|
|
1528
|
+
function orderedWorkspaceSessions(workspaceId, items) {
|
|
1529
|
+
const { value } = workbenchOrderScopeValue()
|
|
1530
|
+
return orderedItems(items, value.sessionIds?.[String(workspaceId)], item => item.sessionId)
|
|
1531
|
+
}
|
|
1532
|
+
function saveWorkbenchOrder(mutator) {
|
|
1533
|
+
const { store, value } = workbenchOrderScopeValue()
|
|
1534
|
+
mutator(value)
|
|
1535
|
+
cacheWrite(WORKBENCH_ORDER_CACHE_KEY, store)
|
|
1536
|
+
}
|
|
1537
|
+
function commitWorkspaceOrder(order) {
|
|
1538
|
+
saveWorkbenchOrder(value => { value.workspaceIds = order.map(String) })
|
|
1539
|
+
renderWorkbench()
|
|
1540
|
+
toast(t('wb.orderSaved'), 'ok')
|
|
1541
|
+
}
|
|
1542
|
+
function commitWorkspaceSessionOrder(workspaceId, order) {
|
|
1543
|
+
if (!workspaceId) return
|
|
1544
|
+
saveWorkbenchOrder(value => {
|
|
1545
|
+
value.sessionIds ||= {}
|
|
1546
|
+
value.sessionIds[String(workspaceId)] = order.map(String)
|
|
1547
|
+
})
|
|
1548
|
+
renderWorkbench()
|
|
1549
|
+
toast(t('wb.orderSaved'), 'ok')
|
|
1550
|
+
}
|
|
1418
1551
|
function workspaceById(workspaceId) {
|
|
1419
1552
|
return workspaceItems().find(w => w.workspaceId === workspaceId) || null
|
|
1420
1553
|
}
|
|
@@ -1541,32 +1674,48 @@ function renderWorkbench() {
|
|
|
1541
1674
|
toggle.setAttribute('aria-expanded', state.wbOpen ? 'true' : 'false')
|
|
1542
1675
|
panel.classList.toggle('hidden', !state.wbOpen)
|
|
1543
1676
|
if (!state.wbOpen) { panel.innerHTML = ''; return }
|
|
1544
|
-
const projects = state.wbProjects.filter(w => wbStrictInside(w.path, workbenchRoot()))
|
|
1677
|
+
const projects = orderedWorkspaceItems(state.wbProjects.filter(w => wbStrictInside(w.path, workbenchRoot())))
|
|
1545
1678
|
if (!projects.length) {
|
|
1546
1679
|
panel.innerHTML = `<div class="wb-empty">${esc(t('wb.noProjects'))}</div>`
|
|
1547
1680
|
return
|
|
1548
1681
|
}
|
|
1549
1682
|
const archivedSet = new Set(state.wbArchived || [])
|
|
1550
|
-
|
|
1683
|
+
const projectHtml = projects.map(w => {
|
|
1551
1684
|
const id = String(w.workspaceId || '')
|
|
1552
1685
|
const open = !!state.wbOpenProjects[id]
|
|
1553
|
-
const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isTopLevelSession).filter(s => !archivedSet.has(s.sessionId))
|
|
1686
|
+
const sessions = orderedWorkspaceSessions(id, (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isTopLevelSession).filter(s => !archivedSet.has(s.sessionId)))
|
|
1554
1687
|
const body = open ? `<div class="wb-sessions">${sessions.length ? sessions.map(s => `
|
|
1555
1688
|
<div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
|
|
1556
|
-
<button class="wb-session" type="button" data-wb-session="${esc(s.sessionId)}">
|
|
1689
|
+
<button class="wb-session" type="button" data-wb-session="${esc(s.sessionId)}" data-motion-key="${esc(s.sessionId)}">
|
|
1690
|
+
<span class="wb-session-drag-handle" data-reorder-handle aria-hidden="true">⠿</span>
|
|
1557
1691
|
<span class="wb-session-title">${esc(titleOf(s))}</span>
|
|
1558
1692
|
<span class="wb-session-meta">${s.running ? esc(t('sessions.running')) : esc(fmtTime(sessionSortTime(s)))}</span>
|
|
1559
1693
|
</button>
|
|
1560
1694
|
<button type="button" class="sc-archive-btn" data-archive-session="${esc(s.sessionId)}">${esc(t('session.archive'))}</button>
|
|
1561
1695
|
</div>`).join('') : `<div class="wb-empty">${esc(t('wb.noSessions'))}</div>`}</div>` : ''
|
|
1562
|
-
return `<div class="wb-project ${open ? 'open' : ''}" data-wb-project="${esc(id)}">
|
|
1696
|
+
return `<div class="wb-project ${open ? 'open' : ''}" data-wb-project="${esc(id)}" data-motion-key="${esc(id)}">
|
|
1563
1697
|
<div class="wb-project-head">
|
|
1698
|
+
<span class="wb-drag-handle" data-reorder-handle aria-hidden="true">⠿</span>
|
|
1564
1699
|
<span class="wb-chevron" aria-hidden="true">${open ? '▾' : '▸'}</span>
|
|
1565
1700
|
<span class="wb-project-title">${esc(w.title || wbBaseName(w.path) || w.path)}</span>
|
|
1566
1701
|
<button class="mini-btn wb-new" type="button" data-wb-new="${esc(id)}">${esc(t('wb.newSession'))}</button>
|
|
1567
1702
|
</div>${body}
|
|
1568
1703
|
</div>`
|
|
1569
1704
|
}).join('')
|
|
1705
|
+
if (window.DshMotion?.relayout) {
|
|
1706
|
+
window.DshMotion.relayout(panel, '.wb-project', () => { panel.innerHTML = projectHtml })
|
|
1707
|
+
} else panel.innerHTML = projectHtml
|
|
1708
|
+
window.DshMotion?.list(panel, '.wb-session')
|
|
1709
|
+
window.DshMotion?.bindLongPressReorder(panel, '.wb-project', {
|
|
1710
|
+
handleSelector: '.wb-project-head',
|
|
1711
|
+
excludeSelector: '[data-wb-new]',
|
|
1712
|
+
onCommit: ({ order }) => commitWorkspaceOrder(order)
|
|
1713
|
+
})
|
|
1714
|
+
window.DshMotion?.bindLongPressReorder(panel, '.session-swipe', {
|
|
1715
|
+
groupSelector: '.wb-project',
|
|
1716
|
+
handleSelector: '.wb-session',
|
|
1717
|
+
onCommit: ({ item, order }) => commitWorkspaceSessionOrder(item.closest('[data-wb-project]')?.dataset.wbProject, order)
|
|
1718
|
+
})
|
|
1570
1719
|
}
|
|
1571
1720
|
|
|
1572
1721
|
function sessionCwd(s) { return typeof s?.cwd === 'string' ? s.cwd.trim() : '' }
|
|
@@ -1594,6 +1743,16 @@ function noteSessionTurnTime(sessionId, eventOrTime) {
|
|
|
1594
1743
|
if (!sessionId || !Number.isFinite(time)) return
|
|
1595
1744
|
state.sessionTurnTimes[sessionId] = Math.max(Number(state.sessionTurnTimes[sessionId]) || 0, time)
|
|
1596
1745
|
}
|
|
1746
|
+
function sessionWorkspaceOrderKey(s) {
|
|
1747
|
+
const workspace = workspaceForSession(s)
|
|
1748
|
+
return String(workspace?.workspaceId || 'path:' + (sessionWorkspaceLabel(s) || WORKSPACE_UNGROUPED))
|
|
1749
|
+
}
|
|
1750
|
+
function commitWorkspaceGroupOrder(order) {
|
|
1751
|
+
saveWorkbenchOrder(value => { value.workspaceIds = order.map(String) })
|
|
1752
|
+
renderSessions()
|
|
1753
|
+
renderWorkbench()
|
|
1754
|
+
toast(t('wb.orderSaved'), 'ok')
|
|
1755
|
+
}
|
|
1597
1756
|
function sortedSessions() {
|
|
1598
1757
|
const items = topLevelSessions()
|
|
1599
1758
|
if (state.sessionSort === 'workspace') {
|
|
@@ -1618,16 +1777,9 @@ function renderSessions() {
|
|
|
1618
1777
|
const archived = visible.filter(s => archivedSet.has(s.sessionId))
|
|
1619
1778
|
const main = visible.filter(s => !archivedSet.has(s.sessionId))
|
|
1620
1779
|
const showArchived = LS.get('showArchivedV1', '0') === '1'
|
|
1621
|
-
const
|
|
1622
|
-
let lastWorkspace = null
|
|
1623
|
-
const rows = []
|
|
1624
|
-
for (const s of items) {
|
|
1780
|
+
const renderSession = s => {
|
|
1625
1781
|
const workspace = sessionWorkspaceLabel(s)
|
|
1626
1782
|
const workspaceTitle = sessionWorkspaceName(s)
|
|
1627
|
-
if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
|
|
1628
|
-
rows.push(`<div class="session-group-label" title="${esc(workspace)}"><span class="session-group-icon" aria-hidden="true">⌂</span><span class="session-group-name">${esc(workspaceTitle)}</span></div>`)
|
|
1629
|
-
lastWorkspace = workspace
|
|
1630
|
-
}
|
|
1631
1783
|
const title = titleOf(s)
|
|
1632
1784
|
const goal = goalOf(s)
|
|
1633
1785
|
const pending = (state.approvals.some(a => a.sessionId === s.sessionId) || state.questions.some(q => q.sessionId === s.sessionId)) ? 'pending' : ''
|
|
@@ -1638,7 +1790,7 @@ function renderSessions() {
|
|
|
1638
1790
|
const badge = goal ? `<span class="sc-badge ${goal.phase === 'active' ? 'goal-active' : ''}">${esc(t('sessions.goalBadge', { phase: goal.phase || '?' }))}</span>` : ''
|
|
1639
1791
|
const queueBadge = queueN ? `<span class="sc-badge">${esc(t('sessions.queueBadge', { n: queueN }))}</span>` : ''
|
|
1640
1792
|
const archiveButton = archivedSet.has(s.sessionId) ? '' : `<button type="button" class="sc-archive-btn" data-archive-session="${esc(s.sessionId)}">${esc(t('session.archive'))}</button>`
|
|
1641
|
-
|
|
1793
|
+
return `<div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
|
|
1642
1794
|
<div class="session-card ${state.current === s.sessionId ? 'current' : ''}">
|
|
1643
1795
|
<div class="sc-title">${esc(title)}</div>
|
|
1644
1796
|
<div class="sc-meta">
|
|
@@ -1651,13 +1803,42 @@ function renderSessions() {
|
|
|
1651
1803
|
<span class="sc-arrow">›</span>
|
|
1652
1804
|
</div>
|
|
1653
1805
|
${archiveButton}
|
|
1654
|
-
</div>`
|
|
1806
|
+
</div>`
|
|
1807
|
+
}
|
|
1808
|
+
const renderItems = (items) => {
|
|
1809
|
+
if (state.sessionSort !== 'workspace') return items.map(renderSession).join('')
|
|
1810
|
+
const groups = []
|
|
1811
|
+
const byKey = new Map()
|
|
1812
|
+
for (const session of items) {
|
|
1813
|
+
const key = sessionWorkspaceOrderKey(session)
|
|
1814
|
+
let group = byKey.get(key)
|
|
1815
|
+
if (!group) {
|
|
1816
|
+
group = { key, label: sessionWorkspaceName(session), path: sessionWorkspaceLabel(session), items: [] }
|
|
1817
|
+
byKey.set(key, group)
|
|
1818
|
+
groups.push(group)
|
|
1819
|
+
}
|
|
1820
|
+
group.items.push(session)
|
|
1655
1821
|
}
|
|
1656
|
-
|
|
1822
|
+
const { value } = workbenchOrderScopeValue()
|
|
1823
|
+
return orderedItems(groups, value.workspaceIds, group => group.key).map(group => `<div class="session-workspace-group" data-workspace-group="${esc(group.key)}" data-motion-key="${esc(group.key)}">
|
|
1824
|
+
<div class="session-group-label" data-reorder-handle title="${esc(group.path)}"><span class="session-group-drag-handle" aria-hidden="true">⠿</span><span class="session-group-icon" aria-hidden="true">⌂</span><span class="session-group-name">${esc(group.label)}</span></div>
|
|
1825
|
+
${orderedWorkspaceSessions(group.key, group.items).map(renderSession).join('')}
|
|
1826
|
+
</div>`).join('')
|
|
1657
1827
|
}
|
|
1658
1828
|
const divider = archived.length ? `<button class="archived-toggle" type="button" data-archived-toggle>${esc(showArchived ? t('wb.archivedShown') : t('wb.archivedHidden'))}</button>` : ''
|
|
1659
1829
|
const rows = renderItems(main) + divider + (showArchived ? renderItems(archived) : '')
|
|
1660
|
-
list.innerHTML = rows || `<div class="empty">${esc(t('home.empty'))}</div>`
|
|
1830
|
+
const renderList = () => { list.innerHTML = rows || `<div class="empty">${esc(t('home.empty'))}</div>` }
|
|
1831
|
+
if (window.DshMotion?.relayout) window.DshMotion.relayout(list, '.session-swipe', renderList)
|
|
1832
|
+
else renderList()
|
|
1833
|
+
window.DshMotion?.bindLongPressReorder(list, '.session-workspace-group', {
|
|
1834
|
+
handleSelector: '.session-group-label',
|
|
1835
|
+
onCommit: ({ order }) => commitWorkspaceGroupOrder(order)
|
|
1836
|
+
})
|
|
1837
|
+
window.DshMotion?.bindLongPressReorder(list, '.session-swipe', {
|
|
1838
|
+
groupSelector: '.session-workspace-group',
|
|
1839
|
+
handleSelector: '.session-card',
|
|
1840
|
+
onCommit: ({ item, order }) => commitWorkspaceSessionOrder(item.closest('[data-workspace-group]')?.dataset.workspaceGroup, order)
|
|
1841
|
+
})
|
|
1661
1842
|
list.classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
|
|
1662
1843
|
const sort = $('session-sort')
|
|
1663
1844
|
if (sort) { sort.value = state.sessionSort; syncCustomSelect(sort) }
|
|
@@ -1674,9 +1855,12 @@ function renderSessions() {
|
|
|
1674
1855
|
/* ---------------- 会话详情 ---------------- */
|
|
1675
1856
|
async function openSession(id) {
|
|
1676
1857
|
state.current = id
|
|
1858
|
+
setSessionRecovery('loading')
|
|
1677
1859
|
state.history = emptyHistory()
|
|
1678
1860
|
document.body.classList.add('in-session')
|
|
1679
1861
|
showView('view-session')
|
|
1862
|
+
$('btn-rename-session').classList.remove('hidden')
|
|
1863
|
+
$('btn-archive-session').classList.toggle('hidden', (state.wbArchived || []).includes(id))
|
|
1680
1864
|
$('session-cards').innerHTML = ''
|
|
1681
1865
|
renderSessionTitle(); renderSessionSub(); updateCancelBtn(); updateSessionStatus()
|
|
1682
1866
|
$('history').innerHTML = '<div class="empty">' + t('history.loading') + '</div>'
|
|
@@ -1687,14 +1871,49 @@ async function openSession(id) {
|
|
|
1687
1871
|
refreshSessions()
|
|
1688
1872
|
}
|
|
1689
1873
|
|
|
1690
|
-
function
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
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
|
+
}
|
|
1698
1917
|
}
|
|
1699
1918
|
|
|
1700
1919
|
/* Android 手势返回/实体返回: 注册后系统不再直接杀 App, 由这里接管导航 */
|
|
@@ -1705,8 +1924,8 @@ function bindNativeBack() {
|
|
|
1705
1924
|
if ($('composer-wrap')?.classList.contains('fs')) { setComposerFullscreen(false); return }
|
|
1706
1925
|
if (customSelectCurrent) { closeCustomSelect(); return }
|
|
1707
1926
|
const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
|
|
1708
|
-
if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else if (openModal.id === 'modal-archive') closeArchiveConfirm(); else if (openModal.id === 'modal-app-version-warning') closeAppVersionWarning(false); else if (openModal.id === 'modal-scan-live') closeLiveScan(''); else openModal.classList.add('hidden'); return } // 先关弹窗
|
|
1709
|
-
if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
|
|
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 } // 先关弹窗
|
|
1928
|
+
if (document.body.classList.contains('in-session')) { void closeSession(); return } // 会话页 → 回主页
|
|
1710
1929
|
if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
|
|
1711
1930
|
if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
|
|
1712
1931
|
showView('view-home'); return
|
|
@@ -1728,6 +1947,8 @@ function renderSessionSub() {
|
|
|
1728
1947
|
if (s.cwd) parts.push(s.cwd)
|
|
1729
1948
|
if (s.running) parts.push(t('session.running'))
|
|
1730
1949
|
else if (s.error) parts.push(t('session.interrupted'))
|
|
1950
|
+
const recovery = recoveryLabel()
|
|
1951
|
+
if (recovery) parts.push(recovery)
|
|
1731
1952
|
$('session-sub').textContent = parts.join(' · ')
|
|
1732
1953
|
}
|
|
1733
1954
|
|
|
@@ -1755,11 +1976,19 @@ const HISTORY_MAX_VISIBLE = 5000 // 已加载的可显示事件上限(消息/
|
|
|
1755
1976
|
function emptyHistory() {
|
|
1756
1977
|
return {
|
|
1757
1978
|
visible: [], seqs: new Set(), minSeq: Infinity,
|
|
1758
|
-
hasMore: false, loading: false, renderStart: 0, renderEnd: 0,
|
|
1979
|
+
hasMore: false, loading: false, loaded: false, renderStart: 0, renderEnd: 0,
|
|
1759
1980
|
partialReasoning: new Map()
|
|
1760
1981
|
}
|
|
1761
1982
|
}
|
|
1762
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
|
+
|
|
1763
1992
|
function reasoningStreamKey(data, index) {
|
|
1764
1993
|
return `${data?.turn ?? '?'}:${data?.step ?? '?'}:${index ?? '?'}`
|
|
1765
1994
|
}
|
|
@@ -1867,6 +2096,7 @@ function restoreCachedHistory() {
|
|
|
1867
2096
|
if (!id) return false
|
|
1868
2097
|
const cached = readHistoryCache()[id]
|
|
1869
2098
|
if (!cached?.events?.length) return false
|
|
2099
|
+
if (cached.title) hydrateSessionProjections(id, { values: { title: cached.title }, asOfSeq: 0 })
|
|
1870
2100
|
const h = emptyHistory()
|
|
1871
2101
|
for (const e of cached.events) {
|
|
1872
2102
|
if (e?.seq == null) continue
|
|
@@ -1874,6 +2104,7 @@ function restoreCachedHistory() {
|
|
|
1874
2104
|
h.visible.push(e)
|
|
1875
2105
|
}
|
|
1876
2106
|
h.visible.sort((a, b) => a.seq - b.seq)
|
|
2107
|
+
h.loaded = true
|
|
1877
2108
|
state.history = h
|
|
1878
2109
|
$('history-hint').textContent = t('history.offlineCache', { n: h.visible.length })
|
|
1879
2110
|
renderHistory(true)
|
|
@@ -1883,7 +2114,9 @@ function restoreCachedHistory() {
|
|
|
1883
2114
|
async function loadHistory(reset) {
|
|
1884
2115
|
const id = state.current
|
|
1885
2116
|
if (!id || state.history.loading) return
|
|
1886
|
-
|
|
2117
|
+
const history = state.history
|
|
2118
|
+
history.loading = true
|
|
2119
|
+
if (reset) setSessionRecovery('loading')
|
|
1887
2120
|
const moreBtn = $('history-more')
|
|
1888
2121
|
if (moreBtn) moreBtn.classList.add('hidden')
|
|
1889
2122
|
const payload = { sessionId: id, maxMessages: 60 }
|
|
@@ -1893,13 +2126,16 @@ async function loadHistory(reset) {
|
|
|
1893
2126
|
try {
|
|
1894
2127
|
v = await rpc('session.history', payload)
|
|
1895
2128
|
} catch (e) {
|
|
1896
|
-
state.history
|
|
2129
|
+
if (state.current !== id || state.history !== history) return
|
|
2130
|
+
history.loading = false
|
|
1897
2131
|
if (e.message === 'AUTH') { authFailure(); return }
|
|
1898
2132
|
if (restoreCachedHistory()) {
|
|
2133
|
+
setSessionRecovery('cached', e.message)
|
|
1899
2134
|
toast(t('history.cacheFallback'), 'ok')
|
|
1900
2135
|
return
|
|
1901
2136
|
}
|
|
1902
2137
|
const msg = e.message || t('err.dshError')
|
|
2138
|
+
setSessionRecovery('error', msg)
|
|
1903
2139
|
const box = $('history')
|
|
1904
2140
|
if (box && (reset || !state.history.visible.length)) {
|
|
1905
2141
|
box.innerHTML = `<div class="empty"><div>${esc(t('history.loadFailed', { msg }))}</div><button type="button" class="mini-btn" id="btn-history-retry" style="margin-top:10px">${esc(t('history.retry'))}</button></div>`
|
|
@@ -1911,6 +2147,9 @@ async function loadHistory(reset) {
|
|
|
1911
2147
|
return
|
|
1912
2148
|
}
|
|
1913
2149
|
|
|
2150
|
+
if (state.current !== id || state.history !== history) return
|
|
2151
|
+
hydrateSessionProjections(id, v.projections)
|
|
2152
|
+
history.loaded = true
|
|
1914
2153
|
const incoming = v.events || []
|
|
1915
2154
|
let added = 0
|
|
1916
2155
|
if (reset) state.history.partialReasoning.clear()
|
|
@@ -1931,7 +2170,9 @@ async function loadHistory(reset) {
|
|
|
1931
2170
|
state.history.visible.sort((a, b) => a.seq - b.seq)
|
|
1932
2171
|
trimVisible()
|
|
1933
2172
|
state.history.hasMore = !!v.hasMore
|
|
1934
|
-
|
|
2173
|
+
history.loading = false
|
|
2174
|
+
setSessionRecovery('ready')
|
|
2175
|
+
renderSessionTitle(); renderSessionSub(); renderSessionCards()
|
|
1935
2176
|
try {
|
|
1936
2177
|
if (reset) renderHistory(true)
|
|
1937
2178
|
else if (added) renderHistory(false, 'keep')
|
|
@@ -2249,10 +2490,15 @@ async function renderSessionCards() {
|
|
|
2249
2490
|
const running = e.activity === 'running'
|
|
2250
2491
|
return `<div class="card-row"><span class="k">${running ? '▶ ' : ''}${esc(label)}</span><span class="v">${esc(e.mode)} ${running ? t('subagent.running') : ''}${e.mode === 'continuable' && running ? ` <button class="mini-btn" data-sub-interrupt="${esc(e.id)}">${t('subagent.interrupt')}</button>` : ''}</span></div>`
|
|
2251
2492
|
}).join('')
|
|
2252
|
-
|
|
2493
|
+
const subagentClosedIcon = 'M7 10l5 5 5-5'
|
|
2494
|
+
const subagentOpenIcon = 'M7 14l5-5 5 5'
|
|
2495
|
+
const subagentIcon = expanded ? subagentOpenIcon : subagentClosedIcon
|
|
2496
|
+
box.insertAdjacentHTML('beforeend', `<div class="card subagent-card"><button type="button" class="subagent-toggle" data-subagent-toggle aria-expanded="${expanded}" aria-label="${esc(toggleLabel)}" title="${esc(toggleLabel)}"><span class="card-title">${esc(t('subagent.count', { n: sub.entries.length }))}</span><span class="subagent-toggle-icon" aria-hidden="true"><morph-icon data-morph-state="${expanded ? 'open' : 'closed'}" data-morph-closed="${subagentClosedIcon}" data-morph-open="${subagentOpenIcon}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${subagentIcon}"/></svg></morph-icon></span></button><div class="subagent-list${expanded ? '' : ' hidden'}">${rows}</div></div>`)
|
|
2253
2497
|
box.querySelector('[data-subagent-toggle]')?.addEventListener('click', () => {
|
|
2498
|
+
const icon = box.querySelector('[data-subagent-toggle] morph-icon')
|
|
2499
|
+
if (icon) icon.setAttribute('data-morph-state', expanded ? 'closed' : 'open')
|
|
2254
2500
|
state.subagentExpandedSession = expanded ? '' : sessionId
|
|
2255
|
-
renderSessionCards()
|
|
2501
|
+
setTimeout(() => renderSessionCards(), 240)
|
|
2256
2502
|
})
|
|
2257
2503
|
box.querySelectorAll('[data-sub-interrupt]').forEach(btn =>
|
|
2258
2504
|
btn.addEventListener('click', () => interruptSubagent(btn.dataset.subInterrupt)))
|
|
@@ -2422,29 +2668,48 @@ async function sendSessionText(text) {
|
|
|
2422
2668
|
async function sendSessionContent(text, images) {
|
|
2423
2669
|
const clean = String(text || '').trim()
|
|
2424
2670
|
if ((!clean && !images.length) || !state.current) return false
|
|
2425
|
-
|
|
2671
|
+
const sessionId = state.current
|
|
2672
|
+
if (images.length === 0 && clean && await runSlashCommand(clean)) {
|
|
2673
|
+
state.sessionActivity.add(sessionId)
|
|
2674
|
+
return true
|
|
2675
|
+
}
|
|
2426
2676
|
const buttons = [$('btn-send'), $('btn-fs-send')].filter(Boolean)
|
|
2427
2677
|
buttons.forEach(button => { button.disabled = true })
|
|
2678
|
+
state.pendingPrompts.add(sessionId)
|
|
2428
2679
|
try {
|
|
2429
2680
|
const content = [...await encodeComposerImagesFor(images)]
|
|
2430
2681
|
if (clean) content.push({ type: 'text', text: clean })
|
|
2682
|
+
setSessionRecovery('resuming')
|
|
2431
2683
|
const v = await safeRpc('session.prompt', {
|
|
2432
|
-
sessionId
|
|
2684
|
+
sessionId,
|
|
2433
2685
|
mode: 'queue',
|
|
2434
2686
|
content
|
|
2435
2687
|
}, t('send.failed'))
|
|
2436
2688
|
if (v?.accepted) {
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
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
|
+
}
|
|
2440
2696
|
return true
|
|
2441
2697
|
}
|
|
2442
|
-
if (v?.command?.text) {
|
|
2698
|
+
if (v?.command?.text) {
|
|
2699
|
+
state.sessionActivity.add(sessionId)
|
|
2700
|
+
if (state.current === sessionId) toast(t('send.commandExecuted'), 'ok')
|
|
2701
|
+
return true
|
|
2702
|
+
}
|
|
2703
|
+
if (state.current === sessionId) setSessionRecovery('error')
|
|
2443
2704
|
return false
|
|
2444
2705
|
} catch (e) {
|
|
2445
|
-
|
|
2706
|
+
if (state.current === sessionId) {
|
|
2707
|
+
setSessionRecovery('error', e?.message)
|
|
2708
|
+
toast(t('composer.imageReadFailed', { msg: e?.message || e }), 'err')
|
|
2709
|
+
}
|
|
2446
2710
|
return false
|
|
2447
2711
|
} finally {
|
|
2712
|
+
state.pendingPrompts.delete(sessionId)
|
|
2448
2713
|
buttons.forEach(button => { button.disabled = false })
|
|
2449
2714
|
}
|
|
2450
2715
|
}
|
|
@@ -2603,7 +2868,41 @@ async function cancelSession() {
|
|
|
2603
2868
|
if (!state.current) return
|
|
2604
2869
|
if (!confirm(t('session.confirmStop'))) return
|
|
2605
2870
|
const v = await safeRpc('session.cancel', { sessionId: state.current }, t('session.stopFailed'))
|
|
2606
|
-
if (v?.accepted) toast(t('session.stopRequested'), 'ok')
|
|
2871
|
+
if (v?.accepted) { setSessionRecovery('ready'); toast(t('session.stopRequested'), 'ok') }
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
let renamePendingSessionId = null
|
|
2875
|
+
function renameSession(sessionId = state.current) {
|
|
2876
|
+
const session = state.byId.get(sessionId)
|
|
2877
|
+
if (!session) return
|
|
2878
|
+
renamePendingSessionId = sessionId
|
|
2879
|
+
$('rename-session-input').value = titleOf(session)
|
|
2880
|
+
$('modal-rename').classList.remove('hidden')
|
|
2881
|
+
setTimeout(() => { $('rename-session-input').focus(); $('rename-session-input').select() }, 40)
|
|
2882
|
+
}
|
|
2883
|
+
function closeRenameSession() {
|
|
2884
|
+
renamePendingSessionId = null
|
|
2885
|
+
$('modal-rename').classList.add('hidden')
|
|
2886
|
+
}
|
|
2887
|
+
async function confirmRenameSession() {
|
|
2888
|
+
const sessionId = renamePendingSessionId
|
|
2889
|
+
if (!sessionId) return
|
|
2890
|
+
const title = $('rename-session-input').value.trim()
|
|
2891
|
+
if (!title) return toast(t('session.renameEmpty'), 'err')
|
|
2892
|
+
const button = $('rename-confirm')
|
|
2893
|
+
button.disabled = true
|
|
2894
|
+
setSessionRecovery('resuming')
|
|
2895
|
+
try {
|
|
2896
|
+
const value = await safeRpc('session.rename', { sessionId, title }, t('session.renameFailed'))
|
|
2897
|
+
if (value == null) { setSessionRecovery('error'); return }
|
|
2898
|
+
if (value.title) applyProjection(sessionId, 'title', value.title, value.seq)
|
|
2899
|
+
closeRenameSession()
|
|
2900
|
+
setSessionRecovery('ready')
|
|
2901
|
+
toast(t('session.renamed'), 'ok')
|
|
2902
|
+
await refreshSessions()
|
|
2903
|
+
} finally {
|
|
2904
|
+
button.disabled = false
|
|
2905
|
+
}
|
|
2607
2906
|
}
|
|
2608
2907
|
|
|
2609
2908
|
async function newSession() {
|
|
@@ -2673,6 +2972,7 @@ async function confirmArchiveSession() {
|
|
|
2673
2972
|
closeArchiveConfirm()
|
|
2674
2973
|
toast(t('session.archived'), 'ok')
|
|
2675
2974
|
await refreshSessions()
|
|
2975
|
+
if (state.current === sessionId) void closeSession()
|
|
2676
2976
|
} finally {
|
|
2677
2977
|
button.disabled = false
|
|
2678
2978
|
}
|
|
@@ -3346,6 +3646,8 @@ async function runFsUpload(up) {
|
|
|
3346
3646
|
xhr.setRequestHeader('authorization', 'Bearer ' + state.token)
|
|
3347
3647
|
xhr.setRequestHeader('x-dsh-remote-client', CAP?.isNativePlatform?.() ? 'app' : 'web')
|
|
3348
3648
|
if (CLIENT_ID) xhr.setRequestHeader('x-dsh-remote-client-id', CLIENT_ID)
|
|
3649
|
+
if (params.offset != null) xhr.setRequestHeader('Upload-Offset', String(params.offset))
|
|
3650
|
+
if (params.size != null) xhr.setRequestHeader('Upload-Length', String(params.size))
|
|
3349
3651
|
xhr.upload.onprogress = (e) => {
|
|
3350
3652
|
if (e.lengthComputable) {
|
|
3351
3653
|
const loaded = up.offset + Math.min(e.loaded, e.total)
|
|
@@ -3370,7 +3672,7 @@ async function runFsUpload(up) {
|
|
|
3370
3672
|
})
|
|
3371
3673
|
|
|
3372
3674
|
const probe = async () => {
|
|
3373
|
-
const res = await fetch(fsApiUrl('/upload-probe', { path: up.path, name: up.name, session: up.session }), { headers: fsHeaders() })
|
|
3675
|
+
const res = await fetch(fsApiUrl('/upload-probe', { path: up.path, name: up.name, session: up.session, size: String(up.size) }), { headers: fsHeaders() })
|
|
3374
3676
|
if (res.status === 401) { fsAuthError(401); return null }
|
|
3375
3677
|
const json = await res.json().catch(() => ({}))
|
|
3376
3678
|
if (json.ok) up.offset = json.partialSize || 0
|
|
@@ -3402,7 +3704,7 @@ async function runFsUpload(up) {
|
|
|
3402
3704
|
}
|
|
3403
3705
|
hasher.update(chunkBytes)
|
|
3404
3706
|
const isLast = end >= up.size
|
|
3405
|
-
const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset) }
|
|
3707
|
+
const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset), size: String(up.size) }
|
|
3406
3708
|
if (isLast) { params.finish = '1'; params.sha256 = hasher.hex() }
|
|
3407
3709
|
if (overwrite) params.overwrite = '1'
|
|
3408
3710
|
const r = await uploadChunk(params, blob)
|
|
@@ -3444,7 +3746,7 @@ async function runFsUpload(up) {
|
|
|
3444
3746
|
// 发一个空 finish 块完成收尾, 同时带上全量 SHA-256 校验
|
|
3445
3747
|
if (up.offset >= up.size) {
|
|
3446
3748
|
const expected = hasher.hex()
|
|
3447
|
-
const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset), finish: '1', sha256: expected }
|
|
3749
|
+
const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset), size: String(up.size), finish: '1', sha256: expected }
|
|
3448
3750
|
if (overwrite) params.overwrite = '1'
|
|
3449
3751
|
const r = await uploadChunk(params, new Blob([]))
|
|
3450
3752
|
if (r.status === 401) { fsAuthError(401); return }
|
|
@@ -4264,7 +4566,7 @@ function saveBgConfig(enabled) {
|
|
|
4264
4566
|
const b = bgBridge()
|
|
4265
4567
|
if (!b?.saveBackgroundConfig) return false
|
|
4266
4568
|
const base = bgBase()
|
|
4267
|
-
const intervalMin = parseFloat($('bg-interval')?.value || '
|
|
4569
|
+
const intervalMin = parseFloat($('bg-interval')?.value || '0.5') || 0.5
|
|
4268
4570
|
const notifyTaskDone = $('opt-task-done')?.checked !== false
|
|
4269
4571
|
b.saveBackgroundConfig(JSON.stringify({ enabled, intervalMin, base, token: state.token || '', clientId: CLIENT_ID || '', notifyTaskDone }))
|
|
4270
4572
|
if (enabled) $('bg-auth-status')?.classList.add('hidden')
|
|
@@ -4391,12 +4693,723 @@ async function restorePeakReminders() {
|
|
|
4391
4693
|
if (peakRemindOn() && legacyCleaned) await schedulePeakReminders({ legacyCleaned: true })
|
|
4392
4694
|
}
|
|
4393
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
|
+
}
|
|
4394
5406
|
/* ---------------- 视图切换 ---------------- */
|
|
4395
5407
|
function showView(id) {
|
|
4396
5408
|
for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-stats', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
|
|
4397
5409
|
// 离开会话页必须清掉 in-session, 否则其他页面顶栏被 body 样式隐藏
|
|
4398
5410
|
document.body.classList.toggle('in-session', id === 'view-session')
|
|
4399
5411
|
document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === id))
|
|
5412
|
+
window.DshMotion?.view($(id))
|
|
4400
5413
|
window.scrollTo(0, 0)
|
|
4401
5414
|
if (id === 'view-files' && !state.fs.loaded) {
|
|
4402
5415
|
const workspace = workspaceById(state.fs.workspaceId)
|
|
@@ -4406,7 +5419,7 @@ function showView(id) {
|
|
|
4406
5419
|
if (id === 'view-settings') showSettingsHome()
|
|
4407
5420
|
}
|
|
4408
5421
|
|
|
4409
|
-
const SETTINGS_GROUPS = ['general', 'servers', 'notify', 'theme', 'about']
|
|
5422
|
+
const SETTINGS_GROUPS = ['general', 'model', 'tests', 'servers', 'notify', 'theme', 'about']
|
|
4410
5423
|
function showSettingsHome() {
|
|
4411
5424
|
const home = $('settings-home')
|
|
4412
5425
|
if (!home) return
|
|
@@ -4420,6 +5433,7 @@ function showSettingsPage(name) {
|
|
|
4420
5433
|
home.classList.add('hidden')
|
|
4421
5434
|
for (const g of SETTINGS_GROUPS) $('settings-page-' + g)?.classList.toggle('hidden', g !== name)
|
|
4422
5435
|
window.scrollTo(0, 0)
|
|
5436
|
+
if (name === 'model') void loadModelSettings()
|
|
4423
5437
|
}
|
|
4424
5438
|
|
|
4425
5439
|
function updateConn() {
|
|
@@ -4485,8 +5499,7 @@ function updateComposerFullscreenButton() {
|
|
|
4485
5499
|
const shouldShow = active || input.scrollHeight > 120
|
|
4486
5500
|
button.classList.toggle('hidden', !shouldShow)
|
|
4487
5501
|
$('composer-input-wrap')?.classList.toggle('has-fs-btn', shouldShow)
|
|
4488
|
-
$('fs-ico
|
|
4489
|
-
$('fs-ico-collapse')?.classList.toggle('hidden', !active)
|
|
5502
|
+
$('fs-ico')?.setAttribute('data-morph-state', active ? 'open' : 'closed')
|
|
4490
5503
|
button.title = t(active ? 'composer.exitFullscreen' : 'composer.fullscreen')
|
|
4491
5504
|
button.setAttribute('aria-label', t(active ? 'composer.exitFullscreen' : 'composer.fullscreen'))
|
|
4492
5505
|
}
|
|
@@ -4545,19 +5558,24 @@ function bindComposerFullscreenGesture() {
|
|
|
4545
5558
|
}
|
|
4546
5559
|
|
|
4547
5560
|
/* ---------------- 初始化 ---------------- */
|
|
4548
|
-
/** 解析 dshremote://pair?token=..&server=..
|
|
5561
|
+
/** 解析 dshremote://pair?token=..&server=.. 配对二维码;server 可重复以携带多个主机地址。 */
|
|
4549
5562
|
function applyPairUrl(url) {
|
|
4550
5563
|
try {
|
|
4551
5564
|
const u = new URL(String(url).trim())
|
|
4552
5565
|
if (u.protocol !== 'dshremote:' || u.hostname !== 'pair') return false
|
|
4553
5566
|
const tok = (u.searchParams.get('token') || '').trim()
|
|
4554
|
-
const
|
|
4555
|
-
|
|
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
|
|
4556
5571
|
state.token = tok
|
|
4557
5572
|
LS.set('token', tok)
|
|
4558
|
-
state.server =
|
|
4559
|
-
|
|
4560
|
-
|
|
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
|
+
}
|
|
4561
5579
|
}
|
|
4562
5580
|
saveServers()
|
|
4563
5581
|
renderServers()
|
|
@@ -4871,6 +5889,10 @@ function dshControlFailureText(value) {
|
|
|
4871
5889
|
INVALID_SERVICE: 'settings.dshErrorInvalidService',
|
|
4872
5890
|
SYSTEMCTL_NOT_FOUND: 'settings.dshErrorSystemctlNotFound',
|
|
4873
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',
|
|
4874
5896
|
PERMISSION_DENIED: 'settings.dshErrorPermissionDenied',
|
|
4875
5897
|
COMMAND_TIMEOUT: 'settings.dshErrorCommandTimeout',
|
|
4876
5898
|
COMMAND_FAILED: 'settings.dshErrorCommandFailed',
|
|
@@ -5105,6 +6127,8 @@ function bindUi() {
|
|
|
5105
6127
|
renderAnnouncementBoard()
|
|
5106
6128
|
renderPending(); renderQueue(); renderJobs()
|
|
5107
6129
|
updateConn()
|
|
6130
|
+
if (state.modelSettings.status === 'ready' || state.modelSettings.status === 'error' || state.modelSettings.status === 'loading') renderModelSettings()
|
|
6131
|
+
renderAsrTest()
|
|
5108
6132
|
if (state.current) { renderSessionTitle(); renderSessionSub(); renderSessionCards(); renderHistory(true) }
|
|
5109
6133
|
else renderModelMenu()
|
|
5110
6134
|
loadLocalVersion()
|
|
@@ -5198,7 +6222,7 @@ function bindUi() {
|
|
|
5198
6222
|
const session = e.target.closest('[data-wb-session]')
|
|
5199
6223
|
if (session) openSession(session.dataset.wbSession)
|
|
5200
6224
|
})
|
|
5201
|
-
$('btn-back').addEventListener('click', closeSession)
|
|
6225
|
+
$('btn-back').addEventListener('click', () => { void closeSession() })
|
|
5202
6226
|
$('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
|
|
5203
6227
|
$('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
|
|
5204
6228
|
$('btn-refresh').addEventListener('click', () => { toast(t('common.refreshing')); openStreams(); refreshAll() })
|
|
@@ -5275,6 +6299,12 @@ function bindUi() {
|
|
|
5275
6299
|
})
|
|
5276
6300
|
$('modal-file-preview').addEventListener('click', (e) => { if (e.target === $('modal-file-preview')) closeFsPreview() })
|
|
5277
6301
|
$('btn-cancel').addEventListener('click', cancelSession)
|
|
6302
|
+
$('btn-rename-session').addEventListener('click', () => renameSession())
|
|
6303
|
+
$('btn-archive-session').addEventListener('click', () => archiveSession(state.current))
|
|
6304
|
+
$('rename-cancel').addEventListener('click', closeRenameSession)
|
|
6305
|
+
$('rename-confirm').addEventListener('click', confirmRenameSession)
|
|
6306
|
+
$('rename-session-input').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.isComposing) confirmRenameSession() })
|
|
6307
|
+
$('modal-rename').addEventListener('click', e => { if (e.target === $('modal-rename')) closeRenameSession() })
|
|
5278
6308
|
$('btn-send').addEventListener('click', sendMessage)
|
|
5279
6309
|
$('btn-fs-send').addEventListener('click', sendMessage)
|
|
5280
6310
|
$('btn-plus').addEventListener('click', toggleComposerMenu)
|
|
@@ -5399,6 +6429,17 @@ function bindUi() {
|
|
|
5399
6429
|
if (group) { showSettingsPage(group.dataset.settingsGroup); return }
|
|
5400
6430
|
if (e.target.closest('[data-settings-back]')) { showSettingsHome(); return }
|
|
5401
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()
|
|
5402
6443
|
$('btn-scan-camera').addEventListener('click', () => scanPair('CAMERA'))
|
|
5403
6444
|
$('btn-scan-gallery').addEventListener('click', () => scanPair('PHOTOS'))
|
|
5404
6445
|
$('scan-live-cancel')?.addEventListener('click', () => closeLiveScan(''))
|