dsh-remote-plugin 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apk/dsh-remote.apk +0 -0
- package/client.js +0 -12
- package/gateway.cjs +41 -4
- package/package.json +1 -1
- package/public/admin.html +3 -3
- package/public/admin.js +16 -1
- package/public/app.js +222 -30
- package/public/index.html +35 -5
- package/public/jsqr.min.js +10104 -0
- package/public/styles.css +58 -6
- package/public/update.json +3 -3
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
package/client.js
CHANGED
|
@@ -19,11 +19,6 @@ window.__ModuleLoader__.load({
|
|
|
19
19
|
transform: 'translateX(102%)', transition: 'transform .22s ease',
|
|
20
20
|
display: 'flex', flexDirection: 'column',
|
|
21
21
|
}
|
|
22
|
-
var HEAD_STYLE = {
|
|
23
|
-
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
24
|
-
gap: 8, padding: '9px 12px', background: '#0f766e', color: '#fff',
|
|
25
|
-
font: '600 13px/1 system-ui, sans-serif', flexShrink: 0,
|
|
26
|
-
}
|
|
27
22
|
// DSH 原生 footerActions 容器默认 flex-row(官方类名已编译定型);
|
|
28
23
|
// 动态注入列布局, 让各 footer action 上下堆叠。
|
|
29
24
|
var FOOTER_FIX_ID = 'dsh-remote-footer-stack'
|
|
@@ -87,13 +82,6 @@ window.__ModuleLoader__.load({
|
|
|
87
82
|
'aria-hidden': !open,
|
|
88
83
|
style: Object.assign({}, DRAWER_STYLE, { transform: open ? 'translateX(0)' : 'translateX(102%)' }),
|
|
89
84
|
},
|
|
90
|
-
React.createElement('div', { style: HEAD_STYLE },
|
|
91
|
-
React.createElement('span', null, '🖥 DSH Remote'),
|
|
92
|
-
React.createElement('button', {
|
|
93
|
-
type: 'button', 'aria-label': '关闭',
|
|
94
|
-
onClick: function () { props.actions.close() },
|
|
95
|
-
style: { border: 'none', background: 'transparent', color: '#fff', font: '600 16px/1 system-ui, sans-serif', cursor: 'pointer', padding: '2px 6px' },
|
|
96
|
-
}, '✕')),
|
|
97
85
|
loaded.current ? React.createElement('iframe', {
|
|
98
86
|
src: '/remote/admin/?embedded=1',
|
|
99
87
|
title: 'DSH Remote',
|
package/gateway.cjs
CHANGED
|
@@ -255,13 +255,29 @@ function kickDevice(ip) {
|
|
|
255
255
|
}
|
|
256
256
|
|
|
257
257
|
// ---------- GitHub/镜像 更新检查 ----------
|
|
258
|
+
function parseVersion(v) {
|
|
259
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v || '').trim())
|
|
260
|
+
if (!m) return { core: [0, 0, 0], pre: null }
|
|
261
|
+
return { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null }
|
|
262
|
+
}
|
|
258
263
|
function cmpVersion(a, b) {
|
|
259
|
-
const pa =
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const d = (pa[i] || 0) - (pb[i] || 0)
|
|
264
|
+
const pa = parseVersion(a), pb = parseVersion(b)
|
|
265
|
+
for (let i = 0; i < 3; i++) {
|
|
266
|
+
const d = pa.core[i] - pb.core[i]
|
|
263
267
|
if (d) return d
|
|
264
268
|
}
|
|
269
|
+
if (!pa.pre && !pb.pre) return 0
|
|
270
|
+
if (!pa.pre) return 1
|
|
271
|
+
if (!pb.pre) return -1
|
|
272
|
+
const sa = String(pa.pre).split('.'), sb = String(pb.pre).split('.')
|
|
273
|
+
for (let i = 0; i < Math.max(sa.length, sb.length); i++) {
|
|
274
|
+
const x = sa[i] ?? '', y = sb[i] ?? ''
|
|
275
|
+
if (x === y) continue
|
|
276
|
+
const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y)
|
|
277
|
+
if (nx && ny) { const d = Number(x) - Number(y); if (d) return d }
|
|
278
|
+
else if (nx !== ny) return nx ? -1 : 1
|
|
279
|
+
else { const d = x.localeCompare(y); if (d) return d }
|
|
280
|
+
}
|
|
265
281
|
return 0
|
|
266
282
|
}
|
|
267
283
|
|
|
@@ -394,6 +410,26 @@ function serveStatic(req, res, url) {
|
|
|
394
410
|
}
|
|
395
411
|
if (pathname === '/') pathname = '/index.html'
|
|
396
412
|
if (pathname === '/admin') pathname = '/admin.html'
|
|
413
|
+
// 兼容旧版 App(版本比较不认 -rc): 无 local 参数的请求把 0.5.2-rc.1 显示为 0.5.2,
|
|
414
|
+
// 引导升级到新 APK; 新 App 带 ?local= 拿到真实 rc 版本, 不会循环提示。
|
|
415
|
+
if (pathname === '/update.json') {
|
|
416
|
+
fs.readFile(path.join(PUBLIC_DIR, 'update.json'), 'utf8', (err, raw) => {
|
|
417
|
+
if (err) {
|
|
418
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
419
|
+
res.end('404 Not Found')
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
let json = {}
|
|
423
|
+
try { json = JSON.parse(raw) } catch {}
|
|
424
|
+
if (!url.searchParams.has('local')) {
|
|
425
|
+
json = { ...json, version: String(json.version || '').replace(/-.*$/, '') }
|
|
426
|
+
}
|
|
427
|
+
cors(res)
|
|
428
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
429
|
+
res.end(JSON.stringify(json))
|
|
430
|
+
})
|
|
431
|
+
return
|
|
432
|
+
}
|
|
397
433
|
const apkOverride = pathname === '/dsh-remote.apk'
|
|
398
434
|
const baseDir = apkOverride ? path.join(ROOT, 'apk') : PUBLIC_DIR
|
|
399
435
|
const filePath = path.normalize(path.join(baseDir, pathname))
|
|
@@ -451,6 +487,7 @@ function serveAdminApi(req, res, url) {
|
|
|
451
487
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
452
488
|
res.end(JSON.stringify({
|
|
453
489
|
ok: true,
|
|
490
|
+
mode: 'gateway',
|
|
454
491
|
version: VERSION,
|
|
455
492
|
pid: process.pid,
|
|
456
493
|
hostname: os.hostname(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
package/public/admin.html
CHANGED
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
.token-row code { flex: 1; background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; font-size: 12px; word-break: break-all; }
|
|
35
35
|
.pair-box { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px; margin-bottom: 14px; text-align: center; }
|
|
36
36
|
.pair-box .pair-title { font-weight: 600; margin-bottom: 8px; }
|
|
37
|
-
.pair-qr { display: inline-block; background: #fff; border-radius: 8px; padding: 6px; }
|
|
38
|
-
.pair-qr svg { display: block; }
|
|
37
|
+
.pair-qr { display: inline-block; background: #fff; border-radius: 8px; padding: 6px; line-height: 0; }
|
|
38
|
+
.pair-qr svg { display: block; width: 180px; height: 180px; }
|
|
39
39
|
.pair-box .muted { margin-top: 8px; font-size: 12px; }
|
|
40
40
|
.empty { text-align: center; color: var(--muted); padding: 30px 0; }
|
|
41
41
|
.empty-actions { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 12px; }
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8a8.01 8.01 0 0 0 5.47 7.59c.4.08.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>
|
|
59
59
|
仓库
|
|
60
60
|
</a>
|
|
61
|
-
<
|
|
61
|
+
<button id="conn-badge" class="conn-badge off" title="网关面板">未认证</button>
|
|
62
62
|
</div>
|
|
63
63
|
</div>
|
|
64
64
|
|
package/public/admin.js
CHANGED
|
@@ -69,7 +69,8 @@ function render(st) {
|
|
|
69
69
|
const isGateway = st.mode === 'gateway'
|
|
70
70
|
shownToken = st.token || token
|
|
71
71
|
$('conn-badge').textContent = isPlugin ? '内嵌' : isGateway ? '网关' : '已连接'
|
|
72
|
-
$('conn-badge').className = 'conn-badge on'
|
|
72
|
+
$('conn-badge').className = 'conn-badge ' + (isPlugin || isGateway ? 'on' : 'off')
|
|
73
|
+
$('conn-badge').title = isGateway ? '新标签页打开网关管理面板' : '网关未运行'
|
|
73
74
|
$('token-full').textContent = shownToken || (isPlugin ? '插件模式 · 未接网关, 无需令牌' : '未获取到令牌')
|
|
74
75
|
// 主机端插件模式: 显示真实令牌(复制可用), 只隐藏退出按钮; 令牌门禁本身不存在
|
|
75
76
|
$('btn-copy').classList.toggle('hidden', !shownToken)
|
|
@@ -258,6 +259,20 @@ $('btn-qr').addEventListener('click', () => {
|
|
|
258
259
|
renderQr(lastState || { mode: '', token: shownToken })
|
|
259
260
|
})
|
|
260
261
|
|
|
262
|
+
/* 右上角「网关」徽章: 新标签页打开独立网关管理面板(带 token 免登录) */
|
|
263
|
+
$('conn-badge').addEventListener('click', () => {
|
|
264
|
+
const st = lastState
|
|
265
|
+
if (!st || st.mode !== 'gateway') { toast('网关未运行', 'err'); return }
|
|
266
|
+
const host = location.hostname || '127.0.0.1'
|
|
267
|
+
const port = st.port || 8787
|
|
268
|
+
const url = `http://${host}:${port}/admin?token=${encodeURIComponent(shownToken || token)}`
|
|
269
|
+
try {
|
|
270
|
+
window.open(url, '_blank', 'noopener')
|
|
271
|
+
} catch {
|
|
272
|
+
toast('浏览器阻止了新窗口,请允许弹窗', 'err')
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
|
|
261
276
|
$('btn-rotate').addEventListener('click', async () => {
|
|
262
277
|
if (!confirm('轮换后旧令牌立即失效,手机与浏览器都需要重新扫码/输入。继续?')) return
|
|
263
278
|
try {
|
package/public/app.js
CHANGED
|
@@ -50,7 +50,8 @@ const state = {
|
|
|
50
50
|
history: emptyHistory(),
|
|
51
51
|
errCount: 0,
|
|
52
52
|
refreshTimer: null,
|
|
53
|
-
fs: { path: null, initial: null, loaded: false, upload: null }
|
|
53
|
+
fs: { path: null, initial: null, loaded: false, upload: null },
|
|
54
|
+
models: { loaded: false, loading: false, groups: [], current: null, failures: [] }
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
const $ = (id) => document.getElementById(id)
|
|
@@ -298,6 +299,13 @@ function openStream(kind, handler, refreshOnOpen) {
|
|
|
298
299
|
state.streamsOk[kind] = true
|
|
299
300
|
state.errCount = 0
|
|
300
301
|
updateConn()
|
|
302
|
+
// mux 每次(重)连接都会重放“仍待处理”的审批/提问基线:
|
|
303
|
+
// 先清空旧列表, 避免“桌面已自定义回答, 手机漏收 question/resolved”后永久残留。
|
|
304
|
+
if (kind === 'mux') {
|
|
305
|
+
state.approvals = []
|
|
306
|
+
state.questions = []
|
|
307
|
+
renderPending()
|
|
308
|
+
}
|
|
301
309
|
if (refreshOnOpen) refreshAll()
|
|
302
310
|
}
|
|
303
311
|
ws.onmessage = (msg) => {
|
|
@@ -386,10 +394,15 @@ function onHostFrame(full) {
|
|
|
386
394
|
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()
|
|
387
395
|
if (f.type === 'host/session-status') {
|
|
388
396
|
const s = state.byId.get(f.sessionId)
|
|
389
|
-
if (s) { s.running = f.running; if (state.current === f.sessionId) { renderSessionCards(); updateCancelBtn() } }
|
|
397
|
+
if (s) { s.running = f.running; if (state.current === f.sessionId) { renderSessionCards(); updateCancelBtn(); renderSessionSub(); updateSessionStatus() } }
|
|
390
398
|
return
|
|
391
399
|
}
|
|
392
|
-
if (f.type === 'host/agent-error')
|
|
400
|
+
if (f.type === 'host/agent-error') {
|
|
401
|
+
const s = state.byId.get(f.sessionId)
|
|
402
|
+
if (s) { s.error = true; s.running = false }
|
|
403
|
+
if (state.current === f.sessionId) { renderSessionSub(); updateSessionStatus() }
|
|
404
|
+
return toast(`会话出错:${f.message}`, 'err')
|
|
405
|
+
}
|
|
393
406
|
if (f.type === 'host/remote-event') return scheduleRefresh()
|
|
394
407
|
}
|
|
395
408
|
|
|
@@ -398,8 +411,8 @@ function onSessionEvent(sessionId, event) {
|
|
|
398
411
|
const s = state.byId.get(sessionId)
|
|
399
412
|
if (s) s.updatedAt = Date.now()
|
|
400
413
|
if (event.type === 'agent/status') {
|
|
401
|
-
if (s) { s.running = !!event.data?.running; s.blank = false }
|
|
402
|
-
if (state.current === sessionId) { updateCancelBtn(); renderSessionSub() }
|
|
414
|
+
if (s) { s.running = !!event.data?.running; s.blank = false; if (s.running) s.error = false }
|
|
415
|
+
if (state.current === sessionId) { updateCancelBtn(); renderSessionSub(); updateSessionStatus() }
|
|
403
416
|
}
|
|
404
417
|
if (event.type === 'session/title' || event.type === 'title') {
|
|
405
418
|
if (event.data?.title && s) s.projections.values.title = event.data.title
|
|
@@ -417,7 +430,7 @@ function scheduleRefresh() {
|
|
|
417
430
|
|
|
418
431
|
async function refreshAll() {
|
|
419
432
|
await refreshSessions()
|
|
420
|
-
if (state.current) { renderSessionCards(); renderSessionSub(); updateCancelBtn() }
|
|
433
|
+
if (state.current) { renderSessionCards(); renderSessionSub(); updateCancelBtn(); updateSessionStatus() }
|
|
421
434
|
renderPending(); renderQueue(); renderJobs(); updateConn()
|
|
422
435
|
}
|
|
423
436
|
|
|
@@ -508,7 +521,7 @@ async function openSession(id) {
|
|
|
508
521
|
state.history = emptyHistory()
|
|
509
522
|
document.body.classList.add('in-session')
|
|
510
523
|
showView('view-session')
|
|
511
|
-
renderSessionTitle(); renderSessionSub(); updateCancelBtn()
|
|
524
|
+
renderSessionTitle(); renderSessionSub(); updateCancelBtn(); updateSessionStatus()
|
|
512
525
|
$('history').innerHTML = '<div class="empty">加载历史…</div>'
|
|
513
526
|
await loadHistory(true)
|
|
514
527
|
renderSessionCards()
|
|
@@ -519,6 +532,7 @@ function closeSession() {
|
|
|
519
532
|
state.current = null
|
|
520
533
|
state.history = emptyHistory()
|
|
521
534
|
document.body.classList.remove('in-session')
|
|
535
|
+
hideComposerMenu()
|
|
522
536
|
showView('view-home')
|
|
523
537
|
}
|
|
524
538
|
|
|
@@ -550,9 +564,21 @@ function renderSessionSub() {
|
|
|
550
564
|
const parts = [short(s.sessionId)]
|
|
551
565
|
if (s.cwd) parts.push(s.cwd)
|
|
552
566
|
if (s.running) parts.push('运行中')
|
|
567
|
+
else if (s.error) parts.push('已中断')
|
|
553
568
|
$('session-sub').textContent = parts.join(' · ')
|
|
554
569
|
}
|
|
555
570
|
|
|
571
|
+
/** 顶栏状态: 运行中=蓝色流动渐变, 中断/出错=橙红渐变, 空闲=原样式 */
|
|
572
|
+
function updateSessionStatus() {
|
|
573
|
+
const s = state.byId.get(state.current)
|
|
574
|
+
const head = $('session-head')
|
|
575
|
+
if (!head) return
|
|
576
|
+
head.classList.remove('running', 'interrupted')
|
|
577
|
+
const queued = (state.queues[state.current] || []).some(i => i.placement !== 'context')
|
|
578
|
+
if (s?.running || queued) head.classList.add('running')
|
|
579
|
+
else if (s?.error) head.classList.add('interrupted')
|
|
580
|
+
}
|
|
581
|
+
|
|
556
582
|
function updateCancelBtn() {
|
|
557
583
|
const s = state.byId.get(state.current)
|
|
558
584
|
const running = s?.running || (state.queues[state.current] || []).some(i => i.placement !== 'context')
|
|
@@ -966,20 +992,128 @@ async function interruptSubagent(childId) {
|
|
|
966
992
|
setTimeout(renderSessionCards, 600)
|
|
967
993
|
}
|
|
968
994
|
|
|
969
|
-
/* ---------------- 发送 / 取消 ---------------- */
|
|
970
|
-
async function
|
|
971
|
-
const
|
|
972
|
-
|
|
973
|
-
if (!text || !state.current) return
|
|
995
|
+
/* ---------------- 发送 / 取消 / 快捷菜单 ---------------- */
|
|
996
|
+
async function sendSessionText(text) {
|
|
997
|
+
const clean = String(text || '').trim()
|
|
998
|
+
if (!clean || !state.current) return false
|
|
974
999
|
$('btn-send').disabled = true
|
|
975
1000
|
const v = await safeRpc('session.prompt', {
|
|
976
1001
|
sessionId: state.current,
|
|
977
1002
|
mode: 'queue',
|
|
978
|
-
content: [{ type: 'text', text }]
|
|
1003
|
+
content: [{ type: 'text', text: clean }]
|
|
979
1004
|
}, '发送失败')
|
|
980
1005
|
$('btn-send').disabled = false
|
|
981
|
-
if (v?.accepted) {
|
|
982
|
-
|
|
1006
|
+
if (v?.accepted) { toast(clean.startsWith('/') ? '指令已发送' : '已发送', 'ok'); return true }
|
|
1007
|
+
if (v?.command?.text) { toast('命令已执行', 'ok'); return true }
|
|
1008
|
+
return false
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
async function sendMessage() {
|
|
1012
|
+
const input = $('composer-input')
|
|
1013
|
+
const text = input.value.trim()
|
|
1014
|
+
if (!text || !state.current) return
|
|
1015
|
+
if (await sendSessionText(text)) { input.value = ''; autosize(input) }
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function hideComposerMenu() {
|
|
1019
|
+
$('composer-menu').classList.add('hidden')
|
|
1020
|
+
$('btn-plus').classList.remove('active')
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function toggleComposerMenu() {
|
|
1024
|
+
const menu = $('composer-menu')
|
|
1025
|
+
const show = menu.classList.contains('hidden')
|
|
1026
|
+
menu.classList.toggle('hidden', !show)
|
|
1027
|
+
$('btn-plus').classList.toggle('active', show)
|
|
1028
|
+
if (show && !state.models.loaded && !state.models.loading) loadSessionModels()
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
async function loadSessionModels() {
|
|
1032
|
+
if (!state.current || state.models.loading) return
|
|
1033
|
+
state.models.loading = true
|
|
1034
|
+
renderModelMenu()
|
|
1035
|
+
try {
|
|
1036
|
+
const v = await rpc('session.models', { sessionId: state.current })
|
|
1037
|
+
state.models.groups = v.groups || []
|
|
1038
|
+
state.models.current = v.current || null
|
|
1039
|
+
state.models.failures = v.failures || []
|
|
1040
|
+
state.models.loaded = true
|
|
1041
|
+
} catch (e) {
|
|
1042
|
+
if (e.message === 'AUTH') { authFailure(); return }
|
|
1043
|
+
toast('模型列表加载失败:' + e.message, 'err')
|
|
1044
|
+
}
|
|
1045
|
+
state.models.loading = false
|
|
1046
|
+
renderModelMenu()
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function renderModelMenu() {
|
|
1050
|
+
const box = $('menu-models')
|
|
1051
|
+
if (!box) return
|
|
1052
|
+
if (state.models.loading) { box.innerHTML = '<span>模型加载中…</span>'; return }
|
|
1053
|
+
const groups = state.models.groups || []
|
|
1054
|
+
if (!groups.length) {
|
|
1055
|
+
box.innerHTML = '<span>' + ((state.models.failures || []).map(f => f.name + '不可用').join(';') || '没有可用模型') + '</span>'
|
|
1056
|
+
const effortGroup = $('menu-effort-group')
|
|
1057
|
+
if (effortGroup) effortGroup.classList.add('hidden')
|
|
1058
|
+
return
|
|
1059
|
+
}
|
|
1060
|
+
const cur = state.models.current
|
|
1061
|
+
box.innerHTML = groups.map(g => `
|
|
1062
|
+
<div style="width:100%">
|
|
1063
|
+
<div class="model-provider">${esc(g.name || g.id)}</div>
|
|
1064
|
+
<div class="menu-chips">${(g.models || []).map(m => {
|
|
1065
|
+
const isCur = cur && cur.provider === g.id && cur.model === m.id
|
|
1066
|
+
return `<button class="model-chip ${isCur ? 'current' : ''}" data-model="${esc(m.id)}" data-provider="${esc(g.id)}">${esc(m.name || m.id)}</button>`
|
|
1067
|
+
}).join('')}</div>
|
|
1068
|
+
</div>`).join('')
|
|
1069
|
+
box.querySelectorAll('[data-model]').forEach(btn =>
|
|
1070
|
+
btn.addEventListener('click', () => selectSessionModel(btn.dataset.provider, btn.dataset.model)))
|
|
1071
|
+
renderEffortMenu()
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function renderEffortMenu() {
|
|
1075
|
+
const group = $('menu-effort-group')
|
|
1076
|
+
const box = $('menu-efforts')
|
|
1077
|
+
if (!group || !box) return
|
|
1078
|
+
const cur = state.models.current
|
|
1079
|
+
const provider = (state.models.groups || []).find(g => g.id === cur?.provider)
|
|
1080
|
+
const model = (provider?.models || []).find(m => m.id === cur?.model)
|
|
1081
|
+
const efforts = model?.reasoning?.efforts || []
|
|
1082
|
+
group.classList.toggle('hidden', !efforts.length)
|
|
1083
|
+
box.innerHTML = efforts.map(e => {
|
|
1084
|
+
const isCur = cur?.reasoningEffort === e.id || (!cur?.reasoningEffort && e.id === model.reasoning.defaultEffort)
|
|
1085
|
+
return `<button class="menu-chip ${isCur ? 'current' : ''}" data-effort="${esc(e.id)}" title="${esc(e.description || '')}">${esc(e.name || e.id)}</button>`
|
|
1086
|
+
}).join('')
|
|
1087
|
+
box.querySelectorAll('[data-effort]').forEach(btn =>
|
|
1088
|
+
btn.addEventListener('click', () => selectSessionEffort(btn.dataset.effort)))
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
async function selectSessionEffort(effortId) {
|
|
1092
|
+
const cur = state.models.current
|
|
1093
|
+
if (!state.current || !cur) return
|
|
1094
|
+
const v = await safeRpc('session.selectModel', {
|
|
1095
|
+
sessionId: state.current, provider: cur.provider, model: cur.model, reasoningEffort: effortId
|
|
1096
|
+
}, '切换思考深度失败')
|
|
1097
|
+
if (v?.selected) {
|
|
1098
|
+
state.models.current = v.selected
|
|
1099
|
+
renderEffortMenu()
|
|
1100
|
+
toast(`思考深度:${effortId}`, 'ok')
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
async function selectSessionModel(provider, modelId) {
|
|
1105
|
+
if (!state.current) return
|
|
1106
|
+
const group = (state.models.groups || []).find(g => g.id === provider)
|
|
1107
|
+
const model = (group?.models || []).find(m => m.id === modelId)
|
|
1108
|
+
const payload = { sessionId: state.current, provider, model: modelId }
|
|
1109
|
+
const effort = model?.reasoning?.defaultEffort || model?.reasoning?.efforts?.[0]?.id
|
|
1110
|
+
if (effort) payload.reasoningEffort = effort
|
|
1111
|
+
const v = await safeRpc('session.selectModel', payload, '切换模型失败')
|
|
1112
|
+
if (v?.selected) {
|
|
1113
|
+
state.models.current = v.selected
|
|
1114
|
+
renderModelMenu()
|
|
1115
|
+
toast(`已切换模型:${v.selected.model}`, 'ok')
|
|
1116
|
+
}
|
|
983
1117
|
}
|
|
984
1118
|
|
|
985
1119
|
async function cancelSession() {
|
|
@@ -1546,13 +1680,30 @@ async function submitGoalEdit() {
|
|
|
1546
1680
|
}
|
|
1547
1681
|
|
|
1548
1682
|
/* ---------------- 检查更新 ---------------- */
|
|
1683
|
+
function parseVersion(v) {
|
|
1684
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v || '').trim())
|
|
1685
|
+
if (!m) return { core: [0, 0, 0], pre: null }
|
|
1686
|
+
return { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null }
|
|
1687
|
+
}
|
|
1549
1688
|
function cmpVersion(a, b) {
|
|
1550
|
-
const pa =
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
const d = (pa[i] || 0) - (pb[i] || 0)
|
|
1689
|
+
const pa = parseVersion(a), pb = parseVersion(b)
|
|
1690
|
+
for (let i = 0; i < 3; i++) {
|
|
1691
|
+
const d = pa.core[i] - pb.core[i]
|
|
1554
1692
|
if (d) return d
|
|
1555
1693
|
}
|
|
1694
|
+
// 无预发布后缀 = 正式版 > 任何 rc; 两个 rc 按段比较(数字段按数值, 字母段按字典序)
|
|
1695
|
+
if (!pa.pre && !pb.pre) return 0
|
|
1696
|
+
if (!pa.pre) return 1
|
|
1697
|
+
if (!pb.pre) return -1
|
|
1698
|
+
const sa = String(pa.pre).split('.'), sb = String(pb.pre).split('.')
|
|
1699
|
+
for (let i = 0; i < Math.max(sa.length, sb.length); i++) {
|
|
1700
|
+
const x = sa[i] ?? '', y = sb[i] ?? ''
|
|
1701
|
+
if (x === y) continue
|
|
1702
|
+
const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y)
|
|
1703
|
+
if (nx && ny) { const d = Number(x) - Number(y); if (d) return d }
|
|
1704
|
+
else if (nx !== ny) return nx ? -1 : 1 // 数字段 < 字母段(semver 规则)
|
|
1705
|
+
else { const d = x.localeCompare(y); if (d) return d }
|
|
1706
|
+
}
|
|
1556
1707
|
return 0
|
|
1557
1708
|
}
|
|
1558
1709
|
|
|
@@ -1573,7 +1724,7 @@ async function checkUpdate(silent) {
|
|
|
1573
1724
|
}
|
|
1574
1725
|
if (!silent) toast('正在检查更新…')
|
|
1575
1726
|
try {
|
|
1576
|
-
const res = await fetch(base + '/update.json?t=' + Date.now())
|
|
1727
|
+
const res = await fetch(base + '/update.json?t=' + Date.now() + '&local=' + encodeURIComponent(state.localVersion))
|
|
1577
1728
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
1578
1729
|
const info = await res.json()
|
|
1579
1730
|
if (info.version && cmpVersion(info.version, state.localVersion) > 0) {
|
|
@@ -1705,20 +1856,55 @@ function applyPairUrl(url) {
|
|
|
1705
1856
|
}
|
|
1706
1857
|
}
|
|
1707
1858
|
|
|
1708
|
-
/**
|
|
1859
|
+
/** 拍照/相册得到的 dataUrl → 画到 canvas → jsQR 纯本地解码(不依赖任何谷歌服务) */
|
|
1860
|
+
async function decodeQrDataUrl(dataUrl) {
|
|
1861
|
+
const img = new Image()
|
|
1862
|
+
await new Promise((resolve, reject) => {
|
|
1863
|
+
img.onload = resolve
|
|
1864
|
+
img.onerror = () => reject(new Error('图片加载失败'))
|
|
1865
|
+
img.src = dataUrl
|
|
1866
|
+
})
|
|
1867
|
+
const maxSide = 1600
|
|
1868
|
+
const scale = Math.min(1, maxSide / Math.max(img.naturalWidth || 1, img.naturalHeight || 1))
|
|
1869
|
+
const w = Math.max(1, Math.round((img.naturalWidth || 1) * scale))
|
|
1870
|
+
const h = Math.max(1, Math.round((img.naturalHeight || 1) * scale))
|
|
1871
|
+
const canvas = document.createElement('canvas')
|
|
1872
|
+
canvas.width = w
|
|
1873
|
+
canvas.height = h
|
|
1874
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
|
1875
|
+
if (!ctx) throw new Error('当前设备不支持图片解码')
|
|
1876
|
+
ctx.drawImage(img, 0, 0, w, h)
|
|
1877
|
+
const imageData = ctx.getImageData(0, 0, w, h)
|
|
1878
|
+
const code = window.jsQR?.(imageData.data, w, h, { inversionAttempts: 'attemptBoth' })
|
|
1879
|
+
return code?.data || ''
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
/** App 内扫码: 官方 Camera 拍照/相册 + jsQR 本地解码(无 Google ML Kit/GMS 依赖, 国内可用)。
|
|
1883
|
+
* 冗余路径 1: 系统相机扫 dshremote:// 二维码直接唤起 App(见 bindNativeLinks);
|
|
1884
|
+
* 冗余路径 2: 设置页手动粘贴令牌。 */
|
|
1709
1885
|
async function scanPair() {
|
|
1710
1886
|
if (!CAP?.isNativePlatform?.()) {
|
|
1711
1887
|
toast('浏览器请打开主机管理页,用手机相机扫码', 'err')
|
|
1712
1888
|
return
|
|
1713
1889
|
}
|
|
1714
|
-
const
|
|
1715
|
-
if (!
|
|
1890
|
+
const camera = CAP.Plugins?.Camera
|
|
1891
|
+
if (!camera?.getPhoto) { toast('当前 App 版本不支持拍照扫码,请先更新 App', 'err'); return }
|
|
1716
1892
|
try {
|
|
1717
|
-
const
|
|
1718
|
-
if (
|
|
1719
|
-
const
|
|
1720
|
-
|
|
1721
|
-
|
|
1893
|
+
const perm = await camera.requestPermissions?.({ permissions: ['camera'] })
|
|
1894
|
+
if (perm && perm.camera !== 'granted') { toast('未授予相机权限', 'err'); return }
|
|
1895
|
+
const photo = await camera.getPhoto({
|
|
1896
|
+
resultType: 'dataUrl',
|
|
1897
|
+
source: 'PROMPT', // 拍照 / 从相册选择都行, 相册可扫截图
|
|
1898
|
+
quality: 85,
|
|
1899
|
+
correctOrientation: true,
|
|
1900
|
+
saveToGallery: false,
|
|
1901
|
+
promptLabelHeader: '扫描 DSH Remote 配对二维码',
|
|
1902
|
+
promptLabelPhoto: '拍照扫描',
|
|
1903
|
+
promptLabelPicture: '从相册选择',
|
|
1904
|
+
})
|
|
1905
|
+
if (!photo?.dataUrl) { toast('没有拿到图片,请重试', 'err'); return }
|
|
1906
|
+
const raw = await decodeQrDataUrl(photo.dataUrl)
|
|
1907
|
+
if (!raw) { toast('未识别到二维码,请对准后重试', 'err'); return }
|
|
1722
1908
|
if (applyPairUrl(raw)) {
|
|
1723
1909
|
toast('配对成功,正在连接', 'ok')
|
|
1724
1910
|
openStreams()
|
|
@@ -1728,7 +1914,7 @@ async function scanPair() {
|
|
|
1728
1914
|
}
|
|
1729
1915
|
} catch (e) {
|
|
1730
1916
|
const msg = String(e?.message || e || '')
|
|
1731
|
-
toast(
|
|
1917
|
+
toast(/cancel/i.test(msg) ? '已取消扫码' : '扫码失败:' + msg, 'err')
|
|
1732
1918
|
}
|
|
1733
1919
|
}
|
|
1734
1920
|
|
|
@@ -1776,13 +1962,19 @@ function bindUi() {
|
|
|
1776
1962
|
$('btn-back').addEventListener('click', closeSession)
|
|
1777
1963
|
$('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
|
|
1778
1964
|
$('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
|
|
1779
|
-
$('btn-refresh').addEventListener('click', () => { toast('刷新中…'); refreshAll() })
|
|
1965
|
+
$('btn-refresh').addEventListener('click', () => { toast('刷新中…'); openStreams(); refreshAll() })
|
|
1780
1966
|
$('btn-admin').addEventListener('click', () => {
|
|
1781
1967
|
location.href = state.server ? state.server.replace(/\/+$/, '') + '/admin' : 'admin'
|
|
1782
1968
|
})
|
|
1783
1969
|
$('btn-new-session').addEventListener('click', newSession)
|
|
1784
1970
|
$('btn-cancel').addEventListener('click', cancelSession)
|
|
1785
1971
|
$('btn-send').addEventListener('click', sendMessage)
|
|
1972
|
+
$('btn-plus').addEventListener('click', toggleComposerMenu)
|
|
1973
|
+
$('composer-menu').addEventListener('click', (e) => {
|
|
1974
|
+
const chip = e.target.closest('[data-cmd]')
|
|
1975
|
+
if (chip) { hideComposerMenu(); sendSessionText(chip.dataset.cmd) }
|
|
1976
|
+
})
|
|
1977
|
+
$('btn-model-refresh').addEventListener('click', loadSessionModels)
|
|
1786
1978
|
const input = $('composer-input')
|
|
1787
1979
|
input.addEventListener('input', () => autosize(input))
|
|
1788
1980
|
input.addEventListener('keydown', (e) => {
|
package/public/index.html
CHANGED
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
|
|
60
60
|
<!-- 会话详情 -->
|
|
61
61
|
<section id="view-session" class="view hidden">
|
|
62
|
-
<div class="session-head">
|
|
62
|
+
<div id="session-head" class="session-head">
|
|
63
63
|
<button id="btn-back" class="icon-btn">‹</button>
|
|
64
64
|
<div class="session-head-meta">
|
|
65
65
|
<div id="session-title" class="session-title">加载中…</div>
|
|
@@ -84,9 +84,38 @@
|
|
|
84
84
|
</div>
|
|
85
85
|
</div>
|
|
86
86
|
|
|
87
|
-
<div class="composer">
|
|
88
|
-
<
|
|
89
|
-
|
|
87
|
+
<div class="composer-wrap">
|
|
88
|
+
<div id="composer-menu" class="composer-menu hidden">
|
|
89
|
+
<div class="menu-group">
|
|
90
|
+
<div class="menu-title">输入指令</div>
|
|
91
|
+
<div class="menu-chips">
|
|
92
|
+
<button class="menu-chip" data-cmd="/compact">/compact 压缩上下文</button>
|
|
93
|
+
<button class="menu-chip" data-cmd="/goal">/goal 查看目标</button>
|
|
94
|
+
<button class="menu-chip" data-cmd="/goal pause">/goal 暂停</button>
|
|
95
|
+
<button class="menu-chip" data-cmd="/goal resume">/goal 继续</button>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
<div class="menu-group">
|
|
99
|
+
<div class="menu-title">权限控制</div>
|
|
100
|
+
<div class="menu-chips">
|
|
101
|
+
<button class="menu-chip" data-cmd="/permission workspace-write">工作区写(免审批)</button>
|
|
102
|
+
<button class="menu-chip danger" data-cmd="/permission danger-full-access">完全访问(全部免审批)</button>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
<div class="menu-group">
|
|
106
|
+
<div class="menu-title">模型切换 <button id="btn-model-refresh" class="mini-btn" style="padding:1px 8px">刷新</button></div>
|
|
107
|
+
<div id="menu-models" class="menu-models muted">点开时自动加载</div>
|
|
108
|
+
</div>
|
|
109
|
+
<div class="menu-group hidden" id="menu-effort-group">
|
|
110
|
+
<div class="menu-title">思考深度</div>
|
|
111
|
+
<div id="menu-efforts" class="menu-chips"></div>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
<div class="composer">
|
|
115
|
+
<button id="btn-plus" class="plus-btn" aria-label="更多操作" title="指令/权限/模型">+</button>
|
|
116
|
+
<textarea id="composer-input" rows="1" placeholder="给 DSH 发消息…"></textarea>
|
|
117
|
+
<button id="btn-send" class="send-btn">发送</button>
|
|
118
|
+
</div>
|
|
90
119
|
</div>
|
|
91
120
|
</section>
|
|
92
121
|
|
|
@@ -111,7 +140,7 @@
|
|
|
111
140
|
<button id="btn-server-add" class="mini-btn">添加</button>
|
|
112
141
|
</div>
|
|
113
142
|
<div class="setting-row">
|
|
114
|
-
<div><div class="setting-name">扫码连接</div><div class="setting-desc"
|
|
143
|
+
<div><div class="setting-name">扫码连接</div><div class="setting-desc">拍照/相册本地解码(无谷歌服务依赖);也可系统相机扫码或手动粘贴</div></div>
|
|
115
144
|
<button id="btn-scan-pair" class="mini-btn">扫码</button>
|
|
116
145
|
</div>
|
|
117
146
|
<div class="setting-row">
|
|
@@ -203,6 +232,7 @@
|
|
|
203
232
|
<div id="toast" class="toast hidden"></div>
|
|
204
233
|
|
|
205
234
|
<script src="sha256.js"></script>
|
|
235
|
+
<script src="jsqr.min.js"></script>
|
|
206
236
|
<script src="app.js"></script>
|
|
207
237
|
</body>
|
|
208
238
|
</html>
|