feat(canvas): 云端 Agent - 缩减画布目录上下文并展示实时用量

This commit is contained in:
ddcat666
2026-09-24 23:54:59 +08:00
parent 56b1e36d7e
commit 98f1b31e20
12 changed files with 877 additions and 76 deletions

View File

@@ -735,6 +735,7 @@ export function AgentChatComposer({
onAddFiles,
onRemoveAttachment,
left,
submitAccessory,
onStop,
stopping,
references = [],
@@ -755,6 +756,8 @@ export function AgentChatComposer({
onAddFiles?: (files: FileList | File[] | null) => void | Promise<void>;
onRemoveAttachment?: (id: string) => void;
left?: ReactNode;
/** 发送按钮左侧的附属控件,例如上下文用量环。 */
submitAccessory?: ReactNode;
/** 供「@」插入的画布节点/素材/技能引用候选(可选,默认空,缺省时退化为普通输入框) */
references?: CanvasResourceReference[];
/** 供「/」弹出的技能候选(可选) */
@@ -1020,12 +1023,7 @@ export function AgentChatComposer({
{left}
</div>
<div className="agent-composer-submit flex items-center gap-2">
{disabled ? null : (
<span className="agent-composer-send-hint">
<span className="agent-composer-send-hint-full">{canStop ? "运行中:发送即插话,下一步生效" : "Enter 发送 · Shift+Enter 换行"}</span>
<span className="agent-composer-send-hint-compact">{canStop ? "运行中可插话" : "Enter 发送"}</span>
</span>
)}
{submitAccessory}
{canStop ? (
<motion.button
type="button"

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type Dispatch, type SetStateAction } from "react";
import { Button, Dropdown, Input } from "antd";
import { Button, Dropdown, Input, Popover } from "antd";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowLeft, Check, ChevronRight, CircleDot, Clock3, Download, History, LoaderCircle, MessageSquarePlus, MoveDiagonal2, RotateCcw, Settings2, ShieldCheck, Trash2, Sparkles, X } from "lucide-react";
import { saveAs } from "file-saver";
@@ -7,6 +7,7 @@ import { buildAgentDebugExport } from "@/lib/canvas/agent-debug-export";
import { markdownPlainText } from "@/lib/markdown-plain-text";
import { agentToolRetry, mergeAgentToolRetry } from "@/lib/canvas/agent-tool-retry";
import { agentPlanVisible, latestAgentPlanItems, pendingAgentQuestion } from "@/lib/canvas/cloud-agent-plan";
import { emptyAgentContextUsage, presentAgentContextUsage, reduceAgentContextUsage, type AgentContextPhase, type AgentContextUsage, type AgentContextUsageView } from "@/lib/canvas/agent-context-usage";
import { nanoid } from "nanoid";
import { ModelPicker } from "@/components/model-picker";
@@ -55,6 +56,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
const reducedMotion = useReducedMotion();
const [view, setView] = useState<AgentPanelView>("chat");
const [run, setRun] = useState<AgentRun | null>(null);
const [contextUsage, setContextUsage] = useState<AgentContextUsage>(() => emptyAgentContextUsage(""));
const [connectionStatus, setConnectionStatus] = useState<"connecting" | "connected" | "reconnecting" | "disconnected">("connecting");
const [connectionEpoch, setConnectionEpoch] = useState(0);
const [messages, setMessages] = useState<CloudAgentChatMessage[]>([]);
@@ -462,6 +464,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
lastSeqRef.current = event.seq;
}
setMessages((current) => current.filter((item) => item.id !== `stream-error-${run.id}`));
setContextUsage((current) => reduceAgentContextUsage(current, event));
applyAgentEvent(event, setMessages, setRun, setApproval, setPrompt);
canvasSyncRef.current?.receive(event);
},
@@ -570,7 +573,10 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
setMessages(nextMessages);
const result = submission.parentRunId ? await sendAgentMessage(submission.parentRunId, request) : await createAgentRun(request);
accepted = true;
if (currentScope.current === scope) setRun(result.run);
if (currentScope.current === scope) {
setContextUsage(emptyAgentContextUsage(result.run.id));
setRun(result.run);
}
await clearCloudAgentPendingSubmission(canvasId, activeConversationId);
if (currentScope.current === scope) pendingSubmission.current = null;
} catch (cause) {
@@ -898,6 +904,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
references={[...references, ...buildSkillMentionReferences(installedSkills)]}
slashSkills={installedSkills}
includeAssetLibrary={false}
submitAccessory={<AgentContextRing view={presentAgentContextUsage(contextUsage)} />}
left={
<ComposerControls
reasoningMode={reasoningSupported ? reasoningMode : "off"}
@@ -971,7 +978,7 @@ function AgentLauncher({ theme, statusColor, approvalPending, reducedMotion, onO
whileTap={reducedMotion || dragging ? undefined : { scale: 0.96 }}
transition={{ duration: reducedMotion ? 0 : 0.18 }}
>
{live ? <Live2DAvatar url={live2DModelURL(appearance.live2dResourceId, appearance.live2dEntry)} width={width} height={height} reducedMotion={reducedMotion} fallback={<FluidOrb size={62} color="#7164f6" />} /> : <FluidOrb size={62} color="#7164f6" />}
{live ? <Live2DAvatar url={live2DModelURL(appearance.live2dResourceId, appearance.live2dEntry)} width={width} height={height} reducedMotion={reducedMotion} fallback={<FluidOrb size={60} color="#7164f6" />} /> : <FluidOrb size={60} color="#7164f6" />}
{appearance.launcherLabel ? <span className="canvas-agent-launcher-label">{appearance.launcherLabel}</span> : null}
<span className={cn("canvas-agent-launcher-status", approvalPending && "is-pending")} style={{ "--canvas-agent-status-color": statusColor } as CSSProperties} />
{approvalPending ? <span className="canvas-agent-launcher-badge">待审批</span> : null}
@@ -1005,6 +1012,165 @@ function AgentHeader({ theme, hasMessages, statusLabel, statusColor, nodeCount,
);
}
const CONTEXT_PHASE_LABEL: Record<AgentContextPhase, string> = {
idle: "尚未测量",
unknown: "窗口未知",
ok: "上下文充足",
watch: "接近压缩",
compress: "即将压缩",
compacting: "正在压缩",
stale: "压缩后待刷新",
};
function formatContextCount(tokens: number | undefined) {
if (tokens === undefined) return "—";
if (tokens >= 1_000_000) return `${Math.round(tokens / 100_000) / 10}M`;
if (tokens >= 1_000) return `${Math.round(tokens / 100) / 10}K`;
return Math.round(tokens).toLocaleString("zh-CN");
}
function formatContextBytes(bytes: number | undefined) {
if (bytes === undefined) return "—";
if (bytes >= 1_000_000) return `${Math.round(bytes / 100_000) / 10} MB`;
if (bytes >= 1_000) return `${Math.round(bytes / 100) / 10} KB`;
return `${Math.round(bytes).toLocaleString("zh-CN")} 字节`;
}
function AgentContextRing({ view }: { view: AgentContextUsageView }) {
const reducedMotion = useReducedMotion();
const [open, setOpen] = useState(false);
const radius = 15;
const circumference = 2 * Math.PI * radius;
const marker = view.compactRatio && view.compactRatio > 0 && view.compactRatio < 1 ? view.compactRatio : undefined;
const percent = view.ratio === undefined ? view.label : `${Math.round(view.ratio * 100)}%`;
const meterLabel = view.ratio === undefined ? "—" : percent;
const used = formatContextCount(view.inputTokens);
const budget = formatContextCount(view.usableTokens);
const remaining = formatContextCount(view.remainingTokens);
const protocolBytes = formatContextBytes(view.protocolBytes);
const usedRatio = view.ratio === undefined ? 0 : Math.max(0, Math.min(1, view.ratio));
const phaseLabel = CONTEXT_PHASE_LABEL[view.phase];
const sourceLabel = view.tokenSource === "provider" ? "模型实测校准" : view.estimate ? "本地估算" : "未测量";
const usageHeading = view.ratio !== undefined
? `上下文已用 ${percent}`
: view.phase === "idle"
? "上下文用量"
: view.phase === "unknown"
? "上下文窗口未知"
: `上下文${view.label}`;
return (
<Popover
open={open}
onOpenChange={setOpen}
trigger="click"
placement="bottomRight"
arrow={false}
overlayClassName="agent-context-popover"
getPopupContainer={(trigger) => trigger.closest<HTMLElement>(".canvas-agent-panel") ?? document.body}
content={(
<div className="agent-context-panel" data-phase={view.phase}>
<span className="agent-context-eyebrow">下一次请求</span>
<div className="agent-context-panel-head">
<strong>{usageHeading}</strong>
{view.phase !== "ok" ? <span className={`agent-context-phase is-${view.phase}`}>{phaseLabel}</span> : null}
</div>
<div className="agent-context-summary">
{view.remainingTokens !== undefined && view.usableTokens !== undefined ? (
<>
<strong>{used}</strong>
<span>/ {budget} Token</span>
<em>剩余 {remaining}</em>
</>
) : (
<>
<strong>{used}</strong>
<span>Token</span>
</>
)}
</div>
<div className="agent-context-progress-head">
<span>输入预算占用</span>
<strong>{percent}</strong>
</div>
<div
className="agent-context-progress"
role="progressbar"
aria-label={`上下文已用 ${percent}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={view.ratio === undefined ? undefined : Math.round(view.ratio * 100)}
>
<span style={{ width: `${usedRatio * 100}%` }} />
{marker ? <i style={{ left: `${marker * 100}%` }} aria-hidden="true" /> : null}
</div>
<p className="agent-context-detail">{view.detail}</p>
{view.breakdown.length || view.protocolBytes !== undefined || view.remainingTokens !== undefined ? (
<ul className="agent-context-breakdown">
{view.breakdown.map((item) => (
<li key={item.key}>
<span className="agent-context-breakdown-dot" aria-hidden="true" />
<span className="agent-context-breakdown-label">{item.label}</span>
<span className="agent-context-breakdown-value">{formatContextCount(item.tokens)}</span>
</li>
))}
{view.protocolBytes !== undefined ? (
<li className="is-secondary">
<span className="agent-context-breakdown-dot" aria-hidden="true" />
<span className="agent-context-breakdown-label">协议外壳</span>
<span className="agent-context-breakdown-value">{protocolBytes}</span>
</li>
) : null}
{view.remainingTokens !== undefined ? (
<li className="is-muted">
<span className="agent-context-breakdown-dot" aria-hidden="true" />
<span className="agent-context-breakdown-label">未使用</span>
<span className="agent-context-breakdown-value">{remaining}</span>
</li>
) : null}
</ul>
) : null}
<div className="agent-context-panel-foot">
<span>{sourceLabel}{view.estimate ? " · 不是计费 Token" : " · 预计下次请求"}</span>
{view.compactAtTokens ? <span>压缩线 {formatContextCount(view.compactAtTokens)}</span> : null}
</div>
{view.lastCompaction ? <p className="agent-context-note">本轮已完成一次上下文压缩,下一次读数会刷新。</p> : null}
</div>
)}
>
<button
type="button"
className={`agent-context-ring is-${view.phase}`}
aria-label={`${usageHeading},${phaseLabel}。点击查看明细`}
aria-expanded={open}
title="查看上下文用量"
onPointerDown={(event) => event.stopPropagation()}
>
<span className="agent-context-ring-visual" aria-hidden="true">
<svg viewBox="0 0 40 40">
<circle className="agent-context-ring-track" cx="20" cy="20" r={radius} />
<circle
className="agent-context-ring-value"
cx="20"
cy="20"
r={radius}
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={circumference * (1 - view.ring)}
style={{ transition: reducedMotion ? "none" : undefined }}
/>
{marker ? <circle className="agent-context-ring-marker" cx="20" cy={20 - radius} r="1.35" transform={`rotate(${marker * 360} 20 20)`} /> : null}
</svg>
</span>
<span className="agent-context-meter-copy">
<strong>{meterLabel}</strong>
<small>上下文</small>
</span>
</button>
</Popover>
);
}
function AgentHistory({ conversations, activeConversationId, theme, onBack, onNew, onOpen, onDelete }: { conversations: CloudAgentConversation[]; activeConversationId: string; theme: CanvasTheme; onBack: () => void; onNew: () => void; onOpen: (conversation: CloudAgentConversation) => void; onDelete: (id: string) => void }) {
return (
<div className="canvas-agent-history-root flex min-h-0 min-w-0 flex-1 flex-col">
@@ -1153,14 +1319,15 @@ function ComposerControls({
const permissionVisual = agentPermissionVisual(permissionMode);
const PermissionIcon = permissionVisual.icon;
return (
<div className="flex min-w-0 flex-wrap items-center gap-0.5">
<div className="agent-composer-selection flex min-w-0 flex-1 flex-nowrap items-center gap-0.5">
<ModelPicker
config={config}
value={selectedModel}
capability="text"
onChange={onModelChange}
variant="creation"
className="!h-8 !min-w-0 !w-52 !max-w-full !border-0 !bg-transparent !px-1.5 !shadow-none"
fullWidth
className="agent-composer-model-trigger !h-8 !min-w-0 !w-full !max-w-full !border-0 !bg-transparent !px-1.5 !shadow-none"
popoverClassName="agent-model-picker-popover"
showSelectedPrice={false}
showOptionPrices

View File

@@ -58,7 +58,7 @@ const permissionOptions: Array<{ value: AgentPermissionMode; label: string; desc
];
const contextOptions: Array<{ value: AgentContextKey; label: string; description: string }> = [
{ value: "canvas", label: "已保存画布摘要", description: "最多 80 个节点的标题与文本片段,不含媒体正文或未同步修改" },
{ value: "canvas", label: "画布节点目录", description: "每次请求只带节点 id、类型和标题;正文按页读取,不含媒体或未同步修改" },
];
const reasoningOptions: Array<{ value: AgentReasoningMode; label: string; description: string }> = [

View File

@@ -50,6 +50,320 @@
font-size: var(--fs-label);
}
.agent-context-ring {
position: relative;
display: inline-flex;
flex: none;
align-items: center;
gap: 7px;
min-width: 68px;
height: 32px;
padding: 3px 8px 3px 4px;
border: 1px solid color-mix(in srgb, var(--agent-ink) 10%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--agent-ink) 4%, transparent);
color: var(--agent-caption);
cursor: pointer;
transition: border-color 160ms ease, background-color 160ms ease, color 160ms ease;
}
.agent-context-ring:hover,
.agent-context-ring[aria-expanded="true"] {
border-color: color-mix(in srgb, var(--agent-accent) 48%, transparent);
background: var(--agent-surface-raised);
color: var(--agent-ink);
}
.agent-context-ring:focus-visible {
outline: 2px solid var(--agent-focus-ring);
outline-offset: 2px;
}
.agent-context-ring-visual,
.agent-context-ring-visual svg {
display: block;
width: 25px;
height: 25px;
flex: none;
}
.agent-context-ring-visual svg {
transform: rotate(-90deg);
}
.agent-context-ring circle {
fill: none;
stroke-linecap: round;
}
.agent-context-ring-track {
stroke: color-mix(in srgb, var(--agent-ink) 18%, transparent);
stroke-width: 2.4;
}
.agent-context-ring-value {
stroke: var(--agent-accent, currentColor);
stroke-width: 2.4;
transition: stroke-dashoffset 240ms ease;
}
.agent-context-ring-marker {
fill: var(--agent-ink);
stroke: none;
}
.agent-context-ring.is-watch .agent-context-ring-value { stroke: #d9973b; }
.agent-context-ring.is-compress .agent-context-ring-value,
.agent-context-ring.is-compacting .agent-context-ring-value { stroke: #d4534e; }
.agent-context-ring.is-compacting .agent-context-ring-visual svg { animation: agent-context-spin 1.4s linear infinite; }
.agent-context-ring.is-unknown .agent-context-ring-value,
.agent-context-ring.is-stale .agent-context-ring-value { stroke-dasharray: 4 6; }
.agent-context-meter-copy {
display: grid;
min-width: 0;
line-height: 1;
text-align: left;
}
.agent-context-meter-copy strong {
color: var(--agent-ink);
font-size: 11px;
font-variant-numeric: tabular-nums;
font-weight: 700;
}
.agent-context-meter-copy small {
margin-top: 2px;
color: var(--agent-caption);
font-size: 9px;
white-space: nowrap;
}
@keyframes agent-context-spin {
to { transform: rotate(270deg); }
}
.agent-context-popover.ant-popover,
.agent-context-popover .ant-popover-content,
.agent-context-popover .ant-popover-inner {
box-shadow: none;
}
.agent-context-popover .ant-popover-inner {
overflow: hidden;
padding: 0;
border: 1px solid color-mix(in srgb, var(--agent-ink) 12%, transparent);
border-radius: 16px;
background: var(--agent-surface-raised, var(--background));
}
.agent-context-popover .ant-popover-inner-content {
padding: 0;
}
.agent-context-panel {
width: min(348px, calc(100vw - 32px));
padding: 14px 15px 12px;
color: var(--agent-ink, inherit);
font-size: 12px;
line-height: 1.45;
}
.agent-context-eyebrow {
display: block;
margin-bottom: 3px;
color: var(--agent-caption);
font-size: 10px;
letter-spacing: 0.02em;
}
.agent-context-panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.agent-context-panel-head strong {
font-size: 15px;
font-weight: 700;
letter-spacing: -0.015em;
font-variant-numeric: tabular-nums;
}
.agent-context-phase {
flex: none;
border-radius: 999px;
padding: 3px 8px;
background: color-mix(in srgb, currentColor 12%, transparent);
font-size: 10px;
line-height: 1.2;
}
.agent-context-phase.is-watch { color: #9a6416; }
.agent-context-phase.is-compress,
.agent-context-phase.is-compacting { color: #a33b36; }
.agent-context-summary {
display: flex;
align-items: baseline;
gap: 4px;
margin-top: 12px;
color: var(--agent-caption);
font-variant-numeric: tabular-nums;
}
.agent-context-summary strong {
color: var(--agent-ink);
font-size: 23px;
letter-spacing: -0.04em;
}
.agent-context-summary span {
font-size: 13px;
}
.agent-context-summary em {
margin-left: auto;
color: var(--agent-caption);
font-size: 12px;
font-style: normal;
}
.agent-context-progress-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-top: 13px;
color: var(--agent-caption);
font-size: 10px;
font-weight: 600;
}
.agent-context-progress-head strong {
color: var(--agent-ink);
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.agent-context-progress {
position: relative;
height: 12px;
margin-top: 5px;
overflow: visible;
border: 0;
border-radius: 999px;
background: rgb(255 255 255 / 14%);
background: color-mix(in srgb, var(--agent-ink, #fff) 24%, var(--agent-surface-raised, #252525));
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 10%);
}
.agent-context-progress > span {
display: block;
height: 100%;
min-width: 6px;
border-radius: inherit;
background: #8b93ff;
background: linear-gradient(90deg, color-mix(in srgb, var(--agent-accent, #8b93ff) 58%, white), var(--agent-accent, #8b93ff));
box-shadow: 0 0 12px color-mix(in srgb, var(--agent-accent, #8b93ff) 48%, transparent);
transition: width 240ms ease;
}
.agent-context-progress > i {
position: absolute;
top: -3px;
bottom: -3px;
width: 1px;
background: color-mix(in srgb, var(--agent-ink) 64%, transparent);
}
.agent-context-detail {
margin: 10px 0 0;
color: var(--agent-caption);
font-size: 11px;
line-height: 1.55;
}
.agent-context-breakdown {
display: grid;
gap: 8px;
margin: 13px 0 0;
padding: 0;
list-style: none;
}
.agent-context-breakdown li {
display: grid;
grid-template-columns: 9px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.agent-context-breakdown-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: color-mix(in srgb, var(--agent-accent) 62%, var(--agent-ink));
}
.agent-context-breakdown-label {
min-width: 0;
overflow: hidden;
color: var(--agent-ink);
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-context-breakdown-value {
min-width: 58px;
color: var(--agent-ink);
font-variant-numeric: tabular-nums;
text-align: right;
}
.agent-context-breakdown li.is-secondary .agent-context-breakdown-dot {
background: color-mix(in srgb, var(--agent-ink) 42%, transparent);
}
.agent-context-breakdown li.is-secondary .agent-context-breakdown-label,
.agent-context-breakdown li.is-secondary .agent-context-breakdown-value {
color: var(--agent-caption);
}
.agent-context-breakdown li.is-muted .agent-context-breakdown-dot {
background: color-mix(in srgb, var(--agent-ink) 24%, transparent);
}
.agent-context-breakdown li.is-muted .agent-context-breakdown-label,
.agent-context-breakdown li.is-muted .agent-context-breakdown-value {
color: var(--agent-caption);
}
.agent-context-panel-foot {
display: flex;
justify-content: space-between;
gap: 10px;
margin-top: 13px;
padding-top: 10px;
border-top: 1px solid color-mix(in srgb, var(--agent-ink) 9%, transparent);
color: var(--agent-caption);
font-size: 10px;
}
.agent-context-note {
margin: 6px 0 0;
color: var(--agent-caption);
font-size: 10px;
}
@media (prefers-reduced-motion: reduce) {
.agent-context-ring.is-compacting .agent-context-ring-visual svg { animation: none; }
.agent-context-ring-value,
.agent-context-progress > span { transition: none; }
}
.agent-header-actions .ant-btn {
color: inherit;
}
@@ -335,6 +649,10 @@
}
.agent-panel-status { white-space: nowrap; }
.agent-conversation { padding-inline: var(--space-4); }
.agent-context-ring { min-width: 32px; padding-right: 4px; gap: 0; }
.agent-context-meter-copy { display: none; }
.agent-context-panel-foot { display: grid; }
.agent-welcome-actions button { padding-inline: var(--space-2); }
}
@@ -613,15 +931,47 @@
}
.agent-composer-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.agent-composer-submit {
.agent-composer-controls {
min-width: 0;
justify-self: end;
flex: 1 1 auto;
flex-wrap: nowrap;
overflow: hidden;
}
.agent-composer-selection {
min-width: 0;
flex: 1 1 auto;
flex-wrap: nowrap;
overflow: hidden;
}
.agent-composer-selection > .w-full {
min-width: 0;
max-width: 13rem;
flex: 1 1 13rem;
}
.agent-composer-selection .agent-composer-model-trigger,
.agent-composer-selection .canvas-model-picker-label {
min-width: 0;
}
.agent-composer-selection > button {
flex: 0 0 auto;
}
.agent-composer-submit {
display: flex;
min-width: max-content;
flex: 0 0 auto;
align-items: center;
justify-content: flex-end;
}
.canvas-agent-launcher {
@@ -632,7 +982,7 @@
border: 0;
border-radius: 999px;
background: transparent;
padding: 7px;
padding: 0;
box-shadow: 0 16px 38px color-mix(in srgb, var(--canvas-agent-launcher-shadow) 64%, transparent);
cursor: grab;
touch-action: none;
@@ -644,6 +994,15 @@
cursor: grabbing;
}
.canvas-agent-launcher > .fluid-orb {
position: absolute;
top: 50%;
left: 50%;
z-index: 1;
margin: 0;
transform: translate(-50%, -50%);
}
.canvas-agent-launcher::before {
position: absolute;
inset: 3px;
@@ -720,27 +1079,6 @@
.canvas-agent-launcher { transition: none !important; }
}
.agent-composer-send-hint {
color: color-mix(in srgb, currentColor 52%, transparent);
font-size: var(--fs-tiny);
line-height: 16px;
white-space: nowrap;
}
/* 面板宽度包含内边距;默认 448px 面板放不下完整快捷键说明,窄态降级为短文案而不是隐藏提示本身。 */
.agent-composer-send-hint .agent-composer-send-hint-compact {
display: none;
}
@container (max-width: 520px) {
.agent-composer-send-hint .agent-composer-send-hint-full {
display: none;
}
.agent-composer-send-hint .agent-composer-send-hint-compact {
display: inline;
}
}
.agent-tool-status {
display: grid;

View File

@@ -0,0 +1,188 @@
export type AgentContextUsage = {
runId: string;
reading: Record<string, unknown> | null;
readingSeq: number;
readingStale: boolean;
compactionPending: Record<string, unknown> | null;
lastCompaction: Record<string, unknown> | null;
};
export type AgentContextUsageEvent = {
runId: string;
seq?: number;
type: string;
payload?: Record<string, unknown>;
};
/** What the ring is telling the user. Compaction, not the raw window, is the decision. */
export type AgentContextPhase = "idle" | "unknown" | "ok" | "watch" | "compress" | "compacting" | "stale";
export type AgentContextBreakdownItem = {
key: string;
label: string;
tokens: number;
bytes?: number;
unit: "tokens" | "bytes";
};
export type AgentContextUsageView = {
phase: AgentContextPhase;
/** Share of the input budget, 0–1 when the model window is known. */
ratio?: number;
/** Where semantic compaction starts, as a share of the same input budget. */
compactRatio?: number;
/** Ring fill. 1 means "at or past the compaction line", not "window is full". */
ring: number;
label: string;
detail: string;
inputTokens?: number;
remainingTokens?: number;
usableTokens?: number;
compactAtTokens?: number;
contextWindowTokens?: number;
protocolBytes?: number;
tokenSource?: "provider" | "estimate";
estimate: boolean;
breakdown: AgentContextBreakdownItem[];
lastCompaction: Record<string, unknown> | null;
};
export function emptyAgentContextUsage(runId: string): AgentContextUsage {
return { runId, reading: null, readingSeq: 0, readingStale: false, compactionPending: null, lastCompaction: null };
}
/** Reduces durable Agent events without mixing readings from different runs. */
export function reduceAgentContextUsage(current: AgentContextUsage, event: AgentContextUsageEvent): AgentContextUsage {
const scoped = current.runId === event.runId ? current : emptyAgentContextUsage(event.runId);
const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
if (event.type === "context_pressure") {
return { ...scoped, reading: payload, readingSeq: event.seq || 0, readingStale: false, compactionPending: null };
}
if (event.type === "context_compaction_requested") {
return { ...scoped, compactionPending: payload };
}
if (event.type === "context_compacted") {
return { ...scoped, readingStale: Boolean(scoped.reading), compactionPending: null, lastCompaction: payload };
}
return scoped;
}
export function finiteContextNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
}
/** Returns a ratio only when the backend has a trustworthy configured model budget. */
export function contextPressureRatio(reading: Record<string, unknown> | null): number | undefined {
if (!reading || reading.modelLimitConfigured !== true) return undefined;
const usable = finiteContextNumber(reading.usableInputTokens);
if (!usable || usable <= 0) return undefined;
const ratio = reading.tokenSource === "provider"
? finiteContextNumber(reading.projectedPressureRatio)
: finiteContextNumber(reading.pressureRatio);
return ratio;
}
export function contextInputTokens(reading: Record<string, unknown> | null): number | undefined {
if (!reading) return undefined;
if (reading.tokenSource === "provider") {
return finiteContextNumber(reading.projectedNextInputTokens) ?? finiteContextNumber(reading.estimatedInputTokens);
}
return finiteContextNumber(reading.estimatedInputTokens);
}
const BREAKDOWN_LABELS: Record<string, string> = {
system: "系统提示(含画布摘要)",
tools: "工具 schema",
messages: "会话消息(含工具结果)",
};
function contextBreakdown(reading: Record<string, unknown> | null): AgentContextBreakdownItem[] {
const breakdown = reading?.breakdown;
if (!breakdown || typeof breakdown !== "object") return [];
const buckets = (breakdown as { buckets?: unknown }).buckets;
if (!Array.isArray(buckets)) return [];
return buckets.flatMap((bucket) => {
if (!bucket || typeof bucket !== "object") return [];
const item = bucket as { key?: unknown; label?: unknown; scaledTokens?: unknown; tokens?: unknown; bytes?: unknown };
const key = typeof item.key === "string" ? item.key : "";
const tokens = finiteContextNumber(item.scaledTokens) ?? finiteContextNumber(item.tokens);
if (!key || tokens === undefined) return [];
const label = BREAKDOWN_LABELS[key] || (typeof item.label === "string" ? item.label : key);
const bytes = finiteContextNumber(item.bytes);
return [{ key, label, tokens, bytes, unit: "tokens" as const }];
});
}
function contextProtocolBytes(reading: Record<string, unknown> | null): number | undefined {
if (!reading?.breakdown || typeof reading.breakdown !== "object") return undefined;
return finiteContextNumber((reading.breakdown as { envelopeBytes?: unknown }).envelopeBytes);
}
function formatContextTokens(tokens: number | undefined): string {
if (tokens === undefined) return "—";
if (tokens >= 1_000_000) return `${Math.round(tokens / 100_000) / 10}M`;
if (tokens >= 1_000) return `${Math.round(tokens / 100) / 10}K`;
return Math.round(tokens).toLocaleString("zh-CN");
}
/**
* One view of the compaction mechanism.
* The ring fills against compactAtTokens (85% of the input budget): full means
* the next model call is about to pause and compact, not that the window is 100% used.
*/
export function presentAgentContextUsage(usage: AgentContextUsage): AgentContextUsageView {
const reading = usage.reading;
const inputTokens = contextInputTokens(reading);
const usableTokens = finiteContextNumber(reading?.usableInputTokens);
const compactAtTokens = finiteContextNumber(reading?.compactAtTokens);
const contextWindowTokens = finiteContextNumber(reading?.contextWindowTokens);
const ratio = contextPressureRatio(reading);
const compactRatio = usableTokens && compactAtTokens ? Math.min(1, compactAtTokens / usableTokens) : undefined;
const tokenSource = reading?.tokenSource === "provider" ? "provider" : reading ? "estimate" : undefined;
const estimate = tokenSource !== "provider";
const remainingTokens = usableTokens !== undefined && inputTokens !== undefined
? Math.max(0, usableTokens - inputTokens)
: undefined;
const base: AgentContextUsageView = {
phase: "idle",
ratio,
compactRatio,
ring: 0,
label: "未测量",
detail: "发出下一条消息后,这里显示下一次请求离压缩还有多远。",
inputTokens,
remainingTokens,
usableTokens,
compactAtTokens,
contextWindowTokens,
protocolBytes: contextProtocolBytes(reading),
tokenSource,
estimate,
breakdown: contextBreakdown(reading),
lastCompaction: usage.lastCompaction,
};
if (usage.compactionPending) {
const basis = usage.compactionPending.basis === "bytes" ? "消息体积已到兜底线" : "已到上下文压缩线";
return { ...base, phase: "compacting", ring: 1, label: "压缩中", detail: `${basis},正在把较早对话收成检查点,最近两轮原样保留。` };
}
if (!reading) return base;
if (usage.readingStale) {
return { ...base, phase: "stale", ring: ratio === undefined ? 0 : Math.min(1, ratio / (compactRatio || 0.85)), label: "刚压缩", detail: "上一份读数是压缩前的;下一次模型调用会给出压缩后的占用。" };
}
if (ratio === undefined || usableTokens === undefined || usableTokens <= 0) {
const measured = inputTokens === undefined ? "窗口未知" : `约 ${formatContextTokens(inputTokens)} Token`;
return { ...base, phase: "unknown", label: measured, detail: "这个模型没有配置可确认的上下文窗口,不能给出占用百分比;对话过长时仍会按条数和体积压缩。" };
}
const line = compactRatio && compactRatio > 0 ? compactRatio : 0.85;
const ring = Math.max(0, Math.min(1, ratio / line));
const percent = Math.round(ratio * 100);
const source = estimate ? "本地估算" : "模型实测校准";
if (ratio >= line) {
return { ...base, phase: "compress", ring: 1, label: `${percent}%`, detail: `已到压缩线(输入预算的 ${Math.round(line * 100)}%)。下一次调用前会暂停,把历史收成检查点后再继续。当前 ${formatContextTokens(inputTokens)} / ${formatContextTokens(usableTokens)}(${source})。` };
}
if (ring >= 0.72) {
return { ...base, phase: "watch", ring, label: `${percent}%`, detail: `接近压缩。当前 ${formatContextTokens(inputTokens)} / ${formatContextTokens(usableTokens)}(${source}),到 ${formatContextTokens(compactAtTokens)} 时开始压缩。` };
}
return { ...base, phase: "ok", ring, label: `${percent}%`, detail: `当前 ${formatContextTokens(inputTokens)} / ${formatContextTokens(usableTokens)}(${source})。满格表示到达压缩线,不是窗口已经 100% 用完。` };
}