Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions crates/agent-gateway/web/src/pages/StatusDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand All @@ -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) {
Expand Down
135 changes: 135 additions & 0 deletions crates/agent-gui/test/chat/rendering-perf-locks.test.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
}
});
2 changes: 1 addition & 1 deletion crates/agent-ui/src/components/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion crates/agent-ui/src/components/chat/AssistantWorkTrace.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
9 changes: 7 additions & 2 deletions crates/agent-ui/src/components/chat/ConversationStatsBar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]);
Expand Down
3 changes: 3 additions & 0 deletions crates/agent-ui/src/lib/chat/uiMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
18 changes: 11 additions & 7 deletions crates/agent-ui/src/lib/chat/userMessageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand All @@ -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 };
}

Expand Down
36 changes: 36 additions & 0 deletions crates/agent-ui/src/lib/shared/documentVisibility.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading