白框根因:SetWindowCompositionAttribute(Win10 亚克力) / DwmExtendFrameIntoClientArea(客户区变玻璃) / WebView2 Transparent 背景 三者在真机上都会让 8px 边环露白、WebView2 变黑。现在窗口永远由 应用自绘深色,背景效果只作为页面侧半透明(不再碰 DWM 与窗口 alpha)。 新增: - 会话切换:标题栏 ⇄ 按钮 + 列表(会话标题/轮/步/消息数,可'跟随最新');宿主侧 pinnedSessionId, 被选中的会话不再被其它会话的事件抢走;snapshot 里带 sessions[] - 文件桥:桌面壳对未认证请求一律 403(实测 6 种请求形态),浮窗改走文件——宿主每秒写 snapshot.json, 浮窗渲染;浮窗的 chat/watch/newSession 写 outbox.json,宿主轮询执行。端到端实测通过 - 编码修复:快照按显式 UTF-8 读(PS 5.1 Get-Content -Raw 会按 GBK 读坏中文 JSON); outbox 写 UTF-8 无 BOM
899 lines
31 KiB
HTML
899 lines
31 KiB
HTML
<!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="bSet" title="设置">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||
<circle cx="12" cy="12" r="3" />
|
||
<path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M18.4 5.6L17 7M7 17l-1.4 1.4" />
|
||
</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:<repo></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)
|
||
$('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')
|
||
$('sub').textContent = live ? ep.baseUrl.replace(/^https?:\/\//, '') : '未连接'
|
||
$('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('本地桥')
|
||
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
|
||
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 ? '—' : 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')
|
||
$('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>
|