返回 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 { useI18n } from "../lib/i18n";
4 import { formatMoneyLocalized } from "../lib/money";
5 import type { BalanceInfo, ContextInfo, ContextPanelInfo } from "../lib/types";
6 import { AnchoredPopover } from "./AnchoredPopover";
7 import {
8 contextBreakdown,
9 contextWindowStatus,
10 formatCacheHitRate,
11 } from "./ContextPanel";
12
13 interface ContextWindowRingProps {
14 enabled?: boolean;
15 context?: ContextInfo;
16 tabId?: string;
17 turnCost?: number;
18 currency?: string;
19 cacheHitTokens?: number;
20 cacheMissTokens?: number;
21 balance?: BalanceInfo;
22 }
23
24 const RING = 20;
25 const RING_R = (RING - 3) / 2;
26 const RING_C = 2 * Math.PI * RING_R;
27
28 function fmtCompact(n: number): string {
29 if (n <= 0) return "0";
30 if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
31 if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, "") + "k";
32 return String(Math.round(n));
33 }
34
35 function fmtDuration(ms: number, t: ReturnType<typeof useI18n>['t']): string {
36 if (ms <= 0) return "-";
37 const totalSeconds = Math.max(1, Math.round(ms / 1000));
38 const minutes = Math.floor(totalSeconds / 60);
39 const seconds = totalSeconds % 60;
40 if (minutes <= 0) return t("context.durationSeconds", { seconds });
41 return t("context.durationMinutesSeconds", { minutes, seconds });
42 }
43
44 export function ContextWindowRing({ enabled = true, context, tabId, turnCost, currency, cacheHitTokens, cacheMissTokens, balance }: ContextWindowRingProps) {
45 const { locale, t } = useI18n();
46 const [open, setOpen] = useState(false);
47 const [info, setInfo] = useState<ContextPanelInfo | null>(null);
48 const triggerRef = useRef<HTMLButtonElement>(null);
49 const loadingTabRef = useRef<string | null>(null);
50 const requestSeq = useRef(0);
51 const enterTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
52 const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
53
54 const used = context?.used ?? 0;
55 const windowTokens = context?.window ?? 0;
56 const usagePct = windowTokens > 0 ? Math.min(100, Math.round((used / windowTokens) * 100)) : 0;
57 const compactRatio = context?.compactRatio && context.compactRatio > 0 ? context.compactRatio : 0.8;
58 const compactPct = Math.round(compactRatio * 100);
59 const status = contextWindowStatus(usagePct, compactPct);
60
61 const loadInfo = useCallback(() => {
62 if (!enabled || !tabId) return;
63 if (loadingTabRef.current === tabId) return;
64 const requestTab = tabId;
65 const seq = requestSeq.current + 1;
66 requestSeq.current = seq;
67 loadingTabRef.current = requestTab;
68 app.ContextPanel(requestTab).then((next) => {
69 if (requestSeq.current === seq) setInfo(next);
70 }).catch(() => {}).finally(() => {
71 if (requestSeq.current === seq) loadingTabRef.current = null;
72 });
73 }, [enabled, tabId]);
74
75 // Reset when tabId changes so an older panel request cannot paint a new session.
76 useEffect(() => {
77 requestSeq.current += 1;
78 loadingTabRef.current = null;
79 setInfo(null);
80 if (!enabled) setOpen(false);
81 }, [enabled, tabId]);
82
83 useEffect(() => () => {
84 requestSeq.current += 1;
85 if (enterTimer.current != null) clearTimeout(enterTimer.current);
86 if (leaveTimer.current != null) clearTimeout(leaveTimer.current);
87 }, []);
88
89 const onEnter = useCallback(() => {
90 if (leaveTimer.current != null) clearTimeout(leaveTimer.current);
91 loadInfo();
92 enterTimer.current = setTimeout(() => setOpen(true), 200);
93 }, [loadInfo]);
94
95 const onLeave = useCallback(() => {
96 if (enterTimer.current != null) clearTimeout(enterTimer.current);
97 leaveTimer.current = setTimeout(() => setOpen(false), 120);
98 }, []);
99
100 const onPopoverEnter = useCallback(() => {
101 if (leaveTimer.current != null) clearTimeout(leaveTimer.current);
102 }, []);
103
104 const onPopoverLeave = useCallback(() => {
105 setOpen(false);
106 }, []);
107
108 if (!enabled) return null;
109
110 const promptTokens = info?.promptTokens ?? 0;
111 const completionTokens = info?.completionTokens ?? 0;
112 const reasoningTokens = info?.reasoningTokens ?? 0;
113 const breakdown = contextBreakdown(used, windowTokens, promptTokens, completionTokens, reasoningTokens);
114 const turnCacheHit = cacheHitTokens ?? info?.cacheHitTokens ?? 0;
115 const turnCacheMiss = cacheMissTokens ?? info?.cacheMissTokens ?? 0;
116 const turnCacheRate = formatCacheHitRate(turnCacheHit, turnCacheMiss);
117 const compactTokens = windowTokens > 0 ? Math.round(windowTokens * compactRatio) : 0;
118 const tokensToCompact = compactTokens > used ? compactTokens - used : 0;
119 const ringOffset = RING_C * (1 - usagePct / 100);
120 const elapsed = info?.elapsedMs && info.elapsedMs > 0 ? fmtDuration(info.elapsedMs, t) : undefined;
121 const sessionCost = info?.sessionCost && info.sessionCost > 0
122 ? formatMoneyLocalized(info.sessionCost, info.sessionCurrency, { locale, empty: "dash" })
123 : undefined;
124 const turnCostLabel = formatMoneyLocalized(turnCost, info?.sessionCurrency || currency, { locale, empty: "dash" });
125
126 return (
127 <>
128 <button
129 ref={triggerRef}
130 type="button"
131 className={`context-ring${open ? " context-ring--open" : ""} context-ring--${status.tone}`}
132 onMouseEnter={onEnter}
133 onMouseLeave={onLeave}
134 aria-label={t("context.windowUsageSummary", { used: String(used), window: String(windowTokens), pct: usagePct })}
135 >
136 <svg width={RING} height={RING} viewBox={`0 0 ${RING} ${RING}`} className="context-ring__svg">
137 <circle className="context-ring__track" cx={RING / 2} cy={RING / 2} r={RING_R} fill="none" strokeWidth={3} />
138 <circle
139 className="context-ring__arc"
140 cx={RING / 2} cy={RING / 2} r={RING_R}
141 fill="none" strokeWidth={3}
142 strokeLinecap="round"
143 strokeDasharray={RING_C}
144 strokeDashoffset={ringOffset}
145 transform={`rotate(-90 ${RING / 2} ${RING / 2})`}
146 />
147 </svg>
148 </button>
149 <AnchoredPopover
150 open={open}
151 anchorRef={triggerRef}
152 onClose={() => setOpen(false)}
153 className={`context-ring-popover context-ring-popover--${status.tone}`}
154 align="end"
155 placement="auto"
156 >
157 <div className="context-ring-popover__inner" onMouseEnter={onPopoverEnter} onMouseLeave={onPopoverLeave}>
158 <div className="context-ring-popover__header">
159 <span className="context-ring-popover__title">
160 {fmtCompact(used)} / {fmtCompact(windowTokens)}
161 </span>
162 <span className="context-ring-popover__pct">{usagePct}%</span>
163 </div>
164 <div className="context-ring-popover__gauge">
165 <div className="context-ring-popover__bar">
166 <span className="context-ring-popover__seg context-ring-popover__seg--prompt" style={{ width: `${breakdown.promptPct}%` }} />
167 <span className="context-ring-popover__seg context-ring-popover__seg--completion" style={{ width: `${Math.max(0, breakdown.completionPct - breakdown.promptPct)}%` }} />
168 {breakdown.reasoningTokens > 0 && (
169 <span className="context-ring-popover__seg context-ring-popover__seg--reasoning" style={{ width: `${Math.max(0, breakdown.reasoningPct - breakdown.completionPct)}%` }} />
170 )}
171 <span className="context-ring-popover__seg context-ring-popover__seg--other" style={{ width: `${Math.max(0, breakdown.otherPct - breakdown.reasoningPct)}%` }} />
172 <span className="context-ring-popover__mark context-ring-popover__mark--compact" style={{ left: `${compactPct}%` }} />
173 <span className="context-ring-popover__mark context-ring-popover__mark--attention" style={{ left: `30%` }} />
174 </div>
175 </div>
176 <div className="context-ring-popover__rows">
177 <div className="context-ring-popover__row">
178 <span className="context-ring-popover__label">{t("context.windowCompactDistance")}</span>
179 <span className="context-ring-popover__value">{fmtCompact(tokensToCompact)}</span>
180 </div>
181 {info?.requestCount != null && info.requestCount > 0 && (
182 <div className="context-ring-popover__row">
183 <span className="context-ring-popover__label">{t("context.requests")}</span>
184 <span className="context-ring-popover__value">{info.requestCount}</span>
185 </div>
186 )}
187 {elapsed && (
188 <div className="context-ring-popover__row">
189 <span className="context-ring-popover__label">{t("context.time")}</span>
190 <span className="context-ring-popover__value">{elapsed}</span>
191 </div>
192 )}
193 <div className="context-ring-popover__row">
194 <span className="context-ring-popover__label">{t("status.cacheLabel")}</span>
195 <span className="context-ring-popover__value">{turnCacheRate}</span>
196 </div>
197 {turnCost != null && turnCost > 0 && (
198 <div className="context-ring-popover__row">
199 <span className="context-ring-popover__label">{t("status.turnCostLabel")}</span>
200 <span className="context-ring-popover__value">{turnCostLabel}</span>
201 </div>
202 )}
203 {sessionCost && (
204 <div className="context-ring-popover__row">
205 <span className="context-ring-popover__label">{t("context.sessionCost")}</span>
206 <span className="context-ring-popover__value">{sessionCost}</span>
207 </div>
208 )}
209 {balance?.available && balance.display && (
210 <div className="context-ring-popover__row">
211 <span className="context-ring-popover__label">{t("status.balanceLabel")}</span>
212 <span className="context-ring-popover__value context-ring-popover__value--accent">{balance.display}</span>
213 </div>
214 )}
215 </div>
216 </div>
217 </AnchoredPopover>
218 </>
219 );
220 }
221
221 lines Plain Text