From 244a6d4a1fff2ed8ae9927b0ee41ed3b807bb2da Mon Sep 17 00:00:00 2001 From: gongzhiyong Date: Tue, 14 Apr 2026 00:20:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20next-gen=20COT=20system=20=E2=80=94=20s?= =?UTF-8?q?treaming=20reasoning,=20structured=20thinking,=20timeline=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Expand COT prompts from 30-char to 80-200 char structured reasoning (all 4 agents) - Add source/meta fields to thinking blocks (inject-thinking.ts) - Add invokeWithReasoningStream() for Pro mode streaming reasoning (create-llm.ts) - Add writer agent injectThinking + stripThinkingBlocks support - Frontend: Brain pulse animation, real-time timer, tool summary on collapse - Frontend: Markdown rendering for reasoning source, plain text for prompt source - Frontend: Timeline layout with connector dots for multi-tool calls (ToolCallStatus) - Fix main.tsx isStreaming passthrough for thinking messages Co-Authored-By: Claude Opus 4.6 (1M context) --- langgraph/src/agent/coder/nodes/agent.ts | 28 +++- langgraph/src/agent/enterprise/nodes/agent.ts | 28 +++- langgraph/src/agent/searcher/nodes/agent.ts | 28 +++- langgraph/src/agent/utils/create-llm.ts | 119 +++++++++++++++++ langgraph/src/agent/utils/inject-thinking.ts | 13 +- langgraph/src/agent/writer/nodes/agent.ts | 17 ++- langgraph/src/components/MessageBubble.tsx | 126 ++++++++++++++++-- langgraph/src/components/ToolCallStatus.tsx | 46 ++++--- langgraph/src/main.tsx | 2 +- 9 files changed, 356 insertions(+), 51 deletions(-) diff --git a/langgraph/src/agent/coder/nodes/agent.ts b/langgraph/src/agent/coder/nodes/agent.ts index 61fb36a..517befb 100644 --- a/langgraph/src/agent/coder/nodes/agent.ts +++ b/langgraph/src/agent/coder/nodes/agent.ts @@ -52,11 +52,17 @@ const SYSTEM_PROMPT = `你是代码执行助手。通过编写和运行代码来 4. 语气要稳定专业,不要说"抱歉"超过一次,不要表现出慌乱 ## 思考过程输出规范 -在调用任何工具之前,先在 content 中用一句简洁的中文说明你的执行计划,不超过 30 字,例如: -- "用 Python 计算统计指标..." -- "先安装 pandas 依赖..." -- "执行代码分析数据..." -这句话必须出现在 tool_calls 之前的 content 字段中。`; +在调用任何工具之前,先在 content 中输出你的执行思路(80-200字),结构如下: +1. **任务理解**:需要完成什么计算/处理(一句话) +2. **代码方案**:使用什么语言、什么库、核心逻辑 +3. **预期输出**:代码运行后期望得到什么结果 + +示例: +"1. 用户需要计算一组数据的统计指标 +2. 用Python + pandas读取数据,计算均值、中位数、标准差 +3. 预期输出一个统计汇总表格" + +这段分析必须出现在 tool_calls 之前的 content 字段中。`; export const codeExecuteSchema = z.object({ code: z.string().describe("The code to execute"), @@ -149,7 +155,17 @@ export async function agentNode( const contentBlocks: Array> = []; if (result.reasoning) { - contentBlocks.push({ type: "thinking", thinking: result.reasoning }); + contentBlocks.push({ + type: "thinking", + thinking: result.reasoning, + source: "reasoning", + meta: { + toolCount: result.toolCalls.length, + toolNames: result.toolCalls.map((tc) => tc.name), + effort: "high", + timestamp: Date.now(), + }, + }); } contentBlocks.push({ type: "text", text: result.content || "" }); diff --git a/langgraph/src/agent/enterprise/nodes/agent.ts b/langgraph/src/agent/enterprise/nodes/agent.ts index 5ae0d6b..a5af2fd 100644 --- a/langgraph/src/agent/enterprise/nodes/agent.ts +++ b/langgraph/src/agent/enterprise/nodes/agent.ts @@ -119,11 +119,17 @@ const SYSTEM_PROMPT = `你是企业内部助手,帮助用户查询知识库和 - 如果需要展示其他维度的图表,主动调用 chart_generate 工具 ## 思考过程输出规范 -在调用任何工具之前,先在 content 中用一句简洁的中文说明你的判断和计划,不超过 30 字,例如: -- "正在知识库中搜索相关内容..." -- "查询最近工单列表,按时间排序..." -- "需要先获取工单详情再分析..." -这句话必须出现在 tool_calls 之前的 content 字段中,让用户知道你正在做什么。`; +在调用任何工具之前,先在 content 中输出你的判断思路(80-200字),结构如下: +1. **问题分析**:用户的核心诉求是什么(一句话) +2. **查询计划**:选择哪个工具、查询什么参数(工具名 + 参数说明) +3. **数据预期**:预期能找到什么、如果找不到怎么办 + +示例: +"1. 用户想查看最近一周未处理的工单 +2. 调用ticket_list查询,按创建时间倒序,筛选状态为open +3. 预期返回工单列表,若无结果建议调整时间范围或检查筛选条件" + +这段分析必须出现在 tool_calls 之前的 content 字段中。多轮操作时,每轮都要输出新的分析。`; export async function agentNode( state: EnterpriseState, @@ -191,7 +197,17 @@ export async function agentNode( const contentBlocks: Array> = []; if (result.reasoning) { - contentBlocks.push({ type: "thinking", thinking: result.reasoning }); + contentBlocks.push({ + type: "thinking", + thinking: result.reasoning, + source: "reasoning", + meta: { + toolCount: result.toolCalls.length, + toolNames: result.toolCalls.map((tc) => tc.name), + effort: "high", + timestamp: Date.now(), + }, + }); } contentBlocks.push({ type: "text", text: result.content || "" }); diff --git a/langgraph/src/agent/searcher/nodes/agent.ts b/langgraph/src/agent/searcher/nodes/agent.ts index 7c3d29f..3a149ea 100644 --- a/langgraph/src/agent/searcher/nodes/agent.ts +++ b/langgraph/src/agent/searcher/nodes/agent.ts @@ -48,11 +48,17 @@ const SYSTEM_PROMPT = `你是深度搜索助手。通过多步搜索为用户找 4. 语气要稳定专业,不要说"抱歉"超过一次,不要表现出慌乱 ## 思考过程输出规范 -在调用任何工具之前,先在 content 中用一句简洁的中文说明你的搜索计划,不超过 30 字,例如: -- "搜索关键词:AI大模型最新进展..." -- "需要深度搜索以获取完整分析..." -- "读取页面获取详细内容..." -这句话必须出现在 tool_calls 之前的 content 字段中。`; +在调用任何工具之前,先在 content 中输出你的分析思路(80-200字),结构如下: +1. **问题理解**:用户真正想知道什么(一句话) +2. **搜索策略**:选择哪个工具、为什么(工具名 + 理由) +3. **预期结果**:期望找到什么类型的信息 + +示例: +"1. 用户想了解2025年AI大模型发展趋势 +2. 先用google_search搜索'2025 AI大模型趋势'获取概览,因为这是时效性问题 +3. 预期找到行业报告、技术博客等权威来源" + +这段分析必须出现在 tool_calls 之前的 content 字段中。多轮搜索时,每轮都要输出新的分析。`; export const googleSearchSchema = z.object({ query: z.string().describe("The Google search query"), @@ -154,7 +160,17 @@ export async function agentNode( // Build content blocks: thinking (if any) + text (if any) const contentBlocks: Array> = []; if (result.reasoning) { - contentBlocks.push({ type: "thinking", thinking: result.reasoning }); + contentBlocks.push({ + type: "thinking", + thinking: result.reasoning, + source: "reasoning", + meta: { + toolCount: result.toolCalls.length, + toolNames: result.toolCalls.map((tc) => tc.name), + effort: "high", + timestamp: Date.now(), + }, + }); } contentBlocks.push({ type: "text", text: result.content || "" }); diff --git a/langgraph/src/agent/utils/create-llm.ts b/langgraph/src/agent/utils/create-llm.ts index 3768c1e..ad3e8ad 100644 --- a/langgraph/src/agent/utils/create-llm.ts +++ b/langgraph/src/agent/utils/create-llm.ts @@ -219,3 +219,122 @@ export async function invokeWithReasoning(options: { responseId: data.id ?? "", }; } + +/** + * Streaming version of invokeWithReasoning. + * Yields reasoning tokens in real-time via SSE, then returns the complete result. + * Uses Azure OpenAI Responses API with stream: true. + */ +export async function* invokeWithReasoningStream(options: { + messages: Array>; + tools?: readonly AgentToolDef[]; + reasoningEffort?: "low" | "medium" | "high"; + maxOutputTokens?: number; +}): AsyncGenerator< + | { type: "reasoning_delta"; text: string } + | { type: "content_delta"; text: string } + | { type: "done"; result: ReasoningResult }, + void, + undefined +> { + const url = `${config.azureOpenAI.endpoint}/openai/v1/responses`; + + const body: Record = { + model: config.azureOpenAI.deployment, + input: options.messages, + stream: true, + max_output_tokens: options.maxOutputTokens ?? 8192, + reasoning: { + effort: options.reasoningEffort ?? "medium", + summary: "detailed", + }, + }; + + if (options.tools && options.tools.length > 0) { + body.tools = toResponsesTools(options.tools); + } + + const resp = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "api-key": config.azureOpenAI.apiKey, + }, + body: JSON.stringify(body), + }); + + if (!resp.ok) { + const errorBody = await resp.text(); + throw new Error( + `[invokeWithReasoningStream] Responses API returned ${resp.status}: ${errorBody}`, + ); + } + + // Parse SSE stream + const reader = resp.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + let reasoning = ""; + let content = ""; + const toolCalls: ReasoningResult["toolCalls"] = []; + let responseId = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6).trim(); + if (data === "[DONE]") continue; + + try { + const event = JSON.parse(data); + + if (event.id) responseId = event.id; + + // Reasoning summary delta + if (event.type === "response.reasoning_summary_text.delta") { + const delta = (event.delta as string) ?? ""; + reasoning += delta; + yield { type: "reasoning_delta", text: delta }; + } + + // Content text delta + if (event.type === "response.output_text.delta") { + const delta = (event.delta as string) ?? ""; + content += delta; + yield { type: "content_delta", text: delta }; + } + + // Function call complete + if (event.type === "response.function_call_arguments.done") { + let parsedArgs: Record = {}; + try { + parsedArgs = JSON.parse((event.arguments as string) ?? "{}"); + } catch { + /* skip malformed */ + } + toolCalls.push({ + name: event.name as string, + args: parsedArgs, + id: (event.call_id as string) ?? `call_${Date.now()}`, + type: "tool_call", + }); + } + } catch { + /* skip malformed JSON lines */ + } + } + } + + yield { + type: "done", + result: { reasoning, content, toolCalls, responseId }, + }; +} diff --git a/langgraph/src/agent/utils/inject-thinking.ts b/langgraph/src/agent/utils/inject-thinking.ts index 87c029f..a337175 100644 --- a/langgraph/src/agent/utils/inject-thinking.ts +++ b/langgraph/src/agent/utils/inject-thinking.ts @@ -35,9 +35,18 @@ export function injectThinking(message: AIMessage): AIMessage { if (!textContent) return message; - // Build a content array: thinking block + empty text block (required by some serializers) + // Build a content array: thinking block with metadata + empty text block (required by some serializers) const newContent: Array> = [ - { type: "thinking", thinking: textContent }, + { + type: "thinking", + thinking: textContent, + source: "prompt", + meta: { + toolCount: message.tool_calls!.length, + toolNames: message.tool_calls!.map((tc) => tc.name), + timestamp: Date.now(), + }, + }, { type: "text", text: "" }, ]; diff --git a/langgraph/src/agent/writer/nodes/agent.ts b/langgraph/src/agent/writer/nodes/agent.ts index fba75e9..93d7a7c 100644 --- a/langgraph/src/agent/writer/nodes/agent.ts +++ b/langgraph/src/agent/writer/nodes/agent.ts @@ -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 { WriterState, WriterUpdate } from "../types.js"; import { ALL_WRITER_TOOLS } from "./tool-defs.js"; @@ -54,7 +55,15 @@ const SYSTEM_PROMPT = `你是文档编辑助手。通过工具在 Canvas 侧面 使用 reply_draft 工具时: - customer 模式:语气专业礼貌,开门见山说明处理结果,避免技术术语,结尾提供联系方式或后续步骤 - internal 模式:结构化格式(问题/处理过程/结论/后续),简洁精准,包含关键数据和时间节点 -- key_points 要具体可操作,不要空泛(如"已处理"→"已于2026-04-11 14:00重启服务,恢复正常")`; +- key_points 要具体可操作,不要空泛(如"已处理"→"已于2026-04-11 14:00重启服务,恢复正常") + +## 思考过程输出规范 +在调用任何工具之前,先在 content 中输出你的创作思路(80-200字),结构如下: +1. **内容理解**:用户需要什么类型的文档(一句话) +2. **结构规划**:文档的大纲框架 +3. **风格定位**:正式/专业/友好,目标读者是谁 + +这段分析必须出现在 tool_calls 之前的 content 字段中。`; export async function writerAgentNode( state: WriterState, @@ -66,8 +75,8 @@ export async function writerAgentNode( const llm = createLlm({ modelMode, maxTokens: 8192 }); 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, @@ -77,5 +86,5 @@ export async function writerAgentNode( .bindTools([...ALL_WRITER_TOOLS], { parallel_tool_calls: false }) .invoke(messagesWithSystem); - return { messages: [message], timestamp: Date.now() }; + return { messages: [injectThinking(message)], timestamp: Date.now() }; } diff --git a/langgraph/src/components/MessageBubble.tsx b/langgraph/src/components/MessageBubble.tsx index 78fcb4b..525aeac 100644 --- a/langgraph/src/components/MessageBubble.tsx +++ b/langgraph/src/components/MessageBubble.tsx @@ -97,10 +97,42 @@ function renderWithSourceBadges(text: string): ReactNode[] { } type ContentBlock = - | { type: "thinking"; thinking: string } + | { type: "thinking"; thinking: string; source?: "prompt" | "reasoning"; meta?: ThinkingMeta } | { type: "text"; text: string } | { type: string; [key: string]: unknown }; +interface ThinkingMeta { + toolCount?: number; + toolNames?: string[]; + effort?: string; + timestamp?: number; +} + +/** Map tool names to Chinese labels for thinking summaries */ +const TOOL_CN: Record = { + kb_search: "知识库", + ticket_list: "工单列表", + ticket_detail: "工单详情", + google_search: "Google搜索", + web_search_deep: "深度搜索", + web_read: "网页阅读", + code_execute: "代码执行", + doc_create: "文档创建", + doc_edit: "文档编辑", + doc_translate: "文档翻译", + report_generate: "报告生成", + reply_draft: "回复草稿", + chart_generate: "图表生成", +}; + +function buildToolSummary(meta?: ThinkingMeta): string { + if (!meta?.toolNames?.length) return ""; + const labels = meta.toolNames.map((n) => TOOL_CN[n] ?? n); + // Deduplicate while preserving order + const unique = [...new Set(labels)]; + return unique.join(" → "); +} + interface MessageBubbleProps { content: string | ContentBlock[]; role: "human" | "ai"; @@ -225,25 +257,27 @@ export default function MessageBubble({ content: rawContent, isStreaming }: Mess const [thinkingExpanded, setThinkingExpanded] = useState(() => !!isStreaming); const prevStreamingRef = useRef(isStreaming); useEffect(() => { - // isStreaming 从 true 变为 false:流式结束,自动折叠 if (prevStreamingRef.current && !isStreaming) { setThinkingExpanded(false); } - // isStreaming 从 false/undefined 变为 true:新的流式开始,展开 if (!prevStreamingRef.current && isStreaming) { setThinkingExpanded(true); } prevStreamingRef.current = isStreaming; }, [isStreaming]); - // 解析 content:数组格式提取 thinking 和 text,字符串格式直接使用 + // 解析 content:数组格式提取 thinking(含 source/meta)和 text let thinkingContent = ""; + let thinkingSource: "prompt" | "reasoning" | undefined; + let thinkingMeta: ThinkingMeta | undefined; let content = ""; if (Array.isArray(rawContent)) { for (const block of rawContent) { if (block.type === "thinking" && "thinking" in block && block.thinking) { thinkingContent += block.thinking as string; + if ("source" in block) thinkingSource = block.source as "prompt" | "reasoning"; + if ("meta" in block) thinkingMeta = block.meta as ThinkingMeta; } else if (block.type === "text" && "text" in block && block.text) { content += block.text as string; } @@ -252,6 +286,27 @@ export default function MessageBubble({ content: rawContent, isStreaming }: Mess content = String(rawContent ?? ""); } + // 思考计时器 + const [thinkingDuration, setThinkingDuration] = useState(0); + const thinkingTimerRef = useRef>(); + const thinkingStartRef = useRef(0); + + useEffect(() => { + if (isStreaming && thinkingContent) { + if (!thinkingStartRef.current) { + thinkingStartRef.current = thinkingMeta?.timestamp ?? Date.now(); + } + thinkingTimerRef.current = setInterval(() => { + setThinkingDuration((Date.now() - thinkingStartRef.current) / 1000); + }, 100); + } else if (!isStreaming && thinkingTimerRef.current) { + clearInterval(thinkingTimerRef.current); + } + return () => { if (thinkingTimerRef.current) clearInterval(thinkingTimerRef.current); }; + }, [isStreaming, !!thinkingContent]); // eslint-disable-line react-hooks/exhaustive-deps + + const toolSummary = buildToolSummary(thinkingMeta); + const contentLines = content.split("\n").length; const isLong = contentLines > 25 || content.length > 800; const summary = isLong ? extractSummary(content) : null; @@ -273,19 +328,68 @@ export default function MessageBubble({ content: rawContent, isStreaming }: Mess {(!isLong || summaryExpanded) && ( <> {thinkingContent && ( -
+
+ {/* Header: Brain icon + title + timer + tool summary + expand toggle */} + + {/* Content area */} {thinkingExpanded && ( -
- {thinkingContent} +
+ {thinkingSource === "reasoning" ? ( + + {children}

; }, + strong({ children }) { return {children}; }, + ul({ children }) { return
    {children}
; }, + ol({ children }) { return
    {children}
; }, + }}> + {thinkingContent} +
+
+ ) : ( +
{thinkingContent}
+ )}
)}
diff --git a/langgraph/src/components/ToolCallStatus.tsx b/langgraph/src/components/ToolCallStatus.tsx index 1b47e27..24e21fa 100644 --- a/langgraph/src/components/ToolCallStatus.tsx +++ b/langgraph/src/components/ToolCallStatus.tsx @@ -312,10 +312,10 @@ export default function ToolCallStatus({ } return ( -
+
{/* Global collapse/expand button — only shown when >= 2 tool calls */} {showGlobalToggle && ( -
+