从零开始:用 Vue 3 + Vite 打造一个支持流式输出的 AI 聊天界面

引言

适合人群 :完全没写过代码的小白、刚学 HTML 的新手、对 AI 好奇的任何人
你将学会

✅ 什么是 LLM 流式输出?

✅ 如何用原生 JS 处理二进制流(Buffer)?

✅ 如何用 Vite 快速搭建 Vue 3 项目?

✅ 如何在 Vue 中调用 DeepSeek 等大模型 API 并实现"打字机"效果?


第一章:AI 的"打字机"------什么是流式输出?

想象你去问一个朋友:"讲个喜羊羊的故事"。

  • 非流式回答:他低头想 10 秒,然后一口气说完整个故事。你只能干等。
  • 流式回答:他一边想一边说:"从...前...有...一...只...灰...太...狼..." ------ 你立刻就知道他在讲什么!

这就是 流式输出(Streaming Output)

技术定义:

流式输出是指服务器在生成内容的过程中,边生成、边发送,而不是等全部生成完再一次性返回。

而要实现这种效果,浏览器必须能一块一块地接收数据 ,并实时拼成文字 。这就引出了我们的主角:Buffer(缓冲区)


第二章:手把手拆解 buffer.html ------ 二进制世界的"翻译官"

我们先来看这个看似简单的文件。它其实是在模拟:计算机如何把文字变成网络能传输的"0 和 1",再变回来

xml 复制代码
<!DOCTYPE html>
<!-- 声明文档类型为 HTML5,确保浏览器以标准模式渲染页面 -->
<html lang="en">
<head>
  <!-- 设置字符编码为 UTF-8,支持中文等多语言字符 -->
  <meta charset="UTF-8">
  <!-- 设置视口(viewport),使页面在移动设备上正确缩放和显示 -->
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <!-- 页面标题,在浏览器标签页中显示 -->
  <title>HTML5 Buffer</title>
</head>
<body>
  <!-- 页面主标题,显示"Buffer" -->
  <h1>Buffer</h1>
  <!-- 用于动态显示 JavaScript 处理结果的容器 -->
  <div id="output"></div>

  <!-- 开始嵌入 JavaScript 脚本 -->
  <script>
    // 创建一个 TextEncoder 实例,用于将字符串编码为 UTF-8 格式的 Uint8Array(字节数组)
    // TextEncoder 是 Web API 的一部分,仅支持 UTF-8 编码(这是现代 Web 的标准)
    const encoder = new TextEncoder();
    console.log(encoder); // 在控制台输出 encoder 对象,便于调试(通常显示为 TextEncoder {})

    // 使用 encoder 将字符串 "你好 HTML5" 编码为 UTF-8 字节序列
    // 中文字符"你"和"好"在 UTF-8 中各占 3 字节,空格和 ASCII 字符(H/T/M/L/5)各占 1 字节
    // 总共:3 + 3 + 1 + 1 + 1 + 1 + 1 + 1 = 12 字节
    const myBuffer = encoder.encode("你好 HTML5");
    console.log(myBuffer); // 输出 Uint8Array(12) [228, 189, 160, 229, 165, 189, 32, 72, 84, 77, 76, 53]

    // 创建一个底层的二进制数据缓冲区(ArrayBuffer),大小为 12 字节
    // ArrayBuffer 本身不能直接读写,它只是一个固定长度的原始二进制数据存储区域
    const buffer = new ArrayBuffer(12);

    // 创建一个 Uint8Array 视图(Typed Array),用于以 8 位无符号整数(即字节)的方式操作 buffer
    // Uint8Array 是 ArrayBuffer 的"窗口",允许我们按字节读写数据
    const view = new Uint8Array(buffer);

    // 将 myBuffer(来自 TextEncoder 的 Uint8Array)中的每个字节复制到 view 中
    // 因为 myBuffer 和 view 都是 Uint8Array 类型,可以直接通过索引赋值
    for (let i = 0; i < myBuffer.length; i++) {
      // 可选:取消注释下一行可在控制台查看每个字节值
      // console.log(myBuffer[i]); // 例如:228, 189, 160...
      view[i] = myBuffer[i]; // 将第 i 个字节从 myBuffer 复制到 view(即写入底层 buffer)
    }

    // 创建一个 TextDecoder 实例,用于将二进制数据(如 ArrayBuffer)解码回字符串
    // 默认使用 UTF-8 解码,与 TextEncoder 对应
    const decoder = new TextDecoder();

    // 使用 decoder 将整个 ArrayBuffer(buffer)解码为原始字符串
    // 注意:decoder.decode() 接受 ArrayBuffer 或 TypedArray 作为参数
    const originalText = decoder.decode(buffer);
    console.log(originalText); // 应输出:"你好 HTML5"

    // 获取页面中 id 为 "output" 的 div 元素,用于显示结果
    const outputdiv = document.getElementById("output");

    // 将 view(Uint8Array)转换为字符串形式并插入到 outputdiv 中
    // view.toString() 会输出类似 "228,189,160,229,165,189,32,72,84,77,76,53" 的逗号分隔列表
    // 使用模板字符串(反引号)实现多行或变量插值
    // 模板字符串中的表达式用 ${} 包裹,例如 ${view[0]} 表示插入 view 的第一个字节值
    outputdiv.innerHTML = `
    完整数据:[${view}] <br>
    第一个字节:${view[0]} <br>
    缓冲区的字节长度:${view.byteLength} <br>
    原来的文本:${originalText}
    `;
  </script>
