# 前端技术方案:流式对话与工具调用交互
## 一、整体架构
### 后端 SSE 事件流实现(已完成)
---
## 二、对话控制与话题管理
### 核心概念
本系统采用 **话题(Thread)** 管理对话历史:
1. **话题ID(thread_id)**
- 每个话题有唯一的 `thread_id`
- 在对话请求的 `thread_id` 参数中传递
- 如果不传,后端会自动生成一个新的 `thread_id`
- 同一话题内的所有对话都使用同一个 `thread_id`
2. **话题列表管理**
- 后端存储话题列表(基于 Redis)
- 每个话题对应前端的一个独立窗口
- 在话题窗口内的交互,都传递该话题的 `thread_id`
3. **记忆连续性**
- 同一话题的对话历史自动继承
- 使用 `thread_id` 从 Redis 加载历史记忆
- 支持记忆压缩(达到最大轮数后自动压缩)
---
### 话题交互流程
```
1. 前端展示话题列表(从后端获取)
↓
2. 用户选择/新建话题窗口
↓
3. 在话题窗口内发送消息
- 请求携带该话题的 thread_id
- 后端通过 thread_id 加载该话题的历史记忆
↓
4. 后端处理并返回响应
- 响应中返回 thread_id
- 同一话题的后续消息继续使用该 thread_id
↓
5. 话题结束
- 可以保留话题(继续对话)
- 可以删除话题(清除记忆)
```
---
## 三、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)
> 后端使用 Spring MVC 的 `SseEmitter` 实现,返回标准 SSE 格式,前端可以使用 `EventSource` 或 `fetch ReadableStream` 接收。
### 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` | 开始思考阶段 | - |
| `content_chunk` | 思考过程中的文本片段(实时流式输出) | `"文本内容"` |
| `thinking_end` | 结束思考阶段 | - |
| `tool_call_start` | 开始调用工具 | `{ tool: "工具名", args: {...} }` |
| `tool_call_end` | 工具调用完成 | `{ tool: "工具名", result: {...} }` |
| `content_chunk` | 回答内容的文本片段(实时流式输出) | `"文本内容"` |
| `done` | 对话完成 | - |
| `error` | 发生错误 | `"错误信息"` |
### 2. SSE 输出格式示例
**注意:** `content_chunk` 事件在 `thinking_start` 和 `thinking_end` 之间就会实时发射,前端可以实时展示 AI 的思考过程。不需要在 `thinking_end` 后再模拟打字机效果。
```
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":"content_chunk","data":"让我","timestamp":1712456789005}
data: {"type":"content_chunk","data":"来","timestamp":1712456789006}
data: {"type":"content_chunk","data":"查一下今天的天气","timestamp":1712456789007}
data: {"type":"thinking_end","timestamp":1712456789008}
data: {"type":"tool_call_start","data":{"tool":"search_weather","args":{"query":"今天天气"}},"timestamp":1712456789009}
data: {"type":"tool_call_end","data":{"tool":"search_weather","result":{"temperature":"25°C"}},"timestamp":1712456789010}
data: {"type":"content_chunk","data":"今天","timestamp":1712456789011}
data: {"type":"content_chunk","data":"天气","timestamp":1712456789012}
data: {"type":"content_chunk","data":"很好,温度是25度","timestamp":1712456789013}
data: {"type":"done","timestamp":1712456789014}
```
---
## 四、前端实现
### 技术栈
- **框架**: React 18+
- **状态管理**: Zustand / React Context
- **流式通信**: fetch ReadableStream(比 EventSource 更灵活)
- **UI组件**: shadcn/ui + Tailwind CSS
---
## 五、话题列表管理
### 1. 数据结构
```typescript
// 话题数据结构
interface Thread {
id: string; // thread_id
title: string; // 话题标题(自动生成或用户修改)
createdAt: number; // 创建时间戳
updatedAt: number; // 最后更新时间戳
preview: string; // 最后一条消息预览
}
// 当前激活的话题状态
interface ThreadState {
threads: Thread[]; // 话题列表
activeThreadId: string | null; // 当前激活的话题ID
}
```
### 2. 话题列表 UI 组件
```tsx
import { useState } from 'react';
import { Plus, Trash2, MessageSquare } from 'lucide-react';
interface ThreadSidebarProps {
threads: Thread[];
activeThreadId: string | null;
onSelectThread: (threadId: string) => void;
onCreateThread: () => void;
onDeleteThread: (threadId: string) => void;
}
export function ThreadSidebar({
threads,
activeThreadId,
onSelectThread,
onCreateThread,
onDeleteThread
}: ThreadSidebarProps) {
return (
{/* 顶部:新建话题按钮 */}
{/* 话题列表 */}
{threads.map((thread) => (
onSelectThread(thread.id)}
className={`p-3 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors ${
activeThreadId === thread.id
? 'bg-blue-100 dark:bg-blue-900/30'
: ''
}`}
>
{thread.title}
{thread.preview}
{new Date(thread.updatedAt).toLocaleDateString()}
))}
);
}
```
### 3. 话题窗口布局
```tsx
import { useState } from 'react';
import { ThreadSidebar } from './ThreadSidebar';
import { ChatWindow } from './ChatWindow';
export function ChatApp() {
const [threads, setThreads] = useState([]);
const [activeThreadId, setActiveThreadId] = useState(null);
// 新建话题
const handleCreateThread = () => {
const newThreadId = crypto.randomUUID();
const newThread: Thread = {
id: newThreadId,
title: '新话题',
createdAt: Date.now(),
updatedAt: Date.now(),
preview: ''
};
setThreads([newThread, ...threads]);
setActiveThreadId(newThreadId);
};
// 选择话题
const handleSelectThread = (threadId: string) => {
setActiveThreadId(threadId);
};
// 删除话题
const handleDeleteThread = (threadId: string) => {
setThreads(threads.filter(t => t.id !== threadId));
if (activeThreadId === threadId) {
setActiveThreadId(threads[0]?.id || null);
}
};
return (
{/* 话题列表侧边栏 */}
{/* 对话窗口 */}
{activeThreadId ? (
) : (
)}
);
}
```
---
## 六、核心功能设计
### 1. 打字机效果(流式文本输出)
#### 实现思路
```tsx
// 使用 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
```tsx
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 条相关文档 │ │
│ │ 📝 提取关键信息中... │ │
│ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────┘
```
#### 状态管理
```typescript
type RagState = {
isRetrieving: boolean;
resultCount: number | null;
keyInfo: string | null;
};
```
#### 组件实现
```tsx
function RagStatus({ ragState }: { ragState: RagState }) {
if (!ragState.isRetrieving && !ragState.resultCount) {
return null;
}
return (
{/* RAG 图标 */}
{ragState.isRetrieving ? (
) : (
)}
{/* 状态文本 */}
{ragState.isRetrieving ? (
检索知识库中...
) : (
找到 {ragState.resultCount} 条相关文档
)}
{/* 关键信息提示 */}
{ragState.keyInfo && (
已提取关键信息
)}
);
}
```
---
### 3. 工具调用小窗口(思考动画)
#### 组件结构
```
┌─────────────────────────────────────┐
│ │
│ 🤔 思考中... │
│ │
│ ┌─────────────────────────────┐ │
│ │ ✨ 正在调用 get_weather │ │
│ │ Loading spinner │ │
│ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────┘
```
#### 状态管理
```typescript
type ToolCallState = {
isThinking: boolean;
currentTool: string | null;
toolCalls: Array<{
id: string;
name: string;
status: 'pending' | 'running' | 'success' | 'error';
result?: any;
timestamp: number;
}>;
};
```
#### 动画实现
```tsx
function ToolCallCard({ toolCall }: { toolCall: ToolCall }) {
return (
{/* 状态图标 */}
{toolCall.status === 'running' && (
)}
{toolCall.status === 'success' &&
}
{toolCall.status === 'error' &&
}
{/* 工具名称 */}
{toolCall.name}
{/* 结果预览 */}
{toolCall.status === 'success' && toolCall.result && (
{JSON.stringify(toolCall.result).slice(0, 50)}...
)}
);
}
```
---
## 六、后端事件类型(建议)
### SSE 事件格式
```typescript
// 事件类型定义
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 };
```
### 后端输出示例
**注意:** 思考过程中会实时发射 `content_chunk`,AI 的思考过程(如"我需要查询...")会被前端实时接收并显示。
```
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: content_chunk
data: "让我查一下今天的天气"
event: content_chunk
data: "..."
event: thinking_end
data: {}
event: tool_call_start
data: {"tool": "search_weather", "args": {"query": "今天天气"}}
event: tool_call_end
data: {"tool": "search_weather", "result": {"temperature": "25°C"}}
event: content_chunk
data: "今天天气很好,温度是25度"
event: done
data: {}
```
---
## 七、前端 SSE 客户端实现
### 使用 fetch + ReadableStream
```typescript
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 封装
```typescript
function useChatStream() {
const [messages, setMessages] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [toolCalls, setToolCalls] = useState([]);
const [isThinking, setIsThinking] = useState(false);
// RAG 状态
const [ragState, setRagState] = useState({
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 };
}
```
---
## 八、完整对话组件示例
```tsx
function ChatPage() {
const { messages, isLoading, isThinking, toolCalls, ragState, sendMessage } = useChatStream();
return (
{/* 聊天消息区域 */}
{messages.map((message, index) => (
))}
{/* 加载状态 - RAG + 思考 + 工具调用 */}
{isLoading && (
🤖
{/* RAG 状态组件 */}
{/* 工具调用状态组件 */}
{toolCalls.length > 0 && (
{toolCalls.map(tool => (
))}
)}
{/* 思考中状态 */}
{isThinking && (
)}
)}
{/* 输入框 */}
);
}
function ChatMessage({ message }: { message: ChatMessage }) {
const [showTools, setShowTools] = useState(false);
return (
{/* 头像 */}
{message.role === 'user' ? '👤' : '🤖'}
{/* 工具调用区域 */}
{message.toolCalls && message.toolCalls.length > 0 && (
{showTools && (
{message.toolCalls.map(tool => (
))}
)}
)}
{/* 消息内容 - 打字机效果 */}
);
}
```
---
## 九、优化建议
1. **虚拟滚动**: 对话历史很长时使用
2. **Markdown渲染**: 使用 `react-markdown` 渲染富文本
3. **代码高亮**: 使用 `prismjs` 或 `shiki`
4. **暂停/恢复**: 支持暂停和恢复流式输出
5. **错误重试**: 网络断开时自动重连
---
## 十、参考实现库
- [Vercel AI SDK](https://sdk.vercel.ai/) - 完整的 AI 聊天 SDK
- [LangChain UI](https://js.langchain.com/docs/modules/chains/popular/chat_vector_db) - LangChain 官方 UI 组件
- [Chatbot UI](https://github.com/mckaywrigley/chatbot-ui) - 开源聊天界面参考