mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
perf(ui): cache chat transcript renders (#88952)
This commit is contained in:
@@ -44,6 +44,64 @@ const loadSessionsMock = vi.hoisted(() =>
|
||||
}
|
||||
}),
|
||||
);
|
||||
const buildChatItemsMock = vi.hoisted(() =>
|
||||
vi.fn(
|
||||
(props: { messages: unknown[]; stream: string | null; streamStartedAt: number | null }) => {
|
||||
if (
|
||||
props.messages.some(
|
||||
(message) =>
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
(message as { __testDivider?: unknown })["__testDivider"] === true,
|
||||
)
|
||||
) {
|
||||
return [
|
||||
{
|
||||
kind: "divider",
|
||||
key: "divider:compaction:test",
|
||||
label: "Compacted history",
|
||||
description:
|
||||
"The compacted transcript is preserved as a checkpoint. Open session checkpoints to branch or restore from that compacted view.",
|
||||
action: {
|
||||
kind: "session-checkpoints",
|
||||
label: "Open checkpoints",
|
||||
},
|
||||
timestamp: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (props.messages.length > 0) {
|
||||
return [
|
||||
{
|
||||
kind: "group",
|
||||
key: "group:assistant:test",
|
||||
role: "assistant",
|
||||
messages: props.messages.map((message, index) => ({
|
||||
key: `message:${index}`,
|
||||
message,
|
||||
})),
|
||||
timestamp: 1,
|
||||
isStreaming: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (props.stream !== null) {
|
||||
return props.stream
|
||||
? [
|
||||
{
|
||||
kind: "stream",
|
||||
key: "stream:test",
|
||||
text: props.stream,
|
||||
startedAt: props.streamStartedAt ?? 1,
|
||||
isStreaming: true,
|
||||
},
|
||||
]
|
||||
: [{ kind: "reading-indicator", key: "reading:test" }];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
function requireFirstAttachmentsChange(
|
||||
onAttachmentsChange: ReturnType<typeof vi.fn>,
|
||||
@@ -64,64 +122,7 @@ vi.mock("../icons.ts", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../chat/build-chat-items.ts", () => ({
|
||||
buildChatItems: (props: {
|
||||
messages: unknown[];
|
||||
stream: string | null;
|
||||
streamStartedAt: number | null;
|
||||
}) => {
|
||||
if (
|
||||
props.messages.some(
|
||||
(message) =>
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
(message as { __testDivider?: unknown })["__testDivider"] === true,
|
||||
)
|
||||
) {
|
||||
return [
|
||||
{
|
||||
kind: "divider",
|
||||
key: "divider:compaction:test",
|
||||
label: "Compacted history",
|
||||
description:
|
||||
"The compacted transcript is preserved as a checkpoint. Open session checkpoints to branch or restore from that compacted view.",
|
||||
action: {
|
||||
kind: "session-checkpoints",
|
||||
label: "Open checkpoints",
|
||||
},
|
||||
timestamp: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (props.messages.length > 0) {
|
||||
return [
|
||||
{
|
||||
kind: "group",
|
||||
key: "group:assistant:test",
|
||||
role: "assistant",
|
||||
messages: props.messages.map((message, index) => ({
|
||||
key: `message:${index}`,
|
||||
message,
|
||||
})),
|
||||
timestamp: 1,
|
||||
isStreaming: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (props.stream !== null) {
|
||||
return props.stream
|
||||
? [
|
||||
{
|
||||
kind: "stream",
|
||||
key: "stream:test",
|
||||
text: props.stream,
|
||||
startedAt: props.streamStartedAt ?? 1,
|
||||
isStreaming: true,
|
||||
},
|
||||
]
|
||||
: [{ kind: "reading-indicator", key: "reading:test" }];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
buildChatItems: buildChatItemsMock,
|
||||
}));
|
||||
|
||||
vi.mock("../chat/grouped-render.ts", () => ({
|
||||
@@ -673,6 +674,7 @@ describe("chat composer workbench", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
buildChatItemsMock.mockClear();
|
||||
loadSessionsMock.mockClear();
|
||||
refreshVisibleToolsEffectiveForCurrentSessionMock.mockClear();
|
||||
resetChatViewState();
|
||||
@@ -680,6 +682,44 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("chat transcript rendering cache", () => {
|
||||
it("does not rebuild transcript items for draft-only rerenders", () => {
|
||||
const messages = [{ role: "assistant", content: "ready" }];
|
||||
const toolMessages: unknown[] = [];
|
||||
const streamSegments: Array<{ text: string; ts: number }> = [];
|
||||
const queue: ChatQueueItem[] = [];
|
||||
|
||||
renderChatView({ messages, toolMessages, streamSegments, queue, draft: "" });
|
||||
renderChatView({ messages, toolMessages, streamSegments, queue, draft: "h" });
|
||||
renderChatView({ messages, toolMessages, streamSegments, queue, draft: "hello" });
|
||||
|
||||
expect(buildChatItemsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rebuilds transcript items when the transcript reference changes", () => {
|
||||
const toolMessages: unknown[] = [];
|
||||
const streamSegments: Array<{ text: string; ts: number }> = [];
|
||||
const queue: ChatQueueItem[] = [];
|
||||
|
||||
renderChatView({
|
||||
messages: [{ role: "assistant", content: "ready" }],
|
||||
toolMessages,
|
||||
streamSegments,
|
||||
queue,
|
||||
draft: "",
|
||||
});
|
||||
renderChatView({
|
||||
messages: [{ role: "assistant", content: "new reply" }],
|
||||
toolMessages,
|
||||
streamSegments,
|
||||
queue,
|
||||
draft: "",
|
||||
});
|
||||
|
||||
expect(buildChatItemsMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat loading skeleton", () => {
|
||||
it("renders realtime Talk transcript as ordered voice turns", () => {
|
||||
const container = renderChatView({
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
CHAT_ATTACHMENT_ACCEPT,
|
||||
isSupportedChatAttachmentFile,
|
||||
} from "../chat/attachment-support.ts";
|
||||
import { buildChatItems } from "../chat/build-chat-items.ts";
|
||||
import { buildChatItems, type BuildChatItemsProps } from "../chat/build-chat-items.ts";
|
||||
import { renderChatQueue } from "../chat/chat-queue.ts";
|
||||
import { buildRawSidebarContent } from "../chat/chat-sidebar-raw.ts";
|
||||
import { renderWelcomeState, resolveAssistantDisplayAvatar } from "../chat/chat-welcome.ts";
|
||||
@@ -486,12 +486,49 @@ function createChatEphemeralState(): ChatEphemeralState {
|
||||
|
||||
const vs = createChatEphemeralState();
|
||||
|
||||
type CachedChatItems = {
|
||||
input: BuildChatItemsProps | null;
|
||||
items: ReturnType<typeof buildChatItems>;
|
||||
};
|
||||
|
||||
const chatItemsBySession = new Map<string, CachedChatItems>();
|
||||
|
||||
function sameChatItemsInput(previous: BuildChatItemsProps, next: BuildChatItemsProps): boolean {
|
||||
return (
|
||||
previous.sessionKey === next.sessionKey &&
|
||||
previous.messages === next.messages &&
|
||||
previous.toolMessages === next.toolMessages &&
|
||||
previous.streamSegments === next.streamSegments &&
|
||||
previous.stream === next.stream &&
|
||||
previous.streamStartedAt === next.streamStartedAt &&
|
||||
previous.queue === next.queue &&
|
||||
previous.showToolCalls === next.showToolCalls &&
|
||||
previous.searchOpen === next.searchOpen &&
|
||||
previous.searchQuery === next.searchQuery
|
||||
);
|
||||
}
|
||||
|
||||
function buildCachedChatItems(input: BuildChatItemsProps): ReturnType<typeof buildChatItems> {
|
||||
const cached = getOrCreateSessionCacheValue(chatItemsBySession, input.sessionKey, () => ({
|
||||
input: null,
|
||||
items: [],
|
||||
}));
|
||||
if (cached.input && sameChatItemsInput(cached.input, input)) {
|
||||
return cached.items;
|
||||
}
|
||||
const items = buildChatItems(input);
|
||||
cached.input = input;
|
||||
cached.items = items;
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset chat view ephemeral state when navigating away.
|
||||
* Clears search/slash UI that should not survive navigation.
|
||||
*/
|
||||
export function resetChatViewState() {
|
||||
Object.assign(vs, createChatEphemeralState());
|
||||
chatItemsBySession.clear();
|
||||
}
|
||||
|
||||
export const cleanupChatModuleState = resetChatViewState;
|
||||
@@ -1301,7 +1338,7 @@ export function renderChat(props: ChatProps) {
|
||||
);
|
||||
};
|
||||
|
||||
const chatItems = buildChatItems({
|
||||
const chatItems = buildCachedChatItems({
|
||||
sessionKey: props.sessionKey,
|
||||
messages: props.messages,
|
||||
toolMessages: props.toolMessages,
|
||||
|
||||
Reference in New Issue
Block a user