</body>
</html>

第一步:文字 → 二进制(编码)

const encoder = new TextEncoder();

  • TextEncoder 是浏览器内置的一个"翻译工具"。
  • 它的作用:把人类能读的文字,翻译成计算机能传输的数字(字节)
  • 就像把中文翻译成摩斯电码。

小知识:所有网络传输的底层都是 0 和 1。文字、图片、视频最终都要变成数字才能发出去。

const myBuffer = encoder.encode("你好 HTML5");

  • 调用 encode() 方法,把字符串 "你好 HTML5" 转成一串数字。

  • 结果是一个 Uint8Array 对象(你可以把它想象成一个"数字数组")。

  • 实际值是:[228, 189, 160, 229, 165, 189, 32, 72, 84, 77, 76, 53]

    • "你" → [228, 189, 160]
    • "好" → [229, 165, 189]
    • 空格 → [32]
    • "H" → [72],依此类推

为什么是 12 个数字?

因为 UTF-8 编码中:

  • 中文字符占 3 字节
  • 英文字母/数字/空格占 1 字节
    所以:3 + 3 + 1 + 1+1+1+1+1 = 12 字节。

第二步:准备一块"内存白板"

const buffer = new ArrayBuffer(12);

  • ArrayBuffer 是 JavaScript 提供的一种原始二进制数据容器
  • 它就像一张 12 格的空白表格,每格能放一个 0~255 的数字(1 字节)。
  • 但你不能直接往里面写字!它只是"预留空间"。

重要:ArrayBuffer 本身不能读写,必须通过"视图"(View)来操作。

const view = new Uint8Array(buffer);

  • Uint8Array 是一种"视图",意思是:以 8 位无符号整数的方式看这块内存
  • view 现在就是一个长度为 12 的数组,初始值全是 0。
  • 你可以通过 view[0] = 228 这样的方式写入数据。

类比:

  • ArrayBuffer = 一张白纸
  • Uint8Array = 一支笔,让你能在纸上写字

第三步:把数据"抄"到白板上

循环复制

ini 复制代码
for (let i = 0; i < myBuffer.length; i++) {
  view[i] = myBuffer[i];
}
  • 这个循环的意思是:myBuffer 里的每个数字,依次写入 view 的对应位置
  • 比如:view[0] = 228, view[1] = 189, ..., view[11] = 53
  • 现在,viewmyBuffer 内容完全一样了!

💡 为什么需要这一步?

在真实网络中,数据是一块一块到达的。我们需要一个地方(buffer)来临时存放这些碎片,直到拼完整。


第四步:二进制 → 文字(解码)

const decoder = new TextDecoder();

  • TextDecoderTextEncoder 的反向工具。
  • 它的作用:把数字序列还原成人类能读的文字

