返回 DeepSeek-Reasonix
ContextWindowRing.tsx
根目录 / desktop / frontend / src / components / ContextWindowRing.tsx
1 import { useCallback, useEffect, useRef, useState } from "react";
2 import { app } from "../lib/bridge";
3 import { contextWindowPercentages } from "../lib/contextWindow";
4 import { useI18n } from "../lib/i18n";
5 import { formatMoneyLocalized } from "../lib/money";
6 import { appendRateBand, rateBandLabel } from "../lib/costRateBand";
7 import type { BalanceInfo, ContextInfo, ContextPanelInfo } from "../lib/types";
8 import { AnchoredPopover } from "./AnchoredPopover";
9 import { contextWindowStatus, formatCacheHitRate } from "../lib/contextPanelUtils";
10
11 interface ContextWindowRingProps {
12 turnMetrics?: { elapsed: string; tokens: string | null; tps: string | null };
13 enabled?: boolean;
14 context?: ContextInfo;
15 tabId?: string;
16 turnCost?: number;
17 turnRateBand?: string;
18 currency?: string;
19 cacheHitTokens?: number;
20 cacheMissTokens?: number;
21 balance?: BalanceInfo;
22 dismissSignal?: number;
23 }
24
25 const RING = 18;
26 const RING_R = (RING - 2) / 2;
27 const RING_C = 2 * Math.PI * RING_R;
28
29 function fmtCompact(n: number): string {
30 if (n <= 0) return "0";
31 if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
32 if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, "") + "k";
33 return String(Math.round(n));
34 }
35
36 function fmtDuration(ms: number, t: ReturnType<typeof useI18n>['t']): string {
37 if (ms <= 0) return "-";
38 const totalSeconds = Math.max(1, Math.round(ms / 1000));
39 const minutes = Math.floor(totalSeconds / 60);
40 const seconds = totalSeconds % 60;
41 if (minutes <= 0) return t("context.durationSeconds", { seconds });
42 return t("context.durationMinutesSeconds", { minutes, seconds });
43 }
44
45 export function ContextWindowRing({ enabled = true, context, tabId, turnCost, turnRateBand, currency, cacheHitTokens, cacheMissTokens, balance, turnMetrics, dismissSignal }: ContextWindowRingProps) {
46 const { locale, t } = useI18n();
47 const [open, setOpen] = useState(false);
48 const [info, setInfo] = useState<ContextPanelInfo | null>(null);
49 const triggerRef = useRef<HTMLButtonElement>(null);
50 const loadingTabRef = useRef<string | null>(null);
51 const requestSeq = useRef(0);
52 const enterTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
53 const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
54
55 const used = context?.used ?? 0;
56 const windowTokens = context?.window ?? 0;
57 const usagePercentages = contextWindowPercentages(used, windowTokens);
58 const rawUsagePct = usagePercentages.raw;
59 const usagePct = usagePercentages.display;
60 const compactRatio = context?.compactRatio && context.compactRatio > 0 ? context.compactRatio : 0.80;
61 const compactPct = Math.round(compactRatio * 100);
62 const status = contextWindowStatus(rawUsagePct, compactPct);
63
64 const loadInfo = useCallback(() => {
65 if (!enabled || !tabId) return;
66 if (loadingTabRef.current === tabId) return;
67 const requestTab = tabId;
68 const seq = requestSeq.current + 1;
69 requestSeq.current = seq;
70 loadingTabRef.current = requestTab;
71 app.ContextPanel(requestTab).then((next) => {
72 if (requestSeq.current === seq) setInfo(next);
73 }).catch(() => {}).finally(() => {
74 if (requestSeq.current === seq) loadingTabRef.current = null;
75 });
76 }, [enabled, tabId]);
77
78 // Reset when tabId changes so an older panel request cannot paint a new session.
79 useEffect(() => {
80 requestSeq.current += 1;
81 loadingTabRef.current = null;
82 setInfo(null);
83 if (enterTimer.current != null) clearTimeout(enterTimer.current);
84 if (leaveTimer.current != null) clearTimeout(leaveTimer.current);
85 setOpen(false);
86 }, [dismissSignal, enabled, tabId]);
87
88 useEffect(() => () => {
89 requestSeq.current += 1;
90 if (enterTimer.current != null) clearTimeout(enterTimer.current);
91 if (leaveTimer.current != null) clearTimeout(leaveTimer.current);
92 }, []);
93
94 const onEnter = useCallback(() => {
95 if (enterTimer.current != null) clearTimeout(enterTimer.current);
96 if (leaveTimer.current != null) clearTimeout(leaveTimer.current);
97 loadInfo();
98 enterTimer.current = setTimeout(() => setOpen(true), 200);
99 }, [loadInfo]);
100
101 const onLeave = useCallback(() => {
102 if (enterTimer.current != null) clearTimeout(enterTimer.current);
103 leaveTimer.current = setTimeout(() => setOpen(false), 120);
104 }, []);
105
106 const onPopoverEnter = useCallback(() => {
107 if (leaveTimer.current != null) clearTimeout(leaveTimer.current);
108 }, []);
109
110 const onPopoverLeave = useCallback(() => {
111 setOpen(false);
112 }, []);
113
114 if (!enabled) return null;
115
116 const turnCacheHit = cacheHitTokens ?? info?.cacheHitTokens ?? 0;
117 const turnCacheMiss = cacheMissTokens ?? info?.cacheMissTokens ?? 0;
118 const turnCacheRate = formatCacheHitRate(turnCacheHit, turnCacheMiss);
119 const compactTokens = windowTokens > 0 ? Math.round(windowTokens * compactRatio) : 0;
120 const tokensToCompact = compactTokens > used ? compactTokens - used : 0;
121 const ringOffset = RING_C * (1 - usagePct / 100);
122 const elapsed = info?.elapsedMs && info.elapsedMs > 0 ? fmtDuration(info.elapsedMs, t) : undefined;
123 const quoteStatus = info?.sessionCostQuote?.displayStatus;
124 const sessionCostBucketed = quoteStatus === "bucketed" || info?.sessionCostQuote?.aggregateMode === "currency_buckets";
125 const sessionCostFallback = quoteStatus === "fallback_original";
126 const sessionCostComplete = (sessionCostFallback || info?.sessionCostComplete !== false) && quoteStatus !== "unavailable";
127 const sessionCostRaw = info?.sessionCostQuote?.selected
128 ? Number(info.sessionCostQuote.selected.amount)
129 : info?.sessionCost;
130 const sessionCostCurrency = info?.sessionCostQuote?.selected?.currency || info?.sessionCurrency;
131 const sessionCost =
132 !sessionCostBucketed && sessionCostComplete && typeof sessionCostRaw === "number" && sessionCostRaw > 0
133 ? `≈${formatMoneyLocalized(sessionCostRaw, sessionCostCurrency, { locale, empty: "dash" }).replace(/^≈/, "")}`
134 : undefined;
135 const sessionCostHint =
136 info?.sessionBillingMode === "subscription_equivalent"
137 ? "payg_equivalent"
138 : sessionCostFallback
139 ? "fallback_original"
140 : sessionCost
141 ? "estimated"
142 : undefined;
143 const turnCostLabel = appendRateBand(formatMoneyLocalized(turnCost, info?.sessionCurrency || currency, { locale, empty: "dash" }), turnRateBand, t);
144 const sessionCostLabel = sessionCost ? appendRateBand(sessionCost, info?.sessionCostQuote?.rateBand, t) : undefined;
145 const turnRateBandTitle = rateBandLabel(turnRateBand, t) ? t("billing.rateBand.tooltip") : undefined;
146 const sessionRateBandTitle = rateBandLabel(info?.sessionCostQuote?.rateBand, t) ? t("billing.rateBand.tooltip") : undefined;
147
148 return (
149 <>
150 <button
151 ref={triggerRef}
152 type="button"
153 className={`context-ring${open ? " context-ring--open" : ""} context-ring--${status.tone}`}
154 onMouseEnter={onEnter}
155 onMouseLeave={onLeave}
156 onFocus={onEnter}
157 onBlur={onLeave}
158 onClick={() => {
159 if (enterTimer.current != null) clearTimeout(enterTimer.current);
160 if (leaveTimer.current != null) clearTimeout(leaveTimer.current);
161 loadInfo();
162 setOpen(true);
163 }}
164 aria-haspopup="dialog"
165 aria-expanded={open}
166 aria-label={t("context.windowUsageSummary", { used: String(used), window: String(windowTokens), pct: rawUsagePct })}
167 >
168 <svg width={RING} height={RING} viewBox={`0 0 ${RING} ${RING}`} className="context-ring__svg">
169 <circle className="context-ring__track" cx={RING / 2} cy={RING / 2} r={RING_R} fill="none" strokeWidth={2} />
170 <circle
171 className="context-ring__arc"
172 cx={RING / 2} cy={RING / 2} r={RING_R}
173 fill="none" strokeWidth={2}
174 strokeLinecap="round"
175 strokeDasharray={RING_C}
176 strokeDashoffset={ringOffset}
177 transform={`rotate(-90 ${RING / 2} ${RING / 2})`}
178 />
179 </svg>
180 <span className="context-ring__percent">{rawUsagePct}%</span>
181 </button>
182 <AnchoredPopover
183 open={open}
184 anchorRef={triggerRef}
185 onClose={() => {
186 if (enterTimer.current != null) clearTimeout(enterTimer.current);
187 setOpen(false);
188 }}
189 className={`context-ring-popover context-ring-popover--${status.tone} composer-menu-surface`}
190 align="end"
191 placement="auto"
192 >
193 <div className="context-ring-popover__inner" role="dialog" aria-label={t("context.windowUsageSummary", { used: String(used), window: String(windowTokens), pct: rawUsagePct })} onMouseEnter={onPopoverEnter} onMouseLeave={onPopoverLeave}>
194 <div className="context-ring-popover__header">
195 <span className="context-ring-popover__title">
196 {fmtCompact(used)} / {fmtCompact(windowTokens)}
197 </span>
198 <span className="context-ring-popover__pct">{rawUsagePct}%</span>
199 </div>
200 <div className="context-ring-popover__gauge">
201 <div className="context-ring-popover__bar">
202 <span className="context-ring-popover__fill" style={{ width: `${usagePct}%` }} />
203 <span className="context-ring-popover__mark context-ring-popover__mark--compact" style={{ left: `${compactPct}%` }} />
204 <span className="context-ring-popover__mark context-ring-popover__mark--attention" style={{ left: `30%` }} />
205 </div>
206 </div>
207 <div className="context-ring-popover__rows">
208 <div className="context-ring-popover__row">
209 <span className="context-ring-popover__label">{t("context.windowCompactDistance")}</span>
210 <span className="context-ring-popover__value">{fmtCompact(tokensToCompact)}</span>
211 </div>
212 {info?.requestCount != null && info.requestCount > 0 && (
213 <div className="context-ring-popover__row">
214 <span className="context-ring-popover__label">{t("context.requests")}</span>
215 <span className="context-ring-popover__value">{info.requestCount}</span>
216 </div>
217 )}
218 {elapsed && (
219 <div className="context-ring-popover__row">
220 <span className="context-ring-popover__label">{t("context.sessionTime")}</span>
221 <span className="context-ring-popover__value">{elapsed}</span>
222 </div>
223 )}
224 {turnMetrics && <div className="context-ring-popover__turn-metrics">
225 {([
226 [t("context.turnTime"), t("context.turnTimeTitle"), turnMetrics.elapsed],
227 [t("status.turnTpsLabel"), t("status.turnTpsTitle"), turnMetrics.tps],
228 [t("status.turnOutputTokensLabel"), t("status.turnOutputTokensTitle"), turnMetrics.tokens],
229 ] as const).map(([label, hint, value]) => value && (
230 <div className="context-ring-popover__row" key={label} title={hint}>
231 <span className="context-ring-popover__label">{label}</span>
232 <span className="context-ring-popover__value">{value}</span>
233 </div>
234 ))}
235 </div>}
236 <div className="context-ring-popover__row">
237 <span className="context-ring-popover__label">{t("status.cacheLabel")}</span>
238 <span className="context-ring-popover__value">{turnCacheRate}</span>
239 </div>
240 {turnCost != null && turnCost > 0 && (
241 <div className="context-ring-popover__row">
242 <span className="context-ring-popover__label">{t("status.turnCostLabel")}</span>
243 <span className="context-ring-popover__value" title={turnRateBandTitle}>{turnCostLabel}</span>
244 </div>
245 )}
246 {sessionCostLabel && (
247 <div className="context-ring-popover__row">
248 <span className="context-ring-popover__label">
249 {sessionCostHint === "payg_equivalent"
250 ? t("context.sessionCostPaygEquivalent")
251 : sessionCostHint === "fallback_original"
252 ? t("context.sessionCostFallback")
253 : t("context.sessionCostEstimated")}
254 </span>
255 <span className="context-ring-popover__value" title={sessionRateBandTitle}>{sessionCostLabel}</span>
256 </div>
257 )}
258 {!sessionCost && sessionCostBucketed && (
259 <div className="context-ring-popover__row">
260 <span className="context-ring-popover__label">{t("context.sessionCost")}</span>
261 <span className="context-ring-popover__value">{t("context.sessionCostBucketed")}</span>
262 </div>
263 )}
264 {!sessionCost && !sessionCostBucketed && info?.sessionCostComplete === false && (
265 <div className="context-ring-popover__row">
266 <span className="context-ring-popover__label">{t("context.sessionCostEstimated")}</span>
267 <span className="context-ring-popover__value">—</span>
268 </div>
269 )}
270 {balance?.available && balance.display && (
271 <div className="context-ring-popover__row">
272 <span className="context-ring-popover__label">{t("status.balanceLabel")}</span>
273 <span className="context-ring-popover__value context-ring-popover__value--accent">{balance.display}</span>
274 </div>
275 )}
276 </div>
277 </div>
278 </AnchoredPopover>
279 </>
280 );
281 }
282
282 lines Plain Text