feat: next-gen COT system — streaming reasoning, structured thinking, timeline UI
Trigger auto deployment for soc-langgraph / build-and-deploy (push) Failing after 20s
Deploy LangGraph UI to Azure Static Web Apps / build-and-deploy (push) Failing after 39s

- 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) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-14 00:20:20 +08:00
co-authored by Claude Opus 4.6
parent 157a3c6a83
commit 244a6d4a1f
9 changed files with 356 additions and 51 deletions
+22 -6
View File
@@ -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<Record<string, unknown>> = [];
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 || "" });
+22 -6
View File
@@ -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<Record<string, unknown>> = [];
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 || "" });
+22 -6
View File
@@ -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<Record<string, unknown>> = [];
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 || "" });
+119
View File
@@ -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<Record<string, unknown>>;
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<string, unknown> = {
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<string, unknown> = {};
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 },
};
}
+11 -2
View File
@@ -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<Record<string, unknown>> = [
{ 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: "" },
];
+13 -4
View File
@@ -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() };
}
+115 -11
View File
@@ -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<string, string> = {
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<ReturnType<typeof setInterval>>();
const thinkingStartRef = useRef<number>(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 && (
<div className="mb-2 rounded-lg bg-muted/50 border border-border">
<div className="mb-2 rounded-lg bg-muted/50 border border-border overflow-hidden">
{/* Header: Brain icon + title + timer + tool summary + expand toggle */}
<button
type="button"
onClick={() => setThinkingExpanded((v) => !v)}
className="w-full flex items-center gap-1 px-3 py-1.5 text-xs text-muted-foreground cursor-pointer select-none"
className="w-full flex items-center gap-2 px-3 py-2 text-xs cursor-pointer select-none hover:bg-muted/80 transition-colors"
>
<Brain className="w-3 h-3 shrink-0" />
<span>思考过程</span>
<span className="ml-auto">{thinkingExpanded ? "▲" : "▼"}</span>
{/* Brain icon with pulse animation during streaming */}
<Brain className={cn(
"w-3.5 h-3.5 shrink-0",
isStreaming && thinkingContent ? "text-primary animate-pulse" : "text-muted-foreground"
)} />
{/* Title: 深度推理 for reasoning API, 思考过程 for prompt thinking */}
<span className={cn(
"font-medium",
thinkingSource === "reasoning" ? "text-primary" : "text-muted-foreground"
)}>
{thinkingSource === "reasoning" ? "深度推理" : "思考过程"}
</span>
{/* Timer badge — ChatGPT o3 style */}
{(thinkingDuration > 0 || isStreaming) && (
<span className="text-[10px] text-muted-foreground/70 tabular-nums">
{isStreaming
? `思考中 ${thinkingDuration.toFixed(1)}s`
: `${thinkingDuration.toFixed(1)}s`}
</span>
)}
{/* Collapsed tool summary */}
{!thinkingExpanded && toolSummary && (
<span className="text-[10px] text-muted-foreground/60 truncate max-w-[200px]">
· {toolSummary}
</span>
)}
<span className="ml-auto text-muted-foreground/50">{thinkingExpanded ? "▲" : "▼"}</span>
</button>
{/* Content area */}
{thinkingExpanded && (
<div className="px-3 py-2 text-xs text-muted-foreground leading-relaxed whitespace-pre-wrap border-t border-border">
{thinkingContent}
<div className={cn(
"px-3 py-2 text-xs leading-relaxed border-t border-border",
thinkingSource === "reasoning"
? "text-foreground/80"
: "text-muted-foreground"
)}>
{thinkingSource === "reasoning" ? (
<MarkdownErrorBoundary fallback={thinkingContent}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{
p({ children }) { return <p className="my-1 leading-relaxed">{children}</p>; },
strong({ children }) { return <strong className="font-semibold">{children}</strong>; },
ul({ children }) { return <ul className="list-disc list-inside space-y-0.5 my-1">{children}</ul>; },
ol({ children }) { return <ol className="list-decimal list-inside space-y-0.5 my-1">{children}</ol>; },
}}>
{thinkingContent}
</ReactMarkdown>
</MarkdownErrorBoundary>
) : (
<div className="whitespace-pre-wrap">{thinkingContent}</div>
)}
</div>
)}
</div>
+31 -15
View File
@@ -312,10 +312,10 @@ export default function ToolCallStatus({
}
return (
<div className="flex flex-col gap-1.5 mb-1">
<div className={showGlobalToggle ? "flex flex-col mb-1" : "flex flex-col gap-1.5 mb-1"}>
{/* Global collapse/expand button — only shown when >= 2 tool calls */}
{showGlobalToggle && (
<div className="flex justify-end">
<div className="flex justify-end mb-1">
<button
type="button"
onClick={handleGlobalToggle}
@@ -371,19 +371,35 @@ export default function ToolCallStatus({
const isFallbackStatus = logEntry?.status === "fallback_success";
return (
<ToolCallRow
key={tc.id ?? i}
tc={tc}
isDone={!!isDone}
isFailed={isFailed}
isPartial={isPartial}
isFallback={isFallbackStatus}
uiItem={uiItem}
stream={stream}
components={components}
logEntry={logEntry ?? undefined}
globalExpanded={showGlobalToggle ? globalExpanded : undefined}
/>
<div key={tc.id ?? i} className={showGlobalToggle ? "relative pl-4" : ""}>
{/* Timeline connector line for multi-tool calls */}
{showGlobalToggle && (
<>
{/* Vertical line */}
{i < toolCalls.length - 1 && (
<div className="absolute left-[7px] top-5 bottom-0 w-px bg-border" />
)}
{/* Dot node */}
<div className={`absolute left-[3px] top-[6px] w-[9px] h-[9px] rounded-full border-2 ${
isFailed ? "border-red-500 bg-red-100 dark:bg-red-900/30" :
isDone ? "border-green-500 bg-green-100 dark:bg-green-900/30" :
"border-muted-foreground bg-muted animate-pulse"
}`} />
</>
)}
<ToolCallRow
tc={tc}
isDone={!!isDone}
isFailed={isFailed}
isPartial={isPartial}
isFallback={isFallbackStatus}
uiItem={uiItem}
stream={stream}
components={components}
logEntry={logEntry ?? undefined}
globalExpanded={showGlobalToggle ? globalExpanded : undefined}
/>
</div>
);
})}
</div>
+1 -1
View File
@@ -932,7 +932,7 @@ function App() {
{/* Thinking block for intermediate messages (with tool_calls): shown above tool status */}
{hasThinking && hasToolCalls && (
<div className="max-w-[85%]">
<MessageBubble content={bubbleContent} role="ai" isStreaming={false} />
<MessageBubble content={bubbleContent} role="ai" isStreaming={thread.isLoading && isLastAi} />
</div>
)}