const originalText = decoder.decode(buffer);

  • 调用 decode(),传入我们准备好的 buffer
  • 浏览器会读取这 12 个字节,按 UTF-8 规则还原成 "你好 HTML5"
  • 成功!文字回来了!

✅ 验证:console.log(originalText) 会打印出 你好 HTML5


第五步:显示结果到网页

ini 复制代码
const outputdiv = document.getElementById("output");
outputdiv.innerHTML = `
完整数据:[${view}] <br>
第一个字节:${view[0]} <br>
缓冲区的字节长度:${view.byteLength} <br>
原来的文本:${originalText}
`;
  • document.getElementById("output"):找到网页中 id="output"<div>
  • innerHTML:设置这个 div 的内容
  • 完整数据:[${view}]:把 view 数组转成字符串,比如 [228,189,160,229,165,189,32,72,84,77,76,53]
  • 第一个字节:${view[0]}:插入 view 的第一个字节值,例如 228
  • 缓冲区的字节长度:${view.byteLength}:插入 view 的字节长度,即 12
  • 原来的文本:${originalText}:插入之前解码的字符串 "你好 HTML5"

最终效果:


第三章:用 Vite 创建 Vue 3 项目(超简单!)

打开终端(Mac 用 Terminal,Windows 用 CMD 或 PowerShell),输入:

perl 复制代码
npm create vite@latest my-ai-chat -- --template vue
cd my-ai-chat
npm install
npm run dev

解释:

  1. npm create vite...:用 Vite 脚手架创建一个叫 my-ai-chat 的 Vue 项目
  2. cd my-ai-chat:进入这个文件夹
  3. npm install:安装依赖(就像下载 App 所需的插件)
  4. npm run dev:启动开发服务器

浏览器会自动打开 http://localhost:5173,看到一个 Vue 欢迎页。


第四章:逐行详解 App.vue ------ 让 AI "打字"给你看!

现在,我们把前面学到的 Buffer 知识,用到真正的 AI 聊天中!

先看整体结构

xml 复制代码
<script setup>
  // JavaScript 逻辑写在这里
</script>

<template>
  <!-- HTML 结构写在这里 -->
</template>

<style scoped>
  /* CSS 样式写在这里 */
</style>

这是 Vue 3 的 单文件组件(SFC) 格式,把逻辑、结构、样式放在一起,非常清晰。


第一部分:定义"会变的数据"(响应式)

csharp 复制代码
import { ref } from 'vue'

const question = ref('讲一个喜羊羊与灰太狼的故事');
const stream = ref(true);
const content = ref('');
  • ref() 是 Vue 3 的魔法函数,用来创建"会自动更新页面的数据"。
  • 比如:当 content.value = "你好" 时,页面上显示 {{content}} 的地方会自动变成"你好"

举个栗子:
question 就像一个"问题盒子",初始装着"讲个故事"
content 就像一个"答案盒子",初始是空的

当 AI 回答时,我们不断往"答案盒子"里加字,页面就自动更新!


第二部分:点击"提交"时做什么?------发起网络请求

javascript 复制代码
const askLLM = async () => {
  if (!question.value) return;

  const endpoint = 'https://api.deepseek.com/chat/completions';
  const headers = {
    'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
    'Content-Type': 'application/json'
  }

  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: 'deepseek-chat',
      stream: stream.value,
      messages: [{ role: 'user', content: question.value }]
    })
  })
4.2.1 const askLLM = async () => { ... }:定义异步函数
  • askLLM 是一个函数的名字,意思是"向大语言模型(LLM)提问"。
  • async 关键字是关键!它告诉 JavaScript:"这个函数里面会有一些需要等待的操作(比如网络请求),但我希望你能聪明地处理,不要卡死整个页面。"

同步 vs 异步:煮咖啡的比喻

  • 同步:你走进咖啡店,点了一杯咖啡,然后站在柜台前一直等,直到咖啡做好。在这期间,你什么都不能做。
  • 异步:你点完咖啡后,拿到一个号码牌,然后你可以去逛书店、看手机。当咖啡好了,店员会叫你的号。你在这期间可以做其他事。

