前端 + AI进阶 Day4: 接入真实 AI 流式接口(SSE)

前端 + AI 进阶学习路线|Week 1-2:流式体验优化

Day 4:接入真实 AI 流式接口(SSE)

学习时间:2025年12月28日(星期日)
关键词:SSE、流式 API、fetchEventSource、Ollama、OpenAI、token 流


📁 项目文件结构

day04-ai-streaming/
├── src/
│   ├── components/
│   │   ├── StreamingChat.jsx        # 主聊天组件(含流式控制)
│   │   ├── VirtualChatList.jsx       # 虚拟列表(支持动态高度)
│   │   └── StreamingMarkdown.jsx     # 流式 Markdown 渲染器(复用 Day 2)
│   ├── lib/
│   │   └── aiClient.js              # 通用流式 AI 客户端(对接 Ollama)
│   └── App.jsx                      # 主应用入口
├── package.json                     # 需添加 proxy 配置
└── public/

🎯 今日学习目标

  1. 理解服务器发送事件(SSE)在 AI 流式响应中的作用
  2. 使用 fetchEventSource 消费兼容 OpenAI / Ollama 的流式接口
  3. 将真实 token 数据逐块拼接并渲染到聊天界面
  4. 处理连接状态(加载中、错误、完成)

💡 为什么需要真实流式接口?

前 3 天我们用 模拟字符串 演示了流式效果,但真实 AI 应用需与后端模型通信。主流本地/云模型均支持 流式响应

模型平台 流式协议 响应格式
Ollama SSE(Server-Sent Events) {"model":"llama3","response":"Hello"}
OpenAI SSE data: {"choices": [{"delta": {"content": "world"}}]}
vLLM / LM Studio SSE 兼容 OpenAI 格式

✅ 目标:编写一个 通用流式客户端,可无缝切换不同后端


📚 核心技术:SSE 与 fetchEventSource

  • SSE(Server-Sent Events):基于 HTTP 的单向流(服务器 → 客户端)
  • 浏览器原生支持 EventSource,但功能有限(无法传 headers、难重试)
  • 推荐库@microsoft/fetch-event-source
    → 支持自定义 headers、重连、取消、兼容 OpenAI/Ollama
npm install @microsoft/fetch-event-source

⚠️ CORS 解决方案(开发阶段)
package.json 中添加代理:

{
  "proxy": "http://localhost:11434"
}

🔧 动手实践:构建通用流式 AI 客户端

步骤 1:创建项目并安装依赖

npx create-react-app day04-ai-streaming
cd day04-ai-streaming
npm install react-virtual react-markdown remark-gfm rehype-highlight rehype-katex katex @microsoft/fetch-event-source

步骤 2:配置代理(package.json

package.json 中添加(必须):

{
  "name": "day04-ai-streaming",
  "proxy": "http://localhost:11434",
  "dependencies": {
    // ... 其他依赖
  }
}

步骤 3:创建通用流式客户端

// src/lib/aiClient.js
import { fetchEventSource } from '@microsoft/fetch-event-source';

/**
 * 通用流式 AI 请求函数
 * @param {Object} options
 * @param {string} options.prompt - 用户输入
 * @param {string} [options.model='llama3'] - 模型名称
 * @param {function} options.onToken - (token: string) => void
 * @param {function} options.onComplete - () => void
 * @param {function} options.onError - (error: Error) => void
 */
export const streamAIResponse = async ({
  prompt,
  model = 'llama3',
  onToken,
  onComplete,
  onError,
}) => {
  // 构建 Ollama 兼容请求体
  const payload = {
    model,
    prompt,
    stream: true,
  };

  let fullResponse = '';

  try {
    await fetchEventSource('/api/generate', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(payload),
      openWhenHidden: true, // 页面后台时也保持连接
      onmessage(event) {
        if (event.data === '[DONE]') {
          onComplete?.();
          return;
        }

        try {
          const data = JSON.parse(event.data);
          // Ollama 的 token 在 response 字段
          const token = data.response;
          if (token) {
            fullResponse += token;
            onToken?.(token);
          }
        } catch (e) {
          console.warn('解析 SSE 消息失败:', event.data);
        }
      },
      onerror(error) {
        console.error('SSE 连接错误:', error);
        onError?.(error);
        throw error; // 触发终止
      },
    });
  } catch (error) {
    onError?.(error);
  }
};

