feat(canvas): 云端 Agent - 缩减画布目录上下文并展示实时用量
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }> = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
188
web/src/lib/canvas/agent-context-usage.ts
Normal file
188
web/src/lib/canvas/agent-context-usage.ts
Normal 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% 用完。` };
|
||||
}
|
||||
Reference in New Issue
Block a user