async/await 就是 JavaScript 实现"异步"的优雅方式。

4.2.2 if (!question.value) return;:防御性编程

这是一个很好的习惯。如果用户什么都没输入就点击"提交",我们就直接退出函数,什么都不做。避免发送无效请求。

4.2.3 构建请求:URL、Headers 和 Body

网络请求有三个基本要素:去哪里(URL)带什么身份证明(Headers)说什么(Body)

  1. URL (endpoint)(请求行)

    ini 复制代码
    const endpoint = 'https://api.deepseek.com/chat/completions';

    这是 DeepSeek API 的入口地址。所有请求都要发到这里。

  2. Headers (请求头)

    javascript 复制代码
    const headers = {
      'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
      'Content-Type': 'application/json'
    }
    • 'Authorization':这是你的"身份证"。API 需要验证你是谁,是否有权限使用服务。Bearer 是一种常见的认证方式。

    • 环境变量 import.meta.env.VITE_DEEPSEEK_API_KEY

      • 这是 Vite 框架提供的一个安全机制。
      • 你在项目根目录的 .env 文件里写 VITE_DEEPSEEK_API_KEY=sk-xxx...
      • 在代码中,通过 import.meta.env.VITE_... 来读取。
      • 为什么加 VITE_ 前缀 ?这是 Vite 的规定,只有以 VITE_ 开头的环境变量才会被嵌入到客户端代码中,防止你不小心泄露了服务器端的密钥。
      • 重要提醒 :这种方式只适用于免费或测试用途。在生产环境中,API Key 绝对不应该暴露在前端代码里!应该由你自己的后端服务器来代理请求。
    • 'Content-Type':告诉服务器,"我发给你的数据是 JSON 格式的,请按 JSON 来解析"。

  3. Body (请求体)

    css 复制代码
    body: JSON.stringify({
      model: 'deepseek-chat',
      stream: stream.value,
      messages: [{ role: 'user', content: question.value }]
    })
    • JSON.stringify():把一个 JavaScript 对象转换成 JSON 字符串。因为网络只能传输文本,不能直接传对象。
    • model: 指定要使用的 AI 模型。
    • stream: 这就是我们的"开关"!如果 stream.valuetrue,API 就会启用流式输出模式。
    • messages: 这是对话的历史记录。目前我们只有一条消息,角色是 user(用户),内容是用户输入的问题。
4.2.4 fetch():浏览器内置的"信使"

fetch 是现代浏览器提供的一个用于发起网络请求的全局函数。它返回一个 Promise 对象。

  • await fetch(...)await 会让代码在这里暂停,等待 fetch 的 Promise 完成(即收到服务器的响应),然后把响应对象赋值给 response 变量。
  • 关键点 :即使是在 await 等待的时候,浏览器的 UI 线程依然是畅通无阻的,用户仍然可以滚动页面、点击按钮,这就是异步的威力!

第三部分:处理流式响应(核心中的核心!)

这才是实现"打字机"效果的真正战场。让我们进入 if (stream.value) 分支。

