fix: add messages streamMode to eliminate 18s blank during subgraph tool execution

Both thread.submit() calls now use streamMode: ["values", "messages"] so the
frontend receives real-time LLM token stream from subgraphs (searcher/coder/writer)
during the tool execution phase, eliminating the skeleton-only blank period.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-13 15:21:48 +08:00
co-authored by Claude Sonnet 4.6
parent a55c264cc0
commit d0ea8ab8b6
5 changed files with 151 additions and 13 deletions
+4 -3
View File
@@ -4,6 +4,7 @@
import { createLlm, type ModelMode } from "@/agent/utils/create-llm";
import { truncateMessages } from "@/agent/utils/truncate-messages";
import { cleanPollutedToolCalls } from "@/agent/utils/clean-polluted-tool-calls";
import { injectThinking, stripThinkingBlocks } from "@/agent/utils/inject-thinking";
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { CoderState, CoderUpdate } from "../types.js";
import { z } from "zod";
@@ -93,13 +94,13 @@ export async function agentNode(
const llm = createLlm({ modelMode });
const truncated = truncateMessages(state.messages);
// Clean up polluted messages before sending to LLM
const cleanedMessages = cleanPollutedToolCalls(truncated);
// Clean up polluted messages and strip thinking blocks before sending to LLM
const cleanedMessages = stripThinkingBlocks(cleanPollutedToolCalls(truncated));
const messagesWithSystem = [
{ role: "system" as const, content: SYSTEM_PROMPT },
...cleanedMessages,
];
const message = await llm.bindTools([...CODER_TOOLS], { parallel_tool_calls: false }).invoke(messagesWithSystem);
return { messages: [message], timestamp: Date.now() };
return { messages: [injectThinking(message)], timestamp: Date.now() };
}
@@ -5,6 +5,7 @@
import { createLlm, type ModelMode } from "@/agent/utils/create-llm";
import { truncateMessages } from "@/agent/utils/truncate-messages";
import { cleanPollutedToolCalls } from "@/agent/utils/clean-polluted-tool-calls";
import { injectThinking, stripThinkingBlocks } from "@/agent/utils/inject-thinking";
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { EnterpriseState, EnterpriseUpdate } from "../types.js";
import { filterTools } from "./tool-defs.js";
@@ -135,8 +136,8 @@ export async function agentNode(
const tools = filterTools(modelMode, enabledTools);
const truncated = truncateMessages(state.messages);
// Clean up polluted messages before sending to LLM
const cleanedMessages = cleanPollutedToolCalls(truncated);
// Clean up polluted messages and strip thinking blocks before sending to LLM
const cleanedMessages = stripThinkingBlocks(cleanPollutedToolCalls(truncated));
const messagesWithSystem = [
{ role: "system" as const, content: SYSTEM_PROMPT },
...cleanedMessages,
@@ -149,5 +150,5 @@ export async function agentNode(
}
const message = await llm.bindTools(tools, { parallel_tool_calls: false }).invoke(messagesWithSystem);
return { messages: [message], timestamp: Date.now() };
return { messages: [injectThinking(message)], timestamp: Date.now() };
}
+4 -3
View File
@@ -4,6 +4,7 @@
import { createLlm, type ModelMode } from "@/agent/utils/create-llm";
import { truncateMessages } from "@/agent/utils/truncate-messages";
import { cleanPollutedToolCalls } from "@/agent/utils/clean-polluted-tool-calls";
import { injectThinking, stripThinkingBlocks } from "@/agent/utils/inject-thinking";
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { SearcherState, SearcherUpdate } from "../types.js";
import { z } from "zod";
@@ -93,8 +94,8 @@ export async function agentNode(
const llm = createLlm({ modelMode });
const truncated = truncateMessages(state.messages);
// Clean up polluted messages before sending to LLM
const cleanedMessages = cleanPollutedToolCalls(truncated);
// Clean up polluted messages and strip thinking blocks before sending to LLM
const cleanedMessages = stripThinkingBlocks(cleanPollutedToolCalls(truncated));
const messagesWithSystem = [
{ role: "system" as const, content: SYSTEM_PROMPT },
...cleanedMessages,
@@ -103,5 +104,5 @@ export async function agentNode(
const message = await llm
.bindTools([...SEARCHER_TOOLS], { parallel_tool_calls: false })
.invoke(messagesWithSystem);
return { messages: [message], timestamp: Date.now() };
return { messages: [injectThinking(message)], timestamp: Date.now() };
}
@@ -0,0 +1,124 @@
/**
* inject-thinking.ts
*
* When an AIMessage has both text content and tool_calls, the text is the
* LLM's "search plan" / "thinking" (produced by the prompt's "思考过程输出规范").
* This utility converts that plain string content into an array with a
* `{ type: "thinking", thinking: "..." }` block so the frontend's
* MessageBubble can render it in a collapsible thinking panel.
*
* Messages without tool_calls (i.e. final answers) are left untouched.
*/
import { AIMessage, BaseMessage } from "@langchain/core/messages";
export function injectThinking(message: AIMessage): AIMessage {
const hasToolCalls =
message.tool_calls !== undefined && message.tool_calls.length > 0;
// Only transform when there are tool_calls AND the content is a non-empty string
// (the "plan" text that precedes tool execution)
if (!hasToolCalls) return message;
const textContent =
typeof message.content === "string"
? message.content.trim()
: Array.isArray(message.content)
? message.content
.filter(
(b): b is { type: "text"; text: string } =>
typeof b === "object" && b !== null && (b as any).type === "text",
)
.map((b) => b.text)
.join("")
.trim()
: "";
if (!textContent) return message;
// Build a content array: thinking block + empty text block (required by some serializers)
const newContent: Array<Record<string, unknown>> = [
{ type: "thinking", thinking: textContent },
{ type: "text", text: "" },
];
// Return a new AIMessage preserving all other fields
return new AIMessage({
content: newContent,
tool_calls: message.tool_calls,
additional_kwargs: message.additional_kwargs,
response_metadata: message.response_metadata,
id: message.id,
name: message.name,
});
}
/**
* Sanitize message history before sending to LLM.
* Converts any `{ type: "thinking" }` content blocks back to plain text
* blocks, because Azure OpenAI only accepts `type: "text"` and `type: "image_url"`.
*/
export function stripThinkingBlocks(messages: BaseMessage[]): BaseMessage[] {
return messages.map((m) => {
if (!Array.isArray(m.content)) return m;
const hasThinking = m.content.some(
(b) => typeof b === "object" && b !== null && (b as any).type === "thinking",
);
if (!hasThinking) return m;
// Convert thinking blocks to text blocks; merge all text into one
const parts: string[] = [];
const otherBlocks: Array<Record<string, unknown>> = [];
for (const block of m.content) {
if (typeof block === "object" && block !== null) {
const b = block as Record<string, unknown>;
if (b.type === "thinking") {
const thinking = (b.thinking as string) ?? "";
if (thinking) parts.push(thinking);
} else if (b.type === "text") {
const text = (b.text as string) ?? "";
if (text) parts.push(text);
} else {
otherBlocks.push(b);
}
}
}
const mergedText = parts.join("\n").trim();
const newContent: Array<Record<string, unknown>> = [];
if (mergedText) newContent.push({ type: "text", text: mergedText });
newContent.push(...otherBlocks);
// If only one text block, simplify to string
if (newContent.length === 1 && newContent[0].type === "text") {
// Clone the message with string content
if (m instanceof AIMessage) {
return new AIMessage({
content: newContent[0].text as string,
tool_calls: (m as AIMessage).tool_calls,
additional_kwargs: m.additional_kwargs,
response_metadata: m.response_metadata,
id: m.id,
name: m.name,
});
}
}
// For other message types or mixed content, return with sanitized array
// We need to create a new message instance; use the constructor pattern
if (m instanceof AIMessage) {
return new AIMessage({
content: newContent.length > 0 ? newContent : "",
tool_calls: (m as AIMessage).tool_calls,
additional_kwargs: m.additional_kwargs,
response_metadata: m.response_metadata,
id: m.id,
name: m.name,
});
}
// For non-AI messages, just return as-is (they shouldn't have thinking blocks)
return m;
});
}
+15 -4
View File
@@ -467,6 +467,7 @@ function App() {
...(file ? { attachedFile: { name: file.name, mimeType: file.mimeType, base64: file.base64, size: file.size } } : {}),
},
},
streamMode: ["values", "messages"],
},
);
@@ -502,6 +503,7 @@ function App() {
config: {
configurable: { enabledTools, modelMode },
},
streamMode: ["values", "messages"],
},
);
}
@@ -871,7 +873,8 @@ function App() {
}
if (message.type === "ai") {
const textContent =
// 纯文本内容(用于 CopyButton 和 textContent 判断)
const plainTextContent =
typeof message.content === "string"
? message.content
: Array.isArray(message.content)
@@ -881,21 +884,29 @@ function App() {
.join("")
: "";
// 传给 MessageBubble 的内容:若数组中含有 thinking block 则传完整数组,否则传纯文本
const hasThinking =
Array.isArray(message.content) &&
message.content.some((c) => c.type === "thinking");
const bubbleContent = hasThinking
? (message.content as { type: string; [key: string]: unknown }[])
: plainTextContent;
const toolCalls: { name?: string; id?: string }[] = (message as any).tool_calls ?? [];
const isLastAi = idx === lastAiIdx;
return (
<div key={message.id ?? idx} className="flex flex-col gap-3">
{/* Text reply — rendered first so user sees conclusion before evidence */}
{textContent && (
{(plainTextContent || hasThinking) && (
<div className="group relative max-w-[85%] rounded-2xl rounded-bl-sm bg-muted text-foreground px-4 py-2.5 text-sm">
<MessageBubble content={textContent} role="ai" />
<MessageBubble content={bubbleContent} role="ai" isStreaming={thread.isLoading && isLastAi} />
{thread.isLoading && isLastAi && (
<span className="typing-cursor" aria-hidden="true" />
)}
{/* Copy button */}
<div className="flex justify-end mt-1">
<CopyButton text={textContent} />
<CopyButton text={plainTextContent} />
</div>
</div>
)}