PicoBot/webui/src/lib/components/ActivitySpine.svelte
xiaoxixi ac0a0c42ad feat(webui): Fluent 2 mica/acrylic materials, acrylic topbar overlay, token consistency
- Restore official light neutral background ramp; add danger hover/pressed and warning-border tokens; drop dead --edge
- Mica ambient brand-tinted backdrop; acrylic sidebar/topbar/flyouts with reduced-transparency fallback
- Content and session rail scroll under the acrylic topbar (--topbar-h driven offsets)
- Global .danger-solid, shared .chip/.dot, card hover lift, radius/type ramp normalization; bump to 1.5.1
2026-08-06 14:05:13 +08:00

156 lines
9.0 KiB
Svelte

<script>
import { chat } from "../chat.svelte.js";
let { version = "" } = $props();
const running = $derived(chat.turn?.status === "running" && chat.turn?.session_id === chat.currentSessionId);
const turnLabel = $derived(running ? `Turn ${String(chat.turn?.id ?? "").slice(0, 6).toUpperCase()}` : "");
const stats = $derived(chat.currentStats);
const context = $derived(stats?.context ?? null);
const lifetime = $derived(stats?.lifetime_usage ?? null);
const percent = $derived(context?.effective_window_tokens
? context.used_tokens / context.effective_window_tokens * 100
: 0);
const boundedPercent = $derived(Math.max(0, Math.min(percent, 100)));
const pressure = $derived(percent >= 90 ? "danger" : percent >= 70 ? "warning" : "normal");
function compactTokens(value) {
if (!Number.isFinite(value)) return "—";
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 1 : 2)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(value >= 100_000 ? 0 : 1)}k`;
return String(value);
}
function exactTokens(value) {
return Number.isFinite(value) ? new Intl.NumberFormat("zh-CN").format(value) : "—";
}
function sourceLabel(value) {
return value === "hybrid" ? "混合估算" : "字符估算";
}
function trackedSince(value) {
if (!Number.isFinite(value)) return "尚无已提交 Turn";
return `自 ${new Date(value).toLocaleString("zh-CN", { hour12: false })}`;
}
</script>
<details class="spine-shell mono" data-pressure={pressure} title={version}>
<summary class="spine">
{#if running}
<span class="spine-turn active"><i class="pulse-dot active"></i>{turnLabel} · 生成中</span>
{:else}
<span class="spine-turn idle"><i class="pulse-dot idle"></i>{chat.connected ? "空闲" : "重连中"}</span>
{/if}
{#if context}
<span class="context-readout">
<span class="context-label">上下文</span>
<span
class="context-rail"
role="progressbar"
aria-label="当前上下文窗口占用"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={Math.round(boundedPercent)}
style={`--context-fill: ${boundedPercent}%`}
><i></i><b></b></span>
<span class="context-value">{compactTokens(context.used_tokens)} / {compactTokens(context.effective_window_tokens)}</span>
<strong class="context-percent">{percent.toFixed(1)}%</strong>
</span>
<span class="usage-summary"><span>{compactTokens(lifetime?.input_tokens)}</span><span>{compactTokens(lifetime?.output_tokens)}</span></span>
{:else}
<span class="context-pending">等待会话统计</span>
{/if}
<span class="spine-right" class:spine-ok={chat.connected} class:spine-down={!chat.connected}>
{chat.connected ? "已连接" : "重连中"}
</span>
</summary>
{#if stats && context && lifetime}
<section class="stats-panel" aria-label="会话 Token 与上下文详情">
<header>
<div><span>当前上下文</span><strong>{percent.toFixed(1)}%</strong></div>
<small>{sourceLabel(context.source)}</small>
</header>
<div class="detail-rail" role="presentation" style={`--context-fill: ${boundedPercent}%`}><i></i><b></b></div>
<dl class="context-grid">
<div><dt>占用</dt><dd>{exactTokens(context.used_tokens)} / {exactTokens(context.effective_window_tokens)}</dd></div>
<div><dt>剩余</dt><dd>{exactTokens(context.remaining_tokens)}</dd></div>
<div><dt>压缩阈值</dt><dd>{exactTokens(context.compression_threshold_tokens)} · 70%</dd></div>
<div><dt>最近实测</dt><dd>{exactTokens(context.last_observed_prompt_tokens)}</dd></div>
</dl>
<div class="usage-heading"><span>会话累计</span><small>Provider 报告 · 已提交 Turns</small></div>
<dl class="usage-grid">
<div><dt>输入</dt><dd>{exactTokens(lifetime.input_tokens)}</dd></div>
<div><dt>输出</dt><dd>{exactTokens(lifetime.output_tokens)}</dd></div>
<div><dt>缓存输入</dt><dd>{exactTokens(lifetime.cached_input_tokens)}</dd></div>
<div><dt>请求 / Turns</dt><dd>{exactTokens(lifetime.request_count)} / {exactTokens(lifetime.turn_count)}</dd></div>
</dl>
<footer>{stats.provider} / {stats.model}<span>{trackedSince(lifetime.tracked_since)}</span></footer>
</section>
{/if}
</details>
<style>
.spine-shell { position: relative; margin-left: auto; min-width: 0; }
.spine-shell summary { list-style: none; }
.spine-shell summary::-webkit-details-marker { display: none; }
.spine { display: flex; align-items: center; gap: 10px; min-height: 32px; padding: 0 11px; border: 1px solid var(--line); border-radius: 6px; font-size: 11px; color: var(--text-soft); background: var(--panel-2); font-variant-numeric: tabular-nums; cursor: pointer; user-select: none; }
.spine:hover { border-color: var(--line-strong); background: var(--color-neutral-background-4); }
.spine:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.spine-turn { display: inline-flex; align-items: center; gap: 7px; font-weight: 700; white-space: nowrap; }
.spine-turn.active { color: var(--accent); }
.spine-turn.idle, .spine-ok { color: var(--signal); }
.context-readout { display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; }
.context-label { color: var(--muted); }
.context-rail, .detail-rail { position: relative; overflow: hidden; background: var(--color-neutral-background-4); }
.context-rail { width: 74px; height: 6px; border-radius: 99px; }
.context-rail i, .detail-rail i { position: absolute; inset: 0 auto 0 0; width: var(--context-fill); background: var(--accent); }
.context-rail b, .detail-rail b { position: absolute; inset: 0 auto 0 70%; width: 1px; background: var(--warning); }
.context-value { color: var(--text-soft); }
.context-percent { color: var(--accent); font-weight: 700; }
[data-pressure="warning"] .context-percent, [data-pressure="warning"] .context-rail i, [data-pressure="warning"] .detail-rail i { color: var(--warning); background: var(--warning); }
[data-pressure="danger"] .context-percent, [data-pressure="danger"] .context-rail i, [data-pressure="danger"] .detail-rail i { color: var(--danger); background: var(--danger); }
.usage-summary { display: inline-flex; gap: 8px; padding-left: 10px; border-left: 1px solid var(--line); color: var(--muted); white-space: nowrap; }
.spine-right { padding-left: 10px; border-left: 1px solid var(--line); white-space: nowrap; }
.spine-down { color: var(--danger); }
.context-pending { color: var(--muted); }
.stats-panel { position: absolute; z-index: 30; top: calc(100% + 8px); right: 0; width: min(390px, calc(100vw - 28px)); padding: 17px; border: 1px solid var(--line-strong); border-radius: 10px; color: var(--text); background: var(--surface-acrylic); backdrop-filter: var(--acrylic-blur); -webkit-backdrop-filter: var(--acrylic-blur); box-shadow: var(--shadow-16); font-family: var(--font-ui); }
.stats-panel header, .usage-heading, .stats-panel footer { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.stats-panel header div { display: flex; align-items: baseline; gap: 10px; }
.stats-panel header span, .usage-heading span { font-size: 12px; font-weight: 700; }
.stats-panel header strong { font-family: var(--font-mono); font-size: 21px; letter-spacing: -0.04em; }
.stats-panel small, .stats-panel footer { color: var(--muted); font-size: 10px; }
.detail-rail { height: 8px; margin: 12px 0 15px; border-radius: 99px; }
.context-grid, .usage-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 8px; background: var(--line); }
.context-grid div, .usage-grid div { min-width: 0; padding: 9px 10px; background: var(--panel); }
dt { color: var(--muted); font-size: 10px; }
dd { margin: 3px 0 0; overflow: hidden; font-family: var(--font-mono); font-size: 11px; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
.usage-heading { margin: 16px 0 8px; }
.stats-panel footer { margin-top: 12px; padding-top: 11px; border-top: 1px solid var(--line); }
.stats-panel footer span { text-align: right; }
@media (max-width: 1180px) {
.context-value, .usage-summary { display: none; }
.context-rail { width: 58px; }
}
@media (max-width: 760px) {
.spine-shell { margin-left: 0; }
.spine { padding: 0 9px; }
.spine-turn, .context-label, .context-rail, .spine-right, .context-pending { display: none; }
.context-readout { gap: 4px; }
.context-percent::before { content: "上下文 "; color: var(--muted); font-weight: 400; }
.stats-panel { position: fixed; top: auto; right: 14px; bottom: 14px; left: 14px; width: auto; }
}
@media (prefers-reduced-motion: reduce) {
.context-rail i, .detail-rail i { transition: none; }
}
@media (prefers-reduced-transparency: reduce) {
.stats-panel { background: var(--overlay); backdrop-filter: none; -webkit-backdrop-filter: none; }
}
</style>