javascript 复制代码
// 当stream.value为true时,开启流式模式:
if (stream.value) {
  // 清空上次的对话记录,准备接收新的流
  content.value = ""
  
  // 获取"数据流读取器" - 像接水管一样接收数据
  const reader = response.body?.getReader()
  
  // 创建解码器 - 把二进制流翻译成文字
  const decoder = new TextDecoder()
  
  let done = false  // 数据流是否结束?刚开始当然没结束
  let buffer = ''   // 临时缓冲区,存放未处理完的数据碎片
  
  // 开始接收数据流的魔法循环
  while(!done) {  // 只要没结束,就继续接收
    // 读取一块数据(await表示耐心等待数据到来)
    const { value, done: doneReading } = await reader?.read()
    // value: 二进制数据块,doneReading: 这次读取是否结束
    
    done = doneReading  // 更新整体结束状态
    
    // 把新数据块和之前未处理完的buffer合并
    const chunkValue = buffer + decoder.decode(value)
    // decoder.decode()把二进制变成字符串,就像把摩斯密码翻译成文字
    
    buffer = ''  // 清空临时缓冲区,准备重新使用
    
    // 把接收到的数据按行分割,只保留以"data: "开头的行
    const lines = chunkValue.split('\n')
      .filter(line => line.startsWith('data: '))
    
    // 逐行处理
    for (const line of lines) {
      const incoming = line.slice(6)  // 去掉"data: "前缀,只保留内容
      
      if (incoming === '[DONE]') {  // AI说:"我说完了"
        done = true  // 标记结束
        break  // 跳出循环
      }
      
      try {
        // 尝试解析JSON数据
        const data = JSON.parse(incoming)  // 把字符串变成JavaScript对象
        
        // 提取AI生成的内容片段
        const delta = data.choices[0].delta.content
        
        if (delta) {  // 如果有新内容
          content.value += delta  // 拼接到显示内容中
          // 这就是"边生成边显示"的魔法所在!
        }
      } catch(err) {
        // JSON解析失败(数据不完整),把数据放回buffer下次再试
        buffer += `data: ${incoming}`
      }
    }
  }
}
4.3.1 response.body?.getReader():获取数据流的"阅读器"
  • response.body 是一个 ReadableStream(可读流)对象。它代表了服务器正在源源不断发送过来的数据。
  • .getReader() 方法会返回一个 StreamReader (流阅读器)。这个阅读器提供了 read() 方法,让我们可以按需、分块地读取数据。

流(Stream) vs 普通响应:水管 vs 水桶

  • 普通响应:服务器把所有水(数据)装进一个大水桶(内存)里,等装满了才一次性倒给你。如果水很多,你会等很久,而且你的家(内存)可能放不下。
  • 流式响应 :服务器打开一根水管,水(数据)一边产生一边流出来。你拿一个杯子(reader.read())在下面接,接到一点就可以用一点。这样既快又省空间。
4.3.2 new TextDecoder():二进制到文本的"翻译官"

正如我们在 buffer.html 中学到的,网络传输的底层是二进制(Uint8Array)。TextDecoder 的作用就是把这些冰冷的数字翻译回我们能读懂的文字。

4.3.3 主循环 while(!done):持续监听数据流

这个 while 循环会一直运行,直到数据流结束(done 变成 true)。

bash 复制代码
const { value, done: doneReading } = await reader?.read()
done = doneReading;
  • reader.read() 也是一个异步操作,它会返回一个 Promise。

  • 这个 Promise 解析后会得到一个对象 { value, done }

    • value: 就是我们期待的数据块,类型是 Uint8Array
    • done: 一个布尔值,表示数据流是否已经结束。
  • 我们用解构赋值 const { value, done: doneReading } 来提取这两个值,并将 done 重命名为 doneReading 以避免和外层的 done 变量冲突。

4.3.4 处理数据块

现在,我们拿到了一个数据块 valueUint8Array)。真正的挑战开始了。

ini 复制代码
const chunkValue = buffer + decoder.decode(value);
buffer = '';
  1. decoder.decode(value) :首先,把二进制数据块 value 翻译成字符串。
  2. buffer + ... :把上次循环中残留的不完整数据(buffer)和这次新来的数据拼在一起。这是处理网络碎片化的关键!
  3. buffer = '' :清空 buffer,准备迎接下一次可能的碎片。
4.3.5 解析 SSE 协议:理解服务器的语言

DeepSeek API 使用的是 SSE (Server-Sent Events) 协议。这是一种服务器向客户端推送事件的简单标准。

SSE 的数据格式非常固定:

kotlin 复制代码
data: {"some": "json"}\n\n
data: {"more": "json"}\n\n
data: [DONE]\n\n
  • 每条有效消息都以 data: 开头。
  • 消息之间用两个换行符 \n\n 分隔。
  • 最后一条消息通常是 data: [DONE],表示流已结束。

因此,我们的解析逻辑如下:

arduino 复制代码
const lines = chunkValue.split('\n').filter(line => line.startsWith('data: '))
  1. chunkValue.split('\n') :把整个字符串按换行符 \n 切分成一个数组。例如,"line1\nline2\n\n" 会被切成 ["line1", "line2", "", ""]
  2. .filter(...) :过滤掉所有不以 data: 开头的行。这能帮我们剔除空行和其他无关信息,只留下有效的数据行。
