Files
dsh-float-window/shell/ui.html
T

785 lines
26 KiB
HTML
Raw 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;
}
#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="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:&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="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
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'
}
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.settings && !settingsApplied) { settingsApplied = true; applyDshSettings(msg.settings) }
if (msg.metrics) metrics(msg.metrics)
if (msg.messages) messages(msg.messages)
if (msg.title) {
const t = String(msg.title).slice(0, 42)
$('titleText').textContent = 'DSH · ' + t
}
}
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')
$('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>