返回 DeepSeek-Reasonix
TurnStats.tsx
根目录 / desktop / frontend / src / components / TurnStats.tsx
1 import { createPortal } from "react-dom";
2 import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
3 import { Clock3, Database } from "lucide-react";
4 import type { TurnUsage } from "../lib/types";
5 import { getLocale, useT } from "../lib/i18n";
6
7 const PANEL_GAP = 8;
8 const PANEL_MARGIN = 12;
9 const MEASURE_STYLE: CSSProperties = { visibility: "hidden", left: 0, top: 0 };
10
11 function samePosition(a: CSSProperties | null, b: CSSProperties): boolean {
12 return a?.left === b.left && a?.top === b.top && a?.visibility === b.visibility;
13 }
14
15 function useStatDialog() {
16 const [open, setOpen] = useState(false);
17 const [position, setPosition] = useState<CSSProperties | null>(null);
18 const rootRef = useRef<HTMLSpanElement>(null);
19 const panelRef = useRef<HTMLDivElement>(null);
20
21 const updatePosition = useCallback(() => {
22 const anchor = rootRef.current;
23 const panel = panelRef.current;
24 if (!anchor || !panel) return;
25 const trigger = anchor.getBoundingClientRect();
26 const bounds = panel.getBoundingClientRect();
27 const maxLeft = Math.max(PANEL_MARGIN, window.innerWidth - bounds.width - PANEL_MARGIN);
28 const left = Math.min(Math.max(trigger.left, PANEL_MARGIN), maxLeft);
29 const above = trigger.top - bounds.height - PANEL_GAP;
30 const top = above >= PANEL_MARGIN
31 ? above
32 : Math.min(trigger.bottom + PANEL_GAP, Math.max(PANEL_MARGIN, window.innerHeight - bounds.height - PANEL_MARGIN));
33 const next = { left, top } satisfies CSSProperties;
34 setPosition(current => samePosition(current, next) ? current : next);
35 }, []);
36
37 useLayoutEffect(() => {
38 if (!open) {
39 setPosition(null);
40 return;
41 }
42 updatePosition();
43 }, [open, updatePosition]);
44
45 useEffect(() => {
46 if (!open) return;
47 const dismiss = (event: PointerEvent) => {
48 const target = event.target as Node;
49 if (!rootRef.current?.contains(target) && !panelRef.current?.contains(target)) setOpen(false);
50 };
51 const keydown = (event: KeyboardEvent) => {
52 if (event.key === "Escape") {
53 setOpen(false);
54 rootRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
55 }
56 };
57 document.addEventListener("pointerdown", dismiss, true);
58 document.addEventListener("keydown", keydown);
59 window.addEventListener("resize", updatePosition);
60 window.addEventListener("scroll", updatePosition, true);
61 return () => {
62 document.removeEventListener("pointerdown", dismiss, true);
63 document.removeEventListener("keydown", keydown);
64 window.removeEventListener("resize", updatePosition);
65 window.removeEventListener("scroll", updatePosition, true);
66 };
67 }, [open, updatePosition]);
68
69 return { open, setOpen, rootRef, panelRef, position };
70 }
71
72 function localeTag(): string {
73 return getLocale() === "zh" ? "zh-CN" : getLocale();
74 }
75
76 function exactTokens(value: number): string {
77 return `${new Intl.NumberFormat(localeTag()).format(Math.max(0, value))} tok`;
78 }
79
80 function compactTokens(value: number): string {
81 const scaled = (candidate: number) => candidate >= 100
82 ? String(Math.round(candidate))
83 : String(Math.round(candidate * 10) / 10);
84 if (value < 1_000) return String(value);
85 if (value < 1_000_000) return `${scaled(value / 1_000)}K`;
86 return `${scaled(value / 1_000_000)}M`;
87 }
88
89 function formatRunDuration(durationMs: number): string {
90 const total = Math.max(0, Math.floor(durationMs / 1000));
91 const hours = Math.floor(total / 3600);
92 const minutes = Math.floor(total / 60) % 60;
93 const seconds = total % 60;
94 const zh = getLocale() !== "en";
95 if (hours > 0) return zh
96 ? `${hours}时 ${String(minutes).padStart(2, "0")}分 ${String(seconds).padStart(2, "0")}秒`
97 : `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`;
98 if (minutes > 0) return zh
99 ? `${minutes}分 ${String(seconds).padStart(2, "0")}秒`
100 : `${minutes}m ${String(seconds).padStart(2, "0")}s`;
101 return zh ? `${seconds}秒` : `${seconds}s`;
102 }
103
104 export function formatMessageClock(time: number, now = Date.now()): string {
105 const date = new Date(time);
106 const current = new Date(now);
107 const clock = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
108 const sameDay = date.getFullYear() === current.getFullYear()
109 && date.getMonth() === current.getMonth()
110 && date.getDate() === current.getDate();
111 if (sameDay) return clock;
112 const day = date.getFullYear() === current.getFullYear()
113 ? `${date.getMonth() + 1}/${date.getDate()}`
114 : `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
115 return `${day} ${clock}`;
116 }
117
118 export function TurnUsagePanel({ usage }: { usage: TurnUsage }) {
119 const t = useT();
120 const dialog = useStatDialog();
121 const routes = usage.routes?.join(", ") ?? "";
122 const inputTotal = Math.max(0, usage.totalTokens - usage.outputTokens);
123 const cacheHit = usage.cacheReadTokens == null || inputTotal <= 0
124 ? undefined
125 : `${((usage.cacheReadTokens / inputTotal) * 100).toFixed(1)}%`;
126 return <span ref={dialog.rootRef} className="chat-stat-root">
127 <button type="button" className="chat-stat-trigger" aria-haspopup="dialog" aria-expanded={dialog.open}
128 onClick={() => dialog.setOpen(!dialog.open)}>
129 <Database aria-hidden="true" /><span>{t("chat.turnUsage", { total: `${compactTokens(usage.totalTokens)} tok` })}</span>
130 </button>
131 {dialog.open && createPortal(<div ref={dialog.panelRef} className="chat-stat-dialog" role="dialog"
132 aria-label={t("chat.turnUsageTitle")} style={dialog.position ?? MEASURE_STYLE}>
133 <div className="chat-stat-dialog__title"><strong><Database aria-hidden="true" />{t("chat.turnUsageTitle")}</strong><b>{exactTokens(usage.totalTokens)}</b></div>
134 <div className="chat-stat-dialog__rule" aria-hidden="true" />
135 <dl className="chat-stat-dialog__details" data-turn-usage-details>
136 {routes ? <><dt>{t("chat.turnUsageModel")}</dt><dd className="chat-stat-dialog__route" title={routes}>{routes}</dd></> : null}
137 {cacheHit ? <><dt>{t("chat.turnUsageCacheHit")}</dt><dd>{cacheHit}</dd></> : null}
138 <dt>{t("chat.turnUsageInput")}</dt><dd>{exactTokens(usage.uncachedInputTokens)}</dd>
139 {usage.cacheReadTokens != null ? <><dt>{t("chat.turnUsageCacheRead")}</dt><dd>{exactTokens(usage.cacheReadTokens)}</dd></> : null}
140 <dt>{t("chat.turnUsageOutput")}</dt><dd>{exactTokens(usage.outputTokens)}{(usage.reasoningTokens ?? 0) > 0
141 ? <span className="chat-stat-dialog__reasoning">{t("chat.turnUsageReasoning", { tokens: exactTokens(usage.reasoningTokens!) })}</span>
142 : null}</dd>
143 </dl>
144 </div>, document.body)}
145 </span>;
146 }
147
148 export function TurnTimePanel({ durationMs, tokensPerSecond }: { durationMs: number; tokensPerSecond?: number }) {
149 const t = useT();
150 const dialog = useStatDialog();
151 const duration = formatRunDuration(durationMs);
152 return <span ref={dialog.rootRef} className="chat-stat-root">
153 <button type="button" className="chat-stat-trigger" aria-haspopup="dialog" aria-expanded={dialog.open}
154 onClick={() => dialog.setOpen(!dialog.open)}>
155 <Clock3 aria-hidden="true" /><span>{t("chat.turnTime", { duration })}</span>
156 </button>
157 {dialog.open && createPortal(<div ref={dialog.panelRef} className="chat-stat-dialog" role="dialog"
158 aria-label={t("chat.turnTimeTitle")} style={dialog.position ?? MEASURE_STYLE}>
159 <div className="chat-stat-dialog__title"><strong><Clock3 aria-hidden="true" />{t("chat.turnTimeTitle")}</strong></div>
160 <div className="chat-stat-dialog__rule" aria-hidden="true" />
161 <dl className="chat-stat-dialog__details" data-turn-time-details>
162 <dt>{t("chat.turnTimeDuration")}</dt><dd>{duration}</dd>
163 {tokensPerSecond != null && tokensPerSecond > 0 ? <><dt>{t("chat.turnTimeSpeed")}</dt><dd>{t("chat.tokensPerSecond", { tps: tokensPerSecond >= 10 ? Math.round(tokensPerSecond) : Math.round(tokensPerSecond * 10) / 10 })}</dd></> : null}
164 </dl>
165 </div>, document.body)}
166 </span>;
167 }
168
168 lines Plain Text