# 前端技术方案:流式对话与工具调用交互 ## 一、整体架构 ### 后端 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) - 开源聊天界面参考