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:
co-authored by
Claude Sonnet 4.6
parent
a55c264cc0
commit
d0ea8ab8b6
@@ -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,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
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user