步骤 4:复用并集成组件

复用文件(可直接从 Day 2/3 复制):

  • src/components/StreamingMarkdown.jsx
  • src/components/VirtualChatList.jsx(需支持动态高度)
主聊天组件(src/components/StreamingChat.jsx
// src/components/StreamingChat.jsx
import { useState, useRef, useEffect } from 'react';
import VirtualChatList from './VirtualChatList';
import { streamAIResponse } from '../lib/aiClient';

const StreamingChat = () => {
  const [messages, setMessages] = useState([
    { id: 0, role: 'assistant', content: '你好!我是基于本地 Llama 3 的 AI 助手。你可以问我任何问题。' }
  ]);
  const [inputValue, setInputValue] = useState('');
  const [streamingMessage, setStreamingMessage] = useState('');
  const [isStreaming, setIsStreaming] = useState(false);
  const [error, setError] = useState(null);

  const chatContainerRef = useRef(null);
  const userIsScrolling = useRef(false);
  const streamingMessageRef = useRef('');

  // 监听滚动,判断用户是否在查看历史
  const handleScroll = () => {
    if (!chatContainerRef.current) return;
    const { scrollTop, scrollHeight, clientHeight } = chatContainerRef.current;
    const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
    userIsScrolling.current = !isAtBottom;
  };

  // 自动滚动到底部
  useEffect(() => {
    if (chatContainerRef.current && !userIsScrolling.current) {
      chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
    }
  }, [messages, streamingMessage]);

  // 保持最新 streamingMessage(避免闭包问题)
  useEffect(() => {
    streamingMessageRef.current = streamingMessage;
  }, [streamingMessage]);

  const handleSend = async () => {
    const userMsg = inputValue.trim();
    if (!userMsg) return;

    // 添加用户消息
    const newUserMsg = { id: Date.now(), role: 'user', content: userMsg };
    setMessages(prev => [...prev, newUserMsg]);
    setInputValue('');
    setError(null);

    // 启动 AI 流式响应
    setIsStreaming(true);
    setStreamingMessage('');
    
    try {
      await streamAIResponse({
        prompt: userMsg,
        model: 'llama3',
        onToken: (token) => {
          setStreamingMessage(prev => prev + token);
        },
        onComplete: () => {
          setMessages(prev => [
            ...prev,
            { id: Date.now(), role: 'assistant', content: streamingMessageRef.current }
          ]);
          setIsStreaming(false);
        },
        onError: (err) => {
          setError('AI 响应失败:' + (err.message || '未知错误'));
          setIsStreaming(false);
        }
      });
    } catch (err) {
      setError('请求出错');
      setIsStreaming(false);
    }
  };

  // 构建渲染消息列表
  const renderMessages = [...messages];
  if (streamingMessage) {
    renderMessages.push({
      id: 'streaming',
      role: 'assistant',
      content: streamingMessage,
    });
  }

  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
      {/* 聊天区域 */}
      <div
        ref={chatContainerRef}
        onScroll={handleScroll}
        style={{
          flex: 1,
          overflow: 'auto',
          border: '1px solid #e8e8e8',
          borderRadius: '8px',
          padding: '8px',
          marginBottom: '16px',
          backgroundColor: '#fff',
        }}
      >
        <VirtualChatList messages={renderMessages} />
      </div>

      {/* 输入区域 */}
      <div style={{ display: 'flex', gap: '8px' }}>
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          onKeyDown={(e) => e.key === 'Enter' && handleSend()}
          placeholder="输入你的问题(如:JavaScript 闭包是什么?)"
          disabled={isStreaming}
          style={{
            flex: 1,
            padding: '10px 12px',
            border: '1px solid #d9d9d9',
            borderRadius: '6px',
            fontSize: '16px',
          }}
        />
        <button
          onClick={handleSend}
          disabled={!inputValue.trim() || isStreaming}
          style={{
            padding: '10px 20px',
            backgroundColor: (!inputValue.trim() || isStreaming) ? '#f5f5f5' : '#1890ff',
            color: (!inputValue.trim() || isStreaming) ? '#ccc' : 'white',
            border: 'none',
            borderRadius: '6px',
            cursor: (!inputValue.trim() || isStreaming) ? 'not-allowed' : 'pointer',
          }}
        >
          发送
        </button>
      </div>

      {/* 错误提示 */}
      {error && (
        <div style={{ marginTop: '12px', color: '#ff4d4f', fontSize: '14px' }}>
          ❌ {error}
        </div>
      )}
    </div>
  );
};

