perf(frontend): stop re-deriving message content on every stream chunk (#4441)

This commit is contained in:
Aari 2026-07-26 08:25:01 +08:00 committed by GitHub
parent f090f01806
commit adac3e185e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 138 additions and 10 deletions

View File

@ -364,7 +364,12 @@ export function extractTextFromMessage(message: Message) {
const THINK_OPEN_TAG = "<think>";
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
function splitInlineReasoning(content: string) {
interface InlineReasoningSplit {
content: string;
reasoning: string | null;
}
function splitInlineReasoning(content: string): InlineReasoningSplit {
const reasoningParts: string[] = [];
// First pass: strip every fully closed `<think>...</think>` pair and
@ -401,11 +406,29 @@ function splitInlineReasoning(content: string) {
};
}
// The split is re-derived on every render: `hasContent`, `hasReasoning`,
// `extractContentFromMessage` and `extractReasoningContentFromMessage` all run
// over the whole message list on each stream chunk, so an unmemoized scan costs
// O(total content) per chunk — quadratic across a long run. Cache per message
// object, keyed by the exact content string it was derived from so a message
// whose `content` is reassigned recomputes instead of serving a stale split.
const inlineReasoningCache = new WeakMap<
object,
{ content: string; split: InlineReasoningSplit }
>();
function splitInlineReasoningFromAIMessage(message: Message) {
if (message.type !== "ai" || typeof message.content !== "string") {
return null;
}
return splitInlineReasoning(message.content);
const content = message.content;
const cached = inlineReasoningCache.get(message);
if (cached?.content === content) {
return cached.split;
}
const split = splitInlineReasoning(content);
inlineReasoningCache.set(message, { content, split });
return split;
}
export function extractContentFromMessage(message: Message) {
@ -454,7 +477,7 @@ export function extractReasoningContentFromMessage(message: Message) {
}
}
if (typeof message.content === "string") {
return splitInlineReasoning(message.content).reasoning;
return splitInlineReasoningFromAIMessage(message)?.reasoning ?? null;
}
return null;
}
@ -507,7 +530,9 @@ export function hasReasoning(message: Message) {
return (part as unknown as { type: "thinking" })?.type === "thinking";
}
if (typeof message.content === "string") {
return splitInlineReasoning(message.content).reasoning !== null;
return (
(splitInlineReasoningFromAIMessage(message)?.reasoning ?? null) !== null
);
}
return false;
}
@ -567,14 +592,26 @@ export function findToolCallResult(toolCallId: string, messages: Message[]) {
}
export function isHiddenFromUIMessage(message: Message) {
if (message.additional_kwargs?.hide_from_ui === true) {
return true;
}
if (
typeof message.name === "string" &&
HIDDEN_CONTROL_MESSAGE_NAMES.has(message.name)
) {
return true;
}
// Only the human branch consults the text. Extracting it up front made every
// caller pay a full content scan for every AI message it was about to
// discard, and this predicate runs over the whole message list on each
// stream chunk (grouping, dedup, human-input state).
if (message.type !== "human") {
return false;
}
const content = extractTextFromMessage(message);
return (
message.additional_kwargs?.hide_from_ui === true ||
(typeof message.name === "string" &&
HIDDEN_CONTROL_MESSAGE_NAMES.has(message.name)) ||
(message.type === "human" &&
content.includes("<slash_skill_activation>") &&
stripUploadedFilesTag(content).length === 0)
content.includes("<slash_skill_activation>") &&
stripUploadedFilesTag(content).length === 0
);
}

View File

@ -14,6 +14,7 @@ import {
hasContent,
hasReasoning,
isAssistantMessageGroupStreaming,
isHiddenFromUIMessage,
parseUploadedFiles,
stripInternalMarkers,
stripUploadedFilesTag,
@ -295,6 +296,96 @@ describe("inline <think> tag splitting", () => {
expect(extractContentFromMessage(message)).toBe("Documentation: `<think>");
expect(extractReasoningContentFromMessage(message)).toBeNull();
});
test("re-splits when the same message object gets new content", () => {
// Streaming replaces `content` on the accumulating message, so a split
// cached against the message object must be keyed by the content it was
// derived from.
const message = aiMessage("<think>first</think>one");
expect(extractContentFromMessage(message)).toBe("one");
expect(extractReasoningContentFromMessage(message)).toBe("first");
(message as { content: string }).content = "<think>second</think>two";
expect(extractContentFromMessage(message)).toBe("two");
expect(extractReasoningContentFromMessage(message)).toBe("second");
expect(hasReasoning(message)).toBe(true);
});
});
describe("isHiddenFromUIMessage", () => {
function contentReadCounter(
message: Omit<Message, "content">,
content: string,
) {
const counter = { reads: 0 };
const probe = {
...message,
get content() {
counter.reads++;
return content;
},
} as unknown as Message;
return { probe, counter };
}
test("does not read AI content to decide visibility", () => {
// Only the human branch consults the text, but this predicate runs over
// every message on every stream chunk (grouping, dedup, human-input
// state), so reading AI content here costs a full content scan per
// message per chunk.
const { probe, counter } = contentReadCounter(
{ id: "ai-visible", type: "ai" } as Message,
"<think>long reasoning</think>a long streamed answer",
);
expect(isHiddenFromUIMessage(probe)).toBe(false);
expect(counter.reads).toBe(0);
});
test("does not read content when a control message name already hides it", () => {
const { probe, counter } = contentReadCounter(
{ id: "summary-1", type: "ai", name: "summary" } as Message,
"compressed context",
);
expect(isHiddenFromUIMessage(probe)).toBe(true);
expect(counter.reads).toBe(0);
});
test("still hides a human message that is only slash skill activation", () => {
const message = {
id: "slash-activation",
type: "human",
content:
"<slash_skill_activation>\n<skill_content># SKILL.md</skill_content>\n</slash_skill_activation>",
} as Message;
expect(isHiddenFromUIMessage(message)).toBe(true);
});
test("keeps a human message that carries real text alongside the activation", () => {
const message = {
id: "slash-activation-with-task",
type: "human",
content:
"<slash_skill_activation>\n<skill_content># SKILL.md</skill_content>\n</slash_skill_activation>\nreal user task",
} as Message;
expect(isHiddenFromUIMessage(message)).toBe(false);
});
test("hides any message flagged with hide_from_ui", () => {
const message = {
id: "hidden-1",
type: "human",
content: "internal reply",
additional_kwargs: { hide_from_ui: true },
} as Message;
expect(isHiddenFromUIMessage(message)).toBe(true);
});
});
describe("human message internal context stripping", () => {