引言
适合人群 :完全没写过代码的小白、刚学 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 - 现在,
view和myBuffer内容完全一样了!
💡 为什么需要这一步?
在真实网络中,数据是一块一块到达的。我们需要一个地方(
buffer)来临时存放这些碎片,直到拼完整。
第四步:二进制 → 文字(解码)
const decoder = new TextDecoder();
TextDecoder是TextEncoder的反向工具。- 它的作用:把数字序列还原成人类能读的文字。
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
解释:
npm create vite...:用 Vite 脚手架创建一个叫my-ai-chat的 Vue 项目cd my-ai-chat:进入这个文件夹npm install:安装依赖(就像下载 App 所需的插件)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) 。
-
URL (
endpoint)(请求行)iniconst endpoint = 'https://api.deepseek.com/chat/completions';这是 DeepSeek API 的入口地址。所有请求都要发到这里。
-
Headers (请求头)
javascriptconst 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 来解析"。
-
-
Body (请求体)
cssbody: JSON.stringify({ model: 'deepseek-chat', stream: stream.value, messages: [{ role: 'user', content: question.value }] })JSON.stringify():把一个 JavaScript 对象转换成 JSON 字符串。因为网络只能传输文本,不能直接传对象。model: 指定要使用的 AI 模型。stream: 这就是我们的"开关"!如果stream.value是true,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 处理数据块
现在,我们拿到了一个数据块 value(Uint8Array)。真正的挑战开始了。
ini
const chunkValue = buffer + decoder.decode(value);
buffer = '';
decoder.decode(value):首先,把二进制数据块value翻译成字符串。buffer + ...:把上次循环中残留的不完整数据(buffer)和这次新来的数据拼在一起。这是处理网络碎片化的关键!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: '))
chunkValue.split('\n'):把整个字符串按换行符\n切分成一个数组。例如,"line1\nline2\n\n"会被切成["line1", "line2", "", ""]。.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}`
}
}
让我们逐行分析这个精妙的处理过程:
-
line.slice(6):data:这个前缀正好是 6 个字符。slice(6)会返回从第 7 个字符开始到末尾的子字符串,也就是我们想要的纯 JSON 或[DONE]。 -
if (incoming === '[DONE]'): 如果是结束信号,就把done设为true,并跳出for循环。下一次while循环检查到done为真,就会退出整个主循环。 -
try { ... } catch { ... }: 这是处理 JSON 解析错误的关键。为什么会有错误?- 原因 :网络传输的不确定性。很可能一个完整的 JSON 字符串
{"choices": [...]}被切成了两半,第一次只收到了{"choic,第二次才收到es": [...]}。 JSON.parse(incoming)会尝试把字符串解析成 JavaScript 对象。如果incoming不是一个完整的 JSON(比如{"choic),就会抛出异常。
- 原因 :网络传输的不确定性。很可能一个完整的 JSON 字符串
-
catch块里的buffer += ...:- 当
JSON.parse失败时,说明incoming是一个不完整的 JSON 片段。 - 我们不能丢弃它!必须把它存起来。
- 注意,我们存回去的时候,重新加上了
data:前缀 。这是因为下一次循环开始时,我们会再次执行split('\n')和filter,需要保证格式正确。 - 这样,当下一个数据块到来时,
buffer(不完整片段)和新数据拼接后,就可能形成一个完整的 JSON 字符串,从而成功解析。
- 当
-
成功解析后的处理:
iniconst 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 最强大的两个指令。
-
v-model="question":- 这是 双向数据绑定 的语法糖。
- 它做了两件事: a. 将
input元素的value属性绑定到question.value。 b. 监听input元素的input事件,当用户输入时,自动更新question.value。 - 效果 :
question和输入框的内容永远保持同步,无论变化来自哪一方。
-
@click="askLLM":@是v-on:的缩写,用于监听 DOM 事件。- 当用户点击"提交"按钮时,
askLLM函数就会被调用。
-
{{content}}:- 这是 插值表达式。
- Vue 会在此处插入
content.value的当前值。 - 由于
content是响应式的,它的任何变化都会导致此处的文本自动更新。
第五章:运行你的 AI 聊天机器人!
在运行之前,请务必注意以下几点:
- API Key 安全 :再次强调,
.env.local文件中的 Key 仅用于学习。切勿将包含真实 Key 的代码提交到 GitHub 等公共仓库。可以创建一个.gitignore文件,把.env加进去。 - CORS 问题 :某些 API 可能会因为跨域资源共享(CORS)策略而拒绝来自
localhost的请求。如果遇到CORS error,通常意味着该 API 不允许直接从前端调用,你需要搭建一个自己的后端代理。 - 错误处理 :我们的
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 保护起来,彻底解决安全问题。
编程不是魔法,而是逻辑的积木。而你,不仅搭出了第一座城堡,还学会了如何设计和制造每一块砖。未来的路,就在你脚下。继续前行吧! 🏰