React 对接流式接口实现

React 实现流式接口对接:打造实时响应的 AI 对话体验

在现代 AI 对话应用中,流式响应(Streaming Response)已经成为提升用户体验的关键技术。本文将详细介绍如何在 React 应用中实现流式接口的对接。

一、流式接口的基本概念

流式接口允许服务器以流的形式持续发送数据,而不是等待所有数据准备就绪后一次性返回。在 AI 对话场景中,这意味着用户可以实时看到 AI 的回复,而不是等待完整回复后才能看到内容。

二、技术实现

1. 服务端请求实现

typescript 复制代码
const response = await fetch('/api/stream', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'text/event-stream',
  },
  cache: 'no-store',
  keepalive: true,
  body: JSON.stringify(payload)
});

const reader = response.body?.getReader();
const decoder = new TextDecoder();

关键点:

  • 使用 fetch API 发起请求
  • 设置 Accept: text/event-stream 头部
  • 使用 ReadableStream 读取流数据
  • 通过 TextDecoder 解码二进制数据

2. 数据处理与解析

typescript 复制代码
while (reader) {
  const { done, value } = await reader.read();
  if (done) break;
  
  const chunk = decoder.decode(value, { stream: true });
  const lines = chunk.split('\n').filter(line => line.trim());
  
  for (const line of lines) {
    if (line.startsWith('data:')) {
      const jsonStr = line.replace(/^data:/, '').trim();
      const message = JSON.parse(jsonStr);
      // 处理消息
    }
  }
}

关键点:

  • 循环读取流数据
  • 按行解析数据
  • 处理 SSE(Server-Sent Events)格式
  • JSON 解析与错误处理

3. React 状态更新

由于后端返回的分片长度可能不一(网关、AP、协议等原因)以及React短时间多次更新状态会合并成成一次更新,所以需要前端自己兼容实现稳定的输出

typescript 复制代码
const [messages, setMessages] = useState<Message[]>([]);

const handleNewContent = (content: string) => {
  flushSync(() => {
    setMessages(oldMessages => {
      const newMessages = [...oldMessages];
      newMessages[newMessages.length - 1] = {
        ...newMessages[newMessages.length - 1],
        content: newMessages[newMessages.length - 1].content + content
      };
      return newMessages;
    });
  });
};

关键点:

  • 使用 useState 管理消息状态
  • 使用 flushSync 确保状态更新的同步性
  • 增量更新消息内容

4. 打字机效果实现

typescript 复制代码
const chars = content.split('');
await Promise.all(
  chars.map((char, index) =>
    new Promise(resolve =>
      setTimeout(() => {
        onNewMsg(char);
        resolve(null);
      }, index * 50)
    )
  )
);

关键点:

  • 字符分割
  • 使用 Promise.allsetTimeout 实现打字效果
  • 可配置的打字速度

三、错误处理与中断控制

typescript 复制代码
try {
  if (options.abortSignal?.aborted) {
    reader.cancel();
    return false;
  }
  // ... 处理逻辑
} catch (error) {
  console.error('Stream error:', error);
  return { content: '请求失败', isError: true };
}

关键点:

  • 支持请求中断
  • 错误状态处理
  • 用户友好的错误提示

四、性能优化

  1. 批量更新 :使用 flushSync 确保状态更新的及时性
  2. 防抖处理:对频繁的状态更新进行控制
  3. 内存管理:及时清理不需要的数据和监听器

五、用户体验提升

  1. 加载状态:显示打字机效果
  2. 错误处理:友好的错误提示
  3. 实时反馈:即时显示接收到的内容

总结

实现流式接口不仅需要考虑技术实现,还要注重用户体验。通过合理的状态管理、错误处理和性能优化,可以打造出流畅的 AI 对话体验。

关键代码可参考:

  • 请求:
ts 复制代码
export type AIStreamResponse = {
  content: string;
  hasDone: boolean;
  isError: boolean;
};

