当前位置: 首页 > news >正文

基于 fetch + ReadableStream 流式输出 实现 AI 聊天问答

在这里插入图片描述

🤖 作者简介:水煮白菜王,一个web开发工程师 👻
👀 文章专栏: 前端专栏 ,记录一下平时在博客写作中,总结出的一些开发技巧、记录和知识归纳总结✍。
感谢支持💕💕💕

目录

    • ✅ 使用 fetch 获取流式响应并解析
    • 📅后端返回格式(SSE)
    • 🧠 总结
    • 📅 代码示例
      • 🧪 补充:自定义可编辑输入框实现如下:
      • 📌 效果示例:
    • ✅ 最终优化效果示例
    • 如果你觉得这篇文章对你有帮助,请点赞 👍、收藏 👏 并关注我!👀

在构建 AI 聊天问答功能时,通常采用 Server-Sent Events(SSE)进行流式数据推送。然而,标准的 EventSource 接口不支持 POST 请求,仅允许使用 GET 方法发起连接。考虑到需要传递复杂参数或大量请求体的场景,可以使用 fetch 结合 ReadableStream 来模拟 SSE 的流式输出行为。

下面将详细介绍如何通过 fetch + stream 实现一个兼容 POST 请求的流式问答接口对接。

✅ 使用 fetch 获取流式响应并解析

⏳ 代码重点分析

  • 使用 fetch 发起请求,获取响应对象 response。
  • 通过 response.body.getReader() 获取流式数据读取器 ReadableStreamDefaultReader。
  • 读取并解析流式数据:读取、解码、解析、输出更新
  // 请求问答流式输出async getFetchStream() {// 初始化聊天内容占位符this.talkList.push({ id: Date.now(), userType: 0, content: "..." });const responseIndex = this.talkList.length - 1;try {// 构造请求参数const url = "https://models.csdn.net/v1/chat/completions";const API_KEY = "sk-yeaxrzuqufgtkqnvmdzszrsahlaaiyfxmezciiugk";const headers = {Authorization: `Bearer ${API_KEY}`,"Content-Type": "application/json",};const params = {model: `Deepseek-V3`,messages: [{role: "user",content: this.content,},],stream: true,};// fetch发起请求const response = await fetch(url, {method: "POST",headers: headers,body: JSON.stringify(params),});// 检查响应状态if (!response.ok || response.status != 200) {this.talkLoading = false;this.talkList[responseIndex].content = this.chatDesc.responseError;return;}// 获取响应体的读取器const reader = response.body.getReader();// 解码const decoder = new TextDecoder("utf-8");let done = false;let buffer = ""; // 缓冲文本this.talkList[responseIndex].content = ""; // 清除占位符内容while (!done) {const { value, done: doneReading } = await reader.read();done = doneReading;buffer += decoder.decode(value, { stream: !done });let start;// 解析 JSON while ((start = buffer.indexOf("data:")) !== -1) {const end = buffer.indexOf("\n", start);if (end === -1) break;const chunk = buffer.substring(start + 5, end).trim();buffer = buffer.substring(end + 1);console.log(JSON.parse(chunk));// 内容输出try {const data = JSON.parse(chunk);// 提取content内容this.talkList[responseIndex].content +=data.choices[0]?.delta?.content || "";} catch (error) {console.error("Failed to parse JSON:", error);} finally {this.talkLoading = false;}}}} catch (error) {this.talkLoading = false;}},

📌不同模型的接口,返回的流式数据格式可能略有差异,这里调用的是CSDN提供 模型服务接口 ,大部分AI模型厂商返回的数据格式都是按OpenAI 标准的 API 响应格式,来兼容OpenAI 的 API 格式。

📅后端返回格式(SSE)

后端需返回如下格式的流式数据,每条消息以 data: 开头,并以换行 \n 分隔:

data: {"created":1752749854,"usage":null,"model":"Deepseek-V3","id":"0217527498532486352c5e04f127f4d863668155e78c0c219d619","choices":[{"finish_reason":null,"delta":{"role":"assistant","content":"你好"},"index":0}],"object":"chat.completion.chunk"}

