dsh-remote-plugin 0.4.4
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/README.md +34 -0
- package/client.js +140 -0
- package/cordis.patch.yml +4 -0
- package/gateway.cjs +675 -0
- package/index.mjs +392 -0
- package/package.json +56 -0
- package/public/admin.html +89 -0
- package/public/admin.js +265 -0
- package/public/app.js +1163 -0
- package/public/icon.svg +12 -0
- package/public/index.html +176 -0
- package/public/manifest.webmanifest +16 -0
- package/public/styles.css +396 -0
- package/public/update.json +6 -0
- package/public/version.json +3 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,1163 @@
|
|
|
1
|
+
/* DSH Remote 移动控制台 · 零依赖 */
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
/* ---------------- 状态 ---------------- */
|
|
5
|
+
const LS = {
|
|
6
|
+
get(k, d) { try { return localStorage.getItem(k) ?? d } catch { return d } },
|
|
7
|
+
set(k, v) { try { localStorage.setItem(k, v) } catch {} },
|
|
8
|
+
del(k) { try { localStorage.removeItem(k) } catch {} }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const state = {
|
|
12
|
+
token: '',
|
|
13
|
+
server: '', // 网关地址, 空 = 同源(浏览器模式)
|
|
14
|
+
sessions: [],
|
|
15
|
+
byId: new Map(),
|
|
16
|
+
current: null, // 当前打开的 sessionId
|
|
17
|
+
hostInfo: null,
|
|
18
|
+
localVersion: '',
|
|
19
|
+
updateInfo: null,
|
|
20
|
+
approvals: [], // 待处理审批
|
|
21
|
+
questions: [], // 待处理提问
|
|
22
|
+
queues: {}, // sessionId -> queue items
|
|
23
|
+
jobs: {}, // sessionId -> jobs
|
|
24
|
+
history: emptyHistory(),
|
|
25
|
+
errCount: 0,
|
|
26
|
+
refreshTimer: null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const $ = (id) => document.getElementById(id)
|
|
30
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) =>
|
|
31
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
|
32
|
+
|
|
33
|
+
function uuid() { return crypto.randomUUID ? crypto.randomUUID() : 'r' + Math.random().toString(36).slice(2) + Date.now().toString(36) }
|
|
34
|
+
|
|
35
|
+
function toast(text, kind = '') {
|
|
36
|
+
const el = $('toast')
|
|
37
|
+
el.textContent = text
|
|
38
|
+
el.className = 'toast ' + kind
|
|
39
|
+
clearTimeout(toast._t)
|
|
40
|
+
toast._t = setTimeout(() => el.classList.add('hidden'), 3200)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fmtTime(ts) {
|
|
44
|
+
if (!ts) return ''
|
|
45
|
+
const diff = Date.now() - ts
|
|
46
|
+
if (diff < 60e3) return '刚刚'
|
|
47
|
+
if (diff < 3600e3) return Math.floor(diff / 60e3) + ' 分钟前'
|
|
48
|
+
if (diff < 86400e3) return Math.floor(diff / 3600e3) + ' 小时前'
|
|
49
|
+
const d = new Date(ts)
|
|
50
|
+
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fmtTokens(n) {
|
|
54
|
+
if (n == null) return '—'
|
|
55
|
+
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'
|
|
56
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'
|
|
57
|
+
return String(n)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* ---------------- API ---------------- */
|
|
61
|
+
function apiUrl(path) {
|
|
62
|
+
return (state.server || '') + path
|
|
63
|
+
}
|
|
64
|
+
async function rpc(method, payload = {}) {
|
|
65
|
+
const res = await fetch(apiUrl('/api/' + method), {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
|
|
68
|
+
body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
|
|
69
|
+
})
|
|
70
|
+
if (res.status === 401) throw new Error('AUTH')
|
|
71
|
+
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
72
|
+
const full = await res.json()
|
|
73
|
+
if (!full?.result) throw new Error('坏响应')
|
|
74
|
+
if (!full.result.ok) {
|
|
75
|
+
const err = full.result.error || {}
|
|
76
|
+
throw new Error(err.message || 'DSH 返回错误')
|
|
77
|
+
}
|
|
78
|
+
return full.result.value
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function respond(rpcId, value) {
|
|
82
|
+
const res = await fetch(apiUrl('/api/respond'), {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
|
|
85
|
+
body: JSON.stringify({ type: 'client-response', rpcId, result: { ok: true, value } })
|
|
86
|
+
})
|
|
87
|
+
if (res.status === 401) throw new Error('AUTH')
|
|
88
|
+
const receipt = await res.json()
|
|
89
|
+
return receipt?.accepted === true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function safeRpc(method, payload, errText) {
|
|
93
|
+
try { return await rpc(method, payload) }
|
|
94
|
+
catch (e) {
|
|
95
|
+
if (e.message === 'AUTH') authFailure()
|
|
96
|
+
else toast(errText ? `${errText}:${e.message}` : e.message, 'err')
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function authFailure() {
|
|
102
|
+
toast('访问被拒绝:请检查令牌', 'err')
|
|
103
|
+
showView('view-settings')
|
|
104
|
+
$('token-desc').textContent = '令牌无效,点「更换」重新设置'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ---------------- 事件流 (WebSocket) ---------------- */
|
|
108
|
+
const streams = {}
|
|
109
|
+
state.streamsOk = { mux: false, host: false }
|
|
110
|
+
|
|
111
|
+
function openStreams() {
|
|
112
|
+
openStream('mux', onMuxFrame, true)
|
|
113
|
+
openStream('host', onHostFrame, false)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function openStream(kind, handler, refreshOnOpen) {
|
|
117
|
+
let base
|
|
118
|
+
if (state.server) {
|
|
119
|
+
base = state.server.replace(/^http/, 'ws')
|
|
120
|
+
} else {
|
|
121
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
122
|
+
base = `${proto}//${location.host}`
|
|
123
|
+
}
|
|
124
|
+
const clientMark = CAP?.isNativePlatform?.() ? 'app' : 'web'
|
|
125
|
+
const ws = new WebSocket(`${base}/api/events.${kind}?token=${encodeURIComponent(state.token)}&client=${clientMark}`)
|
|
126
|
+
try { streams[kind]?.close() } catch {}
|
|
127
|
+
streams[kind] = ws
|
|
128
|
+
ws.onopen = () => {
|
|
129
|
+
state.streamsOk[kind] = true
|
|
130
|
+
state.errCount = 0
|
|
131
|
+
updateConn()
|
|
132
|
+
if (refreshOnOpen) refreshAll()
|
|
133
|
+
}
|
|
134
|
+
ws.onmessage = (msg) => {
|
|
135
|
+
state.streamsOk[kind] = true
|
|
136
|
+
state.errCount = 0
|
|
137
|
+
updateConn()
|
|
138
|
+
try {
|
|
139
|
+
const full = JSON.parse(msg.data)
|
|
140
|
+
handler(full)
|
|
141
|
+
} catch {}
|
|
142
|
+
}
|
|
143
|
+
ws.onclose = () => {
|
|
144
|
+
state.streamsOk[kind] = false
|
|
145
|
+
state.errCount++
|
|
146
|
+
updateConn()
|
|
147
|
+
if (state.errCount === 3) toast('连接中断,正在重连…', 'err')
|
|
148
|
+
// 无条件重连; 页面被挂起时定时器暂停, visibilitychange 会再触发一次
|
|
149
|
+
if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), 1200)
|
|
150
|
+
}
|
|
151
|
+
ws.onerror = () => { try { ws.close() } catch {} }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* 回前台恢复: 强制重排修复 MIUI WebView 后台切回时 sticky 顶栏不绘制的问题 */
|
|
155
|
+
function onResume() {
|
|
156
|
+
if (document.visibilityState !== 'visible') return
|
|
157
|
+
applyNativeInsets()
|
|
158
|
+
// 视图状态与 body class 兜底同步(会话页顶栏按设计隐藏, 主页必须恢复显示)
|
|
159
|
+
document.body.classList.toggle('in-session', !$('view-session').classList.contains('hidden'))
|
|
160
|
+
const bar = document.querySelector('.topbar')
|
|
161
|
+
if (bar) {
|
|
162
|
+
bar.style.display = 'none'
|
|
163
|
+
void bar.offsetHeight // 强制回流
|
|
164
|
+
bar.style.display = ''
|
|
165
|
+
}
|
|
166
|
+
window.scrollTo(0, 0)
|
|
167
|
+
updateConn()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* 回前台 / 定时兜底: 任何流不在 OPEN 就重连 */
|
|
171
|
+
document.addEventListener('visibilitychange', () => {
|
|
172
|
+
if (document.visibilityState === 'visible') {
|
|
173
|
+
onResume()
|
|
174
|
+
if (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN) openStreams()
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
window.addEventListener('pageshow', onResume)
|
|
178
|
+
setInterval(() => {
|
|
179
|
+
if (document.visibilityState === 'visible') {
|
|
180
|
+
if (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN) openStreams()
|
|
181
|
+
}
|
|
182
|
+
}, 15000)
|
|
183
|
+
function onMuxFrame(full) {
|
|
184
|
+
const f = full.payload
|
|
185
|
+
if (!f) return
|
|
186
|
+
if (f.type === 'session/event') return onSessionEvent(f.sessionId, f.event)
|
|
187
|
+
if (f.type === 'session/subscribed') return
|
|
188
|
+
if (f.type === 'approval/requested') {
|
|
189
|
+
state.approvals = state.approvals.filter(a => a.approvalId !== f.approvalId)
|
|
190
|
+
state.approvals.push({ ...f, rpcId: full.rpcId })
|
|
191
|
+
notify('工具审批', `${f.toolName || '未知工具'} 需要批准`)
|
|
192
|
+
renderPending(); return
|
|
193
|
+
}
|
|
194
|
+
if (f.type === 'approval/resolved') { state.approvals = state.approvals.filter(a => a.approvalId !== f.approvalId); renderPending(); return }
|
|
195
|
+
if (f.type === 'question/requested') {
|
|
196
|
+
state.questions = state.questions.filter(q => q.rpcId !== full.rpcId)
|
|
197
|
+
state.questions.push({ ...f, rpcId: full.rpcId })
|
|
198
|
+
notify('DSH 提问', f.questions?.map(q => q.question).join(' / ') || '需要你回答')
|
|
199
|
+
renderPending(); return
|
|
200
|
+
}
|
|
201
|
+
if (f.type === 'question/resolved') { state.questions = state.questions.filter(q => q.rpcId !== f.questionRpcId); renderPending(); return }
|
|
202
|
+
if (f.type === 'session/queue') { state.queues[f.sessionId] = f.items || []; renderQueue(); return }
|
|
203
|
+
if (f.type === 'session/jobs') { state.jobs[f.sessionId] = f.jobs || []; renderJobs(); return }
|
|
204
|
+
if (f.type === 'session/projection') { applyProjection(f.sessionId, f.key, f.value, f.seq); return }
|
|
205
|
+
if (f.type === 'stream/error') { toast('事件流错误:' + (f.error?.message || ''), 'err') }
|
|
206
|
+
}
|
|
207
|
+
function onHostFrame(full) {
|
|
208
|
+
const f = full.payload
|
|
209
|
+
if (!f) return
|
|
210
|
+
if (['host/session-added', 'host/session-removed', 'host/workspace-changed', 'host/workspace-removed', 'host/workspace-order-changed', 'host/archived-sessions-changed'].includes(f.type)) return scheduleRefresh()
|
|
211
|
+
if (f.type === 'host/session-status') {
|
|
212
|
+
const s = state.byId.get(f.sessionId)
|
|
213
|
+
if (s) { s.running = f.running; if (state.current === f.sessionId) { renderSessionCards(); updateCancelBtn() } }
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
if (f.type === 'host/agent-error') return toast(`会话出错:${f.message}`, 'err')
|
|
217
|
+
if (f.type === 'host/remote-event') return scheduleRefresh()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function onSessionEvent(sessionId, event) {
|
|
221
|
+
if (!event) return
|
|
222
|
+
const s = state.byId.get(sessionId)
|
|
223
|
+
if (s) s.updatedAt = Date.now()
|
|
224
|
+
if (event.type === 'agent/status') {
|
|
225
|
+
if (s) { s.running = !!event.data?.running; s.blank = false }
|
|
226
|
+
if (state.current === sessionId) { updateCancelBtn(); renderSessionSub() }
|
|
227
|
+
}
|
|
228
|
+
if (event.type === 'session/title' || event.type === 'title') {
|
|
229
|
+
if (event.data?.title && s) s.projections.values.title = event.data.title
|
|
230
|
+
if (state.current === sessionId) renderSessionTitle()
|
|
231
|
+
}
|
|
232
|
+
if (state.current === sessionId) insertLiveEvent(event)
|
|
233
|
+
if (['goal/created', 'goal/updated', 'goal/completed', 'goal/cleared', 'todo/updated', 'plan/updated', 'checkpoint/created'].includes(event.type)) scheduleRefresh()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* ---------------- 数据刷新 ---------------- */
|
|
237
|
+
function scheduleRefresh() {
|
|
238
|
+
clearTimeout(state.refreshTimer)
|
|
239
|
+
state.refreshTimer = setTimeout(refreshAll, 700)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function refreshAll() {
|
|
243
|
+
await refreshSessions()
|
|
244
|
+
if (state.current) { renderSessionCards(); renderSessionSub(); updateCancelBtn() }
|
|
245
|
+
renderPending(); renderQueue(); renderJobs(); updateConn()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function refreshSessions() {
|
|
249
|
+
const v = await safeRpc('session.list', {}, '拉取会话列表失败')
|
|
250
|
+
if (!v) return
|
|
251
|
+
state.sessions = v.items || []
|
|
252
|
+
state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
|
|
253
|
+
renderSessions()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
|
|
257
|
+
function applyProjection(sessionId, key, value, seq) {
|
|
258
|
+
const s = state.byId.get(sessionId)
|
|
259
|
+
if (s) {
|
|
260
|
+
s.projections = s.projections || { asOfSeq: 0, values: {} }
|
|
261
|
+
s.projections.values = s.projections.values || {}
|
|
262
|
+
s.projections.values[key] = value
|
|
263
|
+
s.projections.asOfSeq = Math.max(s.projections.asOfSeq || 0, seq || 0)
|
|
264
|
+
}
|
|
265
|
+
if (state.current === sessionId) { renderSessionTitle(); renderSessionCards() }
|
|
266
|
+
if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) scheduleRefresh()
|
|
267
|
+
else renderSessions()
|
|
268
|
+
}function titleOf(s) { return proj(s, 'title') || short(s.sessionId) }
|
|
269
|
+
function short(id) { return '…' + String(id).slice(-8) }
|
|
270
|
+
function goalOf(s) {
|
|
271
|
+
const p = proj(s, 'goal')
|
|
272
|
+
if (!p) return null
|
|
273
|
+
return p.goal && typeof p.goal === 'object' ? p.goal : p
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function updatePendingBadge() {
|
|
277
|
+
const pending = state.approvals.length + state.questions.length
|
|
278
|
+
$('nav-pending').classList.toggle('hidden', pending === 0)
|
|
279
|
+
if (pending) $('nav-pending').textContent = pending
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function renderSessions() {
|
|
283
|
+
const list = $('session-list')
|
|
284
|
+
const items = [...state.sessions].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
|
285
|
+
list.innerHTML = items.map(s => {
|
|
286
|
+
const title = titleOf(s)
|
|
287
|
+
const goal = goalOf(s)
|
|
288
|
+
const pending = (state.approvals.some(a => a.sessionId === s.sessionId) || state.questions.some(q => q.sessionId === s.sessionId)) ? 'pending' : ''
|
|
289
|
+
const queueN = (state.queues[s.sessionId] || []).filter(i => i.placement === 'queued').length
|
|
290
|
+
const dots = []
|
|
291
|
+
if (s.running) dots.push('running')
|
|
292
|
+
if (pending) dots.push('pending')
|
|
293
|
+
const badge = goal ? `<span class="sc-badge ${goal.phase === 'active' ? 'goal-active' : ''}">目标·${esc(goal.phase || '?')}</span>` : ''
|
|
294
|
+
const queueBadge = queueN ? `<span class="sc-badge">队列 ${queueN}</span>` : ''
|
|
295
|
+
return `<div class="session-card ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
|
|
296
|
+
<div class="sc-title">${esc(title)}</div>
|
|
297
|
+
<div class="sc-meta">
|
|
298
|
+
<span class="sc-dot ${dots.join(' ')}"></span>
|
|
299
|
+
<span>${fmtTime(s.updatedAt)}</span>
|
|
300
|
+
${s.running ? '<span>运行中</span>' : ''}
|
|
301
|
+
${badge}${queueBadge}
|
|
302
|
+
</div>
|
|
303
|
+
<span class="sc-arrow">›</span>
|
|
304
|
+
</div>`
|
|
305
|
+
}).join('')
|
|
306
|
+
$('home-empty').classList.toggle('hidden', items.length > 0)
|
|
307
|
+
const running = state.sessions.filter(s => s.running).length
|
|
308
|
+
const pending = state.approvals.length + state.questions.length
|
|
309
|
+
$('stat-strip').innerHTML = `
|
|
310
|
+
<div class="stat running"><div class="v">${running}</div><div class="k">运行中</div></div>
|
|
311
|
+
<div class="stat pending"><div class="v">${pending}</div><div class="k">待处理</div></div>
|
|
312
|
+
<div class="stat ctx"><div class="v">${items.length}</div><div class="k">会话总数</div></div>`
|
|
313
|
+
updatePendingBadge()
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/* ---------------- 会话详情 ---------------- */
|
|
317
|
+
async function openSession(id) {
|
|
318
|
+
state.current = id
|
|
319
|
+
state.history = emptyHistory()
|
|
320
|
+
document.body.classList.add('in-session')
|
|
321
|
+
showView('view-session')
|
|
322
|
+
renderSessionTitle(); renderSessionSub(); updateCancelBtn()
|
|
323
|
+
$('history').innerHTML = '<div class="empty">加载历史…</div>'
|
|
324
|
+
await loadHistory(true)
|
|
325
|
+
renderSessionCards()
|
|
326
|
+
refreshSessions()
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function closeSession() {
|
|
330
|
+
state.current = null
|
|
331
|
+
state.history = emptyHistory()
|
|
332
|
+
document.body.classList.remove('in-session')
|
|
333
|
+
showView('view-home')
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/* Android 手势返回/实体返回: 注册后系统不再直接杀 App, 由这里接管导航 */
|
|
337
|
+
function bindNativeBack() {
|
|
338
|
+
if (!CAP?.isNativePlatform?.()) return
|
|
339
|
+
try {
|
|
340
|
+
CAP.Plugins?.App?.addListener?.('backButton', () => {
|
|
341
|
+
const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
|
|
342
|
+
if (openModal) { openModal.classList.add('hidden'); return } // 先关弹窗
|
|
343
|
+
if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
|
|
344
|
+
try { CAP.Plugins?.App?.exitApp?.() } catch {} // 主页再返回 → 退出(与系统一致)
|
|
345
|
+
})
|
|
346
|
+
} catch {}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function renderSessionTitle() {
|
|
350
|
+
const s = state.byId.get(state.current)
|
|
351
|
+
$('session-title').textContent = s ? titleOf(s) : '未知会话'
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function renderSessionSub() {
|
|
355
|
+
const s = state.byId.get(state.current)
|
|
356
|
+
if (!s) { $('session-sub').textContent = ''; return }
|
|
357
|
+
const parts = [short(s.sessionId)]
|
|
358
|
+
if (s.cwd) parts.push(s.cwd)
|
|
359
|
+
if (s.running) parts.push('运行中')
|
|
360
|
+
$('session-sub').textContent = parts.join(' · ')
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function updateCancelBtn() {
|
|
364
|
+
const s = state.byId.get(state.current)
|
|
365
|
+
const running = s?.running || (state.queues[state.current] || []).some(i => i.placement !== 'context')
|
|
366
|
+
$('btn-cancel').classList.toggle('hidden', !running)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const HISTORY_MAX_VISIBLE = 5000 // 已加载的可显示事件上限(消息/工具/状态, 不含 chunk)
|
|
370
|
+
|
|
371
|
+
function emptyHistory() {
|
|
372
|
+
return {
|
|
373
|
+
visible: [], seqs: new Set(), minSeq: Infinity,
|
|
374
|
+
hasMore: false, loading: false, renderStart: 0, renderEnd: 0
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function trimVisible() {
|
|
379
|
+
const h = state.history
|
|
380
|
+
if (h.visible.length <= HISTORY_MAX_VISIBLE) return
|
|
381
|
+
const drop = h.visible.splice(0, h.visible.length - HISTORY_MAX_VISIBLE)
|
|
382
|
+
for (const e of drop) h.seqs.delete(e.seq)
|
|
383
|
+
h.renderStart = Math.max(0, h.renderStart - drop.length)
|
|
384
|
+
h.renderEnd = Math.max(h.renderStart, h.renderEnd - drop.length)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function loadHistory(reset) {
|
|
388
|
+
const id = state.current
|
|
389
|
+
if (!id || state.history.loading) return
|
|
390
|
+
state.history.loading = true
|
|
391
|
+
const moreBtn = $('history-more')
|
|
392
|
+
if (moreBtn) moreBtn.classList.add('hidden')
|
|
393
|
+
const payload = { sessionId: id, maxMessages: 60 }
|
|
394
|
+
if (!reset && state.history.minSeq !== Infinity) payload.beforeSeq = state.history.minSeq
|
|
395
|
+
const v = await safeRpc('session.history', payload, '加载历史失败')
|
|
396
|
+
if (!v) { state.history.loading = false; return }
|
|
397
|
+
const incoming = v.events || []
|
|
398
|
+
let added = 0
|
|
399
|
+
for (const entry of incoming) {
|
|
400
|
+
const ev = entry?.event
|
|
401
|
+
const seq = ev?.seq
|
|
402
|
+
if (seq == null || state.history.seqs.has(seq)) continue
|
|
403
|
+
if (!shouldShowEvent(ev.type)) continue // chunk 等内部事件不保留
|
|
404
|
+
state.history.seqs.add(seq)
|
|
405
|
+
state.history.visible.push({ seq, event: ev, view: entry.view })
|
|
406
|
+
added++
|
|
407
|
+
}
|
|
408
|
+
// 向前翻页游标 = 本页最旧的 raw seq(即使它本身被过滤)
|
|
409
|
+
const firstSeq = incoming[0]?.event?.seq
|
|
410
|
+
if (firstSeq != null) state.history.minSeq = Math.min(state.history.minSeq, firstSeq)
|
|
411
|
+
state.history.visible.sort((a, b) => a.seq - b.seq)
|
|
412
|
+
trimVisible()
|
|
413
|
+
state.history.hasMore = !!v.hasMore
|
|
414
|
+
state.history.loading = false
|
|
415
|
+
if (reset) renderHistory(true)
|
|
416
|
+
else if (added) renderHistory(false, 'keep')
|
|
417
|
+
if (moreBtn) moreBtn.classList.toggle('hidden', !state.history.hasMore)
|
|
418
|
+
$('history-hint').textContent = state.history.visible.length ? `${state.history.visible.length} 条` : ''
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function insertLiveEvent(event) {
|
|
422
|
+
const h = state.history
|
|
423
|
+
const seq = event?.seq
|
|
424
|
+
if (seq == null || h.seqs.has(seq) || !shouldShowEvent(event.type)) return
|
|
425
|
+
h.seqs.add(seq)
|
|
426
|
+
h.visible.push({ seq, event })
|
|
427
|
+
h.visible.sort((a, b) => a.seq - b.seq)
|
|
428
|
+
trimVisible()
|
|
429
|
+
const box = $('history')
|
|
430
|
+
const nearBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 240
|
|
431
|
+
if (nearBottom) {
|
|
432
|
+
h.renderEnd = h.visible.length
|
|
433
|
+
h.renderStart = Math.max(0, h.renderEnd - 200)
|
|
434
|
+
renderHistory(false, 'bottom')
|
|
435
|
+
} else {
|
|
436
|
+
renderHistory(false, 'keep')
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function isToolEvent(type) { return type === 'tool/call' || type === 'tool/result' }
|
|
441
|
+
|
|
442
|
+
function filteredEntries() {
|
|
443
|
+
const showTools = LS.get('showTools', '1') !== '0'
|
|
444
|
+
const f = state.history.visible.filter(e => showTools || !isToolEvent(e.event?.type))
|
|
445
|
+
state.history.filtered = f
|
|
446
|
+
return f
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function renderHistory(reset, mode = 'bottom') {
|
|
450
|
+
const box = $('history')
|
|
451
|
+
const h = state.history
|
|
452
|
+
const filtered = filteredEntries()
|
|
453
|
+
const len = filtered.length
|
|
454
|
+
if (!len) {
|
|
455
|
+
box.innerHTML = '<div class="empty">还没有消息</div>'
|
|
456
|
+
h.renderStart = 0; h.renderEnd = 0
|
|
457
|
+
updateRail()
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
if (reset) {
|
|
461
|
+
h.renderEnd = len
|
|
462
|
+
h.renderStart = Math.max(0, len - 200)
|
|
463
|
+
}
|
|
464
|
+
const start = Math.min(h.renderStart, len)
|
|
465
|
+
const end = Math.min(h.renderEnd, len) || len
|
|
466
|
+
const oldH = box.scrollHeight
|
|
467
|
+
const oldTop = box.scrollTop
|
|
468
|
+
// callId → 工具名, 供 tool/result 折叠标题显示
|
|
469
|
+
const toolNames = new Map()
|
|
470
|
+
for (const e of state.history.visible) {
|
|
471
|
+
if (e.event?.type !== 'tool/call') continue
|
|
472
|
+
const d = e.event.data || {}
|
|
473
|
+
if (d.callId && d.name) toolNames.set(d.callId, d.name)
|
|
474
|
+
}
|
|
475
|
+
box.innerHTML = filtered.slice(start, end).map(e => eventHtml(e, { toolNames })).join('')
|
|
476
|
+
if (reset || mode === 'bottom') box.scrollTop = box.scrollHeight
|
|
477
|
+
else if (mode === 'keep') box.scrollTop = Math.max(0, oldTop + (box.scrollHeight - oldH))
|
|
478
|
+
updateRail()
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/* 右侧导航条: 用户发言节点 + 拖动快速定位 */
|
|
482
|
+
function updateRail() {
|
|
483
|
+
const box = $('history')
|
|
484
|
+
const thumb = $('rail-thumb')
|
|
485
|
+
const nodesBox = $('rail-nodes')
|
|
486
|
+
if (!box || !thumb || !nodesBox) return
|
|
487
|
+
const sh = box.scrollHeight
|
|
488
|
+
const ch = box.clientHeight
|
|
489
|
+
if (sh <= ch) {
|
|
490
|
+
thumb.style.display = 'none'
|
|
491
|
+
nodesBox.innerHTML = ''
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
thumb.style.display = ''
|
|
495
|
+
const trackH = Math.max(1, ch - 8)
|
|
496
|
+
const thumbH = Math.max(32, ch / sh * trackH)
|
|
497
|
+
const maxTop = trackH - thumbH
|
|
498
|
+
const ratio = box.scrollTop / Math.max(1, sh - ch)
|
|
499
|
+
thumb.style.height = thumbH + 'px'
|
|
500
|
+
thumb.style.top = (4 + ratio * maxTop) + 'px'
|
|
501
|
+
|
|
502
|
+
const boxTop = box.getBoundingClientRect().top
|
|
503
|
+
const userNodes = [...box.querySelectorAll('.msg.user')]
|
|
504
|
+
nodesBox.innerHTML = userNodes.map(el => {
|
|
505
|
+
const off = el.getBoundingClientRect().top - boxTop + box.scrollTop
|
|
506
|
+
const pos = Math.min(4 + trackH, 4 + off / Math.max(1, sh) * trackH)
|
|
507
|
+
return `<div class="rail-node" data-offset="${Math.round(off)}" style="top:${pos}px"></div>`
|
|
508
|
+
}).join('')
|
|
509
|
+
let activeIdx = -1
|
|
510
|
+
userNodes.forEach((el, i) => {
|
|
511
|
+
const off = el.getBoundingClientRect().top - boxTop + box.scrollTop
|
|
512
|
+
if (off <= box.scrollTop + 60) activeIdx = i
|
|
513
|
+
})
|
|
514
|
+
if (activeIdx >= 0) nodesBox.children[activeIdx]?.classList.add('active')
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function bindRail() {
|
|
518
|
+
const box = $('history')
|
|
519
|
+
const thumb = $('rail-thumb')
|
|
520
|
+
const nodesBox = $('rail-nodes')
|
|
521
|
+
if (!box || !thumb || !nodesBox) return
|
|
522
|
+
nodesBox.addEventListener('click', (e) => {
|
|
523
|
+
const node = e.target.closest('.rail-node')
|
|
524
|
+
if (!node) return
|
|
525
|
+
box.scrollTo({ top: Math.max(0, Number(node.dataset.offset) - 10), behavior: 'smooth' })
|
|
526
|
+
})
|
|
527
|
+
let drag = null
|
|
528
|
+
thumb.addEventListener('pointerdown', (e) => {
|
|
529
|
+
drag = { y: e.clientY, top: box.scrollTop }
|
|
530
|
+
try { thumb.setPointerCapture(e.pointerId) } catch {}
|
|
531
|
+
})
|
|
532
|
+
thumb.addEventListener('pointermove', (e) => {
|
|
533
|
+
if (!drag) return
|
|
534
|
+
const trackH = Math.max(1, box.clientHeight - 8)
|
|
535
|
+
const delta = (e.clientY - drag.y) / trackH * Math.max(1, box.scrollHeight - box.clientHeight)
|
|
536
|
+
box.scrollTop = Math.max(0, Math.min(box.scrollHeight - box.clientHeight, drag.top + delta))
|
|
537
|
+
updateRail()
|
|
538
|
+
})
|
|
539
|
+
thumb.addEventListener('pointerup', () => { drag = null })
|
|
540
|
+
thumb.addEventListener('pointercancel', () => { drag = null })
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/* 事件 → HTML */
|
|
544
|
+
const INTERESTING_EVENTS = new Set([
|
|
545
|
+
'user/message', 'assistant/message',
|
|
546
|
+
'tool/call', 'tool/result',
|
|
547
|
+
'agent/status',
|
|
548
|
+
'checkpoint/created', 'compaction/complete', 'compaction/summary',
|
|
549
|
+
'goal/created', 'goal/updated', 'goal/completed', 'goal/cleared',
|
|
550
|
+
'todo/updated', 'plan/updated',
|
|
551
|
+
'question/asked', 'question/resolved',
|
|
552
|
+
'approval/asked', 'approval/resolved',
|
|
553
|
+
'session/title', 'title'
|
|
554
|
+
])
|
|
555
|
+
function shouldShowEvent(type) {
|
|
556
|
+
if (INTERESTING_EVENTS.has(type)) return true
|
|
557
|
+
return false
|
|
558
|
+
}
|
|
559
|
+
function eventHtml(entry, ctx = {}) {
|
|
560
|
+
const seq = entry.seq
|
|
561
|
+
const ev = entry.event || {}
|
|
562
|
+
const data = ev.data || {}
|
|
563
|
+
const type = ev.type || 'event'
|
|
564
|
+
if (!shouldShowEvent(type)) return ''
|
|
565
|
+
let inner = ''
|
|
566
|
+
|
|
567
|
+
if (type === 'user/message' || type === 'assistant/message') {
|
|
568
|
+
const msg = data.message || {}
|
|
569
|
+
const role = data.role || msg.role || (type.startsWith('user') ? 'user' : 'assistant')
|
|
570
|
+
const blocks = msg.content || data.content || []
|
|
571
|
+
inner = `<div class="msg ${esc(role)}" data-seq="${seq}"><div class="role">${esc(role === 'user' ? '我' : 'DSH')}</div>${blocks.map(blockHtml).join('')}</div>`
|
|
572
|
+
} else if (type === 'tool/call') {
|
|
573
|
+
const name = data.name || data.toolName || '工具'
|
|
574
|
+
const step = (data.turn != null ? ` · turn ${data.turn}` : '') + (data.step != null ? `.${data.step}` : '')
|
|
575
|
+
inner = `<details class="tool" data-seq="${seq}"><summary>🔧 ${esc(name)}<span class="tool-meta-inline">${esc(step)}</span></summary><pre>${esc(safeJson(data.arguments ?? data.args ?? data.input ?? data))}</pre></details>`
|
|
576
|
+
} else if (type === 'tool/result') {
|
|
577
|
+
const callId = data.callId || data.message?.source?.callId
|
|
578
|
+
const name = (callId && ctx.toolNames?.get(callId)) || '结果'
|
|
579
|
+
const err = data.error || data.ok === false
|
|
580
|
+
inner = `<details class="tool result ${err ? 'error' : ''}" data-seq="${seq}"><summary>📦 ${esc(name)}<span class="tool-meta-inline">结果</span></summary><pre>${esc(truncate(safeJson(data.result ?? data.output ?? data.message ?? data), 4000))}</pre></details>`
|
|
581
|
+
} else if (type === 'agent/status') {
|
|
582
|
+
const running = !!data.running
|
|
583
|
+
inner = `<div class="event" data-seq="${seq}">${running ? '▶ 任务开始' : '■ 任务结束'}</div>`
|
|
584
|
+
} else if (type === 'llm/usage') {
|
|
585
|
+
inner = `<div class="event" data-seq="${seq}">tokens ${fmtTokens(data.inputTokens)} → ${fmtTokens(data.outputTokens)}</div>`
|
|
586
|
+
} else if (type === 'checkpoint/created' || type === 'compaction/complete' || type === 'compaction/summary') {
|
|
587
|
+
inner = `<div class="event" data-seq="${seq}">⟳ ${esc(type)}</div>`
|
|
588
|
+
} else {
|
|
589
|
+
inner = `<div class="event" data-seq="${seq}">${esc(type)}</div>`
|
|
590
|
+
}
|
|
591
|
+
return inner
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function blockHtml(b) {
|
|
595
|
+
if (!b || typeof b !== 'object') return `<p>${esc(String(b))}</p>`
|
|
596
|
+
if ((b.type === 'tool-call' || b.type === 'tool-result') && LS.get('showTools', '1') === '0') return ''
|
|
597
|
+
switch (b.type) {
|
|
598
|
+
case 'text': return `<div>${renderMarkdown(b.text ?? '')}</div>`
|
|
599
|
+
case 'image': return `<img alt="图片" src="data:${esc(b.mediaType || 'image/png')};base64,${esc(b.data || '')}">`
|
|
600
|
+
case 'thinking':
|
|
601
|
+
case 'reasoning':
|
|
602
|
+
return `<details class="tool"><summary>🧠 思考过程</summary><div class="tool-text">${esc(truncate(String(b.text ?? b.content ?? safeJson(b)), 6000))}</div></details>`
|
|
603
|
+
case 'code': return `<pre>${esc(b.content ?? b.code ?? '')}</pre>`
|
|
604
|
+
case 'tool-call':
|
|
605
|
+
return `<details class="tool"><summary>🔧 ${esc(b.name || b.toolName || '工具调用')}</summary><pre>${esc(truncate(safeJson(b.arguments ?? b), 4000))}</pre></details>`
|
|
606
|
+
case 'tool-result':
|
|
607
|
+
return `<details class="tool result"><summary>📦 ${esc(b.name || b.toolName || '工具结果')}</summary><pre>${esc(truncate(safeJson(b.content ?? b), 4000))}</pre></details>`
|
|
608
|
+
default: return `<details class="tool"><summary>块 · ${esc(b.type || '?')}</summary><pre>${esc(truncate(safeJson(b), 2000))}</pre></details>`
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function renderMarkdown(text) {
|
|
613
|
+
const parts = String(text ?? '').split(/```/)
|
|
614
|
+
let out = ''
|
|
615
|
+
for (let i = 0; i < parts.length; i++) {
|
|
616
|
+
if (i % 2 === 1) out += `<pre>${esc(parts[i])}</pre>`
|
|
617
|
+
else out += esc(parts[i])
|
|
618
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
619
|
+
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
|
|
620
|
+
.replace(/(?:^|\n)(#{1,4})\s+([^\n]+)/g, (m, h, t) => `\n<b>${t}</b>`)
|
|
621
|
+
.replace(/\n/g, '<br>')
|
|
622
|
+
}
|
|
623
|
+
return out
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function safeJson(v) {
|
|
627
|
+
try { return typeof v === 'string' ? v : JSON.stringify(v, null, 2) }
|
|
628
|
+
catch { return String(v) }
|
|
629
|
+
}
|
|
630
|
+
function truncate(s, n) { return String(s).length > n ? String(s).slice(0, n) + '…(截断)' : s }
|
|
631
|
+
|
|
632
|
+
/* 会话卡片(goal/todo/subagents); 统计进顶栏 📊 弹窗 */
|
|
633
|
+
function statsHtml(s) {
|
|
634
|
+
const stats = proj(s, 'sessionStats')
|
|
635
|
+
const usage = proj(s, 'tokenUsage')
|
|
636
|
+
const ctx = proj(s, 'contextPressure')
|
|
637
|
+
const perms = proj(s, 'permissions')
|
|
638
|
+
let html = ''
|
|
639
|
+
if (stats) {
|
|
640
|
+
const llmMin = stats.llmMs ? (stats.llmMs / 60000).toFixed(1) : null
|
|
641
|
+
html += `<div class="card"><div class="card-title">本轮统计</div>
|
|
642
|
+
<div class="card-row"><span class="k">轮次 / 步骤</span><span class="v">${stats.turns ?? '—'} / ${stats.steps ?? '—'}</span></div>
|
|
643
|
+
<div class="card-row"><span class="k">模型耗时</span><span class="v">${llmMin ? llmMin + ' 分钟' : '—'}</span></div>
|
|
644
|
+
${usage ? `<div class="card-row"><span class="k">输出 / 缓存读</span><span class="v">${fmtTokens(usage.outputTokens)} / ${fmtTokens(usage.cacheReadTokens)}</span></div>` : ''}
|
|
645
|
+
${ctx ? `<div class="card-row"><span class="k">上下文压力</span><span class="v">${fmtTokens(ctx.pressureTokens)} / ${fmtTokens(ctx.contextWindow)}</span></div>` : ''}
|
|
646
|
+
${perms?.currentValue ? `<div class="card-row"><span class="k">权限</span><span class="v">${esc(perms.currentValue)}</span></div>` : ''}
|
|
647
|
+
</div>`
|
|
648
|
+
}
|
|
649
|
+
return html || '<div class="empty">暂无统计数据</div>'
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function renderSessionCards() {
|
|
653
|
+
const s = state.byId.get(state.current)
|
|
654
|
+
const box = $('session-cards')
|
|
655
|
+
const statsBox = $('stats-body')
|
|
656
|
+
if (!s) { box.innerHTML = ''; if (statsBox) statsBox.innerHTML = ''; return }
|
|
657
|
+
if (statsBox) statsBox.innerHTML = statsHtml(s)
|
|
658
|
+
const goal = goalOf(s)
|
|
659
|
+
const todos = proj(s, 'todos')
|
|
660
|
+
let html = ''
|
|
661
|
+
|
|
662
|
+
if (goal) {
|
|
663
|
+
html += `<div class="card"><div class="card-title">目标</div>
|
|
664
|
+
<div class="goal-obj">${esc(goal.objective || '')}</div>
|
|
665
|
+
<div class="goal-phase">phase: ${esc(goal.phase || '?')} · revision ${goal.revision ?? '?'}</div>
|
|
666
|
+
<div class="goal-actions">
|
|
667
|
+
${goal.phase === 'active' ? '<button class="mini-btn" data-goal="pause">暂停</button>' : '<button class="mini-btn" data-goal="resume">继续</button>'}
|
|
668
|
+
<button class="mini-btn" data-goal="complete">完成</button>
|
|
669
|
+
<button class="mini-btn" data-goal="edit">改目标</button>
|
|
670
|
+
<button class="mini-btn" data-goal="clear">清除</button>
|
|
671
|
+
</div></div>`
|
|
672
|
+
}
|
|
673
|
+
if (todos?.items?.length) {
|
|
674
|
+
html += `<div class="card"><div class="card-title">任务清单</div>${todos.items.map(t =>
|
|
675
|
+
`<div><span class="pill ${t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'active' : ''}">${esc(t.status || 'pending')}</span>${esc(t.content || '')}</div>`
|
|
676
|
+
).join('')}</div>`
|
|
677
|
+
}
|
|
678
|
+
box.innerHTML = html
|
|
679
|
+
box.querySelectorAll('[data-goal]').forEach(btn =>
|
|
680
|
+
btn.addEventListener('click', () => goalAction(btn.dataset.goal)))
|
|
681
|
+
|
|
682
|
+
// 子代理
|
|
683
|
+
const sub = await safeRpc('subagent.list', { parentSessionId: state.current })
|
|
684
|
+
if (sub?.entries?.length) {
|
|
685
|
+
const rows = sub.entries.map(e => {
|
|
686
|
+
if (e.kind === 'diagnostic') return `<div class="card-row"><span class="k">诊断项</span><span class="v">${esc(e.reason)}</span></div>`
|
|
687
|
+
const label = e.label || short(e.id)
|
|
688
|
+
const running = e.activity === 'running'
|
|
689
|
+
return `<div class="card-row"><span class="k">${running ? '▶ ' : ''}${esc(label)}</span><span class="v">${esc(e.mode)} ${running ? '· 运行中' : ''}${e.mode === 'continuable' && running ? ` <button class="mini-btn" data-sub-interrupt="${esc(e.id)}">中断</button>` : ''}</span></div>`
|
|
690
|
+
}).join('')
|
|
691
|
+
box.insertAdjacentHTML('beforeend', `<div class="card"><div class="card-title">子代理</div>${rows}</div>`)
|
|
692
|
+
box.querySelectorAll('[data-sub-interrupt]').forEach(btn =>
|
|
693
|
+
btn.addEventListener('click', () => interruptSubagent(btn.dataset.subInterrupt)))
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async function goalAction(kind) {
|
|
698
|
+
const s = state.byId.get(state.current)
|
|
699
|
+
const goal = goalOf(s)
|
|
700
|
+
if (!goal) return toast('当前会话没有目标')
|
|
701
|
+
const ref = { id: goal.id, revision: goal.revision }
|
|
702
|
+
if (kind === 'edit') return openGoalModal(goal)
|
|
703
|
+
const map = { pause: 'goal.pause', resume: 'goal.resume', complete: 'goal.complete', clear: 'goal.clear' }
|
|
704
|
+
const method = map[kind]
|
|
705
|
+
if (!method) return
|
|
706
|
+
if (kind === 'clear' && !confirm('清除当前目标?(不会删除会话)')) return
|
|
707
|
+
if (kind === 'complete' && !confirm('将目标标记为完成?')) return
|
|
708
|
+
await safeRpc(method, { sessionId: state.current, ref }, '目标操作失败')
|
|
709
|
+
toast('目标操作已提交', 'ok')
|
|
710
|
+
scheduleRefresh()
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async function interruptSubagent(childId) {
|
|
714
|
+
if (!confirm('中断这个子代理当前回合?')) return
|
|
715
|
+
await safeRpc('subagent.interrupt', { parentSessionId: state.current, childSessionId: childId, mode: 'continuable' }, '中断失败')
|
|
716
|
+
toast('中断请求已提交', 'ok')
|
|
717
|
+
setTimeout(renderSessionCards, 600)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/* ---------------- 发送 / 取消 ---------------- */
|
|
721
|
+
async function sendMessage() {
|
|
722
|
+
const input = $('composer-input')
|
|
723
|
+
const text = input.value.trim()
|
|
724
|
+
if (!text || !state.current) return
|
|
725
|
+
$('btn-send').disabled = true
|
|
726
|
+
const v = await safeRpc('session.prompt', {
|
|
727
|
+
sessionId: state.current,
|
|
728
|
+
mode: 'queue',
|
|
729
|
+
content: [{ type: 'text', text }]
|
|
730
|
+
}, '发送失败')
|
|
731
|
+
$('btn-send').disabled = false
|
|
732
|
+
if (v?.accepted) { input.value = ''; autosize(input); toast('已发送', 'ok') }
|
|
733
|
+
else if (v?.command?.text) toast('命令已执行')
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function cancelSession() {
|
|
737
|
+
if (!state.current) return
|
|
738
|
+
if (!confirm('停止当前会话正在运行的任务?')) return
|
|
739
|
+
const v = await safeRpc('session.cancel', { sessionId: state.current }, '停止失败')
|
|
740
|
+
if (v?.accepted) toast('已请求停止', 'ok')
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async function newSession() {
|
|
744
|
+
const v = await safeRpc('session.create', {}, '新建会话失败')
|
|
745
|
+
if (!v?.sessionId) return
|
|
746
|
+
toast('会话已创建', 'ok')
|
|
747
|
+
await refreshSessions()
|
|
748
|
+
openSession(v.sessionId)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/* ---------------- 待办 ---------------- */
|
|
752
|
+
function renderPending() {
|
|
753
|
+
const list = $('pending-list')
|
|
754
|
+
const items = [
|
|
755
|
+
...state.approvals.map(a => ({ kind: 'approval', a })),
|
|
756
|
+
...state.questions.map(q => ({ kind: 'question', q }))
|
|
757
|
+
]
|
|
758
|
+
$('pending-count').textContent = items.length ? `${items.length} 项` : ''
|
|
759
|
+
list.innerHTML = items.length ? items.map(it => {
|
|
760
|
+
if (it.kind === 'approval') {
|
|
761
|
+
const a = it.a
|
|
762
|
+
const title = titleOf(state.byId.get(a.sessionId))
|
|
763
|
+
return `<div class="pending-card approval" data-approval="${esc(a.approvalId)}">
|
|
764
|
+
<div class="pc-title">🔧 ${esc(a.toolName || '工具')} 请求批准</div>
|
|
765
|
+
<div class="pc-desc">${esc(a.reason || '无说明')}</div>
|
|
766
|
+
<div class="pc-session">${esc(title)}</div>
|
|
767
|
+
<div class="goal-actions"><button class="mini-btn" data-approve="1">允许</button><button class="mini-btn" data-approve="0">拒绝</button></div>
|
|
768
|
+
</div>`
|
|
769
|
+
}
|
|
770
|
+
const q = it.q
|
|
771
|
+
const title = titleOf(state.byId.get(q.sessionId))
|
|
772
|
+
return `<div class="pending-card question" data-question="${esc(q.rpcId)}">
|
|
773
|
+
<div class="pc-title">❓ ${esc(q.questions?.[0]?.question || 'DSH 提问')}</div>
|
|
774
|
+
<div class="pc-desc">${q.questions?.length > 1 ? `共 ${q.questions.length} 个问题` : ''}</div>
|
|
775
|
+
<div class="pc-session">${esc(title)}</div>
|
|
776
|
+
<div class="goal-actions"><button class="mini-btn" data-answer="1">去回答</button></div>
|
|
777
|
+
</div>`
|
|
778
|
+
}).join('') : '<div class="empty">暂无待处理事项</div>'
|
|
779
|
+
list.querySelectorAll('[data-approve]').forEach(btn => {
|
|
780
|
+
const card = btn.closest('[data-approval]')
|
|
781
|
+
btn.addEventListener('click', () => approveApproval(card?.dataset.approval || '', btn.dataset.approve === '1'))
|
|
782
|
+
})
|
|
783
|
+
list.querySelectorAll('[data-question]').forEach(btn =>
|
|
784
|
+
btn.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === btn.dataset.question))))
|
|
785
|
+
updatePendingBadge()
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function approveApproval(id, allow) {
|
|
789
|
+
const a = state.approvals.find(x => x.approvalId === id)
|
|
790
|
+
if (!a) return
|
|
791
|
+
const ok = await respond(a.rpcId, { sessionId: a.sessionId, approvalId: a.approvalId, outcome: allow ? 'allowed-once' : 'rejected' })
|
|
792
|
+
toast(ok ? (allow ? '已允许' : '已拒绝') : '审批已不在待处理状态', ok ? 'ok' : 'err')
|
|
793
|
+
state.approvals = state.approvals.filter(x => x.approvalId !== id)
|
|
794
|
+
renderPending()
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function openQuestionModal(q) {
|
|
798
|
+
if (!q) return
|
|
799
|
+
state.questionModal = q
|
|
800
|
+
$('question-body').innerHTML = q.questions.map((item, i) => `
|
|
801
|
+
<div class="q-item">
|
|
802
|
+
<div class="q-text">${esc(item.header ? item.header + ':' : '')}${esc(item.question)}</div>
|
|
803
|
+
${(item.options || []).map((o, j) => `
|
|
804
|
+
<label class="q-option"><input type="${item.multiSelect ? 'checkbox' : 'radio'}" name="q${i}" value="${esc(o.label)}" data-q="${i}"><span>${esc(o.label)}${o.description ? `<div class="muted">${esc(o.description)}</div>` : ''}</span></label>`).join('')}
|
|
805
|
+
<textarea rows="2" placeholder="其他 / 自定义回答(可选)" data-qcustom="${i}"></textarea>
|
|
806
|
+
</div>`).join('')
|
|
807
|
+
$('modal-question').classList.remove('hidden')
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async function submitQuestion() {
|
|
811
|
+
const q = state.questionModal
|
|
812
|
+
if (!q) return
|
|
813
|
+
const answers = q.questions.map((item, i) => {
|
|
814
|
+
const sel = [...$('question-body').querySelectorAll(`input[data-q="${i}"]:checked`)].map(x => x.value)
|
|
815
|
+
const custom = $('question-body').querySelector(`[data-qcustom="${i}"]`)?.value?.trim()
|
|
816
|
+
const ans = { id: item.id, selected: sel }
|
|
817
|
+
if (custom) ans.custom = custom
|
|
818
|
+
if (!sel.length && !custom) return null
|
|
819
|
+
return ans
|
|
820
|
+
}).filter(Boolean)
|
|
821
|
+
if (!answers.length) return toast('请先选择或填写回答', 'err')
|
|
822
|
+
const ok = await respond(q.rpcId, { sessionId: q.sessionId, answer: { answers } })
|
|
823
|
+
if (ok) { toast('已提交回答', 'ok'); $('modal-question').classList.add('hidden'); state.questions = state.questions.filter(x => x.rpcId !== q.rpcId); renderPending() }
|
|
824
|
+
else toast('提问已不在待处理状态', 'err')
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/* ---------------- 后台任务 ---------------- */
|
|
828
|
+
function renderQueue() {
|
|
829
|
+
const s = state.byId.get(state.current)
|
|
830
|
+
if (!s) return
|
|
831
|
+
const items = state.queues[state.current] || []
|
|
832
|
+
updateCancelBtn()
|
|
833
|
+
// 队列数量在会话列表已显示; 详情页不重复大 UI
|
|
834
|
+
$('history-hint').textContent = items.length ? `队列 ${items.length} · 历史 ${state.history.visible.length}` : `历史 ${state.history.visible.length}`
|
|
835
|
+
renderSessions()
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function renderJobs() {
|
|
839
|
+
const box = $('jobs-list')
|
|
840
|
+
const all = Object.entries(state.jobs).filter(([, jobs]) => jobs?.length)
|
|
841
|
+
if (!all.length) { box.innerHTML = '<div class="empty">暂无后台任务</div>'; return }
|
|
842
|
+
box.innerHTML = all.flatMap(([sid, jobs]) => jobs.map(j => {
|
|
843
|
+
const title = titleOf(state.byId.get(sid))
|
|
844
|
+
return `<div class="job-card">
|
|
845
|
+
<div class="job-name">${esc(j.label || j.id)} <span class="pill ${j.status === 'running' ? 'active' : 'done'}">${esc(j.status)}</span></div>
|
|
846
|
+
<div class="job-state">${esc(j.kind)} · ${esc(title)} · ${j.startedAt ? fmtTime(j.startedAt) : ''}${j.detail ? ' · ' + esc(j.detail) : ''}</div>
|
|
847
|
+
</div>`
|
|
848
|
+
}).join(''))
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/* ---------------- goal 编辑 ---------------- */
|
|
852
|
+
function openGoalModal(goal) {
|
|
853
|
+
state.goalEdit = goal
|
|
854
|
+
$('goal-body').innerHTML = `
|
|
855
|
+
<div class="kv"><span class="k">phase</span><span class="v">${esc(goal.phase || '?')}</span></div>
|
|
856
|
+
<div class="kv"><span class="k">revision</span><span class="v">${goal.revision ?? '?'}</span></div>
|
|
857
|
+
<textarea id="goal-edit-text" rows="4" style="width:100%;margin-top:10px">${esc(goal.objective || '')}</textarea>`
|
|
858
|
+
$('goal-edit').classList.remove('hidden')
|
|
859
|
+
$('modal-goal').classList.remove('hidden')
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
async function submitGoalEdit() {
|
|
863
|
+
const goal = state.goalEdit
|
|
864
|
+
if (!goal) return
|
|
865
|
+
const objective = $('goal-edit-text')?.value?.trim()
|
|
866
|
+
if (!objective) return toast('目标不能为空', 'err')
|
|
867
|
+
await safeRpc('goal.edit', { sessionId: state.current, ref: { id: goal.id, revision: goal.revision }, objective }, '更新失败')
|
|
868
|
+
$('modal-goal').classList.add('hidden')
|
|
869
|
+
toast('目标已更新', 'ok')
|
|
870
|
+
scheduleRefresh()
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/* ---------------- 检查更新 ---------------- */
|
|
874
|
+
function cmpVersion(a, b) {
|
|
875
|
+
const pa = String(a || '').split('.').map(Number)
|
|
876
|
+
const pb = String(b || '').split('.').map(Number)
|
|
877
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
878
|
+
const d = (pa[i] || 0) - (pb[i] || 0)
|
|
879
|
+
if (d) return d
|
|
880
|
+
}
|
|
881
|
+
return 0
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async function loadLocalVersion() {
|
|
885
|
+
try {
|
|
886
|
+
const res = await fetch('version.json?t=' + Date.now())
|
|
887
|
+
if (res.ok) state.localVersion = (await res.json())?.version || ''
|
|
888
|
+
} catch {}
|
|
889
|
+
$('update-desc').textContent = state.localVersion ? `当前版本 v${state.localVersion}` : '未获取到版本'
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async function checkUpdate(silent) {
|
|
893
|
+
const base = state.server
|
|
894
|
+
if (!base) {
|
|
895
|
+
if (!silent) toast('请先设置服务器地址', 'err')
|
|
896
|
+
$('update-desc').textContent = state.localVersion ? `当前版本 v${state.localVersion} · 未设置服务器` : '请先设置服务器地址'
|
|
897
|
+
return
|
|
898
|
+
}
|
|
899
|
+
if (!silent) toast('正在检查更新…')
|
|
900
|
+
try {
|
|
901
|
+
const res = await fetch(base + '/update.json?t=' + Date.now())
|
|
902
|
+
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
903
|
+
const info = await res.json()
|
|
904
|
+
if (info.version && cmpVersion(info.version, state.localVersion) > 0) {
|
|
905
|
+
state.updateInfo = info
|
|
906
|
+
$('update-desc').textContent = `发现新版本 v${info.version}${info.notes ? ':' + info.notes : ''}`
|
|
907
|
+
$('btn-download-update').classList.remove('hidden')
|
|
908
|
+
if (!silent) toast(`发现新版本 v${info.version}`, 'ok')
|
|
909
|
+
else notify('发现新版本', 'v' + info.version + ' 可更新')
|
|
910
|
+
} else {
|
|
911
|
+
state.updateInfo = null
|
|
912
|
+
$('update-desc').textContent = state.localVersion ? `已是最新 v${state.localVersion}` : `最新版本 v${info.version || '?'}`
|
|
913
|
+
$('btn-download-update').classList.add('hidden')
|
|
914
|
+
if (!silent) toast('已是最新版本', 'ok')
|
|
915
|
+
}
|
|
916
|
+
} catch (e) {
|
|
917
|
+
$('update-desc').textContent = '检查失败:' + (e.message || '网络错误')
|
|
918
|
+
if (!silent) toast('检查更新失败:' + e.message, 'err')
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function downloadUpdate() {
|
|
923
|
+
const info = state.updateInfo
|
|
924
|
+
if (!info) return
|
|
925
|
+
const base = state.server || ''
|
|
926
|
+
let url
|
|
927
|
+
try { url = new URL(info.apkUrl || 'dsh-remote.apk', base + '/').href }
|
|
928
|
+
catch { url = base + '/' + (info.apkUrl || 'dsh-remote.apk') }
|
|
929
|
+
if (CAP?.isNativePlatform?.()) {
|
|
930
|
+
// Android WebView 原生桥(不依赖 Capacitor 插件路由)
|
|
931
|
+
if (window.NativeUpdate?.downloadAndInstall) {
|
|
932
|
+
try {
|
|
933
|
+
window.NativeUpdate.downloadAndInstall(url)
|
|
934
|
+
toast('开始下载,完成后会弹出安装页', 'ok')
|
|
935
|
+
} catch (e) {
|
|
936
|
+
toast('无法启动下载:' + (e?.message || ''), 'err')
|
|
937
|
+
}
|
|
938
|
+
return
|
|
939
|
+
}
|
|
940
|
+
// 兜底: 旧版 App 没有原生桥时用浏览器下载
|
|
941
|
+
toast('当前版本不支持 App 内安装,已转浏览器下载', 'err')
|
|
942
|
+
}
|
|
943
|
+
// 浏览器: 直接触发下载
|
|
944
|
+
location.href = url
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/* ---------------- 通知 ---------------- */
|
|
948
|
+
const CAP = window.Capacitor || null
|
|
949
|
+
async function ensureNotify() {
|
|
950
|
+
// App 内走原生通知插件(WebView 的 Web Notification 在 MIUI 拿不到权限)
|
|
951
|
+
if (CAP?.isNativePlatform?.()) {
|
|
952
|
+
try {
|
|
953
|
+
const L = CAP.Plugins?.LocalNotifications
|
|
954
|
+
if (!L?.requestPermissions) return false
|
|
955
|
+
const p = await L.requestPermissions()
|
|
956
|
+
return p?.display === 'granted'
|
|
957
|
+
} catch (e) {
|
|
958
|
+
toast('通知权限申请失败:' + (e?.message || ''), 'err')
|
|
959
|
+
return false
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
if (!('Notification' in window)) return false
|
|
963
|
+
if (Notification.permission === 'granted') return true
|
|
964
|
+
if (Notification.permission === 'denied') return false
|
|
965
|
+
return await Notification.requestPermission() === 'granted'
|
|
966
|
+
}
|
|
967
|
+
function notify(title, body) {
|
|
968
|
+
if (LS.get('notify', '0') !== '1') return
|
|
969
|
+
if (CAP?.isNativePlatform?.()) {
|
|
970
|
+
try {
|
|
971
|
+
CAP.Plugins.LocalNotifications.schedule({
|
|
972
|
+
notifications: [{
|
|
973
|
+
id: (Date.now() % 100000) + 1,
|
|
974
|
+
title: 'DSH Remote · ' + title,
|
|
975
|
+
body,
|
|
976
|
+
schedule: { at: new Date(Date.now() + 800) }
|
|
977
|
+
}]
|
|
978
|
+
})
|
|
979
|
+
} catch {}
|
|
980
|
+
return
|
|
981
|
+
}
|
|
982
|
+
try {
|
|
983
|
+
if ('Notification' in window && Notification.permission === 'granted') {
|
|
984
|
+
new Notification('DSH Remote · ' + title, { body })
|
|
985
|
+
}
|
|
986
|
+
} catch {}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/* ---------------- 视图切换 ---------------- */
|
|
990
|
+
function showView(id) {
|
|
991
|
+
for (const v of ['view-home', 'view-session', 'view-activity', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
|
|
992
|
+
document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === id))
|
|
993
|
+
window.scrollTo(0, 0)
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function updateConn() {
|
|
997
|
+
const ok = !!state.streamsOk?.mux
|
|
998
|
+
const el = $('conn-badge')
|
|
999
|
+
el.textContent = ok ? '已连接' : '未连接'
|
|
1000
|
+
el.className = 'conn-badge ' + (ok ? 'on' : 'off')
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function autosize(el) {
|
|
1004
|
+
el.style.height = 'auto'
|
|
1005
|
+
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/* ---------------- 初始化 ---------------- */
|
|
1009
|
+
function initToken() {
|
|
1010
|
+
const urlToken = new URLSearchParams(location.search).get('token')
|
|
1011
|
+
if (urlToken) {
|
|
1012
|
+
state.token = urlToken
|
|
1013
|
+
LS.set('token', urlToken)
|
|
1014
|
+
history.replaceState(null, '', location.pathname) // URL 里不留下 token
|
|
1015
|
+
} else {
|
|
1016
|
+
state.token = LS.get('token', '')
|
|
1017
|
+
}
|
|
1018
|
+
state.server = LS.get('server', '')
|
|
1019
|
+
$('token-desc').textContent = state.token ? '已保存(本机)' : '未设置'
|
|
1020
|
+
$('server-desc').textContent = state.server || '默认 = 当前页面地址'
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function bindUi() {
|
|
1024
|
+
// 底部导航
|
|
1025
|
+
document.querySelectorAll('.nav-btn').forEach(b =>
|
|
1026
|
+
b.addEventListener('click', () => showView(b.dataset.view)))
|
|
1027
|
+
// 会话列表点击
|
|
1028
|
+
$('session-list').addEventListener('click', (e) => {
|
|
1029
|
+
const card = e.target.closest('[data-id]')
|
|
1030
|
+
if (card) openSession(card.dataset.id)
|
|
1031
|
+
})
|
|
1032
|
+
$('btn-back').addEventListener('click', closeSession)
|
|
1033
|
+
$('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
|
|
1034
|
+
$('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
|
|
1035
|
+
$('btn-refresh').addEventListener('click', () => { toast('刷新中…'); refreshAll() })
|
|
1036
|
+
$('btn-admin').addEventListener('click', () => {
|
|
1037
|
+
location.href = state.server ? state.server.replace(/\/+$/, '') + '/admin' : 'admin'
|
|
1038
|
+
})
|
|
1039
|
+
$('btn-new-session').addEventListener('click', newSession)
|
|
1040
|
+
$('btn-cancel').addEventListener('click', cancelSession)
|
|
1041
|
+
$('btn-send').addEventListener('click', sendMessage)
|
|
1042
|
+
const input = $('composer-input')
|
|
1043
|
+
input.addEventListener('input', () => autosize(input))
|
|
1044
|
+
input.addEventListener('keydown', (e) => {
|
|
1045
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage() }
|
|
1046
|
+
})
|
|
1047
|
+
|
|
1048
|
+
// 审批
|
|
1049
|
+
$('approval-allow').addEventListener('click', () => {
|
|
1050
|
+
const a = state.approvalModal
|
|
1051
|
+
if (a) { approveApproval(a.approvalId, true); $('modal-approval').classList.add('hidden') }
|
|
1052
|
+
})
|
|
1053
|
+
$('approval-reject').addEventListener('click', () => {
|
|
1054
|
+
const a = state.approvalModal
|
|
1055
|
+
if (a) { approveApproval(a.approvalId, false); $('modal-approval').classList.add('hidden') }
|
|
1056
|
+
})
|
|
1057
|
+
// 提问
|
|
1058
|
+
$('question-submit').addEventListener('click', submitQuestion)
|
|
1059
|
+
$('question-later').addEventListener('click', () => $('modal-question').classList.add('hidden'))
|
|
1060
|
+
// goal
|
|
1061
|
+
$('goal-close').addEventListener('click', () => $('modal-goal').classList.add('hidden'))
|
|
1062
|
+
$('goal-edit').addEventListener('click', submitGoalEdit)
|
|
1063
|
+
// 设置
|
|
1064
|
+
$('btn-change-token').addEventListener('click', () => {
|
|
1065
|
+
const t = prompt('输入访问令牌(网关启动时打印的 token):', state.token)
|
|
1066
|
+
if (t && t.trim()) { state.token = t.trim(); LS.set('token', t.trim()); $('token-desc').textContent = '已保存'; toast('已保存,正在重连', 'ok'); openStreams(); refreshAll() }
|
|
1067
|
+
})
|
|
1068
|
+
$('btn-change-server').addEventListener('click', () => {
|
|
1069
|
+
const s = prompt('输入网关地址(留空 = 当前页面地址):\n例: http://192.168.1.100:8787', state.server)
|
|
1070
|
+
if (s === null) return
|
|
1071
|
+
const v = (s || '').trim().replace(/\/+$/, '')
|
|
1072
|
+
state.server = v
|
|
1073
|
+
v ? LS.set('server', v) : LS.del('server')
|
|
1074
|
+
$('server-desc').textContent = v || '默认 = 当前页面地址'
|
|
1075
|
+
toast('服务器已设置,正在重连', 'ok')
|
|
1076
|
+
openStreams(); refreshAll()
|
|
1077
|
+
})
|
|
1078
|
+
$('btn-host-describe').addEventListener('click', async () => {
|
|
1079
|
+
const v = await safeRpc('host.describe', {}, '探测失败')
|
|
1080
|
+
if (v) {
|
|
1081
|
+
state.hostInfo = v
|
|
1082
|
+
$('host-desc').textContent = `DSH ${v.version} · ${v.cwd} · 附加会话 ${v.attachedSessions}`
|
|
1083
|
+
}
|
|
1084
|
+
})
|
|
1085
|
+
$('btn-check-update').addEventListener('click', () => checkUpdate(false))
|
|
1086
|
+
$('btn-download-update').addEventListener('click', downloadUpdate)
|
|
1087
|
+
$('btn-reset').addEventListener('click', () => {
|
|
1088
|
+
if (!confirm('清除本地令牌、服务器与缓存?')) return
|
|
1089
|
+
LS.del('token'); LS.del('notify'); LS.del('server')
|
|
1090
|
+
location.reload()
|
|
1091
|
+
})
|
|
1092
|
+
$('opt-notify').checked = LS.get('notify', '0') === '1'
|
|
1093
|
+
$('opt-notify').addEventListener('change', async (e) => {
|
|
1094
|
+
if (e.target.checked) {
|
|
1095
|
+
const ok = await ensureNotify()
|
|
1096
|
+
if (!ok) { e.target.checked = false; return toast('通知权限未开启') }
|
|
1097
|
+
}
|
|
1098
|
+
LS.set('notify', e.target.checked ? '1' : '0')
|
|
1099
|
+
})
|
|
1100
|
+
$('opt-tools').checked = LS.get('showTools', '1') !== '0'
|
|
1101
|
+
$('opt-tools').addEventListener('change', (e) => {
|
|
1102
|
+
LS.set('showTools', e.target.checked ? '1' : '0')
|
|
1103
|
+
if (state.current) renderHistory(true)
|
|
1104
|
+
toast(e.target.checked ? '已显示工具调用' : '已隐藏工具调用', 'ok')
|
|
1105
|
+
})
|
|
1106
|
+
bindRail()
|
|
1107
|
+
|
|
1108
|
+
// 向上翻历史 / 向下回最新
|
|
1109
|
+
$('history').addEventListener('scroll', () => {
|
|
1110
|
+
const box = $('history')
|
|
1111
|
+
const h = state.history
|
|
1112
|
+
updateRail()
|
|
1113
|
+
if (!state.current || !h.filtered?.length) return
|
|
1114
|
+
if (box.scrollTop < 80) {
|
|
1115
|
+
if (h.renderStart > 0) {
|
|
1116
|
+
h.renderStart = Math.max(0, h.renderStart - 100)
|
|
1117
|
+
renderHistory(false, 'keep')
|
|
1118
|
+
} else if (h.hasMore && !h.loading) {
|
|
1119
|
+
loadHistory(false)
|
|
1120
|
+
}
|
|
1121
|
+
} else if (box.scrollHeight - box.scrollTop - box.clientHeight < 240) {
|
|
1122
|
+
if (h.renderEnd < h.filtered.length) {
|
|
1123
|
+
h.renderEnd = h.filtered.length
|
|
1124
|
+
h.renderStart = Math.max(0, h.renderEnd - 200)
|
|
1125
|
+
renderHistory(false, 'bottom')
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
})
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/* App 内真实系统栏 inset(刘海/状态栏/手势条) */
|
|
1132
|
+
function applyNativeInsets() {
|
|
1133
|
+
try {
|
|
1134
|
+
const raw = window.NativeUpdate?.getInsets?.()
|
|
1135
|
+
if (!raw) return
|
|
1136
|
+
const ins = JSON.parse(raw)
|
|
1137
|
+
document.documentElement.style.setProperty('--native-top', (ins.top || 0) + 'px')
|
|
1138
|
+
document.documentElement.style.setProperty('--native-bottom', (ins.bottom || 0) + 'px')
|
|
1139
|
+
} catch {}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
async function boot() {
|
|
1143
|
+
initToken()
|
|
1144
|
+
bindUi()
|
|
1145
|
+
bindNativeBack()
|
|
1146
|
+
applyNativeInsets()
|
|
1147
|
+
updateConn()
|
|
1148
|
+
loadLocalVersion()
|
|
1149
|
+
if (!state.token) {
|
|
1150
|
+
showView('view-settings')
|
|
1151
|
+
$('token-desc').textContent = '未设置——点「更换」粘贴网关启动时打印的 token'
|
|
1152
|
+
} else {
|
|
1153
|
+
openStreams()
|
|
1154
|
+
await refreshAll()
|
|
1155
|
+
const host = await safeRpc('host.describe', {}, '')
|
|
1156
|
+
if (host) { state.hostInfo = host; $('host-desc').textContent = `DSH ${host.version} · ${host.cwd} · 附加会话 ${host.attachedSessions}` }
|
|
1157
|
+
// 启动后自动检查一次更新(静默)
|
|
1158
|
+
setTimeout(() => checkUpdate(true), 4000)
|
|
1159
|
+
}
|
|
1160
|
+
renderPending()
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
document.addEventListener('DOMContentLoaded', boot)
|