export const postAIStream = async (
  options: {
    messages: AIMessage[];
    abortSignal?: AbortSignal; // 新增可中断信号
  },
  onNewMsg: (msg: string) => void,
  model: string,
  operator: string,
): Promise<AIStreamResponse | false> => {
  // 检查是否中断
  if (options.abortSignal?.aborted) {
    return false;
  }
  // 新增 usage 变量
  let usage: any = {};
  // 将原来的 Modal.confirm 替换为统一函数 showRetryConfirm

  try {
    const response = await fetch('/model/service/stream', {
      method: 'POST',
      credentials: 'include',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'text/event-stream',
      },
      // 添加HTTP/2相关配置
      cache: 'no-store',
      keepalive: true,
      body: JSON.stringify({
        operator,
        model,
        messages: options.messages,
        stream: true,
      }),
    });

    const reader = response.body?.getReader();
    const decoder = new TextDecoder();

    let hasDone = false;
    let content = '';
    let incompleteLine = ''; // 存储不完整的行

    while (reader) {
      // 检查中断信号
      if (options.abortSignal?.aborted) {
        reader.cancel();
        return false;
      }
      const { done, value } = await reader.read();
      if (done) {
        break;
      }

      const chunk = decoder.decode(value, { stream: true });

      // 将上一个不完整的行与当前chunk拼接
      const textToProcess = incompleteLine + chunk;
      incompleteLine = '';

      // 按行分割,但保持事件标记完整
      const lines = textToProcess
        .split(/\n/)
        .map((line) => line.trim())
        .filter((line) => line);

      for (let i = 0; i < lines.length; i++) {
        const line = lines[i];
        // 标记正常结束
        if (line.includes(`event:done`)) {
          hasDone = true;
        }

        // 如果是最后一行且chunk没有以换行符结束,认为可能是不完整的
        if (i === lines.length - 1 && !chunk.endsWith('\n')) {
          incompleteLine = line;
          continue;
        }

        if (line.startsWith('data:')) {
          try {
            const jsonStr = line.replace(/^data:/, '').trim();
            // 确保不处理空字符串
            if (!jsonStr) continue;

            const message = JSON.parse(jsonStr);
            // 新增:处理 usage 字段
            if (message.data && message.data.usage) {
              usage = message.data.usage;
            }

            if (!message.finish && message.data?.choices?.[0]?.message?.content) {
              const currentContent = message.data.choices[0].message.content;
              // 按字符分割当前内容
              const chars = currentContent.split('');
              // 使用 Promise.all 和 setTimeout 实现均匀的打字效果
              await Promise.all(
                chars.map(
                  (char: string, index: number) =>
                    new Promise(
                      (resolve) =>
                        setTimeout(() => {
                          onNewMsg(char);
                          resolve(null);
                        }, index * 50), // 每个字符之间间隔 50ms
                    ),
                ),
              );
              content += currentContent;
            }
          } catch (e) {
            console.warn('Parse error, might be incomplete JSON:', line);
            // 如果不是最后一行却解析失败,记录错误
            if (i < lines.length - 1) {
              console.error('JSON parse error in middle of chunk:', e);
            }
            continue;
          }
        }
      }
    }
    // 结束时返回 content 和 usage
    if (hasDone) {
      return { content, usage, hasDone, isError: false };
    }
    return { content: '大模型调用失败', usage, hasDone: true, isError: true };
  } catch (error) {
    console.error('Stream error:', error);
    return { content: '大模型调用失败', usage, hasDone: true, isError: true };
  }
};
  • 调用
ts 复制代码
    const res = await postAIStream(
      {
        messages: [newMessages],
      },
      (content) => {
        flushSync(() =>
          setMessages((oldMessage) => {
            const messages = [...oldMessage];
            messages[messages.length - 1] = {
              content: messages[messages.length - 1].content + content,
              role: 'assistant',
            };

            return messages;
          }),
        );
        if (!isStart) {
          isStart = true;
        }
      },
      currentModel,
      userInfo?.username,
    ).finally(() => {
      setLoading(false);
    });
相关推荐
zhougl9961 小时前
html处理Base文件流
linux·前端·html
花花鱼1 小时前
node-modules-inspector 可视化node_modules
前端·javascript·vue.js
HBR666_2 小时前
marked库(高效将 Markdown 转换为 HTML 的利器)
前端·markdown
careybobo3 小时前
海康摄像头通过Web插件进行预览播放和控制
前端
杉之4 小时前
常见前端GET请求以及对应的Spring后端接收接口写法
java·前端·后端·spring·vue
喝拿铁写前端4 小时前
字段聚类,到底有什么用?——从系统混乱到结构认知的第一步
前端
再学一点就睡5 小时前
大文件上传之切片上传以及开发全流程之前端篇
前端·javascript
木木黄木木6 小时前
html5炫酷图片悬停效果实现详解
前端·html·html5
请来次降维打击!!!6 小时前
优选算法系列(5.位运算)
java·前端·c++·算法
難釋懷7 小时前
JavaScript基础-移动端常见特效
开发语言·前端·javascript