前端技术方案.md 25 KB

前端技术方案:流式对话与工具调用交互

一、整体架构

后端 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. 四种组合的返回格式

后端会根据 streamis_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 格式,前端可以使用 EventSourcefetch ReadableStream 接收。

1. 事件类型定义

stream=trueis_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_startthinking_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. 数据结构

// 话题数据结构
interface Thread {
  id: string;              // thread_id
  title: string;           // 话题标题(自动生成或用户修改)
  createdAt: number;       // 创建时间戳
  updatedAt: number;       // 最后更新时间戳
  preview: string;         // 最后一条消息预览
}

// 当前激活的话题状态
interface ThreadState {
  threads: Thread[];        // 话题列表
  activeThreadId: string | null;  // 当前激活的话题ID
}

2. 话题列表 UI 组件

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>
  );
}

3. 话题窗口布局

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>
  );
}

六、核心功能设计

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 };

后端输出示例

注意: 思考过程中会实时发射 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

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>
  );
}

九、优化建议

  1. 虚拟滚动: 对话历史很长时使用
  2. Markdown渲染: 使用 react-markdown 渲染富文本
  3. 代码高亮: 使用 prismjsshiki
  4. 暂停/恢复: 支持暂停和恢复流式输出
  5. 错误重试: 网络断开时自动重连

十、参考实现库