export default StreamingChat;

步骤 5:在 App 中集成

// src/App.jsx
import 'katex/dist/katex.min.css'; // 必须引入 KaTeX CSS
import StreamingChat from './components/StreamingChat';

function App() {
  return (
    <div style={{ 
      padding: '20px', 
      fontFamily: 'Inter, -apple-system, sans-serif', 
      maxWidth: '800px', 
      margin: '0 auto',
      height: '90vh'
    }}>
      <h1 style={{ textAlign: 'center', fontSize: '24px', fontWeight: '700', color: '#333' }}>
        🤖 本地 AI 聊天(Ollama + Llama 3)
      </h1>
      <p style={{ textAlign: 'center', color: '#666', marginBottom: '20px' }}>
        所有对话在本地运行,无需联网
      </p>
      <StreamingChat />
    </div>
  );
}

export default App;

💡 复用组件说明

  • StreamingMarkdown.jsx:来自 Day 2,支持代码高亮、表格、KaTeX 公式
  • VirtualChatList.jsx:来自 Day 3,使用 measureElement 支持动态高度

✅ 效果验证

前提:已安装 Ollama 并运行 ollama run llama3

  • 启动 Ollama:ollama run llama3
  • 启动项目:npm start
  • 输入问题(如“JavaScript 闭包是什么?”)
  • 观察 真实 token 逐块流式输出
  • 断网时显示错误提示
  • 支持 Markdown(代码/公式/表格)正确渲染

🤔 思考与延伸

  1. 如何支持 OpenAI 而不暴露 Key?
    → 需要简单后端代理(如 Express + axios 转发)

  2. 流式中断后如何续传?
    → 记录已生成内容,下次请求带上完整上下文

  3. 能否自动检测后端类型(Ollama vs OpenAI)?
    → 发送试探请求,根据响应结构判断

提示:Ollama 默认不支持跨域,必须使用代理 或启动时加 --host 0.0.0.0


📅 明日预告

Day 5:多模态输入初探 —— 图片上传与预览

  • 实现文件拖拽上传 + 实时预览
  • 支持截图粘贴(Clipboard API)
  • 为“图文混合 AI 对话”打下基础

✍️ 小结

今天,我们跨越了“模拟”与“真实”的界限,将本地运行的 AI 模型(如 Ollama)接入前端,实现了 端到端的流式对话。这不仅是一个技术里程碑,更是迈向 AI 原生应用 的关键一步。真实数据流,让流式体验从演示走向生产。

💬 实践提示:如果遇到 CORS 错误,请确保 package.json 中有 "proxy": "http://localhost:11434"。欢迎分享你与本地大模型的对话截图!

posted @ 2025-12-29 11:36  XiaoZhengTou  阅读(47)  评论(0)    收藏  举报