@skillpie/dsh-remote 0.0.0-stage → 1.0.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/lib/console.js ADDED
@@ -0,0 +1,434 @@
1
+ /**
2
+ * dsh-remote — visitor console page (served by the relay face and by local
3
+ * direct mode). Single self-contained HTML document: no external assets, no
4
+ * build step. The 8-digit code comes from the URL path; all traffic rides
5
+ * `/<code>/ws`.
6
+ */
7
+
8
+ const CSS = `
9
+ :root {
10
+ color-scheme: light dark;
11
+ --bg: #f6f7f9; --bg2: #ffffff; --fg: #1a1c1f; --fg2: #6b7280;
12
+ --line: #e2e5ea; --accent: #4d6bfe; --accentfg: #ffffff;
13
+ --user-bg: #4d6bfe; --user-fg: #ffffff; --ok: #16a34a; --warn: #d97706; --bad: #dc2626;
14
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
15
+ }
16
+ @media (prefers-color-scheme: dark) {
17
+ :root {
18
+ --bg: #101114; --bg2: #17191d; --fg: #e8eaed; --fg2: #9aa0a6;
19
+ --line: #2a2d33; --accent: #6b84ff; --accentfg: #0e1116;
20
+ --user-bg: #4d6bfe; --user-fg: #ffffff; --ok: #4ade80; --warn: #fbbf24; --bad: #f87171;
21
+ }
22
+ }
23
+ * { box-sizing: border-box; }
24
+ html, body { height: 100%; }
25
+ body {
26
+ margin: 0; background: var(--bg); color: var(--fg);
27
+ font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
28
+ display: flex; flex-direction: column;
29
+ }
30
+ header {
31
+ display: flex; align-items: center; gap: 10px; padding: 10px 16px;
32
+ border-bottom: 1px solid var(--line); background: var(--bg2); flex: none;
33
+ }
34
+ header .brand { font-weight: 700; font-size: 13px; display: flex; align-items: center; gap: 8px; }
35
+ header .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--bad); flex: none; }
36
+ header .dot.on { background: var(--ok); }
37
+ header .host { color: var(--fg2); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
38
+ header .code { font-family: var(--mono); font-size: 12px; color: var(--fg2); margin-left: auto; flex: none; }
39
+ main { flex: 1; display: flex; min-height: 0; }
40
+ aside {
41
+ width: 232px; flex: none; border-right: 1px solid var(--line); background: var(--bg2);
42
+ display: flex; flex-direction: column; min-height: 0;
43
+ }
44
+ @media (max-width: 720px) { aside { display: none; } }
45
+ aside .head {
46
+ display: flex; align-items: center; justify-content: space-between;
47
+ padding: 10px 12px 6px; color: var(--fg2); font-size: 12px; flex: none;
48
+ }
49
+ aside .new {
50
+ border: 1px solid var(--line); background: transparent; color: var(--fg);
51
+ border-radius: 6px; padding: 2px 8px; font-size: 12px; cursor: pointer;
52
+ }
53
+ aside .new:hover { border-color: var(--accent); color: var(--accent); }
54
+ aside .list { overflow-y: auto; flex: 1; padding: 4px 6px 10px; }
55
+ .session {
56
+ padding: 7px 9px; border-radius: 8px; cursor: pointer; margin-bottom: 2px;
57
+ display: flex; align-items: center; gap: 8px; min-width: 0;
58
+ }
59
+ .session:hover { background: var(--bg); }
60
+ .session.active { background: var(--bg); outline: 1px solid var(--line); }
61
+ .session .rdot { width: 7px; height: 7px; border-radius: 50%; background: var(--line); flex: none; }
62
+ .session .rdot.run { background: var(--ok); animation: pulse 1.4s ease-in-out infinite; }
63
+ @keyframes pulse { 50% { opacity: 0.35; } }
64
+ .session .title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
65
+ .session .time { color: var(--fg2); font-size: 11px; flex: none; }
66
+ section.chat { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
67
+ #log { flex: 1; overflow-y: auto; padding: 18px 18px 8px; display: flex; flex-direction: column; gap: 10px; }
68
+ .row { display: flex; }
69
+ .row.user { justify-content: flex-end; }
70
+ .bubble {
71
+ max-width: min(76%, 720px); padding: 8px 12px; border-radius: 12px;
72
+ white-space: pre-wrap; word-break: break-word; font-size: 14px;
73
+ }
74
+ .row.user .bubble { background: var(--user-bg); color: var(--user-fg); border-bottom-right-radius: 4px; }
75
+ .row.assistant .bubble { background: var(--bg2); border: 1px solid var(--line); border-bottom-left-radius: 4px; }
76
+ .row.assistant .bubble.thinking { color: var(--fg2); font-style: italic; }
77
+ .row.tool { justify-content: flex-start; }
78
+ .toolchip {
79
+ font-family: var(--mono); font-size: 11.5px; color: var(--fg2);
80
+ background: var(--bg2); border: 1px solid var(--line); border-radius: 7px; padding: 2px 9px;
81
+ max-width: min(76%, 720px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
82
+ }
83
+ .meta { font-size: 11px; color: var(--fg2); margin: 0 6px; }
84
+ .system {
85
+ text-align: center; color: var(--fg2); font-size: 12px; padding: 6px 0;
86
+ }
87
+ .statusbar {
88
+ display: flex; align-items: center; gap: 10px; padding: 4px 18px 0; font-size: 12px; color: var(--fg2); flex: none; min-height: 22px;
89
+ }
90
+ .statusbar .pending { color: var(--warn); }
91
+ .statusbar .spacer { flex: 1; }
92
+ footer { padding: 10px 18px 14px; flex: none; }
93
+ .composer {
94
+ display: flex; gap: 8px; align-items: flex-end;
95
+ background: var(--bg2); border: 1px solid var(--line); border-radius: 12px; padding: 8px 10px;
96
+ }
97
+ .composer:focus-within { border-color: var(--accent); }
98
+ textarea {
99
+ flex: 1; border: none; outline: none; resize: none; background: transparent; color: var(--fg);
100
+ font: inherit; max-height: 160px; min-height: 22px; line-height: 1.5;
101
+ }
102
+ button.go {
103
+ border: none; background: var(--accent); color: var(--accentfg); border-radius: 9px;
104
+ padding: 6px 16px; font-size: 13px; cursor: pointer; flex: none;
105
+ }
106
+ button.go:disabled { opacity: 0.45; cursor: not-allowed; }
107
+ button.stop {
108
+ border: 1px solid var(--bad); background: transparent; color: var(--bad); border-radius: 9px;
109
+ padding: 5px 14px; font-size: 13px; cursor: pointer; flex: none;
110
+ }
111
+ .notice { color: var(--fg2); font-size: 12px; margin-top: 6px; text-align: center; }
112
+ .offline-wrap { flex: 1; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 10px; color: var(--fg2); }
113
+ .offline-wrap .big { font-size: 16px; color: var(--fg); }
114
+ .offline-wrap .hint { font-size: 12.5px; }
115
+ `
116
+
117
+ function pageBody(code, offline) {
118
+ if (offline) {
119
+ return `<div class="offline-wrap">
120
+ <div class="big">宿主机暂时离线</div>
121
+ <div class="hint">远程码 <b style="font-family:var(--mono)">${code}</b> 有效,但宿主机尚未连上中转站。页面会自动重试。</div>
122
+ </div>
123
+ <script>setTimeout(function(){ location.reload() }, 10000)</script>`
124
+ }
125
+ return `
126
+ <header>
127
+ <span class="brand"><span class="dot" id="conn-dot"></span>远程控制</span>
128
+ <span class="host" id="host-name">连接中…</span>
129
+ <span class="code">远程码 ${code}</span>
130
+ </header>
131
+ <main>
132
+ <aside>
133
+ <div class="head"><span>会话</span><button class="new" id="btn-new" title="新建会话">+ 新会话</button></div>
134
+ <div class="list" id="sessions"></div>
135
+ </aside>
136
+ <section class="chat">
137
+ <div id="log"></div>
138
+ <div class="statusbar">
139
+ <span id="run-state"></span>
140
+ <span class="pending" id="pending"></span>
141
+ <span class="spacer"></span>
142
+ <span id="observing"></span>
143
+ </div>
144
+ <footer>
145
+ <div class="composer">
146
+ <textarea id="input" rows="1" placeholder="输入指令,Enter 发送,Shift+Enter 换行"></textarea>
147
+ <button class="stop" id="btn-stop" style="display:none">中断</button>
148
+ <button class="go" id="btn-send">发送</button>
149
+ </div>
150
+ <div class="notice" id="notice"></div>
151
+ </footer>
152
+ </section>
153
+ </main>`
154
+ }
155
+
156
+ const SCRIPT = `
157
+ 'use strict'
158
+ var CODE = location.pathname.replace(/^\\/+/, '').split('/')[0]
159
+ if (!/^\\d{8}$/.test(CODE)) CODE = null
160
+ var ws = null
161
+ var nextId = 1
162
+ var pendingReqs = new Map()
163
+ var sessions = []
164
+ var currentSession = null
165
+ var allowControl = false
166
+ var hostName = ''
167
+ var running = false
168
+ var queued = 0
169
+ var pendingInput = []
170
+
171
+ function $(id) { return document.getElementById(id) }
172
+ function send(cmd, p) {
173
+ return new Promise(function (resolve, reject) {
174
+ if (!ws || ws.readyState !== 1) return reject(new Error('未连接'))
175
+ var id = 'r' + nextId++
176
+ pendingReqs.set(id, { resolve: resolve, reject: reject })
177
+ ws.send(JSON.stringify({ t: 'cmd', id: id, cmd: cmd, p: p || {} }))
178
+ setTimeout(function () {
179
+ if (pendingReqs.has(id)) { pendingReqs.delete(id); reject(new Error('响应超时')) }
180
+ }, 25000)
181
+ })
182
+ }
183
+ function connect() {
184
+ var proto = location.protocol === 'https:' ? 'wss://' : 'ws://'
185
+ ws = new WebSocket(proto + location.host + '/' + CODE + '/ws')
186
+ ws.onopen = function () { setConn(true) }
187
+ ws.onclose = function () {
188
+ setConn(false)
189
+ setTimeout(connect, 3000)
190
+ }
191
+ ws.onmessage = function (ev) {
192
+ var frame
193
+ try { frame = JSON.parse(ev.data) } catch (e) { return }
194
+ if (frame.t === 'welcome') onWelcome(frame)
195
+ else if (frame.t === 'res') {
196
+ var entry = pendingReqs.get(frame.id)
197
+ if (!entry) return
198
+ pendingReqs.delete(frame.id)
199
+ if (frame.ok) entry.resolve(frame.data)
200
+ else entry.reject(new Error(frame.error || '命令失败'))
201
+ } else if (frame.t === 'push') onPush(frame.f)
202
+ }
203
+ }
204
+ function setConn(on) {
205
+ $('conn-dot').className = 'dot' + (on ? ' on' : '')
206
+ if (!on) $('host-name').textContent = '连接断开,重试中…'
207
+ }
208
+ function onWelcome(frame) {
209
+ if (frame.error) { $('notice').textContent = frame.error; return }
210
+ allowControl = !!(frame.host && frame.host.allowControl)
211
+ hostName = (frame.host && frame.host.name) || ''
212
+ $('host-name').textContent = hostName + '(可控)'
213
+ if (!allowControl) $('host-name').textContent = hostName + '(只读)'
214
+ $('btn-new').style.display = allowControl ? '' : 'none'
215
+ applySessions(frame.sessions || [])
216
+ if (!currentSession && sessions.length) observeSession(sessions[0].id)
217
+ if (!sessions.length) $('log').innerHTML = '<div class="system">宿主机还没有会话,点「+ 新会话」开始</div>'
218
+ }
219
+ function applySessions(list) {
220
+ sessions = list || []
221
+ renderSessions()
222
+ }
223
+ function renderSessions() {
224
+ var wrap = $('sessions')
225
+ wrap.innerHTML = ''
226
+ sessions.forEach(function (s) {
227
+ var div = document.createElement('div')
228
+ div.className = 'session' + (s.id === currentSession ? ' active' : '')
229
+ var dot = document.createElement('span')
230
+ dot.className = 'rdot' + (s.running ? ' run' : '')
231
+ var title = document.createElement('span')
232
+ title.className = 'title'
233
+ title.textContent = s.title || '(未命名会话)'
234
+ var time = document.createElement('span')
235
+ time.className = 'time'
236
+ time.textContent = fmtTime(s.updatedAt)
237
+ div.appendChild(dot); div.appendChild(title); div.appendChild(time)
238
+ div.onclick = function () { observeSession(s.id) }
239
+ wrap.appendChild(div)
240
+ })
241
+ }
242
+ function fmtTime(ms) {
243
+ if (!ms) return ''
244
+ var d = new Date(ms)
245
+ var today = new Date(); today.setHours(0, 0, 0, 0)
246
+ var hm = d.getHours() + ':' + String(d.getMinutes()).padStart(2, '0')
247
+ return d.getTime() < today.getTime() ? (d.getMonth() + 1) + '/' + d.getDate() + ' ' + hm : hm
248
+ }
249
+ function observeSession(id) {
250
+ currentSession = id
251
+ $('log').innerHTML = '<div class="system">加载会话…</div>'
252
+ send('sessions.observe', { sessionId: id }).then(function () { renderSessions() })
253
+ .catch(function (err) { $('log').innerHTML = '<div class="system">' + esc(err.message) + '</div>' })
254
+ renderSessions()
255
+ }
256
+ function onPush(f) {
257
+ if (!f) return
258
+ if (f.t === 'sessions-updated') { applySessions(f.sessions); return }
259
+ if (currentSession && f.sessionId !== currentSession) return
260
+ if (f.t === 'session-history') {
261
+ $('log').innerHTML = ''
262
+ foldRecords(f.records || [])
263
+ applyState(f.state || {})
264
+ autoscroll(true)
265
+ } else if (f.t === 'session-event') {
266
+ foldRecords([f.event])
267
+ autoscroll(false)
268
+ } else if (f.t === 'session-state') {
269
+ applyState(f.state || {})
270
+ }
271
+ }
272
+ function applyState(state) {
273
+ running = !!state.running
274
+ queued = state.queued || 0
275
+ pendingInput = state.pendingInput || []
276
+ $('btn-stop').style.display = running && allowControl ? '' : 'none'
277
+ $('btn-send').disabled = !allowControl
278
+ $('input').disabled = !allowControl
279
+ var bits = []
280
+ if (running) bits.push('运行中' + (queued ? '(排队 ' + queued + ')' : ''))
281
+ $('run-state').textContent = bits.join(' ')
282
+ var pend = pendingInput.length ? '待处理:' + pendingInput.map(function (p) { return p === 'approval' ? '权限确认' : p === 'question' ? '提问' : '输入' }).join('、') + '(请在本机 GUI 处理)' : ''
283
+ $('pending').textContent = pend
284
+ $('observing').textContent = currentSession ? '正在查看 ' + short(currentSession) : ''
285
+ }
286
+ function short(id) { return id ? id.slice(0, 8) : '' }
287
+ function foldRecords(records) {
288
+ var log = $('log')
289
+ if (log.querySelector('.system') && records.length) log.innerHTML = ''
290
+ records.forEach(function (entry) {
291
+ var ev = entry && entry.event ? entry.event : entry
292
+ if (!ev || typeof ev.type !== 'string') return
293
+ var data = ev.data || {}
294
+ if (ev.type === 'user/message') {
295
+ var src = data.source || {}
296
+ if (src.kind && src.kind !== 'user') return
297
+ var text = partsText(data.content)
298
+ if (!text) return
299
+ addRow('user', text, ev.time)
300
+ } else if (ev.type === 'assistant/message') {
301
+ var atext = streamText(data.stream)
302
+ if (!atext) atext = partsText(data.content)
303
+ if (!atext) return
304
+ addRow('assistant', atext, ev.time)
305
+ } else if (ev.type === 'tool/call') {
306
+ addTool(data.name || 'tool', data.arguments)
307
+ }
308
+ })
309
+ }
310
+ function partsText(content) {
311
+ if (!Array.isArray(content)) return ''
312
+ return content.filter(function (p) { return p && p.type === 'text' && p.text })
313
+ .map(function (p) { return p.text }).join('\\n').trim()
314
+ }
315
+ function streamText(stream) {
316
+ if (!Array.isArray(stream)) return ''
317
+ var out = ''
318
+ stream.forEach(function (chunk) {
319
+ if (chunk && chunk.type === 'text-delta' && typeof chunk.text === 'string') out += chunk.text
320
+ })
321
+ return out.trim()
322
+ }
323
+ function addRow(kind, text, time) {
324
+ var log = $('log')
325
+ var row = document.createElement('div')
326
+ row.className = 'row ' + kind
327
+ var bubble = document.createElement('div')
328
+ bubble.className = 'bubble' + (kind === 'assistant' && !text ? ' thinking' : '')
329
+ bubble.textContent = text || '…'
330
+ row.appendChild(bubble)
331
+ if (time) {
332
+ var meta = document.createElement('span')
333
+ meta.className = 'meta'
334
+ meta.textContent = fmtTime(time)
335
+ row.appendChild(meta)
336
+ }
337
+ log.appendChild(row)
338
+ autoscroll(false)
339
+ }
340
+ function addTool(name, args) {
341
+ var log = $('log')
342
+ var row = document.createElement('div')
343
+ row.className = 'row tool'
344
+ var chip = document.createElement('div')
345
+ chip.className = 'toolchip'
346
+ var detail = ''
347
+ try {
348
+ if (args && typeof args === 'object') {
349
+ var keys = Object.keys(args)
350
+ if (keys.length) detail = ' ' + JSON.stringify(args[keys[0]]).slice(0, 60)
351
+ } else if (typeof args === 'string') detail = ' ' + args.slice(0, 60)
352
+ } catch (e) { /* display only */ }
353
+ chip.textContent = '⚙ ' + name + detail
354
+ row.appendChild(chip)
355
+ log.appendChild(row)
356
+ autoscroll(false)
357
+ }
358
+ var stick = true
359
+ $('log').addEventListener('scroll', function () {
360
+ var el = $('log')
361
+ stick = el.scrollTop + el.clientHeight >= el.scrollHeight - 40
362
+ })
363
+ function autoscroll(force) {
364
+ if (!force && !stick) return
365
+ var el = $('log')
366
+ el.scrollTop = el.scrollHeight
367
+ }
368
+ function doSend() {
369
+ var input = $('input')
370
+ var text = input.value.trim()
371
+ if (!text || !currentSession || !allowControl) return
372
+ input.value = ''
373
+ autoGrow()
374
+ send('sessions.send', { sessionId: currentSession, text: text })
375
+ .then(function () { addRow('user', text, Date.now()); autoscroll(true) })
376
+ .catch(function (err) { $('notice').textContent = '发送失败:' + err.message })
377
+ }
378
+ function doStop() {
379
+ if (!currentSession) return
380
+ send('sessions.cancel', { sessionId: currentSession })
381
+ .catch(function (err) { $('notice').textContent = '中断失败:' + err.message })
382
+ }
383
+ function doNew() {
384
+ if (!allowControl) return
385
+ send('sessions.create', {}).then(function (data) {
386
+ applySessionsRefresh(function () { observeSession(data.sessionId) })
387
+ }).catch(function (err) { $('notice').textContent = '新建失败:' + err.message })
388
+ }
389
+ function applySessionsRefresh(next) {
390
+ send('sessions.list', {}).then(function (data) {
391
+ applySessions(data.sessions)
392
+ next()
393
+ }).catch(next)
394
+ }
395
+ function autoGrow() {
396
+ var el = $('input')
397
+ el.style.height = 'auto'
398
+ el.style.height = Math.min(el.scrollHeight, 160) + 'px'
399
+ }
400
+ function esc(s) {
401
+ return String(s == null ? '' : s).replace(/[&<>\"]/, function (c) {
402
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;' }[c]
403
+ })
404
+ }
405
+ if (!CODE) {
406
+ document.body.innerHTML = '<div class="offline-wrap"><div class="big">无效的远程码</div></div>'
407
+ } else {
408
+ $('btn-send').onclick = doSend
409
+ $('btn-stop').onclick = doStop
410
+ $('btn-new').onclick = doNew
411
+ $('input').addEventListener('keydown', function (ev) {
412
+ if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); doSend() }
413
+ })
414
+ $('input').addEventListener('input', autoGrow)
415
+ connect()
416
+ }
417
+ `
418
+
419
+ export function consoleHtml({ code, offline }) {
420
+ return `<!DOCTYPE html>
421
+ <html lang="zh-CN">
422
+ <head>
423
+ <meta charset="utf-8">
424
+ <meta name="viewport" content="width=device-width, initial-scale=1">
425
+ <meta name="robots" content="noindex, nofollow">
426
+ <title>远程控制 ${code}</title>
427
+ <style>${CSS}</style>
428
+ </head>
429
+ <body>
430
+ ${pageBody(code, offline === true)}
431
+ <script>${SCRIPT}</script>
432
+ </body>
433
+ </html>`
434
+ }