前端技术方案.md 18 KB

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

一、整体架构

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

后端会根据 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)

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 开始思考阶段 -
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>
  );
}

九、优化建议

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

十、参考实现库