diff --git a/crates/agent-gateway/web/src/pages/StatusDashboardPage.tsx b/crates/agent-gateway/web/src/pages/StatusDashboardPage.tsx index 9b6855646..629b2c63a 100644 --- a/crates/agent-gateway/web/src/pages/StatusDashboardPage.tsx +++ b/crates/agent-gateway/web/src/pages/StatusDashboardPage.tsx @@ -28,6 +28,7 @@ import { import { Button } from "@liveagent/ui/components/ui/button"; import { useAutomation } from "@liveagent/ui/lib/automation/index"; import type { GatewaySettingsSyncPayload } from "@liveagent/ui/lib/settings/sync"; +import { cachedDateTimeFormat, cachedNumberFormat } from "@liveagent/ui/lib/shared/intlFormatters"; import { cn } from "@liveagent/ui/lib/shared/utils"; import type { TerminalSession } from "@liveagent/ui/lib/terminal/types"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -192,7 +193,7 @@ function formatClock(ms: number) { if (!ms) { return "--:--:--"; } - return new Intl.DateTimeFormat("zh-CN", { + return cachedDateTimeFormat("zh-CN", "status-dashboard-clock", { hour: "2-digit", minute: "2-digit", second: "2-digit", @@ -201,9 +202,10 @@ function formatClock(ms: number) { } function compactNumber(value: number) { - return new Intl.NumberFormat("zh-CN", { notation: "compact", maximumFractionDigits: 1 }).format( - Math.max(0, value), - ); + return cachedNumberFormat("zh-CN", "status-dashboard-compact", { + notation: "compact", + maximumFractionDigits: 1, + }).format(Math.max(0, value)); } function percentage(value: number) { diff --git a/crates/agent-gui/test/chat/rendering-perf-locks.test.mjs b/crates/agent-gui/test/chat/rendering-perf-locks.test.mjs new file mode 100644 index 000000000..a9c781405 --- /dev/null +++ b/crates/agent-gui/test/chat/rendering-perf-locks.test.mjs @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +// 渲染进程长会话性能的反漂移锁(见 issue:3.7 小时会话把 WebContent 顶到 88~124% CPU)。 +// +// 1. Intl formatter 必须按 (variant, locale) 复用:`sample` 抓到的热点栈是 +// timerFired → JSEventListener::handleEvent → constructIntlDateTimeFormat → +// udat_open,即渲染/tick 路径里反复新建 formatter(每次一次 ICU 初始化)。 +// 2. 可见性判定必须同时认 `document.hidden` 与 `visibilityState`:Tauri/WKWebView +// 后台启动出现过两者不同步;据此收敛的定时器不能在隐藏时继续跑。 +const loader = createTsModuleLoader(); +const intl = loader.loadModule("@liveagent/ui/lib/shared/intlFormatters.ts"); +const visibility = loader.loadModule("@liveagent/ui/lib/shared/documentVisibility.ts"); +const stats = loader.loadModule("@liveagent/ui/lib/trajectory/stats.ts"); +const presentation = loader.loadModule("@liveagent/ui/lib/trajectory/presentation.ts"); + +/** 统计期间构造了多少个 Intl 实例。 */ +function countConstructions(kind, run) { + const Original = Intl[kind]; + let built = 0; + class Counting extends Original { + constructor(...args) { + super(...args); + built += 1; + } + } + Intl[kind] = Counting; + try { + const result = run(); + return { built, result }; + } finally { + Intl[kind] = Original; + } +} + +test("cachedNumberFormat constructs once per variant and locale", () => { + intl.clearIntlFormatterCaches(); + const { built } = countConstructions("NumberFormat", () => { + for (let index = 0; index < 25; index += 1) { + intl.cachedNumberFormat("zh-CN", "integer-0", { maximumFractionDigits: 0 }).format(index); + } + // 同一个 variant 换 locale 是新的一档。 + intl.cachedNumberFormat("en-US", "integer-0", { maximumFractionDigits: 0 }).format(1); + // 同一个 locale 换 variant 也是新的一档。 + intl.cachedNumberFormat("zh-CN", "decimal-2", { maximumFractionDigits: 2 }).format(1); + }); + assert.equal(built, 3); +}); + +test("cachedNumberFormat returns the same instance for repeated lookups", () => { + intl.clearIntlFormatterCaches(); + const first = intl.cachedNumberFormat("zh-CN", "count"); + const second = intl.cachedNumberFormat("zh-CN", "count"); + assert.equal(first, second); +}); + +test("cached formatters keep the uncached formatting output", () => { + intl.clearIntlFormatterCaches(); + const cases = [ + { locale: "zh-CN", variant: "integer-0", options: { maximumFractionDigits: 0 }, value: 12345.678 }, + { locale: "en-US", variant: "decimal-2", options: { maximumFractionDigits: 2 }, value: 1.239 }, + ]; + for (const item of cases) { + const expected = new Intl.NumberFormat(item.locale, item.options).format(item.value); + assert.equal(intl.cachedNumberFormat(item.locale, item.variant, item.options).format(item.value), expected); + } + + const stamp = Date.UTC(2026, 8, 12, 3, 4, 5); + const expectedClock = new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, + }).format(new Date(stamp)); + assert.equal( + intl + .cachedDateTimeFormat("zh-CN", "clock-ms", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, + }) + .format(new Date(stamp)), + expectedClock, + ); +}); + +test("trajectory stats formatters reuse one formatter per locale", () => { + intl.clearIntlFormatterCaches(); + const { built, result } = countConstructions("NumberFormat", () => { + const values = []; + for (let index = 0; index < 20; index += 1) { + values.push(stats.formatStatTokens(index * 900, "zh-CN")); + values.push(stats.formatStatCount(index, "zh-CN")); + } + return values; + }); + // compact-whole / compact-1 / count —— 三档,与调用次数无关。 + assert.equal(built, 3); + assert.equal(result.length, 40); +}); + +test("trajectory presentation formatters reuse one formatter per locale", () => { + intl.clearIntlFormatterCaches(); + const { built } = countConstructions("NumberFormat", () => { + for (let index = 0; index < 30; index += 1) { + presentation.formatTrajectoryDuration(index * 120, "zh-CN"); + presentation.formatTrajectoryCount(index, "zh-CN"); + } + }); + assert.equal(built, 2); +}); + +test("isDocumentHidden treats both hidden signals as hidden", () => { + const original = Object.getOwnPropertyDescriptor(globalThis, "document"); + try { + assert.equal(visibility.isDocumentHidden(), false, "no document (plain node) is not hidden"); + + globalThis.document = { hidden: true, visibilityState: "visible" }; + assert.equal(visibility.isDocumentHidden(), true, "hidden=true wins over a stale visibilityState"); + + globalThis.document = { hidden: false, visibilityState: "hidden" }; + assert.equal(visibility.isDocumentHidden(), true, "visibilityState=hidden counts too"); + + globalThis.document = { hidden: false, visibilityState: "visible" }; + assert.equal(visibility.isDocumentHidden(), false); + } finally { + if (original) { + Object.defineProperty(globalThis, "document", original); + } else { + delete globalThis.document; + } + } +}); diff --git a/crates/agent-ui/src/components/Markdown.tsx b/crates/agent-ui/src/components/Markdown.tsx index c150a163f..99d5abf82 100644 --- a/crates/agent-ui/src/components/Markdown.tsx +++ b/crates/agent-ui/src/components/Markdown.tsx @@ -682,7 +682,7 @@ const MARKDOWN_EMBED_CLASSNAME = cn( "[&_[data-streamdown='mermaid-block-actions']]:gap-2 [&_[data-streamdown='mermaid-block-actions']]:rounded-none [&_[data-streamdown='mermaid-block-actions']]:border-0 [&_[data-streamdown='mermaid-block-actions']]:bg-transparent [&_[data-streamdown='mermaid-block-actions']]:p-0 [&_[data-streamdown='mermaid-block-actions']]:shadow-none [&_[data-streamdown='mermaid-block-actions']]:backdrop-blur-none", "[&_[data-streamdown='mermaid-block-actions']_svg]:size-3 [&_[data-streamdown='mermaid-block']_button>svg]:size-3", "[&_[data-streamdown='table-wrapper']]:my-4 [&_[data-streamdown='table-wrapper']]:!w-full [&_[data-streamdown='table-wrapper']]:min-w-0 [&_[data-streamdown='table-wrapper']]:gap-0 [&_[data-streamdown='table-wrapper']]:rounded-none [&_[data-streamdown='table-wrapper']]:border-0 [&_[data-streamdown='table-wrapper']]:bg-transparent [&_[data-streamdown='table-wrapper']]:p-0 [&_[data-streamdown='table-wrapper']]:shadow-none [&_[data-streamdown='table-wrapper']]:outline-none [&_[data-streamdown='table-wrapper']]:ring-0", - "[&_[data-streamdown='table-wrapper']>div:last-child]:!w-full [&_[data-streamdown='table-wrapper']>div:last-child]:min-w-0 [&_[data-streamdown='table-wrapper']>div:last-child]:overflow-x-auto [&_[data-streamdown='table-wrapper']>div:last-child]:overflow-y-hidden [&_[data-streamdown='table-wrapper']>div:last-child]:rounded-none [&_[data-streamdown='table-wrapper']>div:last-child]:border-0 [&_[data-streamdown='table-wrapper']>div:last-child]:bg-transparent [&_[data-streamdown='table-wrapper']>div:last-child]:p-0 [&_[data-streamdown='table-wrapper']>div:last-child]:shadow-none [&_[data-streamdown='table-wrapper']>div:last-child]:outline-none [&_[data-streamdown='table-wrapper']>div:last-child]:ring-0", + "[&_[data-streamdown='table-wrapper']>div:last-child]:!w-full [&_[data-streamdown='table-wrapper']>div:last-child]:min-w-0 [&_[data-streamdown='table-wrapper']>div:last-child]:overflow-x-auto [&_[data-streamdown='table-wrapper']>div:last-child]:overflow-y-hidden [&_[data-streamdown='table-wrapper']>div:last-child]:[contain:layout_paint_style] [&_[data-streamdown='table-wrapper']>div:last-child]:rounded-none [&_[data-streamdown='table-wrapper']>div:last-child]:border-0 [&_[data-streamdown='table-wrapper']>div:last-child]:bg-transparent [&_[data-streamdown='table-wrapper']>div:last-child]:p-0 [&_[data-streamdown='table-wrapper']>div:last-child]:shadow-none [&_[data-streamdown='table-wrapper']>div:last-child]:outline-none [&_[data-streamdown='table-wrapper']>div:last-child]:ring-0", "[&_table]:my-2 [&_table]:!w-full [&_table]:!min-w-full [&_table]:max-w-none [&_table]:table-auto [&_table]:border-collapse [&_table]:rounded-none [&_table]:border-0 [&_table]:bg-transparent [&_table]:shadow-none [&_table]:outline-none [&_table]:ring-0", "[&_thead]:bg-transparent [&_tbody]:bg-transparent [&_tr]:border-b [&_tr]:border-border/50 [&_tr]:bg-transparent [&_tbody_tr:last-child]:border-b-0", "[&_th]:border-0 [&_th]:px-0 [&_th]:py-2 [&_th]:pr-8 [&_th]:text-left [&_th]:align-bottom [&_th]:font-semibold [&_th]:tracking-[-0.01em] [&_th]:text-foreground", diff --git a/crates/agent-ui/src/components/chat/AssistantWorkTrace.tsx b/crates/agent-ui/src/components/chat/AssistantWorkTrace.tsx index 6cfe498c5..2a01fe3d6 100644 --- a/crates/agent-ui/src/components/chat/AssistantWorkTrace.tsx +++ b/crates/agent-ui/src/components/chat/AssistantWorkTrace.tsx @@ -1,5 +1,6 @@ import { ChevronDown } from "@liveagent/ui/components/IconSet"; import { useLocale } from "@liveagent/ui/i18n/index"; +import { isDocumentHidden } from "@liveagent/ui/lib/shared/documentVisibility"; import { cn } from "@liveagent/ui/lib/shared/utils"; import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from "react"; import { LazyCollapse } from "./LazyCollapse"; @@ -120,7 +121,12 @@ export function AssistantWorkTrace({ if (startedAt !== null) setElapsedMs(Math.max(0, Date.now() - startedAt)); }; updateElapsed(); - const timer = window.setInterval(updateElapsed, 1_000); + // 不可见时停表:work trace 的秒表只服务于"看着它跑"的观感,隐藏窗口里的 + // 每秒重渲染纯属白烧 CPU;重新可见时 effect 重跑,读数立即补上。 + const timer = window.setInterval(() => { + if (isDocumentHidden()) return; + updateElapsed(); + }, 1_000); return () => window.clearInterval(timer); }, [durationMs, running]); diff --git a/crates/agent-ui/src/components/chat/ConversationSearchDialog.tsx b/crates/agent-ui/src/components/chat/ConversationSearchDialog.tsx index 09c866cd2..96e5c1adc 100644 --- a/crates/agent-ui/src/components/chat/ConversationSearchDialog.tsx +++ b/crates/agent-ui/src/components/chat/ConversationSearchDialog.tsx @@ -11,6 +11,7 @@ import { type PersistedConversationSearchResult, searchPersistedConversations, } from "@liveagent/ui/lib/chat/conversationSearch"; +import { cachedDateTimeFormat } from "@liveagent/ui/lib/shared/intlFormatters"; import { cn } from "@liveagent/ui/lib/shared/utils"; import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ConversationOpenOptions } from "../../lib/sidebar/openController"; @@ -47,7 +48,7 @@ function toSearchResult(item: SidebarConversation): PersistedConversationSearchR function formatUpdatedAt(value: number | undefined, locale: string) { if (!value || !Number.isFinite(value)) return ""; - return new Intl.DateTimeFormat(locale, { + return cachedDateTimeFormat(locale, "search-updated-at", { month: "short", day: "numeric", hour: "2-digit", diff --git a/crates/agent-ui/src/components/chat/ConversationStatsBar.tsx b/crates/agent-ui/src/components/chat/ConversationStatsBar.tsx index cf8845287..251259149 100644 --- a/crates/agent-ui/src/components/chat/ConversationStatsBar.tsx +++ b/crates/agent-ui/src/components/chat/ConversationStatsBar.tsx @@ -1,4 +1,5 @@ import { useLocale } from "@liveagent/ui/i18n/index"; +import { useDocumentHidden } from "@liveagent/ui/lib/shared/documentVisibility"; import { cn } from "@liveagent/ui/lib/shared/utils"; import { useEffect, useState } from "react"; import { canManualCompact, contextUsageRatio } from "../../lib/chat/contextUsage"; @@ -28,12 +29,16 @@ type StatGroup = { /** 运行中每秒重渲染一次,把 *RunningSinceAt 折算进显示值;空闲时零定时器。 */ function useRunningHeartbeat(running: boolean): number { + const hidden = useDocumentHidden(); const [, setBeat] = useState(0); useEffect(() => { - if (!running) return; + // 窗口不可见时不起心跳:这些帧用户看不到,代价却是每秒重渲染一次统计条 + // (连带重建其中的 formatter、以及在长会话里重排可见行邻域)。重新可见时 + // hidden 翻转会重启 effect,读数立刻回到当前值。 + if (!running || hidden) return; const timer = setInterval(() => setBeat((beat) => beat + 1), HEARTBEAT_MS); return () => clearInterval(timer); - }, [running]); + }, [hidden, running]); return Date.now(); } diff --git a/crates/agent-ui/src/components/chat/SharedHistoryManagerModal.tsx b/crates/agent-ui/src/components/chat/SharedHistoryManagerModal.tsx index b7aa98034..683840765 100644 --- a/crates/agent-ui/src/components/chat/SharedHistoryManagerModal.tsx +++ b/crates/agent-ui/src/components/chat/SharedHistoryManagerModal.tsx @@ -22,6 +22,7 @@ import { } from "@liveagent/ui/components/ui/dialog"; import { useLocale } from "@liveagent/ui/i18n/index"; import { buildShareUrl, resolveShareOrigin } from "@liveagent/ui/lib/chat/historyShareOrigin"; +import { cachedDateTimeFormat } from "@liveagent/ui/lib/shared/intlFormatters"; import { cn } from "@liveagent/ui/lib/shared/utils"; import { useMemo, useState } from "react"; @@ -65,7 +66,7 @@ function formatConversationTime(timestamp: number | undefined, locale: string, f if (typeof timestamp !== "number" || !Number.isFinite(timestamp) || timestamp <= 0) { return fallback; } - return new Intl.DateTimeFormat(locale, { + return cachedDateTimeFormat(locale, "shared-history-time", { month: "2-digit", day: "2-digit", hour: "2-digit", diff --git a/crates/agent-ui/src/components/project-tools/BackgroundTasksPanel.tsx b/crates/agent-ui/src/components/project-tools/BackgroundTasksPanel.tsx index f1213d326..0dc3f0715 100644 --- a/crates/agent-ui/src/components/project-tools/BackgroundTasksPanel.tsx +++ b/crates/agent-ui/src/components/project-tools/BackgroundTasksPanel.tsx @@ -9,6 +9,7 @@ import { Trash2, } from "@liveagent/ui/components/IconSet"; import { useLocale } from "@liveagent/ui/i18n/index"; +import { isDocumentHidden } from "@liveagent/ui/lib/shared/documentVisibility"; import { memo, type MouseEvent as ReactMouseEvent, @@ -494,7 +495,11 @@ export const BackgroundTasksPanel = memo(function BackgroundTasksPanel( useEffect(() => { if (!active || !hasRunning) return; - const timer = window.setInterval(() => setNow(Date.now()), 1000); + // 与面板的 30s reconcile 同一口径:窗口不可见时这一秒一跳只是白烧 CPU。 + const timer = window.setInterval(() => { + if (isDocumentHidden()) return; + setNow(Date.now()); + }, 1000); setNow(Date.now()); return () => window.clearInterval(timer); }, [active, hasRunning]); diff --git a/crates/agent-ui/src/lib/chat/uiMessages.ts b/crates/agent-ui/src/lib/chat/uiMessages.ts index 7cdd6b676..88813551f 100644 --- a/crates/agent-ui/src/lib/chat/uiMessages.ts +++ b/crates/agent-ui/src/lib/chat/uiMessages.ts @@ -859,6 +859,9 @@ export function appendThinkingBlockFromAssistant( } function rebalanceHostedSearchTextBoundaries(blocks: UiRoundContentBlock[]): UiRoundContentBlock[] { + // 绝大多数回复里没有任何 hosted-search 块:直接返回同一数组。否则每次文本增量 + // 都会重建整个块数组(长会话里等价于每 delta 一次全量分配 + 数组拷贝)。 + if (!blocks.some((block) => block.kind === "hostedSearch")) return blocks; const out: UiRoundContentBlock[] = []; for (let index = 0; index < blocks.length; index += 1) { const current = blocks[index]; diff --git a/crates/agent-ui/src/lib/chat/userMessageContent.tsx b/crates/agent-ui/src/lib/chat/userMessageContent.tsx index 7a206c415..505dcb098 100644 --- a/crates/agent-ui/src/lib/chat/userMessageContent.tsx +++ b/crates/agent-ui/src/lib/chat/userMessageContent.tsx @@ -28,6 +28,11 @@ import { type PendingUploadedFile, parsePastedTextDisplayReferences, } from "@liveagent/ui/lib/chat/uploadedFiles"; +import { + cachedDateTimeFormat, + cachedNumberFormat, + cachedRelativeTimeFormat, +} from "@liveagent/ui/lib/shared/intlFormatters"; import { type FocusEvent, type MouseEvent, @@ -548,20 +553,20 @@ export function tokenizeUserMessage( } function formatPastedTextCount(value: number) { - return new Intl.NumberFormat().format(value); + return cachedNumberFormat(undefined, "count").format(value); } function formatCommitTooltipDate(value: string | undefined, locale: string) { if (!value) return null; const date = new Date(value); if (Number.isNaN(date.getTime())) return null; - const absolute = date.toLocaleString(locale, { + const absolute = cachedDateTimeFormat(locale, "commit-date", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit", - }); + }).format(date); const deltaSeconds = Math.round((date.getTime() - Date.now()) / 1000); const units: Array<{ unit: "year" | "month" | "day" | "hour" | "minute" | "second"; @@ -578,10 +583,9 @@ function formatCommitTooltipDate(value: string | undefined, locale: string) { unit: "second", seconds: 1, }; - const relative = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format( - Math.round(deltaSeconds / selected.seconds), - selected.unit, - ); + const relative = cachedRelativeTimeFormat(locale, "commit-relative", { + numeric: "auto", + }).format(Math.round(deltaSeconds / selected.seconds), selected.unit); return { relative, absolute }; } diff --git a/crates/agent-ui/src/lib/shared/documentVisibility.ts b/crates/agent-ui/src/lib/shared/documentVisibility.ts new file mode 100644 index 000000000..c44f9ea41 --- /dev/null +++ b/crates/agent-ui/src/lib/shared/documentVisibility.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; + +/** + * 文档是否隐藏。 + * + * 两个信号都当作隐藏:`document.hidden` 与 `document.visibilityState` 本该同步, + * 但 Tauri/WKWebView 的后台启动状态下出现过 `hidden=true` 而 `visibilityState` + * 仍是 `"visible"` 的组合(原实现见设置页的 section-enter 兜底)。 + * + * 退出后台时 `visibilitychange` 只会触发一次,因此以隐藏期间的定时器一律用 + * 「重新订阅 + 重算」而不是「暂停后继续累加」的方式恢复。 + */ +export function isDocumentHidden(): boolean { + if (typeof document === "undefined") return false; + return document.hidden || document.visibilityState === "hidden"; +} + +/** + * 订阅文档可见性。渲染进程在窗口不可见时不该继续跑每秒心跳、重建账本或重绘 + * 长列表——这些工作产生的帧用户看不到,却照样烧 CPU(长会话下表现为渲染进程 + * 持续 80%+ 与整机热限流)。 + */ +export function useDocumentHidden(): boolean { + const [hidden, setHidden] = useState(isDocumentHidden); + + useEffect(() => { + const sync = () => setHidden(isDocumentHidden()); + sync(); + document.addEventListener("visibilitychange", sync); + return () => { + document.removeEventListener("visibilitychange", sync); + }; + }, []); + + return hidden; +} diff --git a/crates/agent-ui/src/lib/shared/intlFormatters.ts b/crates/agent-ui/src/lib/shared/intlFormatters.ts new file mode 100644 index 000000000..2852d9076 --- /dev/null +++ b/crates/agent-ui/src/lib/shared/intlFormatters.ts @@ -0,0 +1,75 @@ +/** + * Intl formatter 缓存。 + * + * 构造一个 `Intl.*Format` 会走 ICU 初始化(`udat_open` → + * `icu::SimpleDateFormat`),是公认的昂贵操作。在渲染体或定时器回调里每次 + * `new` 一次,等价于每个 tick 做一次 ICU 初始化:实测长会话下这条路径把渲染 + * 进程 CPU 顶到 100%+,`sample` 抓到的热点栈正是 + * `timerFired → JSEventListener::handleEvent → constructIntlDateTimeFormat → + * udat_open`。同一个 formatter 的生命周期应当是进程级的——locale 集合极小, + * 选项形状在调用点都是字面量常量。 + * + * 缓存键是 `${variant}|${locale}`,而不是把 options 序列化进去:调用方为每处 + * 调用点给一个稳定的 variant 名,这样键只是短字符串拼接;若用 + * `JSON.stringify(options)` 作键,每次调用都要付一次序列化成本,等于把省下来的 + * ICU 开销换成字符串开销——而那正是同一份 sample 里 `WTF::findCommon` 那类 + * 热点的由来。 + * + * 用法:同一处调用点必须始终使用同一个 variant 名,并始终传同一套 options。 + * variant 与 options 不一致会让缓存返回错误的格式化结果,因此这里不做运行时 + * 校验——调用点相邻书写,评审时一眼可见。 + */ + +const numberFormats = new Map(); +const dateTimeFormats = new Map(); +const relativeTimeFormats = new Map(); + +function cacheKey(variant: string, locale: string | undefined): string { + return `${variant}|${locale ?? ""}`; +} + +export function cachedNumberFormat( + locale: string | undefined, + variant: string, + options?: Intl.NumberFormatOptions, +): Intl.NumberFormat { + const key = cacheKey(variant, locale); + const cached = numberFormats.get(key); + if (cached) return cached; + const formatter = new Intl.NumberFormat(locale, options); + numberFormats.set(key, formatter); + return formatter; +} + +export function cachedDateTimeFormat( + locale: string | undefined, + variant: string, + options?: Intl.DateTimeFormatOptions, +): Intl.DateTimeFormat { + const key = cacheKey(variant, locale); + const cached = dateTimeFormats.get(key); + if (cached) return cached; + const formatter = new Intl.DateTimeFormat(locale, options); + dateTimeFormats.set(key, formatter); + return formatter; +} + +export function cachedRelativeTimeFormat( + locale: string | undefined, + variant: string, + options?: Intl.RelativeTimeFormatOptions, +): Intl.RelativeTimeFormat { + const key = cacheKey(variant, locale); + const cached = relativeTimeFormats.get(key); + if (cached) return cached; + const formatter = new Intl.RelativeTimeFormat(locale, options); + relativeTimeFormats.set(key, formatter); + return formatter; +} + +/** 测试用:清空缓存(省略参数则全清)。 */ +export function clearIntlFormatterCaches(kind?: "number" | "dateTime" | "relativeTime"): void { + if (kind === undefined || kind === "number") numberFormats.clear(); + if (kind === undefined || kind === "dateTime") dateTimeFormats.clear(); + if (kind === undefined || kind === "relativeTime") relativeTimeFormats.clear(); +} diff --git a/crates/agent-ui/src/lib/trajectory/presentation.ts b/crates/agent-ui/src/lib/trajectory/presentation.ts index cf9fbfe26..b8799b83a 100644 --- a/crates/agent-ui/src/lib/trajectory/presentation.ts +++ b/crates/agent-ui/src/lib/trajectory/presentation.ts @@ -5,6 +5,7 @@ * 记录翻译成 i18n key 与格式化数值,真正的取词留给组件。 */ +import { cachedDateTimeFormat, cachedNumberFormat } from "../shared/intlFormatters"; import type { TrajectoryHeaderChange, TrajectoryLedger, @@ -81,9 +82,9 @@ export function formatTrajectoryDuration(milliseconds: number | null, locale: st if (milliseconds === null || !Number.isFinite(milliseconds)) return "—"; const rounded = Math.max(0, milliseconds); if (rounded < 1000) { - return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(rounded)} ms`; + return `${cachedNumberFormat(locale, "integer-0", { maximumFractionDigits: 0 }).format(rounded)} ms`; } - return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(rounded / 1000)} s`; + return `${cachedNumberFormat(locale, "decimal-2", { maximumFractionDigits: 2 }).format(rounded / 1000)} s`; } export function formatTrajectorySeconds(seconds: number | null, locale: string): string { @@ -92,17 +93,17 @@ export function formatTrajectorySeconds(seconds: number | null, locale: string): export function formatTrajectoryCount(value: number | undefined, locale: string): string { if (value === undefined || !Number.isFinite(value)) return "—"; - return new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(value); + return cachedNumberFormat(locale, "integer-0", { maximumFractionDigits: 0 }).format(value); } export function formatTrajectoryClock(timestamp: number | null, locale: string): string { if (timestamp === null || !Number.isFinite(timestamp)) return "—"; - return new Date(timestamp).toLocaleTimeString(locale, { + return cachedDateTimeFormat(locale, "clock-ms", { hour: "2-digit", minute: "2-digit", second: "2-digit", fractionalSecondDigits: 3, - }); + }).format(new Date(timestamp)); } /** 解码吞吐;缺少任一时序事实时返回 null 而不是估算。 */ diff --git a/crates/agent-ui/src/lib/trajectory/stats.ts b/crates/agent-ui/src/lib/trajectory/stats.ts index a35fb0472..7c2c37c3a 100644 --- a/crates/agent-ui/src/lib/trajectory/stats.ts +++ b/crates/agent-ui/src/lib/trajectory/stats.ts @@ -6,6 +6,7 @@ * `*RunningSinceAt` 让展示层用心跳补齐,聚合结果因此保持纯函数、可缓存。 */ +import { cachedNumberFormat } from "../shared/intlFormatters"; import type { TrajectoryLedger } from "./types"; export type ConversationStats = { @@ -189,15 +190,19 @@ export function formatStatLatency(ms: number): string { export function formatStatTokens(value: number, locale: string): string { if (!Number.isFinite(value) || value <= 0) return "0"; - return new Intl.NumberFormat(locale, { + // 两个档位各自成键:<1000 不留小数位(892 → "892",而不是 compact 的 "0.9K")。 + // formatter 按 (variant, locale) 缓存——这一族函数在状态栏渲染体里被逐个调用, + // 而状态栏运行中每秒重渲染一次(see ConversationStatsBar 心跳)。 + const formatter = cachedNumberFormat(locale, value < 1000 ? "compact-whole" : "compact-1", { notation: "compact", maximumFractionDigits: value < 1000 ? 0 : 1, - }).format(Math.round(value)); + }); + return formatter.format(Math.round(value)); } export function formatStatCount(value: number, locale: string): string { if (!Number.isFinite(value) || value <= 0) return "0"; - return new Intl.NumberFormat(locale).format(Math.round(value)); + return cachedNumberFormat(locale, "count").format(Math.round(value)); } export function formatStatThroughput(tokPerSec: number): string { diff --git a/crates/agent-ui/src/pages/settings/SettingsShell.tsx b/crates/agent-ui/src/pages/settings/SettingsShell.tsx index 8cef4763c..fe23c9927 100644 --- a/crates/agent-ui/src/pages/settings/SettingsShell.tsx +++ b/crates/agent-ui/src/pages/settings/SettingsShell.tsx @@ -2,6 +2,7 @@ import { ArrowLeft, Search } from "@liveagent/ui/components/IconSet"; import { useEffect, useMemo, useState } from "react"; import type { SettingsSaveState, UiExtensionRegistry } from "../../contracts/registry"; import { useLocale } from "../../i18n"; +import { useDocumentHidden } from "../../lib/shared/documentVisibility"; import { cn } from "../../lib/shared/utils"; type SettingsShellProps = { @@ -16,28 +17,8 @@ type SettingsShellProps = { // 文档隐藏时 WebKit/Chromium 会暂停 CSS keyframe 动画,`.settings-section-enter` // 与 `.settings-section-title-enter` 因此停在 from 态(opacity:0 + 位移缩放), // 整个设置页看起来是空白。useSettingsOverlay 只兜底了外层浮层容器,内层区块 -// 需要这一份。 -// -// 两个信号都当作隐藏:`document.hidden` 与 `document.visibilityState` 本该同步, -// 但 Tauri/WKWebView 的后台启动状态下出现过 hidden=true 而 visibilityState 仍是 -// "visible" 的组合。 -function isDocumentHidden() { - if (typeof document === "undefined") return false; - return document.hidden || document.visibilityState === "hidden"; -} - -function useIsDocumentHidden() { - const [hidden, setHidden] = useState(isDocumentHidden); - useEffect(() => { - const sync = () => setHidden(isDocumentHidden()); - sync(); - document.addEventListener("visibilitychange", sync); - return () => { - document.removeEventListener("visibilitychange", sync); - }; - }, []); - return hidden; -} +// 需要这一份。可见性判定(含 Tauri/WKWebView 的 hidden/visibilityState 不同步 +// 组合)与其它按可见性收敛的定时器共用 lib/shared/documentVisibility。 function getSaveIndicator(state: SettingsSaveState, t: (key: string) => string) { switch (state.status) { @@ -75,7 +56,7 @@ export function SettingsShell(props: SettingsShellProps) { const { t } = useLocale(); const [section, setSection] = useState(initialSection); const [navQuery, setNavQuery] = useState(""); - const isDocumentHidden = useIsDocumentHidden(); + const isDocumentHidden = useDocumentHidden(); const hiddenSectionSet = useMemo(() => new Set(hiddenSections), [hiddenSections]); const sections = useMemo( () => diff --git a/crates/agent-ui/src/pages/settings/memory/panelModel.ts b/crates/agent-ui/src/pages/settings/memory/panelModel.ts index 553be1991..230b0aa0b 100644 --- a/crates/agent-ui/src/pages/settings/memory/panelModel.ts +++ b/crates/agent-ui/src/pages/settings/memory/panelModel.ts @@ -24,6 +24,7 @@ import type { OrganizerSafeDecision, } from "../../../lib/memory/organizer/runRecord"; import { REJECTION_BUCKET_KEYS, type RejectionBucketKey } from "../../../lib/memory/schema"; +import { cachedDateTimeFormat } from "../../../lib/shared/intlFormatters"; export type MemoryTab = "global" | "project" | "journal"; @@ -68,7 +69,9 @@ export function formatTime(value: number) { if (!value) return ""; const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value); - return date.toLocaleString(); + // 默认 locale 的时间戳(`toLocaleString()` 每次调用都会新建一个 + // DateTimeFormat,即一次 ICU 初始化);面板里每行都会调一次。 + return cachedDateTimeFormat(undefined, "memory-panel-time").format(date); } function dailyTitle(entry: { slug: string; dateLocal?: string | null }) {