⚠️注意事项:

  • 响应头必须包含:Content-Type: text/event-stream
  • 必须开启 flush 输出,确保浏览器能及时接收到流式数据,否则可能因缓冲机制导致延迟

🧩 对比

方法是否支持 POST 请求是否支持 SSE(Server-Sent Events)特点说明
EventSource(或 EventSourcePolyfill❌(仅支持 GET)✅(原生支持)- 原生支持 SSE
- 自动重连机制
- 无法发送请求体(不能传大量参数)
- 只能使用 GET 请求
fetch + ReadableStream✅(模拟支持)- 使用 fetch 获取响应流
- 支持 POST 请求
- 可发送请求体(适合复杂参数)
- 需要手动处理消息解析、连接保持和重连逻辑
- 更加灵活,适用于复杂控制场景

🧠 总结

通过 fetch + ReadableStream 的方式,我们可以在不依赖原生 EventSource 的前提下,实现对 AI 聊天模型服务的流式对接。这种方式不仅支持 POST 请求,还能处理复杂的认证和参数传递需求,适用于现代 Web 应用中对实时性和交互性的高要求场景。

📅 代码示例

在这里插入图片描述

<template><div class="main" id="main"><div class="chat-main"><div class="chat-content w100 h100"><!-- 问答主体talk --><div class="chat-content-list w100" ref="chatContentList"><div:class="['talk', item.userType == 0 ? 'aiTalk' : 'userTalk']"v-for="(item, index) in talkList":key="index"><span v-if="item.userType == 0" v-html="mdRender(item.content)"></span><span v-if="item.userType == 1">{{ item.content }}</span></div></div><!--  --><div class="chat-input w100"><el-form @submit.prevent="getQuestion" class="w100" style="margin: 6px auto 0;width: 57%;"><el-inputplaceholder="请输入内容"v-model="contentVal"size="small"><i slot="suffix" class="el-input__icon el-icon-position" style="cursor: pointer;" @click="getQuestion"></i></el-input></el-form><div class="chat-remark">{{ chatDesc.remark }}</div></div></div></div></div>
</template><script>
export default {name: "aiChat",data() {return {chatDesc: {talkPadding: "回答中",inputTip: "请输入你的问题",placeholder:'来说点什么吧...(试试输入"周报策划"、"自我介绍"或"tag😊"; 挑选优质提示词模版)',MarkdownRead: "Markdown解析器出错! ",streamLoading: "内容输出中! 请稍等...",inputNull: "输入内容不能为空",allError: "无法获取有效内容,请重新尝试!",responseError: "会话出现问题,请重试!",remark:"此对话生成的所有内容均由人工智能模型生成,其生成内容的准确性和完整性无法保证,不代表我们的态度或观点",},talkLoading: false,talkList: [], // 对话列表contentVal: "", // 输入内容content: "", // 对话内容isFocused: false, // 输入框是否获取焦点};},computed: {},updated() {this.scrollToB();},created() {},mounted() {},beforeDestroy() {},methods: {// 解析markdown文本 需要引入markdown插件mdRender(textObj) {const text = textObj.toString();// 如果 markdownit 未加载,返回原文if (!window.markdownit()) {console.warn(this.chatDesc.MarkdownError);return text;}// 尝试解析 Markdowntry {return window.markdownit().render(text);} catch (error) {console.error(`${this.chatDesc.MarkdownRead}`, error);return text;}},// 会话区域滚动scrollToB() {this.$nextTick(() => {let box = this.$el.querySelector(".chat-content-list");if (box) {box.scrollTo({top: box.scrollHeight,behavior: "smooth", // 平滑滚动});}});},// 发起提问getQuestion() {this.content = this.contentVal.trim();if (!this.content) {this.$message.error(this.chatDesc.inputNull);return;}this.talkList.push({id: Date.now(),userType: 1,content: this.content,});this.talkLoading = true;this.getFetchStream();this.contentVal = "";},// 请求问答流式输出async getFetchStream() {// 初始聊天内容占位符this.talkList.push({ id: Date.now(), userType: 0, content: "..." });const responseIndex = this.talkList.length - 1;try {// 构造请求参数const url = "https://models.csdn.net/v1/chat/completions";const API_KEY = "sk-yeaxrzuqufgtkqnvmdzszrsahlaaiyfxmezciiugk";const headers = {Authorization: `Bearer ${API_KEY}`,"Content-Type": "application/json",};const params = {model: `Deepseek-V3`,messages: [{role: "user",content: this.content,},],stream: true,};// fetch发起请求const response = await fetch(url, {method: "POST",headers: headers,body: JSON.stringify(params),});if (!response.ok || response.status != 200) {this.talkLoading = false;this.talkList[responseIndex].content = this.chatDesc.responseError;return;}// 获取响应体的读取器const reader = response.body.getReader();// 解码const decoder = new TextDecoder("utf-8");let done = false;// 缓冲文本let buffer = "";this.talkList[responseIndex].content = ""; // 清除占位符内容while (!done) {const { value, done: doneReading } = await reader.read();done = doneReading;buffer += decoder.decode(value, { stream: !done });let start;// 解析 JSON while ((start = buffer.indexOf("data:")) !== -1) {const end = buffer.indexOf("\n", start);if (end === -1) break;const chunk = buffer.substring(start + 5, end).trim();buffer = buffer.substring(end + 1);console.log(JSON.parse(chunk));// 内容输出try {const data = JSON.parse(chunk);// 提取content内容this.talkList[responseIndex].content += data.choices[0]?.delta?.content || "";} catch (error) {console.error("Failed to parse JSON:", error);} finally {this.talkLoading = false;}}}} catch (error) {this.talkLoading = false;}},// 延迟函数delay(ms) {return new Promise((resolve) => setTimeout(resolve, ms));},},
};
</script>
<style lang="scss" scoped>
.w100 {width: 100%;
}
.h100 {height: 100%;
}
.text-align-left {text-align: left;
}
.text-align-center {text-align: center;
}
.text-align-right {text-align: right;
}
$border-color: #f0f0f0;
$border-radius: 15px;.main {width: 100vw;height: 100vh;background: #fff;overflow: hidden;
}.chat-main {width: calc(100% - 20px);height: calc(100% - 20px);border: 1px solid $border-color;border-radius: $border-radius;overflow: hidden;position: relative;margin: 10px;
}.chat-content {position: relative;margin: 0 auto;font-size: 14px;flex: 1;z-index: 1000;display: flex;flex-direction: column;
}.chat-content-list {flex: 1;margin: 0 auto 10px;overflow: hidden;overflow-y: scroll;transition: height 0.2s ease;width: 60%;
}.talk {display: flex;margin: 10px 10px 25px 10px;font-size: 15px;transition: opacity 0.2s ease;
}.aiTalk span {max-width: calc(100% - 20px);display: inline-block;background: white;border-radius: $border-radius;padding: 0 15px;border: 1px solid $border-color;border-top-left-radius: 2px;word-break: break-all;text-align: left;
}.userTalk {display: flex;flex-direction: row-reverse;margin: 10px 10px 10px 10px;
}
.userTalk span {display: inline-block;border-radius: $border-radius;border-top-right-radius: 2px;background: #eff6ff;padding: 15px;word-break: break-all;text-align: left;
}.chat-input {position: relative;z-index: 1000;margin: 10px auto 0;z-index: 1001;transition: height 0.2s ease;
}.chat-remark {width: 80%;font-size: 11px;margin: 10px auto;color: #a3a3a3;text-align: center;letter-spacing: 0px;
}
</style>

✅ 这段代码主要实现了通过 fetch + ReadableStream 模拟 SSE 流式输出 的基础逻辑,用于支持 AI 聊天问答的实时流式响应。虽然已具备基本的流式接收与渲染能力,但整体实现仍较为简单,缺少中断请求、自动重连、详细的错误处理机制增强功能,在实际生产环境中需进一步完善。

该输入框使用的是标准的 <input> 组件,适用于仅需支持基础文本输入的场景,实现方式简洁且易于维护。但如果需要支持 富文本格式、内容高亮、@提及、代码块插入 等高级功能,则更适合采用 contenteditable="true"<div> 元素,它在内容表现力和交互灵活性方面更具优势。

🧪 补充:自定义可编辑输入框实现如下:

  <!-- 自定义输入框 --><div@keydown.enter.prevent="handleEnter"class="chat-input-box"contenteditable="true"ref="contentEditable":placeholder="contentVal ? '' : chatDesc.placeholder"enterkeyhint="send"v-contenteditable="contentVal":disabled="talkLoading"tabindex="0"@focus="isFocused = true"@blur="isFocused = false"autocomplete="off"spellcheck="false"></div>

📌 效果示例:

在这里插入图片描述

然而,由于contenteditable 元素本身不支持 Vue 的 v-model 双向绑定机制,因此需要通过自定义指令 v-contenteditable 来实现 DOM 与 Vue 数据的双向同步,具体实现方式要看项目框架来做对应策略。

  directives: {// 输入框值绑定指令// 实现 contenteditable 属性元素与 Vue 数据的双向绑定。当用户编辑内容时自动更新数据,当数据变化时自动更新DOM内容。// 解决 DIV 在 contenteditable="true" 模式下输入内容能够正确更新contenteditable: {// 初始化绑定bind(el, binding, vnode) {// el: DOM元素,binding: 绑定对象,vnode: 虚拟节点el.innerHTML = binding.value; // 将初始值设置到可编辑元素el.addEventListener("input", () => {// 监听输入事件vnode.context[binding.expression] = el.innerHTML; // 更新Vue实例数据});},// 数据更新时触发update(el, binding) {if (binding.value !== el.innerHTML) {// 防止无限循环更新el.innerHTML = binding.value; // 同步Vue数据到DOM}},},

✅ 最终优化效果示例

可访问 测试地址 体验实际效果 😊

其中对输入框进行了优化,支持内容高亮与代码块插入功能,结合 DeepSeek R1 模型进行智能思考回复,同时实现了代码语法高亮(Highlight),并提供了快速模板提问等实用功能,整体交互体验更加流畅与高效。

在这里插入图片描述

🌟感谢阅读,如果你在阅读过程中发现任何问题,或有改进建议,也欢迎在评论区指出,我会及时修正并持续优化内容。

如果你觉得这篇文章对你有帮助,请点赞 👍、收藏 👏 并关注我!👀

在这里插入图片描述

http://www.lryc.cn/news/592329.html

相关文章:

  • 平板可以用来办公吗?从文档处理到创意创作的全面测评
  • openinstall九周年:聚焦安全防御,护航业务持续增长
  • 涉及海量数据的查询SQL建议使用“数据库函数”封装并调用
  • TCP通讯开发注意事项及常见问题解析
  • 如何检查GitHub上可能潜在的信息泄漏
  • web开发-HTML
  • leetcode2_135.分发糖果
  • leetcode15.三数之和题解:逻辑清晰带你分析
  • 华为欧拉系统(openEuler)安装 Docker 容器完整教程
  • Gemini Function Calling 和 Qwen3 Embedding和ReRanker模型
  • 服务器清理空间--主要是conda环境清理和删除
  • 弧焊机器人智能节气装置
  • Huber Loss(胡贝损失)详解:稳健回归的秘密武器 + Python实现
  • 【Git专栏】git如何切换到某个commit(超详细)
  • 铁路基础设施无人机巡检技术及管理平台
  • 【IOS webview】IOS13不支持svelte 样式嵌套
  • 计算机网络知名端口分配全表(0-1023)
  • 前端之CSS
  • Http请求中的特殊字符
  • 太阳辐射监测站:洞察太阳能量的科技之眼
  • RabbitMQ—TTL、死信队列、延迟队列
  • k8s:手动创建PV,解决postgis数据库本地永久存储
  • Java Set 集合详解:从基础语法到实战应用,彻底掌握去重与唯一性集合
  • 基于K8s ingress灰度发布配置
  • Docker报错:No address associated with hostname
  • 使用python读取json数据,简单的处理成元组数组
  • 内网部署yum源
  • 美团闪购最新版 mtgsig1.2
  • 从服务实例的元数据中获取配置值 vs 从本地配置文件中获取配置值
  • 4G模块 A7680发送中文短信到手机