前端技术方案:流式对话与工具调用交互
一、整体架构
后端 SSE 事件流实现(已完成)
二、HTTP 接口文档(完整)
1. 接口地址
POST /v1/chat/completions
Content-Type: application/json
2. 请求参数(完整)
| 参数名 |
类型 |
必填 |
说明 |
messages |
Array |
是 |
OpenAI 兼容的消息数组,例如 [{"role": "user", "content": "你好"}] |
stream |
Boolean |
否 |
是否流式输出,默认 false |
is_think |
Boolean |
否 |
是否启用 ReAct 思考模式,默认 false |
thread_id |
String |
否 |
对话 ID,用于保持会话记忆,不传则自动生成 |
agent_id |
String |
否 |
Agent ID,不传则使用默认 Agent |
model |
String |
否 |
模型名称,优先级:接口参数 > 配置文件 > 默认值 |
file_ids |
Array |
否 |
多模态文件 ID 列表(可选) |
3. 四种组合的返回格式
后端会根据 stream 和 is_think 参数自动判断返回格式:
| 组合 |
说明 |
返回格式 |
stream=false + is_think=false |
非流式 + 普通 Agent |
OpenAI 兼容 JSON |
stream=false + is_think=true |
非流式 + ReAct Agent |
OpenAI 兼容 JSON |
stream=true + is_think=false |
流式 + 普通 Agent |
OpenAI 兼容 SSE 流 |
stream=true + is_think=true |
流式 + ReAct Agent |
SSE 事件流(包含 RAG/思考/工具调用事件) ⭐ |
三、SSE 事件格式(stream=true + is_think=true)
1. 事件类型定义
当 stream=true 且 is_think=true 时,后端返回以下事件:
| 事件类型 |
说明 |
数据字段 |
rag_start |
RAG 检索开始 |
- |
rag_retrieve |
RAG 检索到结果 |
{ resultCount: 5 } |
rag_key_info |
提取到 RAG 关键信息 |
{ keyInfo: "{\"key_points\": [...], \"summary\": \"...\"}" } |
rag_end |
RAG 处理完成 |
- |
thinking_start |
开始思考阶段 |
- |
thinking_end |
结束思考阶段 |
- |
tool_call_start |
开始调用工具 |
{ tool: "工具名", args: {...} } |
tool_call_end |
工具调用完成 |
{ tool: "工具名", result: {...} } |
content_chunk |
文本内容块 |
"文本内容" |
done |
对话完成 |
- |
error |
发生错误 |
"错误信息" |
2. SSE 输出格式示例
data: {"type":"rag_start","timestamp":1712456789000}
data: {"type":"rag_retrieve","data":{"resultCount":5},"timestamp":1712456789001}
data: {"type":"rag_key_info","data":{"keyInfo":"{\"key_points\": [...], \"summary\": \"...\"}"},"timestamp":1712456789002}
data: {"type":"rag_end","timestamp":1712456789003}
data: {"type":"thinking_start","timestamp":1712456789004}
data: {"type":"tool_call_start","data":{"tool":"search_web","args":{"query":"今天天气"}},"timestamp":1712456789005}
data: {"type":"tool_call_end","data":{"tool":"search_web","result":{"temperature":"25°C"}},"timestamp":1712456789006}
data: {"type":"content_chunk","data":"今天","timestamp":1712456789007}
data: {"type":"content_chunk","data":"天气","timestamp":1712456789008}
data: {"type":"content_chunk","data":"很好","timestamp":1712456789009}
data: {"type":"done","timestamp":1712456789010}
四、前端实现
技术栈
- 框架: React 18+
- 状态管理: Zustand / React Context
- 流式通信: fetch ReadableStream(比 EventSource 更灵活)
- UI组件: shadcn/ui + Tailwind CSS
五、核心功能设计
1. 打字机效果(流式文本输出)
实现思路
// 使用 fetch + ReadableStream 实现
async function streamChat(messages: ChatMessage[]) {
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages })
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 解析 SSE 格式或直接处理文本
processBuffer(buffer);
}
}
打字机效果 Hook
function useTypewriter(content: string, speed: number = 10) {
const [displayText, setDisplayText] = useState('');
const [index, setIndex] = useState(0);
useEffect(() => {
if (index < content.length) {
const timer = setTimeout(() => {
setDisplayText(prev => prev + content[index]);
setIndex(prev => prev + 1);
}, speed);
return () => clearTimeout(timer);
}
}, [index, content, speed]);
return { displayText, isTyping: index < content.length };
}
2. RAG 检索状态展示
组件结构
┌─────────────────────────────────────┐
│ │
│ 🔍 检索知识库中... │
│ │
│ ┌─────────────────────────────┐ │
│ │ ✨ 找到 5 条相关文档 │ │
│ │ 📝 提取关键信息中... │ │
│ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────┘
状态管理
type RagState = {
isRetrieving: boolean;
resultCount: number | null;
keyInfo: string | null;
};
组件实现
function RagStatus({ ragState }: { ragState: RagState }) {
if (!ragState.isRetrieving && !ragState.resultCount) {
return null;
}
return (
<div className="flex items-center gap-3 p-3 rounded-lg bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800">
{/* RAG 图标 */}
{ragState.isRetrieving ? (
<div className="animate-spin w-4 h-4 border-2 border-purple-500 border-t-transparent rounded-full" />
) : (
<Database className="w-4 h-4 text-purple-500" />
)}
{/* 状态文本 */}
<div className="text-sm">
{ragState.isRetrieving ? (
<span className="text-purple-700 dark:text-purple-300">
检索知识库中...
</span>
) : (
<span className="text-purple-700 dark:text-purple-300">
找到 <span className="font-bold">{ragState.resultCount}</span> 条相关文档
</span>
)}
{/* 关键信息提示 */}
{ragState.keyInfo && (
<div className="mt-1 text-xs text-purple-600 dark:text-purple-400">
已提取关键信息
</div>
)}
</div>
</div>
);
}
3. 工具调用小窗口(思考动画)
组件结构
┌─────────────────────────────────────┐
│ │
│ 🤔 思考中... │
│ │
│ ┌─────────────────────────────┐ │
│ │ ✨ 正在调用 get_weather │ │
│ │ Loading spinner │ │
│ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────┘
状态管理
type ToolCallState = {
isThinking: boolean;
currentTool: string | null;
toolCalls: Array<{
id: string;
name: string;
status: 'pending' | 'running' | 'success' | 'error';
result?: any;
timestamp: number;
}>;
};
动画实现
function ToolCallCard({ toolCall }: { toolCall: ToolCall }) {
return (
<div className="flex items-center gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-800">
{/* 状态图标 */}
{toolCall.status === 'running' && (
<div className="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full" />
)}
{toolCall.status === 'success' && <CheckCircle2 className="w-4 h-4 text-green-500" />}
{toolCall.status === 'error' && <XCircle className="w-4 h-4 text-red-500" />}
{/* 工具名称 */}
<span className="font-mono text-sm">{toolCall.name}</span>
{/* 结果预览 */}
{toolCall.status === 'success' && toolCall.result && (
<span className="text-xs text-gray-500 truncate">
{JSON.stringify(toolCall.result).slice(0, 50)}...
</span>
)}
</div>
);
}
六、后端事件类型(建议)
SSE 事件格式
// 事件类型定义
type StreamEvent =
| { type: 'rag_start' }
| { type: 'rag_retrieve'; data: { resultCount: number } }
| { type: 'rag_key_info'; data: { keyInfo: string } }
| { type: 'rag_end' }
| { type: 'thinking_start' }
| { type: 'thinking_end' }
| { type: 'tool_call_start'; data: { tool: string; args: any } }
| { type: 'tool_call_end'; data: { tool: string; result: any } }
| { type: 'content_chunk'; data: string }
| { type: 'done' }
| { type: 'error'; data: string };
后端输出示例
event: rag_start
data: {}
event: rag_retrieve
data: {"resultCount": 5}
event: rag_key_info
data: {"keyInfo": "{\"key_points\": [...], \"summary\": \"...\"}"}
event: rag_end
data: {}
event: thinking_start
data: {}
event: tool_call_start
data: {"tool": "search_web", "args": {"query": "今天天气"}}
event: tool_call_end
data: {"tool": "search_web", "result": {"temperature": "25°C"}}
event: content_chunk
data: "今天"
event: content_chunk
data: "天气"
event: content_chunk
data: "很好"
event: done
data: {}
七、前端 SSE 客户端实现
使用 fetch + ReadableStream
async function* streamChat(messages: ChatMessage[], threadId?: string) {
const response = await fetch('/api/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages,
thread_id: threadId,
is_think: true,
stream: true
})
});
if (!response.ok) {
throw new Error('Request failed');
}
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 解析 SSE 格式
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 保留不完整的行
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.slice(6);
if (jsonStr === '[DONE]') continue;
try {
const event = JSON.parse(jsonStr);
yield event;
} catch (e) {
console.error('Parse error:', e);
}
}
}
}
}
React Hook 封装
function useChatStream() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [toolCalls, setToolCalls] = useState<ToolCall[]>([]);
const [isThinking, setIsThinking] = useState(false);
// RAG 状态
const [ragState, setRagState] = useState<RagState>({
isRetrieving: false,
resultCount: null,
keyInfo: null,
});
const sendMessage = async (content: string) => {
const userMessage: ChatMessage = { role: 'user', content };
setMessages(prev => [...prev, userMessage]);
setIsLoading(true);
setToolCalls([]);
// 重置 RAG 状态
setRagState({
isRetrieving: false,
resultCount: null,
keyInfo: null,
});
const assistantMessage: ChatMessage = {
role: 'assistant',
content: '',
toolCalls: []
};
try {
for await (const event of streamChat([...messages, userMessage])) {
switch (event.type) {
// ========== RAG 事件处理 ==========
case 'rag_start':
setRagState(prev => ({ ...prev, isRetrieving: true }));
break;
case 'rag_retrieve':
setRagState(prev => ({ ...prev, resultCount: event.data.resultCount }));
break;
case 'rag_key_info':
setRagState(prev => ({ ...prev, keyInfo: event.data.keyInfo }));
break;
case 'rag_end':
setRagState(prev => ({ ...prev, isRetrieving: false }));
break;
// ========== 思考事件处理 ==========
case 'thinking_start':
setIsThinking(true);
break;
case 'thinking_end':
setIsThinking(false);
break;
// ========== 工具调用事件处理 ==========
case 'tool_call_start':
setToolCalls(prev => [...prev, {
id: Date.now().toString(),
name: event.data.tool,
status: 'running',
args: event.data.args,
timestamp: event.timestamp
}]);
break;
case 'tool_call_end':
setToolCalls(prev => prev.map(tc =>
tc.name === event.data.tool
? { ...tc, status: 'success', result: event.data.result }
: tc
));
break;
// ========== 内容事件处理 ==========
case 'content_chunk':
assistantMessage.content += event.data;
setMessages(prev => [
...prev.slice(0, -1),
{ ...assistantMessage }
]);
break;
// ========== 结束/错误事件处理 ==========
case 'done':
setIsLoading(false);
break;
case 'error':
console.error('Stream error:', event.data);
setIsLoading(false);
setRagState({ isRetrieving: false, resultCount: null, keyInfo: null });
break;
}
}
} catch (error) {
console.error('Chat error:', error);
setIsLoading(false);
setRagState({ isRetrieving: false, resultCount: null, keyInfo: null });
}
};
return { messages, isLoading, isThinking, toolCalls, ragState, sendMessage };
}
八、完整对话组件示例
function ChatPage() {
const { messages, isLoading, isThinking, toolCalls, ragState, sendMessage } = useChatStream();
return (
<div className="flex flex-col h-screen">
{/* 聊天消息区域 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((message, index) => (
<ChatMessage key={index} message={message} />
))}
{/* 加载状态 - RAG + 思考 + 工具调用 */}
{isLoading && (
<div className="flex gap-4 justify-start">
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center">
🤖
</div>
<div className="flex-1 space-y-3">
{/* RAG 状态组件 */}
<RagStatus ragState={ragState} />
{/* 工具调用状态组件 */}
{toolCalls.length > 0 && (
<div className="space-y-2">
{toolCalls.map(tool => (
<ToolCallCard key={tool.id} toolCall={tool} />
))}
</div>
)}
{/* 思考中状态 */}
{isThinking && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-gray-50 dark:bg-gray-800">
<div className="animate-pulse w-2 h-2 rounded-full bg-blue-500" />
<span className="text-sm text-gray-500">思考中...</span>
</div>
)}
</div>
</div>
)}
</div>
{/* 输入框 */}
<ChatInput onSend={sendMessage} disabled={isLoading} />
</div>
);
}
function ChatMessage({ message }: { message: ChatMessage }) {
const [showTools, setShowTools] = useState(false);
return (
<div className={`flex gap-4 ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[80%] ${message.role === 'user' ? 'order-2' : 'order-1'}`}>
{/* 头像 */}
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center">
{message.role === 'user' ? '👤' : '🤖'}
</div>
</div>
<div className={`flex-1 ${message.role === 'user' ? 'order-1' : 'order-2'}`}>
{/* 工具调用区域 */}
{message.toolCalls && message.toolCalls.length > 0 && (
<div className="mb-2">
<button
onClick={() => setShowTools(!showTools)}
className="text-xs text-blue-500 hover:underline"
>
{showTools ? '隐藏' : '显示'} 工具调用 ({message.toolCalls.length})
</button>
{showTools && (
<div className="mt-2 space-y-2">
{message.toolCalls.map(tool => (
<ToolCallCard key={tool.id} toolCall={tool} />
))}
</div>
)}
</div>
)}
{/* 消息内容 - 打字机效果 */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm">
<TypewriterText content={message.content} />
</div>
</div>
</div>
);
}
九、优化建议
- 虚拟滚动: 对话历史很长时使用
- Markdown渲染: 使用
react-markdown 渲染富文本
- 代码高亮: 使用
prismjs 或 shiki
- 暂停/恢复: 支持暂停和恢复流式输出
- 错误重试: 网络断开时自动重连
十、参考实现库