dsh-remote-control-plugin 0.0.0-stage → 0.3.0
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/INSTALL.md +35 -0
- package/README.md +96 -2
- package/client.js +563 -0
- package/cordis.patch.yml +3 -0
- package/host/fabric.js +578 -0
- package/host/relay-core.js +189 -0
- package/host/relay-link.js +335 -0
- package/icon.svg +15 -0
- package/index.js +1010 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +45 -4
- package/web/app.css +420 -0
- package/web/app.js +594 -0
- package/web/icon.svg +15 -0
- package/web/index.html +85 -0
package/web/app.js
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
const $ = (id) => document.getElementById(id)
|
|
2
|
+
|
|
3
|
+
const el = {
|
|
4
|
+
login: $('login'),
|
|
5
|
+
app: $('app'),
|
|
6
|
+
pairForm: $('pair-form'),
|
|
7
|
+
code: $('code'),
|
|
8
|
+
pairSubmit: $('pair-submit'),
|
|
9
|
+
pairError: $('pair-error'),
|
|
10
|
+
menu: $('menu'),
|
|
11
|
+
drawer: $('drawer'),
|
|
12
|
+
scrim: $('scrim'),
|
|
13
|
+
sessionList: $('session-list'),
|
|
14
|
+
newSession: $('new-session'),
|
|
15
|
+
logout: $('logout'),
|
|
16
|
+
title: $('session-title'),
|
|
17
|
+
meta: $('session-meta'),
|
|
18
|
+
status: $('status'),
|
|
19
|
+
log: $('log'),
|
|
20
|
+
empty: $('empty'),
|
|
21
|
+
composer: $('composer'),
|
|
22
|
+
input: $('input'),
|
|
23
|
+
send: $('send'),
|
|
24
|
+
stop: $('stop'),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const state = {
|
|
28
|
+
sessions: [],
|
|
29
|
+
sessionId: null,
|
|
30
|
+
source: null,
|
|
31
|
+
seen: new Set(),
|
|
32
|
+
lastSeq: 0,
|
|
33
|
+
running: false,
|
|
34
|
+
pinned: true,
|
|
35
|
+
live: null,
|
|
36
|
+
liveText: '',
|
|
37
|
+
tools: new Map(),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* ---------------- transport ---------------- */
|
|
41
|
+
|
|
42
|
+
async function request(path, body) {
|
|
43
|
+
const response = await fetch(path, {
|
|
44
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
45
|
+
credentials: 'same-origin',
|
|
46
|
+
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
|
|
47
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
48
|
+
})
|
|
49
|
+
const data = await response.json().catch(() => ({}))
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
const error = new Error(data.message || data.error || `HTTP ${response.status}`)
|
|
52
|
+
error.status = response.status
|
|
53
|
+
throw error
|
|
54
|
+
}
|
|
55
|
+
return data
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* ---------------- boot ---------------- */
|
|
59
|
+
|
|
60
|
+
boot()
|
|
61
|
+
|
|
62
|
+
async function boot() {
|
|
63
|
+
try {
|
|
64
|
+
const me = await request('/api/me')
|
|
65
|
+
if (me.authenticated) startApp()
|
|
66
|
+
else showLogin()
|
|
67
|
+
} catch {
|
|
68
|
+
showLogin()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function showLogin() {
|
|
73
|
+
el.app.hidden = true
|
|
74
|
+
el.login.hidden = false
|
|
75
|
+
el.code.focus()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
el.pairForm.addEventListener('submit', async (event) => {
|
|
79
|
+
event.preventDefault()
|
|
80
|
+
el.pairError.hidden = true
|
|
81
|
+
el.pairSubmit.disabled = true
|
|
82
|
+
try {
|
|
83
|
+
await request('/api/pair', { code: el.code.value.trim() })
|
|
84
|
+
location.reload()
|
|
85
|
+
} catch (error) {
|
|
86
|
+
el.pairError.textContent = error.message
|
|
87
|
+
el.pairError.hidden = false
|
|
88
|
+
el.code.select()
|
|
89
|
+
} finally {
|
|
90
|
+
el.pairSubmit.disabled = false
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
function startApp() {
|
|
95
|
+
el.login.hidden = true
|
|
96
|
+
el.app.hidden = false
|
|
97
|
+
|
|
98
|
+
el.menu.addEventListener('click', openDrawer)
|
|
99
|
+
el.scrim.addEventListener('click', closeDrawer)
|
|
100
|
+
el.newSession.addEventListener('click', createSession)
|
|
101
|
+
el.logout.addEventListener('click', async () => {
|
|
102
|
+
try { await request('/api/logout', {}) } catch { /* ignore */ }
|
|
103
|
+
location.reload()
|
|
104
|
+
})
|
|
105
|
+
el.composer.addEventListener('submit', send)
|
|
106
|
+
el.input.addEventListener('input', autosize)
|
|
107
|
+
el.input.addEventListener('keydown', (event) => {
|
|
108
|
+
if (event.key === 'Enter' && !event.shiftKey && !isTouch()) {
|
|
109
|
+
event.preventDefault()
|
|
110
|
+
send(event)
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
el.stop.addEventListener('click', stopTurn)
|
|
114
|
+
el.log.addEventListener('scroll', () => {
|
|
115
|
+
state.pinned = el.log.scrollHeight - el.log.scrollTop - el.log.clientHeight < 60
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
autosize()
|
|
119
|
+
loadSessions().catch((error) => addStrip(error.message))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isTouch() {
|
|
123
|
+
return window.matchMedia('(pointer: coarse)').matches
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* ---------------- sessions ---------------- */
|
|
127
|
+
|
|
128
|
+
async function loadSessions() {
|
|
129
|
+
const value = await request('/api/sessions')
|
|
130
|
+
state.sessions = Array.isArray(value.items) ? value.items : []
|
|
131
|
+
renderSessions()
|
|
132
|
+
if (!state.sessionId && state.sessions.length > 0) {
|
|
133
|
+
const preferred = state.sessions.find((session) => session.running) || state.sessions[0]
|
|
134
|
+
selectSession(preferred.id)
|
|
135
|
+
} else if (state.sessionId) {
|
|
136
|
+
updateMeta()
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function renderSessions() {
|
|
141
|
+
el.sessionList.textContent = ''
|
|
142
|
+
if (state.sessions.length === 0) {
|
|
143
|
+
const empty = document.createElement('p')
|
|
144
|
+
empty.className = 'muted tiny'
|
|
145
|
+
empty.style.padding = '10px 12px'
|
|
146
|
+
empty.textContent = 'No sessions yet.'
|
|
147
|
+
el.sessionList.append(empty)
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
for (const session of state.sessions) {
|
|
151
|
+
const button = document.createElement('button')
|
|
152
|
+
button.type = 'button'
|
|
153
|
+
button.className = 'session-item'
|
|
154
|
+
button.setAttribute('aria-current', String(session.id === state.sessionId))
|
|
155
|
+
|
|
156
|
+
const row1 = document.createElement('div')
|
|
157
|
+
row1.className = 'row1'
|
|
158
|
+
const name = document.createElement('span')
|
|
159
|
+
name.textContent = titleOf(session)
|
|
160
|
+
row1.append(name)
|
|
161
|
+
if (session.running) {
|
|
162
|
+
const dot = document.createElement('span')
|
|
163
|
+
dot.className = 'dot'
|
|
164
|
+
row1.append(dot)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const row2 = document.createElement('div')
|
|
168
|
+
row2.className = 'row2'
|
|
169
|
+
row2.textContent = subtitleOf(session)
|
|
170
|
+
|
|
171
|
+
button.append(row1, row2)
|
|
172
|
+
button.addEventListener('click', () => {
|
|
173
|
+
selectSession(session.id)
|
|
174
|
+
closeDrawer()
|
|
175
|
+
})
|
|
176
|
+
el.sessionList.append(button)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function titleOf(session) {
|
|
181
|
+
if (session.title) return session.title
|
|
182
|
+
if (session.cwd) return baseName(session.cwd)
|
|
183
|
+
return 'Untitled session'
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function subtitleOf(session) {
|
|
187
|
+
const parts = []
|
|
188
|
+
if (session.cwd) parts.push(baseName(session.cwd))
|
|
189
|
+
if (session.updatedAt) parts.push(relativeTime(session.updatedAt))
|
|
190
|
+
if (session.running) parts.push('running')
|
|
191
|
+
return parts.join(' · ') || session.id
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function baseName(value) {
|
|
195
|
+
const parts = String(value).split(/[\\/]/).filter(Boolean)
|
|
196
|
+
return parts[parts.length - 1] || String(value)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function relativeTime(value) {
|
|
200
|
+
const delta = Date.now() - Number(value)
|
|
201
|
+
if (!Number.isFinite(delta)) return ''
|
|
202
|
+
const minutes = Math.round(delta / 60000)
|
|
203
|
+
if (minutes < 1) return 'just now'
|
|
204
|
+
if (minutes < 60) return `${minutes}m ago`
|
|
205
|
+
const hours = Math.round(minutes / 60)
|
|
206
|
+
if (hours < 24) return `${hours}h ago`
|
|
207
|
+
const days = Math.round(hours / 24)
|
|
208
|
+
return `${days}d ago`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function createSession() {
|
|
212
|
+
closeDrawer()
|
|
213
|
+
try {
|
|
214
|
+
const value = await request('/api/new-session', {})
|
|
215
|
+
if (value?.sessionId) {
|
|
216
|
+
await loadSessions()
|
|
217
|
+
selectSession(value.sessionId)
|
|
218
|
+
el.input.focus()
|
|
219
|
+
}
|
|
220
|
+
} catch (error) {
|
|
221
|
+
addStrip(`Could not create a session: ${error.message}`)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function selectSession(id) {
|
|
226
|
+
if (state.sessionId === id && state.source) return
|
|
227
|
+
state.sessionId = id
|
|
228
|
+
state.seen.clear()
|
|
229
|
+
state.lastSeq = 0
|
|
230
|
+
state.tools.clear()
|
|
231
|
+
state.live = null
|
|
232
|
+
state.liveText = ''
|
|
233
|
+
state.running = false
|
|
234
|
+
el.log.textContent = ''
|
|
235
|
+
el.log.append(el.empty)
|
|
236
|
+
el.empty.hidden = true
|
|
237
|
+
setRunning(false)
|
|
238
|
+
updateMeta()
|
|
239
|
+
for (const button of el.sessionList.children) {
|
|
240
|
+
button.setAttribute?.('aria-current', 'false')
|
|
241
|
+
}
|
|
242
|
+
renderSessions()
|
|
243
|
+
openStream()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function updateMeta() {
|
|
247
|
+
const session = state.sessions.find((entry) => entry.id === state.sessionId)
|
|
248
|
+
el.title.textContent = session ? titleOf(session) : 'No session'
|
|
249
|
+
el.meta.textContent = session ? subtitleOf(session) : ''
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/* ---------------- stream ---------------- */
|
|
253
|
+
|
|
254
|
+
function openStream() {
|
|
255
|
+
state.source?.close()
|
|
256
|
+
setStatus('connecting')
|
|
257
|
+
const source = new EventSource(`/api/stream?sessionId=${encodeURIComponent(state.sessionId)}`)
|
|
258
|
+
state.source = source
|
|
259
|
+
source.onopen = () => setStatus('live')
|
|
260
|
+
source.onerror = () => setStatus('error')
|
|
261
|
+
source.onmessage = (event) => {
|
|
262
|
+
let frame
|
|
263
|
+
try { frame = JSON.parse(event.data) } catch { return }
|
|
264
|
+
handleFrame(frame)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function handleFrame(frame) {
|
|
269
|
+
if (!frame || typeof frame !== 'object') return
|
|
270
|
+
if (frame.type === 'snapshot') {
|
|
271
|
+
setStatus('live')
|
|
272
|
+
if (typeof frame.cursor === 'number') state.lastSeq = Math.max(state.lastSeq, frame.cursor)
|
|
273
|
+
for (const record of frame.records || []) {
|
|
274
|
+
if (record?.type === 'event' && record.event) appendEvent(record.event)
|
|
275
|
+
}
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
if (frame.type === 'event') {
|
|
279
|
+
appendEvent(frame.event)
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
if (frame.type === 'assistant-stream') {
|
|
283
|
+
handleLive(frame.frame)
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
if (frame.type === 'remote-error') {
|
|
287
|
+
addStrip(`${frame.code || 'error'}: ${frame.message || 'stream failed'}`)
|
|
288
|
+
setStatus('error')
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function setStatus(value) {
|
|
293
|
+
const labels = { live: 'live', connecting: 'connecting', error: 'offline', idle: 'idle' }
|
|
294
|
+
el.status.dataset.state = value
|
|
295
|
+
el.status.lastElementChild.textContent = labels[value] || value
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/* ---------------- rendering ---------------- */
|
|
299
|
+
|
|
300
|
+
function appendEvent(event) {
|
|
301
|
+
if (!event || typeof event.seq !== 'number' || state.seen.has(event.seq)) return
|
|
302
|
+
state.seen.add(event.seq)
|
|
303
|
+
state.lastSeq = Math.max(state.lastSeq, event.seq)
|
|
304
|
+
|
|
305
|
+
const data = event.data || {}
|
|
306
|
+
switch (event.type) {
|
|
307
|
+
case 'user/message': {
|
|
308
|
+
// Harness-injected context (runtime context, skill catalog, time context)
|
|
309
|
+
// is also stored as user/message; only genuine prompts belong in the chat.
|
|
310
|
+
const kind = data.source?.kind
|
|
311
|
+
if (kind && kind !== 'user') break
|
|
312
|
+
const text = blocksText(data.content ?? data.message?.content)
|
|
313
|
+
const images = Array.isArray(data.content)
|
|
314
|
+
? data.content.filter((block) => block && block.type === 'image').length
|
|
315
|
+
: 0
|
|
316
|
+
if (text) addBubble('user', text)
|
|
317
|
+
else if (images > 0) addBubble('user', `[${images} image${images > 1 ? 's' : ''}]`)
|
|
318
|
+
break
|
|
319
|
+
}
|
|
320
|
+
case 'session/title': {
|
|
321
|
+
const title = typeof data.title === 'string' ? data.title.trim() : ''
|
|
322
|
+
if (title) {
|
|
323
|
+
el.title.textContent = title
|
|
324
|
+
const session = state.sessions.find((entry) => entry.id === state.sessionId)
|
|
325
|
+
if (session) session.title = title
|
|
326
|
+
}
|
|
327
|
+
break
|
|
328
|
+
}
|
|
329
|
+
case 'assistant/message': {
|
|
330
|
+
clearLive()
|
|
331
|
+
const text = blocksText(data.message?.content)
|
|
332
|
+
const reasoning = blocksReasoning(data.message?.content)
|
|
333
|
+
if (text || reasoning) addAssistant(text, reasoning, data.interrupted === true)
|
|
334
|
+
break
|
|
335
|
+
}
|
|
336
|
+
case 'assistant/attempt':
|
|
337
|
+
clearLive()
|
|
338
|
+
break
|
|
339
|
+
case 'tool/call':
|
|
340
|
+
addToolCall(data)
|
|
341
|
+
break
|
|
342
|
+
case 'tool/result':
|
|
343
|
+
addToolResult(data)
|
|
344
|
+
break
|
|
345
|
+
case 'turn/start':
|
|
346
|
+
setRunning(true)
|
|
347
|
+
break
|
|
348
|
+
case 'turn/end': {
|
|
349
|
+
setRunning(false)
|
|
350
|
+
const reason = data.reason || {}
|
|
351
|
+
if (reason.kind === 'error') {
|
|
352
|
+
addStrip(`Turn failed: ${reason.error?.message || reason.error?.code || 'unknown error'}`)
|
|
353
|
+
} else if (reason.kind === 'aborted') {
|
|
354
|
+
addNote('Stopped')
|
|
355
|
+
} else if (reason.kind === 'max-tokens') {
|
|
356
|
+
addNote('Stopped at the token limit')
|
|
357
|
+
}
|
|
358
|
+
break
|
|
359
|
+
}
|
|
360
|
+
default:
|
|
361
|
+
break
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function blocksText(content) {
|
|
366
|
+
if (typeof content === 'string') return content
|
|
367
|
+
if (!Array.isArray(content)) return ''
|
|
368
|
+
return content
|
|
369
|
+
.map((block) => (block && block.type === 'text' ? String(block.text ?? '') : ''))
|
|
370
|
+
.join('')
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function blocksReasoning(content) {
|
|
374
|
+
if (!Array.isArray(content)) return ''
|
|
375
|
+
return content
|
|
376
|
+
.map((block) => (block && block.type === 'reasoning' ? String(block.text ?? '') : ''))
|
|
377
|
+
.join('\n')
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function addBubble(kind, text) {
|
|
381
|
+
const bubble = document.createElement('div')
|
|
382
|
+
bubble.className = `bubble ${kind}`
|
|
383
|
+
bubble.textContent = text
|
|
384
|
+
push(bubble)
|
|
385
|
+
return bubble
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function addAssistant(text, reasoning, interrupted) {
|
|
389
|
+
const bubble = document.createElement('div')
|
|
390
|
+
bubble.className = 'bubble assistant'
|
|
391
|
+
if (interrupted) bubble.classList.add('interrupted')
|
|
392
|
+
if (reasoning) {
|
|
393
|
+
const details = document.createElement('details')
|
|
394
|
+
details.className = 'tool'
|
|
395
|
+
const summary = document.createElement('summary')
|
|
396
|
+
summary.textContent = 'Reasoning'
|
|
397
|
+
const pre = document.createElement('pre')
|
|
398
|
+
pre.textContent = reasoning
|
|
399
|
+
details.append(summary, pre)
|
|
400
|
+
bubble.append(details)
|
|
401
|
+
}
|
|
402
|
+
if (text) {
|
|
403
|
+
const body = document.createElement('div')
|
|
404
|
+
body.textContent = text
|
|
405
|
+
bubble.append(body)
|
|
406
|
+
}
|
|
407
|
+
if (interrupted) {
|
|
408
|
+
const note = document.createElement('span')
|
|
409
|
+
note.className = 'label'
|
|
410
|
+
note.textContent = 'interrupted'
|
|
411
|
+
bubble.append(note)
|
|
412
|
+
}
|
|
413
|
+
push(bubble)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function addToolCall(data) {
|
|
417
|
+
const name = String(data.name || 'tool')
|
|
418
|
+
let hint = ''
|
|
419
|
+
try {
|
|
420
|
+
const args = JSON.parse(data.arguments || '{}')
|
|
421
|
+
hint = args.command || args.path || args.query || args.description || args.file_path || ''
|
|
422
|
+
} catch {
|
|
423
|
+
hint = String(data.arguments || '')
|
|
424
|
+
}
|
|
425
|
+
const details = document.createElement('details')
|
|
426
|
+
details.className = 'tool'
|
|
427
|
+
|
|
428
|
+
const summary = document.createElement('summary')
|
|
429
|
+
const toolName = document.createElement('span')
|
|
430
|
+
toolName.className = 'tool-name'
|
|
431
|
+
toolName.textContent = name
|
|
432
|
+
const toolHint = document.createElement('span')
|
|
433
|
+
toolHint.className = 'tool-hint'
|
|
434
|
+
toolHint.textContent = String(hint).replace(/\s+/g, ' ').slice(0, 120)
|
|
435
|
+
summary.append('⚙', toolName, toolHint)
|
|
436
|
+
|
|
437
|
+
const pre = document.createElement('pre')
|
|
438
|
+
pre.textContent = prettyJson(data.arguments)
|
|
439
|
+
details.append(summary, pre)
|
|
440
|
+
|
|
441
|
+
if (data.callId) state.tools.set(data.callId, details)
|
|
442
|
+
push(details)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function addToolResult(data) {
|
|
446
|
+
const details = data.message?.toolCallId ? state.tools.get(data.message.toolCallId) : undefined
|
|
447
|
+
const output = blocksText(data.message?.content)
|
|
448
|
+
const failed = Boolean(data.error) || data.message?.isError === true
|
|
449
|
+
const target = details || document.createElement('details')
|
|
450
|
+
|
|
451
|
+
if (!details) {
|
|
452
|
+
target.className = 'tool'
|
|
453
|
+
const summary = document.createElement('summary')
|
|
454
|
+
summary.textContent = 'tool result'
|
|
455
|
+
target.append(summary)
|
|
456
|
+
push(target)
|
|
457
|
+
}
|
|
458
|
+
if (failed) target.classList.add('error')
|
|
459
|
+
|
|
460
|
+
const pre = document.createElement('pre')
|
|
461
|
+
if (failed && data.error) {
|
|
462
|
+
pre.textContent = `${data.error.name || 'error'}: ${data.error.reason || data.error.code || ''}`
|
|
463
|
+
if (output) pre.textContent += `\n\n${output}`
|
|
464
|
+
} else {
|
|
465
|
+
pre.textContent = output || '(no output)'
|
|
466
|
+
}
|
|
467
|
+
target.append(pre)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function addNote(text) {
|
|
471
|
+
const note = document.createElement('div')
|
|
472
|
+
note.className = 'note'
|
|
473
|
+
note.textContent = text
|
|
474
|
+
push(note)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function addStrip(text) {
|
|
478
|
+
const strip = document.createElement('div')
|
|
479
|
+
strip.className = 'strip'
|
|
480
|
+
strip.textContent = text
|
|
481
|
+
push(strip)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function push(node) {
|
|
485
|
+
el.empty.hidden = true
|
|
486
|
+
el.log.append(node)
|
|
487
|
+
if (state.pinned) {
|
|
488
|
+
requestAnimationFrame(() => { el.log.scrollTop = el.log.scrollHeight })
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function prettyJson(value) {
|
|
493
|
+
if (typeof value !== 'string') return JSON.stringify(value, null, 2) ?? ''
|
|
494
|
+
try { return JSON.stringify(JSON.parse(value), null, 2) } catch { return value }
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/* ---------------- live assistant stream ---------------- */
|
|
498
|
+
|
|
499
|
+
function handleLive(frame) {
|
|
500
|
+
if (!frame || typeof frame !== 'object') return
|
|
501
|
+
if (frame.type === 'start') {
|
|
502
|
+
clearLive()
|
|
503
|
+
state.live = document.createElement('div')
|
|
504
|
+
state.live.className = 'bubble assistant live'
|
|
505
|
+
state.liveText = ''
|
|
506
|
+
const body = document.createElement('div')
|
|
507
|
+
body.className = 'live-body'
|
|
508
|
+
state.live.append(body)
|
|
509
|
+
push(state.live)
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
if (frame.type === 'chunk') {
|
|
513
|
+
const chunk = frame.chunk || {}
|
|
514
|
+
if (!state.live) return
|
|
515
|
+
const body = state.live.querySelector('.live-body')
|
|
516
|
+
if (chunk.type === 'text-delta') {
|
|
517
|
+
state.liveText += String(chunk.text ?? '')
|
|
518
|
+
body.textContent = state.liveText
|
|
519
|
+
} else if (chunk.type === 'block-end' && chunk.block?.type === 'text') {
|
|
520
|
+
state.liveText = String(chunk.block.text ?? '')
|
|
521
|
+
body.textContent = state.liveText
|
|
522
|
+
}
|
|
523
|
+
if (state.pinned) requestAnimationFrame(() => { el.log.scrollTop = el.log.scrollHeight })
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
if (frame.type === 'end') {
|
|
527
|
+
const live = state.live
|
|
528
|
+
setTimeout(() => {
|
|
529
|
+
if (state.live === live) clearLive()
|
|
530
|
+
}, 800)
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function clearLive() {
|
|
535
|
+
if (state.live) {
|
|
536
|
+
state.live.remove()
|
|
537
|
+
state.live = null
|
|
538
|
+
state.liveText = ''
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/* ---------------- actions ---------------- */
|
|
543
|
+
|
|
544
|
+
function setRunning(value) {
|
|
545
|
+
state.running = value
|
|
546
|
+
el.stop.hidden = !value
|
|
547
|
+
el.send.disabled = false
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function send(event) {
|
|
551
|
+
event?.preventDefault()
|
|
552
|
+
const text = el.input.value.trim()
|
|
553
|
+
if (!text || !state.sessionId) return
|
|
554
|
+
el.input.value = ''
|
|
555
|
+
autosize()
|
|
556
|
+
try {
|
|
557
|
+
await request('/api/prompt', { sessionId: state.sessionId, text })
|
|
558
|
+
if (state.pinned) requestAnimationFrame(() => { el.log.scrollTop = el.log.scrollHeight })
|
|
559
|
+
} catch (error) {
|
|
560
|
+
el.input.value = text
|
|
561
|
+
autosize()
|
|
562
|
+
addStrip(`Could not send: ${error.message}`)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function stopTurn() {
|
|
567
|
+
if (!state.sessionId) return
|
|
568
|
+
el.stop.disabled = true
|
|
569
|
+
try {
|
|
570
|
+
await request('/api/cancel', { sessionId: state.sessionId })
|
|
571
|
+
} catch (error) {
|
|
572
|
+
addStrip(`Could not stop: ${error.message}`)
|
|
573
|
+
} finally {
|
|
574
|
+
el.stop.disabled = false
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function autosize() {
|
|
579
|
+
el.input.style.height = 'auto'
|
|
580
|
+
el.input.style.height = `${Math.min(el.input.scrollHeight, window.innerHeight * 0.4)}px`
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/* ---------------- drawer ---------------- */
|
|
584
|
+
|
|
585
|
+
function openDrawer() {
|
|
586
|
+
el.drawer.hidden = false
|
|
587
|
+
el.scrim.hidden = false
|
|
588
|
+
loadSessions().catch(() => {})
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function closeDrawer() {
|
|
592
|
+
el.drawer.hidden = true
|
|
593
|
+
el.scrim.hidden = true
|
|
594
|
+
}
|
package/web/icon.svg
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Remote Control">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#2f6bff"/>
|
|
5
|
+
<stop offset="1" stop-color="#7a3cff"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect x="2" y="2" width="60" height="60" rx="16" fill="url(#bg)"/>
|
|
9
|
+
<rect x="20" y="12" width="24" height="40" rx="6" fill="none" stroke="#ffffff" stroke-width="3.2"/>
|
|
10
|
+
<line x1="28" y1="17" x2="36" y2="17" stroke="#ffffff" stroke-width="2.6" stroke-linecap="round"/>
|
|
11
|
+
<path d="M27 30l-4 5 4 5" fill="none" stroke="#dce6ff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
|
12
|
+
<line x1="35" y1="29" x2="35" y2="41" stroke="#ffffff" stroke-width="3" stroke-linecap="round"/>
|
|
13
|
+
<path d="M44 24c3 2.5 3 13.5 0 16" fill="none" stroke="#ffffff" stroke-width="2.6" stroke-linecap="round" opacity="0.9"/>
|
|
14
|
+
<path d="M49 19c5.5 4.5 5.5 21.5 0 26" fill="none" stroke="#ffffff" stroke-width="2.6" stroke-linecap="round" opacity="0.55"/>
|
|
15
|
+
</svg>
|
package/web/index.html
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
|
+
<meta name="color-scheme" content="dark light" />
|
|
7
|
+
<meta name="theme-color" content="#0b0d12" />
|
|
8
|
+
<title>DSH Remote Control</title>
|
|
9
|
+
<link rel="icon" href="/icon.svg" />
|
|
10
|
+
<link rel="stylesheet" href="/app.css" />
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<section id="login" class="login" hidden>
|
|
14
|
+
<div class="login-card">
|
|
15
|
+
<img class="login-logo" src="/icon.svg" alt="" width="64" height="64" />
|
|
16
|
+
<h1>Remote Control</h1>
|
|
17
|
+
<p class="muted">
|
|
18
|
+
Enter the pairing code printed by the DeepSeek Harness Host. The code is
|
|
19
|
+
stored in <code>remote-control.json</code> inside your DSH home directory.
|
|
20
|
+
</p>
|
|
21
|
+
<form id="pair-form" autocomplete="off">
|
|
22
|
+
<label class="sr-only" for="code">Pairing code</label>
|
|
23
|
+
<input
|
|
24
|
+
id="code"
|
|
25
|
+
name="code"
|
|
26
|
+
inputmode="numeric"
|
|
27
|
+
pattern="[0-9]*"
|
|
28
|
+
maxlength="12"
|
|
29
|
+
placeholder="000000"
|
|
30
|
+
autocomplete="one-time-code"
|
|
31
|
+
spellcheck="false"
|
|
32
|
+
required
|
|
33
|
+
/>
|
|
34
|
+
<button type="submit" id="pair-submit">Pair this device</button>
|
|
35
|
+
</form>
|
|
36
|
+
<p id="pair-error" class="error" role="alert" hidden></p>
|
|
37
|
+
<p class="muted tiny">
|
|
38
|
+
This page can send prompts and stop turns on the paired Host. Keep the URL
|
|
39
|
+
and code to yourself.
|
|
40
|
+
</p>
|
|
41
|
+
</div>
|
|
42
|
+
</section>
|
|
43
|
+
|
|
44
|
+
<section id="app" class="app" hidden>
|
|
45
|
+
<header class="bar">
|
|
46
|
+
<button id="menu" class="icon-button" type="button" aria-label="Sessions">
|
|
47
|
+
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
|
|
48
|
+
<path d="M4 7h16M4 12h16M4 17h16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
|
|
49
|
+
</svg>
|
|
50
|
+
</button>
|
|
51
|
+
<div class="bar-title">
|
|
52
|
+
<span id="session-title">No session</span>
|
|
53
|
+
<span id="session-meta" class="muted tiny"></span>
|
|
54
|
+
</div>
|
|
55
|
+
<span id="status" class="status" title="Stream status"><i></i><span>idle</span></span>
|
|
56
|
+
</header>
|
|
57
|
+
|
|
58
|
+
<div id="scrim" class="scrim" hidden></div>
|
|
59
|
+
<aside id="drawer" class="drawer" hidden>
|
|
60
|
+
<div class="drawer-head">
|
|
61
|
+
<strong>Sessions</strong>
|
|
62
|
+
<button id="new-session" class="ghost-button" type="button">New</button>
|
|
63
|
+
</div>
|
|
64
|
+
<div id="session-list" class="session-list"></div>
|
|
65
|
+
<div class="drawer-foot">
|
|
66
|
+
<button id="logout" class="link-button" type="button">Forget this device</button>
|
|
67
|
+
</div>
|
|
68
|
+
</aside>
|
|
69
|
+
|
|
70
|
+
<main id="log" class="log" aria-live="polite">
|
|
71
|
+
<div id="empty" class="empty">
|
|
72
|
+
<p>Pick a session from the list, or create a new one.</p>
|
|
73
|
+
</div>
|
|
74
|
+
</main>
|
|
75
|
+
|
|
76
|
+
<form id="composer" class="composer">
|
|
77
|
+
<textarea id="input" rows="1" placeholder="Message the agent…" enterkeyhint="send"></textarea>
|
|
78
|
+
<button id="stop" class="stop" type="button" hidden>Stop</button>
|
|
79
|
+
<button id="send" class="send" type="submit">Send</button>
|
|
80
|
+
</form>
|
|
81
|
+
</section>
|
|
82
|
+
|
|
83
|
+
<script type="module" src="/app.js"></script>
|
|
84
|
+
</body>
|
|
85
|
+
</html>
|