Files
jiangchen 6ee713b145 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 切会话生效
2026-09-27 17:20:14 +08:00

909 lines
32 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DSH 悬浮窗</title>
<style>
:root {
--bg: #0f1115;
--bg-soft: #161a21;
--bg-chip: #1d222b;
--line: #262c37;
--fg: #e6eaf2;
--fg-dim: #8b93a5;
--accent: #4d7cfe;
--accent-soft: #2a3a63;
--ok: #3ecf8e;
--warn: #f0b429;
--alpha: 0.92;
--radius: 12px;
--pad: 10px;
--font: 13px;
--title-font: 12.5px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
}
body {
background: rgba(15, 17, 21, var(--alpha));
color: var(--fg);
font: var(--font)/1.45 "Segoe UI", "Microsoft YaHei UI", system-ui, sans-serif;
user-select: none;
}
#app {
display: flex;
flex-direction: column;
height: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
/* ---------------- titlebar ---------------- */
#bar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: var(--bg-soft);
border-bottom: 1px solid var(--line);
cursor: default;
flex: 0 0 auto;
}
#bar .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--fg-dim);
flex: 0 0 auto;
}
#bar .dot.live {
background: var(--ok);
box-shadow: 0 0 6px var(--ok);
}
#bar .dot.wait {
background: var(--warn);
}
#title {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
font-size: var(--title-font);
font-weight: 600;
letter-spacing: .2px;
}
#titleText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
min-width: 0;
}
#title small {
flex: 0 0 auto;
color: var(--fg-dim);
font-weight: 400;
}
.tbtn {
flex: 0 0 auto;
width: 24px;
height: 24px;
display: grid;
place-items: center;
border-radius: 7px;
border: 1px solid transparent;
background: transparent;
color: var(--fg-dim);
cursor: pointer;
padding: 0;
}
.tbtn:hover {
background: var(--bg-chip);
color: var(--fg);
}
.tbtn.on {
background: var(--accent-soft);
border-color: var(--accent);
color: #cfe0ff;
}
.tbtn svg {
width: 15px;
height: 15px;
display: block;
}
/* ---------------- mirror ---------------- */
#mirror {
flex: 1 1 auto;
overflow-y: auto;
overflow-x: hidden;
padding: var(--pad);
scrollbar-width: thin;
scrollbar-color: #333a47 transparent;
}
#mirror::-webkit-scrollbar {
width: 8px;
}
#mirror::-webkit-scrollbar-thumb {
background: #333a47;
border-radius: 4px;
}
.hint {
color: var(--fg-dim);
font-size: 12px;
line-height: 1.7;
}
.hint b {
color: #b9c4d8;
font-weight: 600;
}
.hint code {
background: var(--bg-chip);
padding: 1px 5px;
border-radius: 5px;
font-family: Consolas, monospace;
font-size: 11.5px;
}
.row {
display: flex;
gap: 8px;
padding: 5px 0;
border-bottom: 1px dashed var(--line);
}
.row:last-child {
border-bottom: 0;
}
.row .who {
flex: 0 0 auto;
font-size: 11px;
color: var(--fg-dim);
min-width: 34px;
padding-top: 1px;
}
.row.user .who {
color: var(--accent);
}
.row .what {
flex: 1 1 auto;
white-space: pre-wrap;
word-break: break-word;
}
.tool {
font-family: Consolas, monospace;
font-size: 11.5px;
color: #9fd0ff;
background: #10151d;
border: 1px solid var(--line);
border-radius: 6px;
padding: 2px 6px;
display: inline-block;
margin: 2px 0;
}
.blink::after {
content: '▍';
animation: b 1s steps(2) infinite;
}
@keyframes b {
50% {
opacity: 0;
}
}
/* ---------------- composer ---------------- */
#composer {
flex: 0 0 auto;
display: flex;
align-items: flex-end;
gap: 6px;
padding: 8px;
border-top: 1px solid var(--line);
background: var(--bg-soft);
}
#input {
flex: 1 1 auto;
resize: none;
min-height: 32px;
max-height: 120px;
background: var(--bg-chip);
color: var(--fg);
border: 1px solid var(--line);
border-radius: 9px;
padding: 7px 9px;
font: inherit;
outline: none;
user-select: text;
}
#input:focus {
border-color: var(--accent);
}
#input:disabled {
opacity: .55;
}
#send {
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 9px;
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
}
#send:disabled {
background: var(--bg-chip);
border-color: var(--line);
color: var(--fg-dim);
cursor: not-allowed;
}
#send svg {
width: 16px;
height: 16px;
}
/* ---------------- metrics ---------------- */
#metrics {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px 7px;
padding: 4px 8px 6px;
border-top: 1px solid var(--line);
background: #12151b;
font-size: 11px;
line-height: 1.5;
color: var(--fg-dim);
overflow: hidden;
}
#metrics .m {
white-space: nowrap;
}
#metrics .m b {
color: #c9d3e6;
font-weight: 600;
}
#metrics .sep {
color: #333a47;
}
#ctxwrap {
display: flex;
align-items: center;
gap: 5px;
flex: 0 0 auto;
}
#bar-ctx {
position: relative;
height: 4px;
border-radius: 3px;
background: #232936;
flex: 0 0 64px;
width: 64px;
overflow: hidden;
}
#bar-ctx i {
position: absolute;
inset: 0 100% 0 0;
background: linear-gradient(90deg, #3ecf8e, #f0b429 70%, #f2555a);
transition: right .3s ease;
}
/* ---------------- settings ---------------- */
#settings {
position: absolute;
right: 8px;
bottom: 52px;
width: min(300px, calc(100% - 16px));
background: #171b22;
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px;
box-shadow: 0 12px 32px rgba(0, 0, 0, .5);
display: none;
z-index: 20;
}
#settings.show {
display: block;
}
/* ---------------- 会话切换器 ---------------- */
#sessList {
position: absolute;
left: 8px;
right: 8px;
top: 36px;
max-height: 60%;
overflow-y: auto;
background: #171b22;
border: 1px solid var(--line);
border-radius: 10px;
padding: 6px;
box-shadow: 0 12px 32px rgba(0, 0, 0, .5);
display: none;
z-index: 25;
}
#sessList.show {
display: block;
}
#sessList .srow {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 7px;
cursor: pointer;
font-size: 12px;
}
#sessList .srow:hover {
background: var(--bg-chip);
}
#sessList .srow.act {
background: var(--accent-soft);
}
#sessList .srow .st {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#sessList .srow .sm {
flex: 0 0 auto;
color: var(--fg-dim);
font-size: 11px;
}
#sessList .head {
color: var(--fg-dim);
font-size: 11px;
padding: 2px 8px 6px;
}
#settings h4 {
margin: 0 0 8px;
font-size: 12px;
color: #c9d3e6;
}
#settings .srow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 5px 0;
}
#settings .srow span {
font-size: 12px;
}
#settings input[type=range] {
width: 132px;
accent-color: var(--accent);
}
#settings select {
background: var(--bg-chip);
color: var(--fg);
border: 1px solid var(--line);
border-radius: 7px;
padding: 3px 6px;
font: inherit;
outline: none;
}
#settings .note {
color: var(--fg-dim);
font-size: 11px;
line-height: 1.6;
border-top: 1px dashed var(--line);
margin-top: 6px;
padding-top: 6px;
}
.switch {
position: relative;
width: 32px;
height: 18px;
border-radius: 10px;
background: #2a3140;
cursor: pointer;
flex: 0 0 auto;
transition: background .15s;
}
.switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #8b93a5;
transition: transform .15s, background .15s;
}
.switch.on {
background: var(--accent-soft);
}
.switch.on::after {
transform: translateX(14px);
background: var(--accent);
}
/* ---------------- resize grip ---------------- */
#grip {
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
z-index: 30;
background:
linear-gradient(135deg, transparent 46%, #3a4256 46%, #3a4256 56%, transparent 56%),
linear-gradient(135deg, transparent 66%, #2b3242 66%, #2b3242 76%, transparent 76%);
}
/* ---------------- adaptive ---------------- */
@container (max-width: 330px) {
.hide-narrow {
display: none;
}
}
@media (max-width: 350px) {
:root {
--pad: 8px;
--font: 12.5px;
}
.hide-narrow {
display: none;
}
}
@media (max-height: 320px) {
#mirror {
display: none;
}
}
@media (max-width: 250px) {
#metrics .m.opt {
display: none;
}
}
</style>
</head>
<body>
<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="切换要镜像的会话">
<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>
</button>
<button class="tbtn" id="bPin" title="固定位置">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M9 4h6l-1 6 3 3v1H7v-1l3-3-1-6z" />
<path d="M12 14v6" />
</svg>
</button>
<button class="tbtn" id="bLock" title="锁定尺寸">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<rect x="5" y="10" width="14" height="10" rx="2" />
<path d="M8 10V7a4 4 0 0 1 8 0v3" />
</svg>
</button>
<button class="tbtn" id="bTop" title="始终置顶">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M12 20V7" />
<path d="M6 12l6-6 6 6" />
</svg>
</button>
<button class="tbtn" id="bFull" title="全屏切换 (F11)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M4 9V5h4M20 15v4h-4M20 9V5h-4M4 15v4h4" />
</svg>
</button>
<button class="tbtn" id="bShot" title="截图时隐藏本窗(Agent 抓屏看不到);想看效果就关掉它">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 8.5h3l1.4-2h7.2L17 8.5h3v10H4z" />
<circle cx="12" cy="13.2" r="3.1" />
</svg>
</button>
<button class="tbtn" id="bSet" title="设置">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3.1" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9v0a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button>
<button class="tbtn" id="bClose" title="关闭">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div id="mirror">
<div class="hint" id="hint">
<b>等待 DSH 插件连接…</b><br />
浮窗已经就绪:拖动标题栏移动,拖动四周边缘或右下角改变尺寸。<br />
安装插件后这里会实时镜像会话:
<code>dsh plugin --profile desktop add link:&lt;repo&gt;</code> 然后重启 DSH。<br />
<span id="hintExclude">壳进程已启用 <b>截图排除</b>:Agent 抓屏时看不到这个浮窗。</span>
</div>
</div>
<div id="composer">
<textarea id="input" rows="1" placeholder="给会话发消息(连接后可编辑)" disabled></textarea>
<button id="send" disabled title="发送">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round">
<path d="M12 19V5M6 11l6-6 6 6" />
</svg>
</button>
</div>
<div id="metrics">
<div class="m" title="对话轮数">轮 <b id="m-turn">—</b></div>
<div class="sep">·</div>
<div class="m" title="工具/推理步数">步 <b id="m-step">—</b></div>
<div class="sep">·</div>
<div class="m" title="每秒输出 token 数"><b id="m-tps">—</b> tok/s</div>
<div class="sep">·</div>
<div class="m" title="累计 token"><b id="m-mtok">—</b> M tok</div>
<div class="sep opt">·</div>
<div class="m opt" title="缓存命中率">缓存 <b id="m-cache">—</b></div>
<div id="ctxwrap" title="上下文用量">
<span class="hide-narrow">上下文</span>
<div id="bar-ctx"><i id="m-ctxbar"></i></div>
<b id="m-ctx">—</b>
</div>
</div>
<div id="sessList"></div>
<div id="settings">
<h4>悬浮窗设置</h4>
<div class="srow"><span>背景效果</span>
<select id="sBackdrop">
<option value="opaque">不透明</option>
<option value="acrylic">毛玻璃(亚克力)</option>
<option value="mica">云母</option>
<option value="blur">模糊</option>
<option value="translucent">半透明</option>
</select>
</div>
<div class="srow"><span>不透明度</span>
<input type="range" id="sOpacity" min="30" max="100" step="1" value="92" />
</div>
<div class="srow"><span>界面缩放</span>
<input type="range" id="sScale" min="80" max="150" step="5" value="100" />
</div>
<div class="srow"><span>始终置顶</span>
<div class="switch on" id="sTop"></div>
</div>
<div class="srow"><span>固定位置</span>
<div class="switch" id="sPin"></div>
</div>
<div class="srow"><span>锁定尺寸</span>
<div class="switch" id="sLock"></div>
</div>
<div class="srow"><span>截图时隐藏本窗</span>
<div class="switch on" id="sExclude"></div>
</div>
<div class="note">
更多设置会同步注册到 <b>DeepSeek 设置 → 悬浮窗</b>(由 DSH 插件提供入口)。
</div>
</div>
<div id="grip" title="拖动改变尺寸"></div>
</div>
<script>
const $ = (id) => document.getElementById(id)
const bridge = window.chrome && window.chrome.webview ? window.chrome.webview : null
const send = (obj) => { if (bridge) bridge.postMessage(JSON.stringify(obj)) }
const S = { topmost: true, posLock: false, sizeLock: false, opacity: .92, exclude: true, scale: 1, fullscreen: false, backdrop: 'opaque' }
function paint() {
$('bPin').classList.toggle('on', S.posLock)
$('bLock').classList.toggle('on', S.sizeLock)
$('bTop').classList.toggle('on', S.topmost)
$('bShot').classList.toggle('on', S.exclude)
$('sTop').classList.toggle('on', S.topmost)
$('sPin').classList.toggle('on', S.posLock)
$('sLock').classList.toggle('on', S.sizeLock)
$('sExclude').classList.toggle('on', S.exclude)
$('sBackdrop').value = S.backdrop || 'opaque'
$('sOpacity').value = Math.round(S.opacity * 100)
$('sScale').value = Math.round(S.scale * 100)
// 玻璃/亚克力模式下窗口本身是透明的,页面用不透明度当"霜化浓度";
// 半透明模式由窗口级 alpha 负责,页面保持不透明以免二次变暗。
const glass = S.backdrop && S.backdrop !== 'opaque' && S.backdrop !== 'translucent'
document.documentElement.style.setProperty('--alpha', glass ? S.opacity : 1)
const he = $('hintExclude')
if (he) he.innerHTML = S.exclude
? '壳进程已启用 <b>截图排除</b>:Agent 抓屏时看不到这个浮窗。'
: '截图排除已关闭:当前浮窗会出现在截屏里。'
}
let es = null
let ENDPOINT = null
/** 文件桥(桌面壳 HTTP 网关不可用时的通道)也叫"连上了" */
function markLive(label) {
$('dot').className = 'dot live'
if (label) $('sub').textContent = label
$('input').disabled = false
$('send').disabled = false
document.body.classList.add('linked')
const hint = $('hint')
if (hint) hint.style.display = 'none'
}
function setEndpoint(ep) {
const live = !!(ep && ep.baseUrl)
ENDPOINT = live ? ep.baseUrl : null
$('dot').className = 'dot ' + (live ? 'live' : 'wait')
// 不再显示 IP:只在没连上时给一个状态词
$('sub').textContent = live ? '' : '未连接'
$('input').disabled = !live
$('send').disabled = !live
document.body.classList.toggle('linked', live)
if (live) {
$('hint').style.display = 'none'
connect(ep)
}
}
/* ---- 与 DSH 插件的实时通道(安装插件后启用) ---- */
function connect(ep) {
const url = ep.baseUrl + '/dsh-float-window/events'
if (es && es.__url === url && es.readyState !== 2) return
if (es) { try { es.close() } catch (e) { } }
try {
es = new EventSource(url)
es.__url = url
es.onmessage = (ev) => {
try { render(JSON.parse(ev.data)) } catch (e) { }
}
es.onerror = () => { $('dot').className = 'dot wait' }
} catch (e) { }
}
function handleCmd(c) {
if (c === 'hide') document.body.style.visibility = 'hidden'
if (c === 'show') document.body.style.visibility = 'visible'
if (c === 'backdropFallback') { // 系统玻璃不可用,宿主已回退成不透明
S.backdrop = 'opaque'
paint()
toast('这台机器上系统毛玻璃不可用,已回退为不透明')
}
}
let settingsApplied = false
function applyDshSettings(s) {
if (!s) return
// DSH 设置页里的值 → 只应用一次,之后以浮窗自己的开关为准,避免来回打架
const want = {}
if (s.opacity != null && Math.abs(s.opacity - S.opacity) > 0.01) { S.opacity = s.opacity; cmd('opacity', { value: s.opacity }) }
if (s.scale != null && Math.abs(s.scale - S.scale) > 0.01) { S.scale = s.scale; cmd('scale', { value: s.scale }) }
if (s.backdrop && s.backdrop !== S.backdrop) { S.backdrop = s.backdrop; cmd('backdrop', { value: s.backdrop }) }
if (s.topmost != null && !!s.topmost !== !!S.topmost) cmd('topmost')
if (s.posLock != null && !!s.posLock !== !!S.posLock) cmd('posLock')
if (s.sizeLock != null && !!s.sizeLock !== !!S.sizeLock) cmd('sizeLock')
if (s.exclude != null && !!s.exclude !== !!S.exclude) cmd('exclude')
paint()
}
function render(msg) {
if (!msg) return
if (msg.type === 'cmd') return handleCmd(msg.cmd)
if (msg.type === 'snapshot') { // 文件桥来的快照
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) }
if (msg.metrics) metrics(msg.metrics)
if (msg.messages) messages(msg.messages)
if (Array.isArray(msg.sessions)) { SESSIONS = msg.sessions; PINNED = msg.pinned || null; if ($('sessList').classList.contains('show')) renderSessions() }
if (msg.title) {
const t = String(msg.title).slice(0, 42)
$('titleText').textContent = 'DSH · ' + t
}
}
/* ---------------- 会话切换 ---------------- */
let SESSIONS = []
let PINNED = null
let APPROX = false
function renderSessions() {
const box = $('sessList')
box.innerHTML = ''
const head = document.createElement('div')
head.className = 'head'
head.textContent = SESSIONS.length ? `共 ${SESSIONS.length} 个会话 · 点一个跟随它` : '还没有会话'
box.appendChild(head)
const mk = (label, sub, active, sid) => {
const row = document.createElement('div')
row.className = 'srow' + (active ? ' act' : '')
const st = document.createElement('div'); st.className = 'st'; st.textContent = label
const sm = document.createElement('div'); sm.className = 'sm'; sm.textContent = sub
row.append(st, sm)
row.onclick = () => { send({ cmd: 'watch', sessionId: sid }); box.classList.remove('show'); toast(sid ? '已切换到:' + label : '已改为跟随最新') }
box.appendChild(row)
}
mk('跟随最新', '', !PINNED, '')
for (const s of SESSIONS) {
const label = s.title || (s.id || '').slice(0, 8) + '…'
const sub = `${s.turns || 0}轮/${s.steps || 0}步`
mk(label, sub, PINNED ? s.id === PINNED : !!s.active, s.id)
}
}
function metrics(m) {
$('m-turn').textContent = m.turns ?? '—'
$('m-step').textContent = m.steps ?? '—'
$('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) {
$('m-ctxbar').style.right = '100%'
$('m-ctx').textContent = '—'
} else {
const p = Math.max(0, Math.min(1, m.contextUsed))
$('m-ctxbar').style.right = (100 - p * 100) + '%'
$('m-ctx').textContent = Math.round(p * 100) + '%'
}
}
function messages(list) {
const box = $('mirror')
box.innerHTML = ''
for (const m of list.slice(-80)) {
const row = document.createElement('div')
row.className = 'row ' + (m.role === 'user' ? 'user' : '')
const who = document.createElement('div'); who.className = 'who'
who.textContent = m.role === 'user' ? '你' : (m.role === 'tool' ? '工具' : 'DSH')
const what = document.createElement('div'); what.className = 'what'
if (m.role === 'tool') { const s = document.createElement('span'); s.className = 'tool'; s.textContent = m.text; what.appendChild(s) }
else { what.textContent = m.text; if (m.streaming) what.classList.add('blink') }
row.append(who, what); box.appendChild(row)
}
box.scrollTop = box.scrollHeight
}
/* ---------------- 交互 ---------------- */
const cmd = (c, extra) => send(Object.assign({ cmd: c }, extra || {}))
$('bar').addEventListener('mousedown', (e) => { if (e.target.closest('.tbtn')) return; cmd('drag') })
$('grip').addEventListener('mousedown', () => cmd('resize'))
$('bPin').onclick = () => cmd('posLock')
$('bLock').onclick = () => cmd('sizeLock')
$('bTop').onclick = () => cmd('topmost')
$('bFull').onclick = () => cmd('fullscreen')
$('bClose').onclick = () => cmd('close')
$('bSet').onclick = () => $('settings').classList.toggle('show')
$('bShot').onclick = () => cmd('exclude')
$('bSess').onclick = () => { const el = $('sessList'); renderSessions(); el.classList.toggle('show') }
$('sTop').onclick = () => cmd('topmost')
$('sPin').onclick = () => cmd('posLock')
$('sLock').onclick = () => cmd('sizeLock')
$('sExclude').onclick = () => cmd('exclude')
$('sBackdrop').onchange = (e) => { S.backdrop = e.target.value; paint(); cmd('backdrop', { value: S.backdrop }) }
$('sOpacity').oninput = (e) => { S.opacity = e.target.value / 100; paint(); cmd('opacity', { value: S.opacity }) }
$('sScale').oninput = (e) => { S.scale = e.target.value / 100; paint(); cmd('scale', { value: S.scale }) }
$('input').addEventListener('input', (e) => {
e.target.style.height = 'auto'
e.target.style.height = Math.min(120, e.target.scrollHeight) + 'px'
})
const submit = () => {
const v = $('input').value.trim()
if (!v) return
$('input').value = ''; $('input').style.height = 'auto'
if (ENDPOINT) {
// 真实发送:POST 给插件,由它走宿主网关送进会话
fetch(ENDPOINT + '/dsh-float-window/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text: v }),
}).then((r) => r.json()).then((j) => {
if (!j.ok) toast('发送失败:' + j.error)
}).catch((e) => toast('发送失败:' + e.message))
} else {
send({ cmd: 'chat', text: v })
}
}
function toast(msg) {
const el = document.createElement('div')
el.textContent = msg
el.style.cssText = 'position:fixed;left:8px;right:8px;bottom:44px;background:#3a2028;border:1px solid #7a2b3a;color:#ffd7de;font-size:11.5px;padding:6px 8px;border-radius:8px;z-index:40'
document.body.appendChild(el)
setTimeout(() => el.remove(), 4000)
}
$('send').onclick = submit
$('input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit() }
})
document.addEventListener('keydown', (e) => {
if (e.key === 'F11') { e.preventDefault(); cmd('fullscreen') }
if (e.key === 'Escape') $('settings').classList.remove('show')
})
if (bridge) {
bridge.addEventListener('message', (ev) => {
const d = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data
if (d.type === 'state') { Object.assign(S, d.state); paint() }
if (d.type === 'endpoint') setEndpoint(d)
if (d.type === 'metrics') metrics(d.metrics)
if (d.type === 'messages') messages(d.messages)
})
}
paint()
cmd('ready')
</script>
</body>
</html>