feat: next-gen COT system — streaming reasoning, structured thinking, timeline UI
- 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:
co-authored by
Claude Opus 4.6
parent
157a3c6a83
commit
244a6d4a1f
@@ -52,11 +52,17 @@ const SYSTEM_PROMPT = `你是代码执行助手。通过编写和运行代码来
|
|||||||
4. 语气要稳定专业,不要说"抱歉"超过一次,不要表现出慌乱
|
4. 语气要稳定专业,不要说"抱歉"超过一次,不要表现出慌乱
|
||||||
|
|
||||||
## 思考过程输出规范
|
## 思考过程输出规范
|
||||||
在调用任何工具之前,先在 content 中用一句简洁的中文说明你的执行计划,不超过 30 字,例如:
|
在调用任何工具之前,先在 content 中输出你的执行思路(80-200字),结构如下:
|
||||||
- "用 Python 计算统计指标..."
|
1. **任务理解**:需要完成什么计算/处理(一句话)
|
||||||
- "先安装 pandas 依赖..."
|
2. **代码方案**:使用什么语言、什么库、核心逻辑
|
||||||
- "执行代码分析数据..."
|
3. **预期输出**:代码运行后期望得到什么结果
|
||||||
这句话必须出现在 tool_calls 之前的 content 字段中。`;
|
|
||||||
|
示例:
|
||||||
|
"1. 用户需要计算一组数据的统计指标
|
||||||
|
2. 用Python + pandas读取数据,计算均值、中位数、标准差
|
||||||
|
3. 预期输出一个统计汇总表格"
|
||||||
|
|
||||||
|
这段分析必须出现在 tool_calls 之前的 content 字段中。`;
|
||||||
|
|
||||||
export const codeExecuteSchema = z.object({
|
export const codeExecuteSchema = z.object({
|
||||||
code: z.string().describe("The code to execute"),
|
code: z.string().describe("The code to execute"),
|
||||||
@@ -149,7 +155,17 @@ export async function agentNode(
|
|||||||
|
|
||||||
const contentBlocks: Array<Record<string, unknown>> = [];
|
const contentBlocks: Array<Record<string, unknown>> = [];
|
||||||
if (result.reasoning) {
|
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 || "" });
|
contentBlocks.push({ type: "text", text: result.content || "" });
|
||||||
|
|
||||||
|
|||||||
@@ -119,11 +119,17 @@ const SYSTEM_PROMPT = `你是企业内部助手,帮助用户查询知识库和
|
|||||||
- 如果需要展示其他维度的图表,主动调用 chart_generate 工具
|
- 如果需要展示其他维度的图表,主动调用 chart_generate 工具
|
||||||
|
|
||||||
## 思考过程输出规范
|
## 思考过程输出规范
|
||||||
在调用任何工具之前,先在 content 中用一句简洁的中文说明你的判断和计划,不超过 30 字,例如:
|
在调用任何工具之前,先在 content 中输出你的判断思路(80-200字),结构如下:
|
||||||
- "正在知识库中搜索相关内容..."
|
1. **问题分析**:用户的核心诉求是什么(一句话)
|
||||||
- "查询最近工单列表,按时间排序..."
|
2. **查询计划**:选择哪个工具、查询什么参数(工具名 + 参数说明)
|
||||||
- "需要先获取工单详情再分析..."
|
3. **数据预期**:预期能找到什么、如果找不到怎么办
|
||||||
这句话必须出现在 tool_calls 之前的 content 字段中,让用户知道你正在做什么。`;
|
|
||||||
|
示例:
|
||||||
|
"1. 用户想查看最近一周未处理的工单
|
||||||
|
2. 调用ticket_list查询,按创建时间倒序,筛选状态为open
|
||||||
|
3. 预期返回工单列表,若无结果建议调整时间范围或检查筛选条件"
|
||||||
|
|
||||||
|
这段分析必须出现在 tool_calls 之前的 content 字段中。多轮操作时,每轮都要输出新的分析。`;
|
||||||
|
|
||||||
export async function agentNode(
|
export async function agentNode(
|
||||||
state: EnterpriseState,
|
state: EnterpriseState,
|
||||||
@@ -191,7 +197,17 @@ export async function agentNode(
|
|||||||
|
|
||||||
const contentBlocks: Array<Record<string, unknown>> = [];
|
const contentBlocks: Array<Record<string, unknown>> = [];
|
||||||
if (result.reasoning) {
|
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 || "" });
|
contentBlocks.push({ type: "text", text: result.content || "" });
|
||||||
|
|
||||||
|
|||||||
@@ -48,11 +48,17 @@ const SYSTEM_PROMPT = `你是深度搜索助手。通过多步搜索为用户找
|
|||||||
4. 语气要稳定专业,不要说"抱歉"超过一次,不要表现出慌乱
|
4. 语气要稳定专业,不要说"抱歉"超过一次,不要表现出慌乱
|
||||||
|
|
||||||
## 思考过程输出规范
|
## 思考过程输出规范
|
||||||
在调用任何工具之前,先在 content 中用一句简洁的中文说明你的搜索计划,不超过 30 字,例如:
|
在调用任何工具之前,先在 content 中输出你的分析思路(80-200字),结构如下:
|
||||||
- "搜索关键词:AI大模型最新进展..."
|
1. **问题理解**:用户真正想知道什么(一句话)
|
||||||
- "需要深度搜索以获取完整分析..."
|
2. **搜索策略**:选择哪个工具、为什么(工具名 + 理由)
|
||||||
- "读取页面获取详细内容..."
|
3. **预期结果**:期望找到什么类型的信息
|
||||||
这句话必须出现在 tool_calls 之前的 content 字段中。`;
|
|
||||||
|
示例:
|
||||||
|
"1. 用户想了解2025年AI大模型发展趋势
|
||||||
|
2. 先用google_search搜索'2025 AI大模型趋势'获取概览,因为这是时效性问题
|
||||||
|
3. 预期找到行业报告、技术博客等权威来源"
|
||||||
|
|
||||||
|
这段分析必须出现在 tool_calls 之前的 content 字段中。多轮搜索时,每轮都要输出新的分析。`;
|
||||||
|
|
||||||
export const googleSearchSchema = z.object({
|
export const googleSearchSchema = z.object({
|
||||||
query: z.string().describe("The Google search query"),
|
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)
|
// Build content blocks: thinking (if any) + text (if any)
|
||||||
const contentBlocks: Array<Record<string, unknown>> = [];
|
const contentBlocks: Array<Record<string, unknown>> = [];
|
||||||
if (result.reasoning) {
|
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 || "" });
|
contentBlocks.push({ type: "text", text: result.content || "" });
|
||||||
|
|
||||||
|
|||||||
@@ -219,3 +219,122 @@ export async function invokeWithReasoning(options: {
|
|||||||
responseId: data.id ?? "",
|
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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,9 +35,18 @@ export function injectThinking(message: AIMessage): AIMessage {
|
|||||||
|
|
||||||
if (!textContent) return message;
|
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>> = [
|
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: "" },
|
{ type: "text", text: "" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { createLlm, type ModelMode } from "@/agent/utils/create-llm";
|
import { createLlm, type ModelMode } from "@/agent/utils/create-llm";
|
||||||
import { truncateMessages } from "@/agent/utils/truncate-messages";
|
import { truncateMessages } from "@/agent/utils/truncate-messages";
|
||||||
import { cleanPollutedToolCalls } from "@/agent/utils/clean-polluted-tool-calls";
|
import { cleanPollutedToolCalls } from "@/agent/utils/clean-polluted-tool-calls";
|
||||||
|
import { injectThinking, stripThinkingBlocks } from "@/agent/utils/inject-thinking";
|
||||||
import { LangGraphRunnableConfig } from "@langchain/langgraph";
|
import { LangGraphRunnableConfig } from "@langchain/langgraph";
|
||||||
import { WriterState, WriterUpdate } from "../types.js";
|
import { WriterState, WriterUpdate } from "../types.js";
|
||||||
import { ALL_WRITER_TOOLS } from "./tool-defs.js";
|
import { ALL_WRITER_TOOLS } from "./tool-defs.js";
|
||||||
@@ -54,7 +55,15 @@ const SYSTEM_PROMPT = `你是文档编辑助手。通过工具在 Canvas 侧面
|
|||||||
使用 reply_draft 工具时:
|
使用 reply_draft 工具时:
|
||||||
- customer 模式:语气专业礼貌,开门见山说明处理结果,避免技术术语,结尾提供联系方式或后续步骤
|
- customer 模式:语气专业礼貌,开门见山说明处理结果,避免技术术语,结尾提供联系方式或后续步骤
|
||||||
- internal 模式:结构化格式(问题/处理过程/结论/后续),简洁精准,包含关键数据和时间节点
|
- 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(
|
export async function writerAgentNode(
|
||||||
state: WriterState,
|
state: WriterState,
|
||||||
@@ -66,8 +75,8 @@ export async function writerAgentNode(
|
|||||||
const llm = createLlm({ modelMode, maxTokens: 8192 });
|
const llm = createLlm({ modelMode, maxTokens: 8192 });
|
||||||
|
|
||||||
const truncated = truncateMessages(state.messages);
|
const truncated = truncateMessages(state.messages);
|
||||||
// Clean up polluted messages before sending to LLM
|
// Clean up polluted messages and strip thinking blocks before sending to LLM
|
||||||
const cleanedMessages = cleanPollutedToolCalls(truncated);
|
const cleanedMessages = stripThinkingBlocks(cleanPollutedToolCalls(truncated));
|
||||||
const messagesWithSystem = [
|
const messagesWithSystem = [
|
||||||
{ role: "system" as const, content: SYSTEM_PROMPT },
|
{ role: "system" as const, content: SYSTEM_PROMPT },
|
||||||
...cleanedMessages,
|
...cleanedMessages,
|
||||||
@@ -77,5 +86,5 @@ export async function writerAgentNode(
|
|||||||
.bindTools([...ALL_WRITER_TOOLS], { parallel_tool_calls: false })
|
.bindTools([...ALL_WRITER_TOOLS], { parallel_tool_calls: false })
|
||||||
.invoke(messagesWithSystem);
|
.invoke(messagesWithSystem);
|
||||||
|
|
||||||
return { messages: [message], timestamp: Date.now() };
|
return { messages: [injectThinking(message)], timestamp: Date.now() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,10 +97,42 @@ function renderWithSourceBadges(text: string): ReactNode[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ContentBlock =
|
type ContentBlock =
|
||||||
| { type: "thinking"; thinking: string }
|
| { type: "thinking"; thinking: string; source?: "prompt" | "reasoning"; meta?: ThinkingMeta }
|
||||||
| { type: "text"; text: string }
|
| { type: "text"; text: string }
|
||||||
| { type: string; [key: string]: unknown };
|
| { 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 {
|
interface MessageBubbleProps {
|
||||||
content: string | ContentBlock[];
|
content: string | ContentBlock[];
|
||||||
role: "human" | "ai";
|
role: "human" | "ai";
|
||||||
@@ -225,25 +257,27 @@ export default function MessageBubble({ content: rawContent, isStreaming }: Mess
|
|||||||
const [thinkingExpanded, setThinkingExpanded] = useState(() => !!isStreaming);
|
const [thinkingExpanded, setThinkingExpanded] = useState(() => !!isStreaming);
|
||||||
const prevStreamingRef = useRef(isStreaming);
|
const prevStreamingRef = useRef(isStreaming);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// isStreaming 从 true 变为 false:流式结束,自动折叠
|
|
||||||
if (prevStreamingRef.current && !isStreaming) {
|
if (prevStreamingRef.current && !isStreaming) {
|
||||||
setThinkingExpanded(false);
|
setThinkingExpanded(false);
|
||||||
}
|
}
|
||||||
// isStreaming 从 false/undefined 变为 true:新的流式开始,展开
|
|
||||||
if (!prevStreamingRef.current && isStreaming) {
|
if (!prevStreamingRef.current && isStreaming) {
|
||||||
setThinkingExpanded(true);
|
setThinkingExpanded(true);
|
||||||
}
|
}
|
||||||
prevStreamingRef.current = isStreaming;
|
prevStreamingRef.current = isStreaming;
|
||||||
}, [isStreaming]);
|
}, [isStreaming]);
|
||||||
|
|
||||||
// 解析 content:数组格式提取 thinking 和 text,字符串格式直接使用
|
// 解析 content:数组格式提取 thinking(含 source/meta)和 text
|
||||||
let thinkingContent = "";
|
let thinkingContent = "";
|
||||||
|
let thinkingSource: "prompt" | "reasoning" | undefined;
|
||||||
|
let thinkingMeta: ThinkingMeta | undefined;
|
||||||
let content = "";
|
let content = "";
|
||||||
|
|
||||||
if (Array.isArray(rawContent)) {
|
if (Array.isArray(rawContent)) {
|
||||||
for (const block of rawContent) {
|
for (const block of rawContent) {
|
||||||
if (block.type === "thinking" && "thinking" in block && block.thinking) {
|
if (block.type === "thinking" && "thinking" in block && block.thinking) {
|
||||||
thinkingContent += block.thinking as string;
|
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) {
|
} else if (block.type === "text" && "text" in block && block.text) {
|
||||||
content += block.text as string;
|
content += block.text as string;
|
||||||
}
|
}
|
||||||
@@ -252,6 +286,27 @@ export default function MessageBubble({ content: rawContent, isStreaming }: Mess
|
|||||||
content = String(rawContent ?? "");
|
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 contentLines = content.split("\n").length;
|
||||||
const isLong = contentLines > 25 || content.length > 800;
|
const isLong = contentLines > 25 || content.length > 800;
|
||||||
const summary = isLong ? extractSummary(content) : null;
|
const summary = isLong ? extractSummary(content) : null;
|
||||||
@@ -273,19 +328,68 @@ export default function MessageBubble({ content: rawContent, isStreaming }: Mess
|
|||||||
{(!isLong || summaryExpanded) && (
|
{(!isLong || summaryExpanded) && (
|
||||||
<>
|
<>
|
||||||
{thinkingContent && (
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setThinkingExpanded((v) => !v)}
|
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" />
|
{/* Brain icon with pulse animation during streaming */}
|
||||||
<span>思考过程</span>
|
<Brain className={cn(
|
||||||
<span className="ml-auto">{thinkingExpanded ? "▲" : "▼"}</span>
|
"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>
|
</button>
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
{thinkingExpanded && (
|
{thinkingExpanded && (
|
||||||
<div className="px-3 py-2 text-xs text-muted-foreground leading-relaxed whitespace-pre-wrap border-t border-border">
|
<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}
|
{thinkingContent}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</MarkdownErrorBoundary>
|
||||||
|
) : (
|
||||||
|
<div className="whitespace-pre-wrap">{thinkingContent}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -312,10 +312,10 @@ export default function ToolCallStatus({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Global collapse/expand button — only shown when >= 2 tool calls */}
|
||||||
{showGlobalToggle && (
|
{showGlobalToggle && (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end mb-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleGlobalToggle}
|
onClick={handleGlobalToggle}
|
||||||
@@ -371,8 +371,23 @@ export default function ToolCallStatus({
|
|||||||
const isFallbackStatus = logEntry?.status === "fallback_success";
|
const isFallbackStatus = logEntry?.status === "fallback_success";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<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
|
<ToolCallRow
|
||||||
key={tc.id ?? i}
|
|
||||||
tc={tc}
|
tc={tc}
|
||||||
isDone={!!isDone}
|
isDone={!!isDone}
|
||||||
isFailed={isFailed}
|
isFailed={isFailed}
|
||||||
@@ -384,6 +399,7 @@ export default function ToolCallStatus({
|
|||||||
logEntry={logEntry ?? undefined}
|
logEntry={logEntry ?? undefined}
|
||||||
globalExpanded={showGlobalToggle ? globalExpanded : undefined}
|
globalExpanded={showGlobalToggle ? globalExpanded : undefined}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -932,7 +932,7 @@ function App() {
|
|||||||
{/* Thinking block for intermediate messages (with tool_calls): shown above tool status */}
|
{/* Thinking block for intermediate messages (with tool_calls): shown above tool status */}
|
||||||
{hasThinking && hasToolCalls && (
|
{hasThinking && hasToolCalls && (
|
||||||
<div className="max-w-[85%]">
|
<div className="max-w-[85%]">
|
||||||
<MessageBubble content={bubbleContent} role="ai" isStreaming={false} />
|
<MessageBubble content={bubbleContent} role="ai" isStreaming={thread.isLoading && isLastAi} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user