dsh-remote-plugin 0.6.25 → 0.6.26

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/gateway.cjs CHANGED
@@ -72,6 +72,8 @@ const WS_IDLE_MS = durationEnv('GATEWAY_WS_IDLE_MS', 180000, 0, 24 * 60 * 60 * 1
72
72
  const WS_UPGRADE_TIMEOUT_MS = durationEnv('GATEWAY_WS_UPGRADE_TIMEOUT_MS', 15000, 1000, 5 * 60 * 1000)
73
73
  const UPSTREAM_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_UPSTREAM_TIMEOUT_MS', 30000, 1000, 10 * 60 * 1000)
74
74
  const UPSTREAM = new URL(process.env.DSH_UPSTREAM || 'http://127.0.0.1:3080')
75
+ // URL 中 IPv6 带方括号,http.request 的 hostname 则要求裸地址。
76
+ const UPSTREAM_HOSTNAME = UPSTREAM.hostname.replace(/^\[|\]$/g, '')
75
77
  const UPSTREAM_TRANSPORT = UPSTREAM.protocol === 'https:' ? https : http
76
78
  const UPSTREAM_PORT = Number(UPSTREAM.port) || (UPSTREAM.protocol === 'https:' ? 443 : 80)
77
79
  const UPSTREAM_AUTHORITY = `${UPSTREAM.hostname}${UPSTREAM.port ? ':' + UPSTREAM.port : ''}`
@@ -2766,7 +2768,7 @@ function serveStatic(req, res, url) {
2766
2768
  // ---------- 管理 API ----------
2767
2769
  function upstreamReachable(cb) {
2768
2770
  const req = UPSTREAM_TRANSPORT.request({
2769
- hostname: UPSTREAM.hostname,
2771
+ hostname: UPSTREAM_HOSTNAME,
2770
2772
  port: UPSTREAM_PORT,
2771
2773
  method: 'GET',
2772
2774
  path: '/health',
@@ -3347,7 +3349,8 @@ function sha256FileHex(file, cb) {
3347
3349
  const activeUploads = new Map()
3348
3350
  const uploadPartDirs = new Set()
3349
3351
  function fsActiveKey(dirReal, name, session) {
3350
- return dirReal + '\n' + name + '\n' + (session || '')
3352
+ const key = fsPartPath(dirReal, name, session)
3353
+ return process.platform === 'win32' ? key.toLowerCase() : key
3351
3354
  }
3352
3355
 
3353
3356
  function rememberUploadDir(dirReal) {
@@ -3355,8 +3358,8 @@ function rememberUploadDir(dirReal) {
3355
3358
  }
3356
3359
 
3357
3360
  function uploadDirHasActive(dirReal) {
3358
- const prefix = dirReal + '\n'
3359
- for (const key of activeUploads.keys()) if (key.startsWith(prefix)) return true
3361
+ const dir = process.platform === 'win32' ? dirReal.toLowerCase() : dirReal
3362
+ for (const key of activeUploads.keys()) if (path.dirname(key) === dir) return true
3360
3363
  return false
3361
3364
  }
3362
3365
 
@@ -3431,24 +3434,49 @@ function fsUploadPipe(res, url, dirLex, dirReal, name) {
3431
3434
  return up ? fsUploadPipeFromTarget(res, up) : null
3432
3435
  }
3433
3436
 
3437
+ /** 同目录提交:覆盖用原子 rename;禁止覆盖用排他 link,绝不先删旧文件。 */
3438
+ function fsCommitUpload(tmp, target, overwrite) {
3439
+ if (overwrite) fs.renameSync(tmp, target)
3440
+ else {
3441
+ fs.linkSync(tmp, target)
3442
+ // 目标已提交,临时名字清理失败不应把成功误报为失败。
3443
+ try { fs.unlinkSync(tmp) } catch {}
3444
+ }
3445
+ }
3446
+
3434
3447
  function fsUploadPipeFromTarget(res, up) {
3435
3448
  let finished = false
3449
+ let ending = false
3450
+ let written = false
3436
3451
  const cleanup = () => {
3437
3452
  if (finished) return
3438
3453
  finished = true
3439
3454
  try { up.stream.destroy() } catch {}
3440
- try { fs.unlinkSync(up.tmp) } catch {}
3455
+ up.stream.once('close', () => { try { fs.unlinkSync(up.tmp) } catch {} })
3441
3456
  }
3442
3457
  up.stream.on('error', () => {
3443
3458
  if (finished) return
3444
3459
  finished = true
3445
- try { fs.unlinkSync(up.tmp) } catch {}
3460
+ up.stream.once('close', () => { try { fs.unlinkSync(up.tmp) } catch {} })
3446
3461
  if (!res.headersSent) fsJson(res, 500, { error: 'write-failed' })
3447
3462
  else try { res.destroy() } catch {}
3448
3463
  })
3464
+ up.stream.once('finish', () => { written = true })
3465
+ up.stream.once('close', () => {
3466
+ if (finished || !written) return
3467
+ finished = true
3468
+ try {
3469
+ fsCommitUpload(up.tmp, up.target, up.overwrite)
3470
+ } catch (err) {
3471
+ try { fs.unlinkSync(up.tmp) } catch {}
3472
+ if (!res.headersSent) fsJson(res, err.code === 'EEXIST' ? 409 : 403, { error: err.code === 'EEXIST' ? 'conflict' : 'write-failed', detail: err.message })
3473
+ return
3474
+ }
3475
+ fsJson(res, 201, { ok: true, path: up.displayPath, name: up.name, size: up.bytes })
3476
+ })
3449
3477
  return {
3450
3478
  write(chunk) {
3451
- if (finished) return
3479
+ if (finished || ending) return
3452
3480
  up.bytes += chunk.length
3453
3481
  if (up.bytes > FS_MAX_UPLOAD) {
3454
3482
  cleanup()
@@ -3459,19 +3487,9 @@ function fsUploadPipeFromTarget(res, up) {
3459
3487
  up.stream.write(chunk)
3460
3488
  },
3461
3489
  end() {
3462
- if (finished) return
3463
- finished = true
3464
- up.stream.end(() => {
3465
- try {
3466
- if (up.overwrite) fs.rmSync(up.target, { force: true })
3467
- fs.renameSync(up.tmp, up.target)
3468
- } catch (err) {
3469
- try { fs.unlinkSync(up.tmp) } catch {}
3470
- if (!res.headersSent) return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
3471
- return
3472
- }
3473
- fsJson(res, 201, { ok: true, path: up.displayPath, name: up.name, size: up.bytes })
3474
- })
3490
+ if (finished || ending) return
3491
+ ending = true
3492
+ up.stream.end()
3475
3493
  },
3476
3494
  abort(status, msg) {
3477
3495
  cleanup()
@@ -3659,6 +3677,8 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
3659
3677
  if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name', detail: '文件名不能为空且不能包含路径分隔符' })
3660
3678
  const session = url.searchParams.get('session') || ''
3661
3679
  if (!session) return fsJson(res, 400, { error: 'missing-session', detail: '断点续传需要 session 参数' })
3680
+ const activeKey = fsActiveKey(dirReal, name, session)
3681
+ if (activeUploads.has(activeKey)) return fsJson(res, 409, { error: 'upload-busy' })
3662
3682
  const queryOffsetRaw = url.searchParams.get('offset')
3663
3683
  const headerOffsetRaw = req.headers['upload-offset']
3664
3684
  const offsetRaw = queryOffsetRaw ?? headerOffsetRaw
@@ -3719,21 +3739,37 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
3719
3739
  } catch (err) {
3720
3740
  return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
3721
3741
  }
3722
- const activeKey = fsActiveKey(dirReal, name, session)
3723
3742
  activeUploads.set(activeKey, stream)
3724
3743
 
3725
3744
  let bytes = 0
3726
3745
  let finished = false
3746
+ let written = false
3747
+ let cancelled = false
3748
+ const release = () => {
3749
+ if (activeUploads.get(activeKey) === stream) activeUploads.delete(activeKey)
3750
+ }
3751
+ res.once('finish', () => { finished = true; if (!cancelled) release() })
3727
3752
  const abort = (status, msg, extra = {}) => {
3728
3753
  if (finished) return
3729
3754
  finished = true
3730
- activeUploads.delete(activeKey)
3755
+ cancelled = true
3756
+ const cleanup = () => {
3757
+ if (status === 413 || status === 500 || msg === 'cancelled') { try { fs.unlinkSync(part) } catch {} }
3758
+ release()
3759
+ }
3760
+ if (stream.closed) cleanup()
3761
+ else stream.once('close', cleanup)
3731
3762
  try { stream.destroy() } catch {}
3732
3763
  // 网络中断时保留分片, 客户端 probe 后续传; 只有超限/写失败才删
3733
- if (status === 413 || status === 500) { try { fs.unlinkSync(part) } catch {} }
3734
3764
  if (!res.headersSent) fsJson(res, status, { error: msg, ...extra })
3735
3765
  else try { res.destroy() } catch {}
3736
3766
  }
3767
+ stream.cancelUpload = () => {
3768
+ const closed = stream.closed ? Promise.resolve() : new Promise(resolve => stream.once('close', resolve))
3769
+ abort(409, 'cancelled')
3770
+ return closed
3771
+ }
3772
+ res.once('close', () => { if (!finished) abort(400, 'client-aborted') })
3737
3773
 
3738
3774
  stream.on('error', (err) => {
3739
3775
  abort(500, err.code === 'ENOENT' ? 'part-missing' : 'write-failed', { detail: err.message })
@@ -3751,9 +3787,11 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
3751
3787
  })
3752
3788
  req.on('end', () => {
3753
3789
  if (finished) return
3754
- finished = true
3755
- stream.end(() => {
3756
- activeUploads.delete(activeKey)
3790
+ stream.end()
3791
+ })
3792
+ stream.once('finish', () => { written = true })
3793
+ stream.once('close', () => {
3794
+ if (finished || !written) return
3757
3795
  const total = offset + bytes
3758
3796
  try {
3759
3797
  const st = fs.statSync(part)
@@ -3771,21 +3809,22 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
3771
3809
  return
3772
3810
  }
3773
3811
  const commit = (actualSha256) => {
3812
+ if (cancelled) return
3774
3813
  try {
3775
3814
  const ts = fsTargetState(target)
3776
3815
  if (ts.status) return fsJson(res, ts.status, { error: ts.error, detail: ts.detail })
3777
3816
  if (ts.exists && !overwrite) return fsJson(res, 409, { error: 'conflict', detail: '文件已存在, overwrite=1 可覆盖' })
3778
- if (ts.exists) fs.rmSync(target, { force: true })
3779
- fs.renameSync(part, target)
3817
+ fsCommitUpload(part, target, overwrite)
3780
3818
  fsJson(res, 201, { ok: true, name, path: path.join(dirLex, name), size: total, resumed: offset > 0, session, uploadLength, ...(actualSha256 ? { sha256: actualSha256 } : {}) }, fsUploadHeaders(total, uploadLength))
3781
3819
  } catch (err) {
3782
- if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
3820
+ if (!res.headersSent) fsJson(res, err.code === 'EEXIST' ? 409 : 403, { error: err.code === 'EEXIST' ? 'conflict' : 'write-failed', detail: err.message })
3783
3821
  else try { res.destroy() } catch {}
3784
3822
  }
3785
3823
  }
3786
3824
  if (sha256Expected) {
3787
3825
  // 落盘前校验: 不匹配保留分片并返回 422, 客户端可重传或取消
3788
3826
  sha256FileHex(part, (err, actual) => {
3827
+ if (cancelled) return
3789
3828
  if (err) return fsJson(res, 403, { error: 'checksum-failed', detail: err.message })
3790
3829
  if (actual !== sha256Expected) {
3791
3830
  return fsJson(res, 422, { error: 'checksum-mismatch', expected: sha256Expected, actual, partialSize: total, session }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
@@ -3799,7 +3838,6 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
3799
3838
  if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
3800
3839
  else try { res.destroy() } catch {}
3801
3840
  }
3802
- })
3803
3841
  })
3804
3842
  }
3805
3843
 
@@ -3828,17 +3866,15 @@ async function fsUploadControl(req, res, url) {
3828
3866
  const part = fsPartPath(checked.abs, name, session)
3829
3867
  const active = activeUploads.get(fsActiveKey(checked.abs, name, session))
3830
3868
  if (active) {
3831
- try { active.destroy() } catch {}
3832
- activeUploads.delete(fsActiveKey(checked.abs, name, session))
3869
+ await active.cancelUpload()
3870
+ return fsJson(res, 200, { ok: true, cancelled: true, session })
3833
3871
  }
3834
- // 等写流关闭后再删, 防止 write 把分片重新创建出来
3835
- setTimeout(() => {
3872
+ // 无活动写流时同步删除,不留下可被新上传插入的延时窗口。
3836
3873
  let removed = false
3837
3874
  try { fs.unlinkSync(part); removed = true } catch (err) {
3838
3875
  if (err.code !== 'ENOENT') return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
3839
3876
  }
3840
3877
  fsJson(res, 200, { ok: true, cancelled: true, removed, session })
3841
- }, 80)
3842
3878
  }
3843
3879
 
3844
3880
  async function serveFs(req, res, url) {
@@ -4164,7 +4200,7 @@ async function proxyLegacyApi(req, res, url) {
4164
4200
 
4165
4201
  let responseDone = false
4166
4202
  const upstreamReq = UPSTREAM_TRANSPORT.request({
4167
- hostname: UPSTREAM.hostname,
4203
+ hostname: UPSTREAM_HOSTNAME,
4168
4204
  port: UPSTREAM_PORT,
4169
4205
  method: req.method,
4170
4206
  path: url.pathname + url.search,
@@ -4552,7 +4588,7 @@ server.on('upgrade', (req, socket, head) => {
4552
4588
  handshakeTimer = null
4553
4589
  }
4554
4590
  const upstreamReq = UPSTREAM_TRANSPORT.request({
4555
- hostname: UPSTREAM.hostname,
4591
+ hostname: UPSTREAM_HOSTNAME,
4556
4592
  port: UPSTREAM_PORT,
4557
4593
  method: req.method,
4558
4594
  path: url.pathname + url.search,
package/index.mjs CHANGED
@@ -77,6 +77,14 @@ try {
77
77
  let dshListen = { host: '127.0.0.1', port: 3080 }
78
78
  let dshConnection = null
79
79
 
80
+ // 监听通配地址不是连接目标;保留地址族,避免 IPv6-only 服务退回 IPv4。
81
+ export function upstreamUrlForListener({ host, port }) {
82
+ let target = String(host || '127.0.0.1').replace(/^\[|\]$/g, '')
83
+ if (target === '0.0.0.0') target = '127.0.0.1'
84
+ if (net.isIP(target) === 6 && new URL(`http://[${target}]`).hostname === '[::]') target = '::1'
85
+ return `http://${net.isIP(target) === 6 ? `[${target}]` : target}:${port}`
86
+ }
87
+
80
88
  function dshUpstreamCookieFile() {
81
89
  return process.env.DSH_REMOTE_DSH_COOKIE_FILE || `${homedir()}/.dsh-remote/dsh-upstream.cookie`
82
90
  }
@@ -88,7 +96,7 @@ function dshUpstreamCookieFile() {
88
96
  */
89
97
  async function refreshDshUpstreamCookie() {
90
98
  if (typeof dshConnection?.authenticatedUrl !== 'function') return false
91
- const upstream = `http://${dshListen.host}:${dshListen.port}`
99
+ const upstream = upstreamUrlForListener(dshListen)
92
100
  try {
93
101
  const loginUrl = dshConnection.authenticatedUrl(upstream)
94
102
  const response = await fetch(loginUrl, {
@@ -254,11 +262,16 @@ function portInUse(port) {
254
262
 
255
263
  async function gatewayRunning() {
256
264
  try {
257
- const res = await fetch(`${gatewayBase()}/health`, { signal: AbortSignal.timeout(3000) })
265
+ const base = gatewayBase()
266
+ const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(6000) })
258
267
  if (!res.ok) return { running: false }
259
268
  const data = await res.json().catch(() => ({}))
269
+ if (data.service !== 'dsh-remote' || !Number.isInteger(data.pid) || data.pid <= 1
270
+ || typeof data.version !== 'string' || typeof data.upstream !== 'string'
271
+ || !data.events?.mux || !data.events?.host) return { running: false }
260
272
  return {
261
273
  running: true,
274
+ base,
262
275
  pid: Number(data.pid) || 0,
263
276
  version: typeof data.version === 'string' ? data.version : '',
264
277
  upstream: typeof data.upstream === 'string' ? data.upstream : '',
@@ -274,15 +287,6 @@ async function gatewayRunning() {
274
287
 
275
288
  function gatewayPidFile() { return `${homedir()}/.dsh-remote/plugin-gateway.pid` }
276
289
 
277
- function readGatewayPid() {
278
- try {
279
- const pid = Number(readFileSync(gatewayPidFile(), 'utf8').trim())
280
- return Number.isFinite(pid) && pid > 0 ? pid : 0
281
- } catch {
282
- return 0
283
- }
284
- }
285
-
286
290
  function writeGatewayPid(pid) {
287
291
  try {
288
292
  mkdirSync(`${homedir()}/.dsh-remote`, { recursive: true })
@@ -298,19 +302,22 @@ function logGateway(msg) {
298
302
  }
299
303
 
300
304
  async function killGateway(health) {
301
- const pid = (health && Number(health.pid)) || readGatewayPid()
302
- if (!pid) return false
305
+ // PID 文件和公开健康响应不能证明进程归属;仅走网关认证关闭接口。
306
+ if (!health?.running || !Number.isInteger(health.pid) || health.pid <= 1) return false
307
+ const token = gatewayToken()
308
+ if (!token) return false
303
309
  try {
304
- if (process.platform === 'win32') {
305
- await runExit('taskkill', ['/F', '/PID', String(pid)])
306
- } else {
307
- process.kill(pid)
308
- }
309
- logGateway('已停止旧网关 PID=' + pid)
310
+ const res = await fetch(`${health.base || gatewayBase()}/admin/api/shutdown`, {
311
+ method: 'POST', headers: { authorization: `Bearer ${token}`, 'x-dsh-remote-client': 'admin' },
312
+ signal: AbortSignal.timeout(2000),
313
+ })
314
+ const body = await res.json().catch(() => ({}))
315
+ if (!res.ok || body.ok !== true || body.bye !== true) return false
316
+ logGateway('已请求旧网关认证关闭')
310
317
  await sleep(300)
311
318
  return true
312
319
  } catch (e) {
313
- logGateway('停止旧网关失败 PID=' + pid + ' ' + (e?.message || String(e)))
320
+ logGateway('认证关闭旧网关失败: ' + (e?.message || String(e)))
314
321
  return false
315
322
  }
316
323
  }
@@ -337,7 +344,7 @@ function setGatewayEnabled(on) {
337
344
 
338
345
  /** 启动随插件分发的 gateway.cjs; 已运行则直接返回。 */
339
346
  async function startGateway() {
340
- const upstream = `http://${dshListen.host}:${dshListen.port}`
347
+ const upstream = upstreamUrlForListener(dshListen)
341
348
  const health = await gatewayRunning()
342
349
  if (health.running) {
343
350
  setGatewayEnabled(true)
@@ -413,7 +420,7 @@ function ensureGateway() {
413
420
  const out = await startGateway()
414
421
  return !!out.running
415
422
  }
416
- const upstream = `http://${dshListen.host}:${dshListen.port}`
423
+ const upstream = upstreamUrlForListener(dshListen)
417
424
  const oldUpstream = health.upstream || ''
418
425
  const oldVersion = health.version || '?'
419
426
  const versionMismatch = oldVersion !== version
@@ -421,7 +428,10 @@ function ensureGateway() {
421
428
  // 重启只能制造额外断线,网关应保持运行并通过 /health 暴露 degraded 状态。
422
429
  if (versionMismatch || (oldUpstream && oldUpstream !== upstream) || (!oldUpstream && upstream)) {
423
430
  logGateway(`网关需刷新: 版本 ${oldVersion} -> ${version}, 上游 ${oldUpstream || '?'} -> ${upstream}`)
424
- await killGateway(health)
431
+ if (!await killGateway(health)) {
432
+ logGateway('拒绝自动重启: 无法通过认证关闭旧网关,请检查令牌或端口占用')
433
+ return false
434
+ }
425
435
  for (let i = 0; i < 10; i++) {
426
436
  if (!(await gatewayRunning()).running) break
427
437
  await sleep(200)
@@ -439,20 +449,10 @@ function ensureGateway() {
439
449
 
440
450
  /** 通过网关自身的 /admin/api/shutdown 优雅停止(不管它当初是谁拉起的); 并写入 off 防自愈拉起。 */
441
451
  async function stopGateway() {
442
- const token = gatewayToken()
443
- if (!token) return { ok: false, running: false, error: '找不到 ~/.dsh-remote/token, 无法认证网关' }
444
- try {
445
- const res = await fetch(`${gatewayBase()}/admin/api/shutdown`, {
446
- method: 'POST',
447
- headers: { authorization: `Bearer ${token}`, 'x-dsh-remote-client': 'admin' },
448
- signal: AbortSignal.timeout(2000),
449
- })
450
- const json = await res.json().catch(() => ({}))
451
- if (res.ok) setGatewayEnabled(false)
452
- return { ok: res.ok, running: false, ...json }
453
- } catch (e) {
454
- return { ok: false, running: false, error: '网关不可达: ' + (e?.message || e) }
455
- }
452
+ const health = await gatewayRunning()
453
+ if (!await killGateway(health)) return { ok: false, running: health.running, error: '无法确认网关身份或认证关闭失败,请检查令牌与端口占用' }
454
+ setGatewayEnabled(false)
455
+ return { ok: true, running: false, bye: true }
456
456
  }
457
457
 
458
458
  // ---------- 统计事件投递(实时 assistant/message + usage -> 网关 /stats/ingest) ----------
@@ -750,6 +750,10 @@ async function serveStatic(req, res, ctx) {
750
750
  // 先在切换配置前读取旧端口上的健康状态;写入新端口后 gatewayRunning()
751
751
  // 只会探测新端口,否则旧网关会变成孤儿进程继续占用旧端口。
752
752
  const oldHealth = process.env.DSH_REMOTE_GATEWAY ? { running: false } : await gatewayRunning()
753
+ if (!process.env.DSH_REMOTE_GATEWAY_PORT && port !== oldPort && oldHealth.running && !await killGateway(oldHealth)) {
754
+ sendJson(res, 409, { ok: false, error: '无法通过认证关闭旧网关,端口配置未修改' })
755
+ return
756
+ }
753
757
  try {
754
758
  mkdirSync(`${homedir()}/.dsh-remote`, { recursive: true })
755
759
  writeFileSync(gatewayPortFile(), String(port) + '\n')
@@ -758,9 +762,6 @@ async function serveStatic(req, res, ctx) {
758
762
  return
759
763
  }
760
764
  const effectivePort = Number(readGatewayPort())
761
- if (effectivePort !== oldPort) {
762
- if (oldHealth.running) await killGateway(oldHealth)
763
- }
764
765
  let running = (await gatewayRunning()).running
765
766
  if (gatewayAutostart()) {
766
767
  const startOut = await startGateway()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.6.25",
3
+ "version": "0.6.26",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
package/public/genui.css CHANGED
@@ -1,2 +1,8 @@
1
1
  .genui{margin:14px 0;padding:14px;border:1px solid #8884;border-radius:12px;line-height:1.65;overflow:hidden;color:inherit;background:#88808;font-size:14px}
2
2
  .genui *{box-sizing:border-box}.genui h1,.genui h2,.genui h3,.genui h4,.genui p{margin:0 0 10px}.genui-caption{font-size:11px;opacity:.65;margin-bottom:12px}.genui-row{display:flex;flex-wrap:wrap;gap:12px}.genui-row>*{flex:1;min-width:120px}.genui-col{display:grid;gap:12px}.genui-grid{display:grid;grid-template-columns:repeat(var(--genui-cols,2),minmax(0,1fr));gap:12px}.genui-card{border:1px solid #8884;border-radius:8px;padding:12px;min-width:0}.genui-card>h4{font-size:16px}.genui-stat{display:flex;flex-direction:column;min-width:0;padding:8px 0}.genui-stat strong{font-size:28px;line-height:1.25;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.genui-stat small,.genui-stat span{opacity:.75}.genui-badge{display:inline-block;padding:2px 9px;border:1px solid #8885;border-radius:20px}.genui progress{display:block;width:100%;accent-color:#2563eb;height:10px;margin:7px 0}.genui-spacer{height:12px}.genui-callout{border-left:3px solid #2563eb;padding:10px 12px;background:#2563eb12}.genui-chart{margin:10px 0}.genui-chart figcaption{font-weight:600}.genui-chart svg{display:block;width:100%;height:auto;max-height:520px}.genui-chart svg text{fill:currentColor;font:11px system-ui}.genui-gridline{stroke:currentColor;opacity:.15;fill:none}.genui-legend{display:flex;flex-wrap:wrap;gap:7px 14px;font-size:12px}.genui-legend i{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px}.genui-scroll{overflow:auto;max-height:420px}.genui table{width:100%;border-collapse:collapse;font-size:12px}.genui td,.genui th{text-align:left;padding:7px;border-bottom:1px solid #8884;white-space:nowrap}.genui-source{margin-top:12px}.genui summary{cursor:pointer;font-size:12px}.genui pre{max-height:320px;overflow:auto;white-space:pre-wrap;word-break:break-word}.genui-unsupported{border-left:2px solid #d97706;padding:8px;margin:8px 0}.genui-html{display:block;width:100%;height:440px;border:1px solid #8884;border-radius:7px;background:white}.genui dl{display:grid;grid-template-columns:auto 1fr;gap:6px 12px}.genui dd{margin:0}.genui li{margin:4px 0}@media(max-width:520px){.genui{padding:10px}.genui-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.genui-stat strong{font-size:23px}.genui-html{height:380px}}
3
+ .genui-tabs>[role=tablist]{display:flex;gap:5px;overflow-x:auto;margin-bottom:12px;border-bottom:1px solid #8884;padding-bottom:6px}
4
+ .genui button{font:inherit;color:inherit;background:transparent;border:1px solid #8884;border-radius:6px;padding:5px 9px;cursor:pointer}
5
+ .genui button:focus-visible{outline:2px solid #2563eb;outline-offset:2px}.genui [role=tab][aria-selected=true]{background:#2563eb20;border-color:#2563eb}.genui [hidden]{display:none!important}
6
+ .genui .genui-spark{display:block;width:120px;height:30px;max-width:100%;color:#2563eb}.genui-ring{display:inline-flex;gap:8px;align-items:center}.genui-ring svg{width:40px;height:40px;flex:none;color:#2563eb}
7
+ .genui-positive{color:#059669}.genui-negative{color:#dc2626}.genui-table tfoot{font-weight:600}.genui-table th[aria-sort=ascending] button::after{content:' ↑'}.genui-table th[aria-sort=descending] button::after{content:' ↓'}
8
+ .genui-tree{list-style:none;padding-left:16px;border-left:1px solid #8884}.genui-diff{margin:10px 0}.genui-diff section{min-width:0}.genui-diff pre{white-space:pre;overflow:auto}.genui-diff .genui-grid{gap:12px;margin-top:8px}@media(max-width:520px){.genui-diff .genui-grid{grid-template-columns:1fr}}
package/public/genui.js CHANGED
@@ -11,13 +11,32 @@
11
11
  const color = (value, i) => /^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i.test(value || '') ? value : palette[i % palette.length]
12
12
  const raw = value => '<pre><code>' + esc(typeof value === 'string' ? value : JSON.stringify(value, null, 2)) + '</code></pre>'
13
13
  const details = value => '<details class="genui-source"><summary>JSON / 源码</summary>' + raw(value) + '</details>'
14
+ let uid = 0
15
+ function spark(values) {
16
+ if (!Array.isArray(values) || values.length < 2 || values.length > 100 || !values.every(number)) return ''
17
+ const low = Math.min(...values), span = Math.max(...values) - low || 1
18
+ return '<svg class="genui-spark" viewBox="0 0 120 30" role="img" aria-label="趋势"><title>' + esc(values.join(', ')) + '</title><polyline fill="none" stroke="currentColor" stroke-width="2" points="' + values.map((v, i) => (2 + i * 116 / (values.length - 1)) + ',' + (28 - (v - low) / span * 26)).join(' ') + '"/></svg>'
19
+ }
20
+ function ring(value, label) {
21
+ const pct = Math.max(0, Math.min(100, value))
22
+ return '<span class="genui-ring"><svg viewBox="0 0 40 40" aria-hidden="true"><circle cx="20" cy="20" r="16" fill="none" stroke="currentColor" opacity=".15" stroke-width="4"/><circle cx="20" cy="20" r="16" fill="none" stroke="currentColor" stroke-width="4" pathLength="100" stroke-dasharray="' + pct + ' 100" transform="rotate(-90 20 20)"/></svg><span>' + esc(label) + '</span></span>'
23
+ }
24
+ function numeric(value) {
25
+ if (typeof value === 'number') return Number.isFinite(value) ? value : NaN
26
+ let s = String(value ?? '').trim().replace(/^[¥$€£]/, '').replace(/%$/, '')
27
+ if (!s) return NaN
28
+ let factor = 1
29
+ const unit = s.slice(-1).toLowerCase()
30
+ if ('kmb万亿'.includes(unit)) { factor = ({ k: 1e3, m: 1e6, b: 1e9, 万: 1e4, 亿: 1e8 })[unit]; s = s.slice(0, -1) }
31
+ return s.trim() ? Number(s.replace(/[,,\s]/g, '')) * factor : NaN
32
+ }
14
33
  function unsupported(node, reason) {
15
34
  return '<div class="genui-unsupported">暂不支持 / Unsupported: ' + esc(reason || node?.type || 'spec') + details(node) + '</div>'
16
35
  }
17
36
 
18
37
  function chart(node) {
19
38
  const kind = node.kind || 'bars'
20
- if (!['bars', 'line', 'donut'].includes(kind) || node.stacked || node.filter || node.sortField) return unsupported(node, 'chart options')
39
+ if (!['bars', 'line', 'donut'].includes(kind) || (node.stacked && kind !== 'bars') || node.filter || node.sortField) return unsupported(node, 'chart options')
21
40
  const series = array(node.series).length ? node.series : [{ label: node.title || '', data: node.data }]
22
41
  if (series.length > 6 || (kind === 'donut' && series.length !== 1)) return unsupported(node, 'chart series')
23
42
  if (series.some(s => !s || !Array.isArray(s.data) || !s.data.length || s.data.length > 100 || s.data.some(d => !d || !number(d.value)))) return unsupported(node, 'chart data')
@@ -40,6 +59,11 @@
40
59
  svg += '<div class="genui-legend">' + data.map((d, i) => '<span><i style="background:' + color(d.color || array(node.palette)[i], i) + '"></i>' + esc(d.label) + ' · ' + (d.value / total * 100).toFixed(1) + '%</span>').join('') + '</div>'
41
60
  } else {
42
61
  const values = series.flatMap(s => s.data.map(d => d.value))
62
+ const stacked = node.stacked === true && kind === 'bars'
63
+ if (stacked) data.forEach((_, i) => {
64
+ values.push(series.reduce((sum, s) => sum + Math.max(0, s.data[i].value), 0))
65
+ values.push(series.reduce((sum, s) => sum + Math.min(0, s.data[i].value), 0))
66
+ })
43
67
  let min = Math.min(0, ...values), max = Math.max(0, ...values)
44
68
  if (min === max) max = min + 1
45
69
  const horizontal = kind === 'bars' && node.horizontal === true
@@ -57,14 +81,15 @@
57
81
  series.forEach((s, si) => {
58
82
  const points = []
59
83
  s.data.forEach((d, i) => {
60
- const center = (horizontal ? top : left) + step * (i + 0.5), pos = scale(d.value)
84
+ const base = stacked ? series.slice(0, si).reduce((sum, prev) => sum + (d.value >= 0 ? Math.max(0, prev.data[i].value) : Math.min(0, prev.data[i].value)), 0) : 0
85
+ const center = (horizontal ? top : left) + step * (i + 0.5), pos = scale(base + d.value)
61
86
  const hint = '<title>' + esc((s.label ? s.label + ' · ' : '') + d.label + ': ' + d.value) + '</title>'
62
87
  if (kind === 'line') {
63
88
  points.push(center + ',' + pos)
64
89
  svg += '<circle cx="' + center + '" cy="' + pos + '" r="3" fill="' + colors[si] + '">' + hint + '</circle>'
65
90
  } else {
66
- const size = step * 0.7 / series.length, lane = center - step * 0.35 + si * size
67
- const zero = scale(0)
91
+ const size = step * 0.7 / (stacked ? 1 : series.length), lane = center - step * 0.35 + (stacked ? 0 : si * size)
92
+ const zero = scale(base)
68
93
  svg += '<rect x="' + (horizontal ? Math.min(zero, pos) : lane) + '" y="' + (horizontal ? lane : Math.min(zero, pos)) + '" width="' + (horizontal ? Math.abs(zero - pos) : size) + '" height="' + (horizontal ? size : Math.abs(zero - pos)) + '" fill="' + (series.length === 1 ? color(d.color || array(node.palette)[i], i) : colors[si]) + '">' + hint + '</rect>'
69
94
  }
70
95
  })
@@ -85,9 +110,38 @@
85
110
  if (!spec || typeof spec !== 'object') throw Error('Invalid spec')
86
111
  let count = 0
87
112
  function nodes(items, depth) { return array(items).map(n => node(n, depth)).join('') }
113
+ function fileTree(items, depth) {
114
+ if (depth > 12 || (count += array(items).length) > 240) throw Error('Component limit')
115
+ return '<ul class="genui-tree">' + array(items).map(v => !v || typeof v !== 'object' ? '<li>' + esc(v) + '</li>' : '<li>' + (v.type === 'dir' || Array.isArray(v.children) ? '<details open><summary>' + esc(v.name) + '</summary>' + fileTree(v.children, depth + 1) + '</details>' : '<span>' + esc(v.name) + '</span>') + '</li>').join('') + '</ul>'
116
+ }
117
+ function table(n, depth) {
118
+ const columns = array(n.columns), rows = array(n.rows), types = array(n.types)
119
+ if (!columns.length || rows.some(r => !Array.isArray(r)) || types.some(t => !['text', 'num', 'delta', 'bar', 'badge', 'spark', 'ring', 'index'].includes(t))) return unsupported(n, 'table shape/types')
120
+ const cell = (v, type, i) => {
121
+ const num = numeric(v)
122
+ if (type === 'index') return String(i + 1)
123
+ if (type === 'spark') return spark(String(v).split(/[\s,;]+/).map(Number)) || esc(v)
124
+ if (type === 'ring' && Number.isFinite(num)) return ring(num, v)
125
+ if (type === 'bar' && Number.isFinite(num)) return '<span>' + esc(v) + '</span><progress max="100" value="' + Math.max(0, Math.min(100, num)) + '"></progress>'
126
+ if (type === 'badge') return '<span class="genui-badge">' + esc(v) + '</span>'
127
+ if (type === 'delta') return '<span class="genui-' + (num > 0 ? 'positive' : num < 0 ? 'negative' : 'neutral') + '">' + esc(v) + '</span>'
128
+ return esc(v)
129
+ }
130
+ let foot = ''
131
+ if (n.total) foot = '<tfoot><tr>' + columns.map((_, j) => {
132
+ const values = rows.map(r => numeric(r[j]))
133
+ return '<td>' + (j === 0 ? '合计 / Total' : values.length && values.every(Number.isFinite) && !['index', 'spark', 'ring', 'bar'].includes(types[j]) ? esc(Number(values.reduce((a, b) => a + b, 0).toPrecision(12))) : '') + '</td>'
134
+ }).join('') + '</tr></tfoot>'
135
+ return '<div class="genui-scroll"><table class="genui-table"><thead><tr>' + columns.map((v, j) => '<th aria-sort="none"><button type="button" data-genui-sort="' + j + '">' + esc(v) + ' ↕</button></th>').join('') + '</tr></thead>' + rows.map((r, i) => '<tbody data-genui-order="' + i + '"><tr>' + columns.map((_, j) => '<td data-genui-value="' + esc(r[j]) + '">' + cell(r[j], types[j], i) + '</td>').join('') + '</tr>' + (array(n.details?.[i]).length ? '<tr><td colspan="' + columns.length + '"><details><summary>详情 / Details</summary>' + nodes(n.details[i], depth + 1) + '</details></td></tr>' : '') + '</tbody>').join('') + foot + '</table></div>' + (n.export ? '<details><summary>导出数据 / CSV</summary>' + raw([columns, ...rows].map(r => r.map(v => '"' + String(v ?? '').replace(/"/g, '""') + '"').join(',')).join('\n')) + '</details>' : '')
136
+ }
88
137
  function node(n, depth) {
89
138
  if (++count > 240 || depth > 12) throw Error('Component limit')
90
139
  if (!n || typeof n !== 'object' || Array.isArray(n)) return unsupported(n)
140
+ // 插件公开的常用字段别名。
141
+ n = { ...n }
142
+ if (n.type === 'text' && n.content === undefined) n.content = n.text
143
+ if (n.type === 'table') { n.columns ??= n.headers; n.rows ??= n.data }
144
+ if (n.type === 'card') { n.title ??= n.label; n.items ??= n.content }
91
145
  const type = n.type
92
146
  // Reject data-transforming options rather than silently displaying different data.
93
147
  if (n.filter || n.sortField) return unsupported(n, type + ' filter/sort')
@@ -98,21 +152,33 @@
98
152
  return '<section class="genui-' + type + '"' + (type === 'grid' ? ' style="--genui-cols:' + cols + '"' : '') + '>' + (n.title ? '<h4>' + esc(n.title) + '</h4>' : '') + nodes(n.items, depth + 1) + '</section>'
99
153
  }
100
154
  if (type === 'text') { const tag = ['h1', 'h2', 'h3'].includes(n.size) ? n.size : 'p'; return '<' + tag + '>' + esc(n.content) + '</' + tag + '>' }
101
- if (type === 'hero') return '<div class="genui-stat"><small>' + esc(n.label) + '</small><strong>' + esc(n.value) + '</strong><h4>' + esc(n.title) + '</h4><span>' + esc(n.subtitle) + '</span><span>' + esc(n.delta) + '</span></div>'
102
- if (type === 'stat') return '<div class="genui-stat"><small>' + esc(n.label) + '</small><strong>' + esc(n.value) + '</strong><span>' + esc(n.delta) + '</span></div>'
155
+ if (type === 'hero') return '<div class="genui-stat"><small>' + esc(n.label) + '</small><strong>' + esc(n.value) + '</strong><h4>' + esc(n.title) + '</h4><span>' + esc(n.subtitle) + '</span><span>' + esc(n.delta) + '</span>' + spark(n.spark) + '</div>'
156
+ if (type === 'stat') return '<div class="genui-stat"><small>' + esc(n.label) + '</small><strong>' + esc(n.value) + '</strong><span>' + esc(n.delta) + '</span>' + spark(n.spark) + '</div>'
103
157
  if (type === 'badge') return '<span class="genui-badge">' + esc(n.label) + '</span>'
104
158
  if (type === 'divider') return '<hr>'
105
159
  if (type === 'spacer') return '<div class="genui-spacer"></div>'
106
160
  if (type === 'progress' && !number(n.value)) return unsupported(n, 'progress value')
161
+ if (type === 'progress' && n.variant === 'ring') return '<div>' + esc(n.label) + ring(n.value, n.valueLabel || n.value + '%') + (number(n.target) ? '<small>目标 / Target: ' + esc(n.target) + '%</small>' : '') + '</div>'
107
162
  if (type === 'progress') return '<div>' + esc(n.label) + '<progress max="100" value="' + Math.max(0, Math.min(100, Number(n.value) || 0)) + '"></progress><small>' + esc(n.valueLabel || String(n.value || 0) + '%') + '</small>' + (number(n.target) ? '<small> · 目标 / Target: ' + esc(n.target) + '%</small>' : '') + '</div>'
108
163
  if (type === 'callout') return '<aside class="genui-callout"><strong>' + esc(n.title) + '</strong><p>' + esc(n.content) + '</p></aside>'
109
164
  if (type === 'code' || type === 'json') return raw(type === 'code' ? n.code : n.value)
110
165
  if (type === 'list' || type === 'timeline' || type === 'steps') return '<ul>' + array(n.items || n.steps).slice(0, 100).map(v => '<li>' + (v && typeof v === 'object' ? (v.type ? node(v, depth + 1) : '<strong>' + esc(v.title) + '</strong> ' + esc(v.desc) + ' ' + esc(v.time)) : esc(v)) + '</li>').join('') + '</ul>'
111
166
  if (type === 'keyvalue') return '<dl>' + array(n.pairs).slice(0, 100).map(v => '<dt>' + esc(v?.key) + '</dt><dd>' + esc(v?.value) + '</dd>').join('') + '</dl>'
112
- if (type === 'table') return '<div class="genui-scroll"><table><thead><tr>' + array(n.columns).slice(0, 30).map(v => '<th>' + esc(v) + '</th>').join('') + '</tr></thead><tbody>' + array(n.rows).slice(0, 200).map(row => '<tr>' + array(row).slice(0, 30).map(v => '<td>' + esc(v) + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>'
167
+ if (type === 'table') return table(n, depth)
168
+ if (type === 'file-tree') return fileTree(n.items, depth + 1)
169
+ if (type === 'breadcrumb') return '<nav aria-label="路径">' + array(n.items).map(esc).join(' › ') + '</nav>'
170
+ if (type === 'avatar') return '<span class="genui-badge">' + esc(n.name) + '</span>'
171
+ if (type === 'diff') {
172
+ if (array(n.diffs).length > 30) return unsupported(n, 'diff size limit')
173
+ return array(n.diffs).map(d => '<details class="genui-diff" open><summary>' + esc(d?.path) + '</summary><div class="genui-grid">' + (d?.oldText == null ? '<section>新增文件 / New file</section>' : '<section><strong>修改前 / Before</strong>' + raw(d.oldText) + '</section>') + '<section><strong>修改后 / After</strong>' + raw(d?.newText) + '</section></div></details>').join('')
174
+ }
113
175
  if (type === 'chart') return chart(n)
114
176
  if (type === 'echart' && !n.option && ['bar', 'line', 'pie'].includes(n.preset)) return '<small>ECharts 基础数据预览 / Basic preview</small>' + chart({ ...n, type: 'chart', kind: { bar: 'bars', line: 'line', pie: 'donut' }[n.preset] })
115
- if (type === 'accordion' || type === 'tabs') return array(n.tabs || n.items).slice(0, 30).map(v => '<details><summary>' + esc(v?.label || v?.title) + '</summary>' + nodes(v?.items, depth + 1) + '</details>').join('')
177
+ if (type === 'tabs') {
178
+ const id = 'genui-tabs-' + (++uid), tabs = array(n.tabs).slice(0, 30)
179
+ return '<div class="genui-tabs"><div role="tablist" aria-label="' + esc(n.title || '标签页') + '">' + tabs.map((v, i) => '<button type="button" role="tab" id="' + id + '-tab-' + i + '" aria-controls="' + id + '-panel-' + i + '" aria-selected="' + (i === 0) + '" tabindex="' + (i === 0 ? 0 : -1) + '" data-genui-tab="' + i + '">' + esc(v?.label) + '</button>').join('') + '</div>' + tabs.map((v, i) => '<div role="tabpanel" id="' + id + '-panel-' + i + '" aria-labelledby="' + id + '-tab-' + i + '"' + (i ? ' hidden' : '') + '>' + nodes(v?.items || v?.content, depth + 1) + '</div>').join('') + '</div>'
180
+ }
181
+ if (type === 'accordion') return array(n.items).slice(0, 30).map(v => '<details><summary>' + esc(v?.title) + '</summary>' + nodes(v?.items, depth + 1) + '</details>').join('')
116
182
  return unsupported(n)
117
183
  }
118
184
  const body = Array.isArray(spec) ? nodes(spec, 0) : spec.type ? node(spec, 0) : nodes(spec.items, 0)
@@ -140,5 +206,49 @@
140
206
  const srcDoc = '<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="' + policy + '"><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{font:14px/1.6 system-ui;margin:16px;overflow-wrap:anywhere}table{border-collapse:collapse}td,th{padding:6px;border:1px solid #ccc}pre{white-space:pre-wrap}*{max-width:100%;box-sizing:border-box}</style></head><body>' + template.innerHTML + '</body></html>'
141
207
  return '<section class="genui"><div class="genui-caption">HTML · 只读预览(脚本及外部资源已禁用)</div><iframe class="genui-html" sandbox="" referrerpolicy="no-referrer" title="HTML preview" srcdoc="' + esc(srcDoc) + '"></iframe></section>'
142
208
  }
143
- return { render, html }
209
+ function activateTab(button, focus = false) {
210
+ const list = button.parentElement, tabs = Array.from(list.children)
211
+ tabs.forEach(tab => {
212
+ const active = tab === button
213
+ tab.setAttribute('aria-selected', String(active)); tab.tabIndex = active ? 0 : -1
214
+ const panel = Array.from(list.parentElement.children).find(p => p.id === tab.getAttribute('aria-controls'))
215
+ if (panel) panel.hidden = !active
216
+ })
217
+ if (focus) button.focus()
218
+ }
219
+ function compareCells(a, b) {
220
+ const x = numeric(a), y = numeric(b)
221
+ if (Number.isFinite(x) && Number.isFinite(y)) return x - y
222
+ if (Number.isFinite(x)) return -1
223
+ if (Number.isFinite(y)) return 1
224
+ return String(a ?? '').localeCompare(String(b ?? ''))
225
+ }
226
+ if (typeof document !== 'undefined') {
227
+ document.addEventListener('click', event => {
228
+ const button = event.target.closest?.('.genui button')
229
+ if (!button) return
230
+ if (button.hasAttribute('data-genui-tab')) activateTab(button)
231
+ if (button.hasAttribute('data-genui-sort')) {
232
+ const table = button.closest('table'), th = button.parentElement
233
+ const col = Number(button.dataset.genuiSort)
234
+ const previous = th.getAttribute('aria-sort'), direction = previous === 'ascending' ? 'descending' : previous === 'descending' ? 'none' : 'ascending'
235
+ Array.from(table.tHead.rows[0].cells).forEach(cell => cell.setAttribute('aria-sort', 'none'))
236
+ th.setAttribute('aria-sort', direction)
237
+ Array.from(table.tBodies).sort((a, b) => {
238
+ const order = Number(a.dataset.genuiOrder) - Number(b.dataset.genuiOrder)
239
+ if (direction === 'none') return order
240
+ const compared = compareCells(a.rows[0].cells[col]?.dataset.genuiValue, b.rows[0].cells[col]?.dataset.genuiValue)
241
+ return (direction === 'ascending' ? compared : -compared) || order
242
+ }).forEach(body => table.insertBefore(body, table.tFoot))
243
+ }
244
+ })
245
+ document.addEventListener('keydown', event => {
246
+ const button = event.target.closest?.('.genui [data-genui-tab]')
247
+ if (!button || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
248
+ const tabs = Array.from(button.parentElement.children), i = tabs.indexOf(button)
249
+ const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (i + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length
250
+ event.preventDefault(); activateTab(tabs[next], true)
251
+ })
252
+ }
253
+ return { render, html, compareCells }
144
254
  })
@@ -1,10 +1,14 @@
1
1
  {
2
- "version": "0.6.25",
2
+ "version": "0.6.26",
3
3
  "apkUrl": "dsh-remote.apk",
4
- "sha256": "56e3aa905bcc4a746c604af7b09a5bfe50c21deafd1b04756a345bd03cc44828",
5
- "releasedAt": "2026-09-16T06:56:53.046Z",
6
- "notes": "0.6.25:适配 DSH 0.1.5 的实时思考、文件消息与 V3 会话统计;修复 Windows 跨盘文件访问和空会话持久归档;完善 APK 下载进度与校验;新增 dsh-ui 常用组件及柱状图、折线图、环形图只读预览,HTML 代码块支持隔离预览,未支持的高级组件保留原始规格。",
4
+ "sha256": "5b60977e3960d65b1ab60cb9f168835c8de927113738d925881340749122d2aa",
5
+ "releasedAt": "2026-09-16T16:59:10.029Z",
6
+ "notes": "0.6.26:修复 DSH 通配监听地址导致的认证与实时连接失败,兼容 IPv6-only;上传覆盖失败保留原文件,避免并发同名覆盖和续传分片竞争;网关改用认证关闭,避免误杀其他进程;完善 dsh-ui 堆叠柱状图、标签页、表格排序与特殊单元格、趋势线、环形进度、文件树和修改前后预览。已通过 Windows 自动测试与浏览器检查,Linux/Docker 专项实测仍待完成。",
7
7
  "history": [
8
+ {
9
+ "version": "0.6.26",
10
+ "notes": "0.6.26:修复 DSH 通配监听地址导致的认证与实时连接失败,兼容 IPv6-only;上传覆盖失败保留原文件,避免并发同名覆盖和续传分片竞争;网关改用认证关闭,避免误杀其他进程;完善 dsh-ui 堆叠柱状图、标签页、表格排序与特殊单元格、趋势线、环形进度、文件树和修改前后预览。已通过 Windows 自动测试与浏览器检查,Linux/Docker 专项实测仍待完成。"
11
+ },
8
12
  {
9
13
  "version": "0.6.25",
10
14
  "notes": "0.6.25:适配 DSH 0.1.5 的实时思考、文件消息与 V3 会话统计;修复 Windows 跨盘文件访问和空会话持久归档;完善 APK 下载进度与校验;新增 dsh-ui 常用组件及柱状图、折线图、环形图只读预览,HTML 代码块支持隔离预览,未支持的高级组件保留原始规格。"
@@ -40,10 +44,6 @@
40
44
  {
41
45
  "version": "0.6.17",
42
46
  "notes": "0.6.17:修复 host.describe 初次失败后总览误报 DSH 上游离线并增加恢复重试;识别 Docker、无 systemd 和插件内嵌等外部生命周期环境,隐藏不可用的 DSH 启停并给出明确提示;兼容 Caddy Basic Auth,避免管理 API 覆盖 Authorization 导致登录循环;支持通过环境变量或管理页手动补充 Docker 宿主、局域网和 Tailscale 地址并写入配对二维码;完整适配 Windows 盘符、UNC、多文件根和跨主机文件路径清理,并新增 Windows 原生 CI 安全门禁。"
43
- },
44
- {
45
- "version": "0.6.16",
46
- "notes": "0.6.16:Windows 端 DSH 服务启动/重启接入 Windows Service(实验性功能),启动后继续检查 DSH HTTP 与 mux/host 实时通道;空对话退出时清理 App 本地残留;网关控制台主机 IP 改为可逐项启用的地址表,关闭地址不再进入配对二维码、诊断和防火墙建议;Android 设置新增模型配置、自定义模型思考深度档位和小米/系统 ASR 功能测试,支持复制设备、权限、partial/final、session 重建和错误诊断日志。"
47
47
  }
48
48
  ]
49
49
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.6.25"
2
+ "version": "0.6.26"
3
3
  }