本系统采用 话题(Thread) 管理对话历史:
话题ID(thread_id)
thread_idthread_id 参数中传递thread_idthread_id话题列表管理
thread_id记忆连续性
thread_id 从 Redis 加载历史记忆1. 前端展示话题列表(从后端获取)
↓
2. 用户选择/新建话题窗口
↓
3. 在话题窗口内发送消息
- 请求携带该话题的 thread_id
- 后端通过 thread_id 加载该话题的历史记忆
↓
4. 后端处理并返回响应
- 响应中返回 thread_id
- 同一话题的后续消息继续使用该 thread_id
↓
5. 话题结束
- 可以保留话题(继续对话)
- 可以删除话题(清除记忆)
POST /v1/chat/completions
Content-Type: application/json
| 参数名 | 类型 | 必填 | 说明 |
|---|---|---|---|
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 列表(可选) |
后端会根据 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/思考/工具调用事件) ⭐ |
后端使用 Spring MVC 的
SseEmitter实现,返回标准 SSE 格式,前端可以使用EventSource或fetch ReadableStream接收。
当 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 |
发生错误 | "错误信息" |
注意: 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}
// 话题数据结构
interface Thread {
id: string; // thread_id
title: string; // 话题标题(自动生成或用户修改)
createdAt: number; // 创建时间戳
updatedAt: number; // 最后更新时间戳
preview: string; // 最后一条消息预览
}
// 当前激活的话题状态
interface ThreadState {
threads: Thread[]; // 话题列表
activeThreadId: string | null; // 当前激活的话题ID
}
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 (
<div className="w-80 bg-gray-50 dark:bg-gray-900 h-full border-r border-gray-200 dark:border-gray-700">
{/* 顶部:新建话题按钮 */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<button
onClick={onCreateThread}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
新建话题
</button>
</div>
{/* 话题列表 */}
<div className="overflow-y-auto h-[calc(100%-80px)]">
{threads.map((thread) => (
<div
key={thread.id}
onClick={() => 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'
: ''
}`}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2 flex-1 min-w-0">
<MessageSquare className="w-4 h-4 text-gray-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">
{thread.title}
</p>
<p className="text-xs text-gray-500 truncate mt-1">
{thread.preview}
</p>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteThread(thread.id);
}}
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
>
<Trash2 className="w-4 h-4 text-gray-400" />
</button>
</div>
<p className="text-xs text-gray-400 mt-1">
{new Date(thread.updatedAt).toLocaleDateString()}
</p>
</div>
))}
</div>
</div>
);
}
import { useState } from 'react';
import { ThreadSidebar } from './ThreadSidebar';
import { ChatWindow } from './ChatWindow';
export function ChatApp() {
const [threads, setThreads] = useState<Thread[]>([]);
const [activeThreadId, setActiveThreadId] = useState<string | null>(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 (
<div className="flex h-screen">
{/* 话题列表侧边栏 */}
<ThreadSidebar
threads={threads}
activeThreadId={activeThreadId}
onSelectThread={handleSelectThread}
onCreateThread={handleCreateThread}
onDeleteThread={handleDeleteThread}
/>
{/* 对话窗口 */}
<div className="flex-1">
{activeThreadId ? (
<ChatWindow threadId={activeThreadId} />
) : (
<div className="flex items-center justify-center h-full text-gray-500">
<div className="text-center">
<MessageSquare className="w-16 h-16 mx-auto mb-4 opacity-50" />
<p>选择或创建一个话题开始对话</p>
</div>
</div>
)}
</div>
</div>
);
}
// 使用 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);
}
}
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 };
}
┌─────────────────────────────────────┐
│ │
│ 🔍 检索知识库中... │
│ │
│ ┌─────────────────────────────┐ │
│ │ ✨ 找到 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>
);
}
┌─────────────────────────────────────┐
│ │
│ 🤔 思考中... │
│ │
│ ┌─────────────────────────────┐ │
│ │ ✨ 正在调用 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>
);
}
// 事件类型定义
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: {}
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);
}
}
}
}
}
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>
);
}
react-markdown 渲染富文本prismjs 或 shiki