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.
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 = String(a || '').split('.').map(Number)
260
- const pb = String(b || '').split('.').map(Number)
261
- for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
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.0",
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
- <span id="conn-badge" class="conn-badge off">未认证</span>
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') return toast(`会话出错:${f.message}`, 'err')
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 sendMessage() {
971
- const input = $('composer-input')
972
- const text = input.value.trim()
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) { input.value = ''; autosize(input); toast('已发送', 'ok') }
982
- else if (v?.command?.text) toast('命令已执行')
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 = String(a || '').split('.').map(Number)
1551
- const pb = String(b || '').split('.').map(Number)
1552
- for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
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
- /** App 内扫码: 调用原生 ML Kit, 扫到 dshremote://pair 后自动配对 */
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 scanner = CAP.Plugins?.BarcodeScanner
1715
- if (!scanner?.scan) { toast('当前 App 版本不支持扫码,请先更新 App', 'err'); return }
1890
+ const camera = CAP.Plugins?.Camera
1891
+ if (!camera?.getPhoto) { toast('当前 App 版本不支持拍照扫码,请先更新 App', 'err'); return }
1716
1892
  try {
1717
- const supported = await scanner.isSupported?.()
1718
- if (supported && supported.supported === false) { toast('设备不支持扫码', 'err'); return }
1719
- const result = await scanner.scan({ formats: ['QR_CODE'], autoZoom: true })
1720
- const raw = result?.barcodes?.[0]?.rawValue || result?.barcodes?.[0]?.displayValue || ''
1721
- if (!raw) { toast('没有扫到内容', 'err'); return }
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(msg.includes('cancel') || msg.includes('Cancel') ? '已取消扫码' : '扫码失败:' + msg, 'err')
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
- <textarea id="composer-input" rows="1" placeholder="给 DSH 发消息…(回车发送,Shift+Enter 换行)"></textarea>
89
- <button id="btn-send" class="send-btn">发送</button>
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">扫主机管理页的令牌二维码,自动填入地址与令牌</div></div>
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>