feat: 独立镜像桥(免 DSH 重启出镜像/指标)+ 去掉 IP 显示 + 上下文占比兜底

- tools/mirror-bridge.mjs:直接读 %DSH_HOME%/sessions/**/session.jsonl.zstd(多帧 zstd 按魔数切帧解)
  与 storages/session_projcache.json,算出 消息行/轮/步/tok·s⁻¹/token/上下文占比 并写
  snapshot-bridge.json(插件缺字段时由浮窗补齐);插件在写 snapshot.json 时它不抢
- 浮窗标题栏不再显示 endpoint IP,只在未连接时显示状态词;数据来自桥时指标带 ≈
- host: contextUsed 兜底按 1M 上下文算(真机 sessionProjection=n,拿不到上下文窗口)
- 实测:snapshot -> page rows=40 turns=1 steps=67 tok=214639;outbox watch 切会话生效
This commit is contained in:
2026-09-27 17:20:14 +08:00
parent cb57f75577
commit 6ee713b145
4 changed files with 278 additions and 9 deletions
+1 -1
View File
@@ -330,7 +330,7 @@ export function apply(ctx, config) {
const denom = input + read
if (denom > 0) out.cacheHit = read / denom
}
let capacity = contextWindows.get(id) || modelContext()
let capacity = contextWindows.get(id) || modelContext() || 1000000 // 兜底:DSH 常用 1M 上下文
const used = m.surfaceTokens ?? m.totalTokens
if (capacity && used) out.contextUsed = Math.min(1, used / capacity)
}
+47 -3
View File
@@ -69,7 +69,11 @@ $commandFile = Join-Path $stateDir 'command.json'
$aliveFile = Join-Path $stateDir 'alive.json'
# 文件桥:插件每秒写 snapshot.json(桌面壳 HTTP 网关 403,浮窗只能走文件)
$snapshotFile = Join-Path $DshHome 'float-window\snapshot.json'
$bridgeSnapshotFile = Join-Path $DshHome 'float-window\snapshot-bridge.json'
$outboxFile = Join-Path $DshHome 'float-window\outbox.json'
$watchFile = Join-Path $DshHome 'float-window\watch.json'
# 独立镜像桥:直接读会话日志/投影缓存(DSH 还没重启、插件没在写快照时的兜底数据源)
$bridgeScript = Join-Path (Split-Path $PSScriptRoot -Parent) 'tools\mirror-bridge.mjs'
if ($alreadyRunning) {
if ($Action) {
try {
@@ -387,7 +391,13 @@ $onWebMessage = {
'scale' { $script:state.scale = [double]$m.value; $wv.ZoomFactor = [double]$m.value; Save-State; Push-State }
'reload' { $wv.Reload() }
'chat' { Write-Outbox @{ cmd = 'chat'; text = [string]$m.text } }
'watch' { Write-Outbox @{ cmd = 'watch'; sessionId = [string]$m.sessionId } }
'watch' {
Write-Outbox @{ cmd = 'watch'; sessionId = [string]$m.sessionId }
# 镜像桥也跟随同一个选择
try {
[System.IO.File]::WriteAllText($watchFile, (@{ sessionId = [string]$m.sessionId } | ConvertTo-Json -Compress), (New-Object System.Text.UTF8Encoding($false)))
} catch { }
}
'newSession' { Write-Outbox @{ cmd = 'session'; action = 'create' } }
'openExternal' { if ($m.url -match '^https?://') { Start-Process $m.url } }
}
@@ -427,7 +437,28 @@ $timer.Add_Tick({
$s = [System.IO.File]::ReadAllText($snapshotFile, [System.Text.Encoding]::UTF8)
if ($s -and $s -ne $script:lastSnapshotText) {
$script:lastSnapshotText = $s
Send-ToPage @{ type = 'snapshot'; data = ($s | ConvertFrom-Json) }
$snap = $s | ConvertFrom-Json
# 用镜像桥的兜底快照补齐插件缺的字段(token/上下文估算、标题)
try {
if (Test-Path $bridgeSnapshotFile) {
$bs = [System.IO.File]::ReadAllText($bridgeSnapshotFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
if ($bs -and $bs.sessionId -eq $snap.sessionId) {
if ($null -eq $snap.metrics.contextUsed) { $snap.metrics.contextUsed = $bs.metrics.contextUsed }
if ($null -eq $snap.metrics.tokens) { $snap.metrics.tokens = $bs.metrics.tokens }
if ($null -eq $snap.metrics.cacheHit) { $snap.metrics.cacheHit = $bs.metrics.cacheHit }
if ($null -eq $snap.metrics.tps -and $bs.metrics.tps) { $snap.metrics.tps = $bs.metrics.tps }
if (-not $snap.title -and $bs.title) { $snap.title = $bs.title }
if ($bs.approx) { $snap.approx = $true }
}
}
} catch { }
Send-ToPage @{ type = 'snapshot'; data = $snap }
# 每 ~5 秒记一行,用来确认数据真的到了页面
$now = Get-Date
if (-not $script:lastSnapLog -or ($now - $script:lastSnapLog).TotalSeconds -gt 5) {
$script:lastSnapLog = $now
Log "snapshot -> page: src=$($snap.source) rows=$(@($snap.messages).Count) turns=$($snap.metrics.turns) steps=$($snap.metrics.steps) tok=$($snap.metrics.tokens) ctx=$($snap.metrics.contextUsed) approx=$($snap.approx)"
}
}
}
} catch { Log "snapshot error: $($_.Exception.Message)" }
@@ -471,6 +502,16 @@ $onWvReady = {
$wv.add_CoreWebView2InitializationCompleted($onWvReady)
$form.Add_Shown({
Log 'form shown'
# 独立镜像桥:以隐藏窗口跑起来(DSH 没重启时也能出镜像与指标)
try {
if (Test-Path $bridgeScript) {
$nodeExe = (Get-Command node -ErrorAction SilentlyContinue).Source
if ($nodeExe) {
$script:bridge = Start-Process -FilePath $nodeExe -ArgumentList @("`"$bridgeScript`"", '--home', "`"$DshHome`"", '--quiet') -WindowStyle Hidden -PassThru
Log "mirror bridge pid=$($script:bridge.Id)"
} else { Log 'node not found; mirror bridge skipped' }
} else { Log "bridge script missing: $bridgeScript" }
} catch { Log "bridge start failed: $($_.Exception.Message)" }
$form.AllowResize = -not $script:state.sizeLock
try {
$udf = Join-Path $stateDir 'webview2'
@@ -489,7 +530,10 @@ $form.Add_Shown({
$form.Add_ResizeEnd({ if (-not $script:state.fullscreen) { $script:state.w = $form.Width; $script:state.h = $form.Height; Save-State; Push-State } })
$form.Add_Move({ if (-not $script:state.fullscreen -and $form.WindowState -eq 'Normal') { $script:state.x = $form.Left; $script:state.y = $form.Top } })
$form.Add_FormClosing({ Save-State })
$form.Add_FormClosing({
Save-State
try { if ($script:bridge -and -not $script:bridge.HasExited) { $script:bridge.Kill() } } catch { }
})
$form.Add_KeyDown({
param($s, $e)
if ($e.KeyCode -eq [System.Windows.Forms.Keys]::F11) { Toggle-Fullscreen }
+7 -5
View File
@@ -539,8 +539,7 @@
<div id="app">
<div id="bar">
<div class="dot wait" id="dot"></div>
<div id="title"><span id="titleText">DSH 悬浮窗</span><small id="sub">未连接</small></div>
<button class="tbtn" id="bSess" title="切换要镜像的会话">
<div id="title"><span id="titleText">DSH 悬浮窗</span><small id="sub">未连接</small></div> <button class="tbtn" id="bSess" title="切换要镜像的会话">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M4 8h13l-3-3M20 16H7l3 3" />
</svg>
@@ -706,7 +705,8 @@
const live = !!(ep && ep.baseUrl)
ENDPOINT = live ? ep.baseUrl : null
$('dot').className = 'dot ' + (live ? 'live' : 'wait')
$('sub').textContent = live ? ep.baseUrl.replace(/^https?:\/\//, '') : '未连接'
// 不再显示 IP:只在没连上时给一个状态词
$('sub').textContent = live ? '' : '未连接'
$('input').disabled = !live
$('send').disabled = !live
document.body.classList.toggle('linked', live)
@@ -760,7 +760,8 @@
if (!msg) return
if (msg.type === 'cmd') return handleCmd(msg.cmd)
if (msg.type === 'snapshot') { // 文件桥来的快照
markLive('本地桥')
markLive(msg.data && msg.data.approx ? '≈ 估算' : '本地桥')
APPROX = !!(msg.data && msg.data.approx)
return render(msg.data)
}
if (msg.settings && !settingsApplied) { settingsApplied = true; applyDshSettings(msg.settings) }
@@ -776,6 +777,7 @@
/* ---------------- 会话切换 ---------------- */
let SESSIONS = []
let PINNED = null
let APPROX = false
function renderSessions() {
const box = $('sessList')
box.innerHTML = ''
@@ -804,7 +806,7 @@
function metrics(m) {
$('m-turn').textContent = m.turns ?? '—'
$('m-step').textContent = m.steps ?? '—'
$('m-tps').textContent = m.tps == null ? '—' : Number(m.tps).toFixed(1)
$('m-tps').textContent = m.tps == null ? '—' : (APPROX ? '≈' : '') + Number(m.tps).toFixed(1)
$('m-mtok').textContent = m.tokens == null ? '—' : (m.tokens / 1e6).toFixed(3)
$('m-cache').textContent = m.cacheHit == null ? '—' : Math.round(m.cacheHit * 100) + '%'
if (m.contextUsed == null) {
+223
View File
@@ -0,0 +1,223 @@
/**
* mirror-bridge.mjs — 独立镜像桥(不依赖 DSH 插件重启)
*
* 为什么不走插件:桌面壳(DSH Desktop)的 HTTP 网关对未认证请求一律 403,浮窗是独立进程拿不到
* cookie;而插件要重启 DSH 才会加载新版。所以这里直接读宿主自己的落盘数据:
*
* %DSH_HOME%/storages/session_projcache.json ← 每个会话的投影:标题 / 轮步 / 令牌 / 上下文压力
* %DSH_HOME%/sessions/<proj>/<sid>/session.jsonl.zstd ← 会话事件流(多帧 zstd)→ 消息行
*
* 产物:%DSH_HOME%/float-window/snapshot.json(与插件写的同一份 schema),浮窗每秒读它渲染。
* 只有当插件没在写(mtime 超过 3s)时才写,避免两边打架。
*
* 用法:node tools/mirror-bridge.mjs [--home <DSH_HOME>] [--interval 1200] [--once]
*/
import fs from 'node:fs'
import path from 'node:path'
import os from 'node:os'
import zlib from 'node:zlib'
const argv = process.argv.slice(2)
const arg = (name, def) => {
const i = argv.indexOf(name)
return i >= 0 && argv[i + 1] ? argv[i + 1] : def
}
const HOME = arg('--home', process.env.DSH_HOME || path.join(os.homedir(), '.dsh'))
const INTERVAL = Number(arg('--interval', '1200'))
const ONCE = argv.includes('--once')
const STATE_DIR = path.join(HOME, 'float-window')
const SNAPSHOT = path.join(STATE_DIR, 'snapshot.json')
const SNAPSHOT_BRIDGE = path.join(STATE_DIR, 'snapshot-bridge.json')
const WATCH = path.join(STATE_DIR, 'watch.json')
const log = (...a) => { if (!argv.includes('--quiet')) console.log('[bridge]', ...a) }
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
/** DSH 往同一个 .zstd 文件追加多个帧,zstdDecompressSync 只吃第一帧 → 按魔数切开逐帧解 */
function decompressFrames(buf) {
let out = ''
let idx = buf.indexOf(ZSTD_MAGIC)
while (idx !== -1) {
const next = buf.indexOf(ZSTD_MAGIC, idx + 4)
const end = next === -1 ? buf.length : next
try { out += zlib.zstdDecompressSync(buf.subarray(idx, end)).toString('utf8') } catch { }
idx = next
}
return out
}
function readJson(file) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null }
}
function listSessionLogs() {
const root = path.join(HOME, 'sessions')
const out = []
if (!fs.existsSync(root)) return out
for (const proj of fs.readdirSync(root)) {
const pdir = path.join(root, proj)
let isDir = false
try { isDir = fs.statSync(pdir).isDirectory() } catch { }
if (!isDir) continue
for (const sid of fs.readdirSync(pdir)) {
const f = path.join(pdir, sid, 'session.jsonl.zstd')
try { out.push({ file: f, sid, mtime: fs.statSync(f).mtimeMs }) } catch { }
}
}
return out.sort((a, b) => b.mtime - a.mtime)
}
function textOf(content) {
if (typeof content === 'string') return content
if (Array.isArray(content)) return content.map((c) => (typeof c === 'string' ? c : (c?.text ?? ''))).join('')
return ''
}
const clip = (s, n = 4000) => (s.length > n ? s.slice(0, n) + '…' : s)
/** 事件流 → 浮窗消息行 + 实时指标(轮/步精确;token 用字符估算,标 ≈) */
function rowsFromLog(text) {
const rows = []
const titles = new Map()
let turns = 0
let steps = 0
let outChars = 0
let userChars = 0
let contextWindow = null
let streamStart = 0
let streamChars = 0
let lastChunkAt = 0
const est = (chars) => Math.round(chars / 3.2) // 中英混排的粗略 token 估算
for (const line of text.split('\n')) {
if (!line) continue
let e
try { e = JSON.parse(line) } catch { continue }
const type = String(e?.type || '')
const d = e?.data || e || {}
const t = e?.time || 0
if (type === 'turn/start') turns++
else if (type === 'step/start') steps++
else if (type === 'request/context' && d.contextWindow) contextWindow = d.contextWindow
else if (type === 'session/title' && d.title) titles.set('title', String(d.title))
else if (type === 'user/message') {
if (d.source?.kind && d.source.kind !== 'user') continue
const s = textOf(d.content)
if (s) { userChars += s.length; rows.push({ role: 'user', text: clip(s) }) }
} else if (type === 'assistant/message') {
const s = textOf(d.content ?? d.message?.content)
if (s) { outChars += s.length; rows.push({ role: 'assistant', text: clip(s) }) }
} else if (type === 'assistant/chunk') {
const c = d.chunk || {}
const kind = String(c.type || '')
const piece = c.text ?? c.delta ?? (kind.includes('text') ? c.content : '')
if (piece) {
const now = t || Date.now()
if (!streamStart || now - lastChunkAt > 8000) { streamStart = now; streamChars = 0 }
lastChunkAt = now
streamChars += String(piece).length
outChars += String(piece).length
const last = rows[rows.length - 1]
if (last && last.role === 'assistant' && last.streaming) last.text += piece
else rows.push({ role: 'assistant', text: String(piece), streaming: true })
} else if (kind === 'finish' && c.reason?.kind === 'error') {
rows.push({ role: 'assistant', text: '⚠️ ' + String(c.reason.failure?.message || c.reason.failure || 'error').slice(0, 300) })
}
} else if (type === 'tool/call') {
rows.push({ role: 'tool', text: `${d.name || 'tool'}(${JSON.stringify(d.args ?? '').slice(0, 120)})` })
}
}
for (const r of rows) delete r.streaming
const streaming = lastChunkAt && Date.now() - lastChunkAt < 8000
const tps = streaming && streamStart ? est(streamChars) / Math.max(0.5, (lastChunkAt - streamStart) / 1000) : null
return {
rows, titles, turns, steps, streaming,
metrics: {
turns,
steps,
tps,
tokens: outChars ? est(outChars + userChars) : null,
cacheHit: null,
contextUsed: contextWindow ? Math.min(1, est(userChars + outChars) / contextWindow) : null,
},
}
}
function buildSnapshot() {
const cache = readJson(path.join(HOME, 'storages', 'session_projcache.json'))
const table = cache?.tables?.sessions || {}
const watch = readJson(WATCH)
const logs = listSessionLogs()
// 会话清单:以**日志**为主(实时 mtime),投影缓存只用于补标题/历史统计
const entries = []
for (const l of logs) {
const rows = table[l.sid]?.rows || table[l.sid.replace(/^session-/, '')]?.rows || {}
const stats = rows.sessionStats?.val || {}
entries.push({
id: l.sid,
title: rows.title?.val || null,
cacheTurns: stats.turns ?? null,
cacheSteps: stats.steps ?? null,
mtime: l.mtime,
log: l,
})
}
// 当前会话:watch.json 指定优先,否则"日志最新"(真正在跑的那个)
let active = null
if (watch?.sessionId) active = entries.find((e) => e.id === watch.sessionId || e.id.replace(/^session-/, '') === watch.sessionId) || null
if (!active) active = entries.sort((a, b) => b.mtime - a.mtime)[0] || null
if (!active) return null
let rows = []
let title = active.title
let metrics = { turns: active.cacheTurns, steps: active.cacheSteps, tps: null, tokens: null, cacheHit: null, contextUsed: null }
let streaming = false
try {
const parsed = rowsFromLog(decompressFrames(fs.readFileSync(active.log.file)))
rows = parsed.rows.slice(-40)
title = title || parsed.titles.get('title') || null
streaming = parsed.streaming
// 轮/步取实时计数(缓存的那份是周期性落盘的,可能滞后)
metrics = {
...parsed.metrics,
turns: Math.max(parsed.metrics.turns, active.cacheTurns || 0),
steps: Math.max(parsed.metrics.steps, active.cacheSteps || 0),
}
} catch (e) { log('log read failed:', String(e?.message || e)) }
return {
ok: true,
at: Date.now(),
source: 'mirror-bridge',
approx: true, // token/上下文是字符估算,界面上会带 ≈
streaming,
sessionId: active.id,
pinned: watch?.sessionId || null,
title,
messages: rows,
metrics,
sessions: entries.slice(0, 20).map((e) => ({ id: e.id, title: e.title, turns: e.cacheTurns || 0, steps: e.cacheSteps || 0, messages: 0, active: e.id === active.id })),
}
}
function writeSnapshot() {
try {
const snap = buildSnapshot()
if (!snap) return
fs.mkdirSync(STATE_DIR, { recursive: true })
// 兜底文件总是写:插件缺的字段(token/上下文估算、标题)由浮窗用它补齐
fs.writeFileSync(SNAPSHOT_BRIDGE, JSON.stringify(snap))
// 插件在写就不抢 snapshot.json:mtime 3 秒内视为它的
try {
const st = fs.statSync(SNAPSHOT)
if (Date.now() - st.mtimeMs < 3000 && readJson(SNAPSHOT)?.source !== 'mirror-bridge') return
} catch { }
fs.writeFileSync(SNAPSHOT, JSON.stringify(snap))
log(`snapshot: ${snap.sessionId} "${(snap.title || '').slice(0, 24)}" turns=${snap.metrics.turns} steps=${snap.metrics.steps} rows=${snap.messages.length} ctx=${snap.metrics.contextUsed == null ? '-' : Math.round(snap.metrics.contextUsed * 100) + '%'}`)
} catch (e) { log('write failed:', String(e?.message || e)) }
}
writeSnapshot()
if (!ONCE) setInterval(writeSnapshot, INTERVAL)