4.3.6 遍历有效行并提取内容
ini 复制代码
for (const line of lines) {
  const incoming = line.slice(6); // 去掉 "data: "
  if (incoming === '[DONE]') {
    done = true;
    break;
  }
  try {
    const data = JSON.parse(incoming);
    const delta = data.choices[0].delta.content;
    if (delta) {
      content.value += delta;
    }
  } catch(err) {
    buffer += `data: ${incoming}`
  }
}

让我们逐行分析这个精妙的处理过程:

  1. line.slice(6) : data: 这个前缀正好是 6 个字符。slice(6) 会返回从第 7 个字符开始到末尾的子字符串,也就是我们想要的纯 JSON 或 [DONE]

  2. if (incoming === '[DONE]') : 如果是结束信号,就把 done 设为 true,并跳出 for 循环。下一次 while 循环检查到 done 为真,就会退出整个主循环。

  3. try { ... } catch { ... } : 这是处理 JSON 解析错误的关键。为什么会有错误?

    • 原因 :网络传输的不确定性。很可能一个完整的 JSON 字符串 {"choices": [...]} 被切成了两半,第一次只收到了 {"choic,第二次才收到 es": [...]}
    • JSON.parse(incoming) 会尝试把字符串解析成 JavaScript 对象。如果 incoming 不是一个完整的 JSON(比如 {"choic),就会抛出异常。
  4. catch 块里的 buffer += ... :

    • JSON.parse 失败时,说明 incoming 是一个不完整的 JSON 片段
    • 我们不能丢弃它!必须把它存起来。
    • 注意,我们存回去的时候,重新加上了 data: 前缀 。这是因为下一次循环开始时,我们会再次执行 split('\n')filter,需要保证格式正确。
    • 这样,当下一个数据块到来时,buffer(不完整片段)和新数据拼接后,就可能形成一个完整的 JSON 字符串,从而成功解析。
  5. 成功解析后的处理:

    ini 复制代码
    const data = JSON.parse(incoming);
    const delta = data.choices[0].delta.content;
    if (delta) {
      content.value += delta;
    }
    • data.choices[0].delta.content 就是本次新增的文本片段(可能是一个字、一个词,甚至为空)。

    • content.value += delta :这是魔法发生的最后一刻!我们将新片段追加到 content 这个 ref 上。Vue 的响应式系统立刻捕捉到这个变化,并驱动 DOM 更新,让用户看到文字一个接一个地出现。

总结这个循环的智慧 : 整个过程就是一个鲁棒的、能应对网络不确定性的数据拼接和解析引擎。它完美地处理了以下问题:

  • 数据分块到达
  • 数据块边界切割了有效信息
  • 协议格式的解析
  • 实时更新 UI

这就是专业级流式处理的精髓所在。


第四部分:非流式模式(对比学习)

kotlin 复制代码
} else {
  // 等待所有数据到达,然后一次性解析
  const data = await response.json()  // 把整个响应变成JavaScript对象
  
  // 提取完整的回复内容
  content.value = data.choices[0].message.content
  // 一次性显示所有内容
}

这部分代码简洁明了,作为流式模式的对照组,更能凸显流式的优势。

  • response.json():这是一个便捷方法,它会等待整个响应体接收完毕,然后自动将其解析为 JSON 对象。

  • 特点

    • 简单:代码量少,逻辑清晰。
    • 延迟高:用户必须等待 AI 生成完整个回答后才能看到结果。
    • 内存占用高:整个回答必须先加载到内存中。
  • 适用场景:调试、获取短答案、或者后端处理等不需要实时反馈的场景。


第五部分:HTML 模板(用户界面)------连接逻辑与视觉

xml 复制代码
<template>
  <div class="container">
    <div>
      <label>输入:</label>
      <input class="input" v-model="question"/>
      <button @click="askLLM">提交</button>
    </div>
    <div class="output">
      <div>
        <label>Streaming</label>
        <input type="checkbox" v-model="stream" />
        <div>{{content}}</div>
      </div>
    </div>
  </div>
</template>

模板部分虽然简短,但包含了 Vue 最强大的两个指令。

  1. v-model="question" :

    • 这是 双向数据绑定 的语法糖。
    • 它做了两件事: a. 将 input 元素的 value 属性绑定到 question.value。 b. 监听 input 元素的 input 事件,当用户输入时,自动更新 question.value
    • 效果question 和输入框的内容永远保持同步,无论变化来自哪一方。
  2. @click="askLLM" :

    • @v-on: 的缩写,用于监听 DOM 事件。
    • 当用户点击"提交"按钮时,askLLM 函数就会被调用。
  3. {{content}} :

    • 这是 插值表达式
    • Vue 会在此处插入 content.value 的当前值。
    • 由于 content 是响应式的,它的任何变化都会导致此处的文本自动更新。

第五章:运行你的 AI 聊天机器人!

在运行之前,请务必注意以下几点:

  1. API Key 安全 :再次强调,.env.local 文件中的 Key 仅用于学习。切勿将包含真实 Key 的代码提交到 GitHub 等公共仓库。可以创建一个 .gitignore 文件,把 .env 加进去。
  2. CORS 问题 :某些 API 可能会因为跨域资源共享(CORS)策略而拒绝来自 localhost 的请求。如果遇到 CORS error,通常意味着该 API 不允许直接从前端调用,你需要搭建一个自己的后端代理。
  3. 错误处理 :我们的 askLLM 函数目前没有完善的错误处理。在生产代码中,你应该用 try...catch 包裹 fetch 调用,以捕获网络错误、认证失败等情况,并给用户友好的提示。

结语:你做到了!

通过这篇超万字的深度解析,你已经不仅仅是"会用"流式输出,而是真正理解了它背后每一行代码的意图和原理

你掌握了:

  • 原生 JavaScript 如何处理二进制数据(Buffer, TextEncoder/Decoder)
  • 现代 Web API 如何进行异步网络通信(fetch, ReadableStream)
  • 流式协议(SSE)的解析技巧
  • Vue 3 的核心概念(响应式 ref, 单文件组件, 指令 v-model

更重要的是,你体验到了从理论到实践的完整闭环。这种亲手构建、亲手理解的成就感,是任何教程都无法替代的。

下一步小挑战(升级版):

  • 添加加载状态:在 AI 思考时,显示一个"正在输入..."的提示。
  • 美化 UI:用 CSS 让聊天界面看起来更像 ChatGPT。
  • 保存对话历史:让用户能看到之前的问答记录。
  • 搭建后端代理:用 Node.js/Express 写一个简单的后端,将 API Key 保护起来,彻底解决安全问题。

编程不是魔法,而是逻辑的积木。而你,不仅搭出了第一座城堡,还学会了如何设计和制造每一块砖。未来的路,就在你脚下。继续前行吧! 🏰

相关推荐
玉宇夕落1 小时前
Vue 3 实现 LLM 流式输出:从零搭建一个简易 Chat 应用
前端·vue.js
开源之眼1 小时前
github star都很多的 React Native 和 React 有什么区别?一文教你快速分清
前端
听风说图1 小时前
AI编程助手为何总是"健忘"?
前端
踩着两条虫1 小时前
开源一个架构,为什么能让VTJ.PRO在低代码赛道“炸裂”?
前端·低代码
悦来客栈的老板1 小时前
AST反混淆实战|reese84_jsvmp反编译前的优化处理
java·前端·javascript·数据库·算法
爱看书的小沐1 小时前
【小沐学WebGIS】基于Cesium.JS绘制雷达波束/几何体/传感器Sensor(Cesium / vue / react )
javascript·vue.js·react.js·雷达·cesium·传感器·波束
踢球的打工仔1 小时前
前端css(2)
前端·css
半瓶榴莲奶^_^1 小时前
后端Web实战(登录认证)--会话技术,统一拦截技术
前端
zlpzlpzyd1 小时前
vue.js是干什么的?各个版本有什么区别?
前端·javascript·vue.js