现在越来越多的博客开始接入 AI 功能,为读者提供智能摘要和交互式问答。但很多时候现成的插件难以满足高度定制化的需求,或者需要付费订阅才能使用。于是我决定自己动手,打造一个完全免费的博客专属 AI 聊天助手,并且可以在前端实现流式输出,解决大模型生成慢导致的 504 超时问题。
今天我将分享一篇从零开始打造博客专属 AI 聊天助手的完整实战教程!这套方案基于 Netlify Serverless Functions,并在前后端彻底实现了 SSE (Server-Sent Events) 流式输出,完美解决请求超时问题,带来顺滑的打字机对话体验!
0. 前言(API的选择)
本着完全免费的原则,下面推荐几个免费的大模型 API 平台:
Gemini API:
- 作为 Google Gemini 的官方 API,提供了比较慷慨的免费额度。但是国内需要代理,且必须是美国、新加坡等地区的 IP 才能访问,注册门槛较高。
- 但是开通过后可以通过开源项目 openai-gemini 把 Gemini API 伪装成 OpenAI 格式,再部署到 Netlify 国内节点,实现免代理,但是门槛还是较高。
OpenRouter
- 国内直连,是一大优势,带free的是免费模型。
- 目前每天提供50次免费调用,但你充值10 credits,也就是10刀,就有每天1000次的调用次数。充值后的credits,可以用于其他收费模型的调用
SiliconFlow(硅基流动)
- 国内直连,注册门槛低,Qwen3-8B等模型免费额度无限(好像是)但是有很大的速率限制,速度会比较慢。
modelScope(魔塔社区)
- 好像是每天提供2000次免费调用,不是很了解,具体看官网。
BigModel(智谱AI)
- 目前注册送2000万token,还支持免费模型: GLM-4.7-flash等,比较推荐。
本站使用的是智谱AI的免费模型,注册后在后台获取API Key即可使用。
1. 架构思路与准备工作
由于直接在前端页面发起 AI API 请求会暴露敏感的 API Key,我们必须通过服务端的 Serverless 云函数作为中转。
而大多数 Serverless 的免费额度限制为每次请求最多 10 秒,一旦大模型思考超过 10 秒,链接就会被强行切断导致报错。因此,核心解法是利用 HTTP 流(Stream),让后端在拿到 AI 的第一个字时就开始往前端吐数据,从而保持 HTTP 连接常开。
本教程需要用到的工具:
- 前端页面: 任何静态博客(Hexo / Hugo 等均可,提供原生的 HTML / CSS / JS 代码)
- 中间层: Netlify Functions
- 大模型 API: 支持 OpenAI 接口格式的 AI 平台
2. 后端:开发 Netlify Serverless 函数
首先,在博客所在的根目录下安装所需的依赖包:
npm install @netlify/functions node-fetch
新建文件夹和文件 netlify/functions/chat.js,填入以下代码,其中使用了 @netlify/functions 的 stream 方法:
const fetch = require("node-fetch");
const { stream } = require("@netlify/functions");
const DEFAULT_API_URL = ""; // 支持 OpenAI 接口的模型提供商的 API 地址
const DEFAULT_MODEL = ""; // 支持 OpenAI 接口的模型
function buildHeaders() {
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
}
function buildSystemPrompt(articleContextMessage) {
const basePrompt = "你是本博客的专属 AI 助手。请用中文简洁地回答问题。";
if (!articleContextMessage || !articleContextMessage.content)
return basePrompt;
return [basePrompt, articleContextMessage.content].join("\n\n");
}
exports.handler = stream(async function (event) {
if (event.httpMethod === "OPTIONS") {
return { statusCode: 204, headers: buildHeaders(), body: "" };
}
const API_KEY = process.env.API_KEY;
const API_URL = process.env.API_URL || DEFAULT_API_URL;
const MODEL = process.env.API_MODEL || DEFAULT_MODEL;
try {
const requestBody = JSON.parse(event.body || "{}");
const messages = requestBody.messages || [];
const response = await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: MODEL,
messages: [
{
role: "system",
content: buildSystemPrompt(requestBody.articleContext),
},
...messages,
],
stream: true,
}),
});
if (!response.ok) {
return {
statusCode: response.status,
headers: buildHeaders(),
body: await response.text(),
};
}
// 关键所在:直接将 node-fetch 的 Readable Stream 返给前端
return {
statusCode: 200,
headers: {
...buildHeaders(),
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
body: response.body,
};
} catch (error) {
return {
statusCode: 500,
headers: buildHeaders(),
body: JSON.stringify({ error: error.message }),
};
}
});
注意:部署代码前,请务必登录 Netlify 后台配置环境变量 API_KEY。
3. 前端 UI:Tool Island 悬浮窗
在我的主题中,聊天框被集成在了右下角的悬浮工具岛 (Tool Island) 内。我们利用了主题的全局变量 AI_CONFIG。在 EJS 模板库(如 layout/_partials/tool_island.ejs)添加类似如下的 HTML 结构:
<div class="tool-island">
<div class="ai-content">
<div class="ai-messages" id="ai-messages">
<!-- 消息将被插入这里 -->
</div>
<div class="ai-input-area">
<input
type="text"
id="ai-input"
placeholder="发送消息..."
onkeypress="if(event.key==='Enter') sendAiMessage()"
/>
<button onclick="sendAiMessage()">
<i class="fa-solid fa-angle-up"></i>
</button>
</div>
</div>
</div>
<script>
// 从 _config.yml 解析出我们在后端的配置(千万不要暴露 API KEY!)
window.AI_CONFIG = {
enable: <%= theme.ai ? theme.ai.enable : false %>,
api_url: "<%= theme.ai ? theme.ai.api_url : '' %>",
model: "<%= theme.ai ? theme.ai.model : 'Qwen/Qwen3-8B' %>"
};
</script>
4. 前端逻辑:本地存储历史记录与 SSE 打字机流式解析
我们需要拦截用户的输入,带上博客上下文,请求我们刚刚开发的 Serverless 函数。收到响应后,解析 ReadableStream 逐字吐出字符,并将记录存到 localStorage 里实现断点记忆。
在 main.js 核心逻辑如下:
const AI_HISTORY_KEY = "ai_chat_history";
// 处理聊天记录保存逻辑
function appendMessage(role, text, save = true) {
const container = document.getElementById("ai-messages");
const div = document.createElement("div");
div.className = `message ${role === "user" ? "user-message" : "ai-message"}`;
div.innerText = text;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
if (save) {
const history = JSON.parse(localStorage.getItem(AI_HISTORY_KEY) || "[]");
history.push({ role, content: text });
if (history.length > 50) history.shift();
localStorage.setItem(AI_HISTORY_KEY, JSON.stringify(history));
}
}
// 核心发送功能
async function sendAiMessage() {
const input = document.getElementById("ai-input");
const text = input.value.trim();
if (!text) return;
// 清空输入框并显示用户消息
input.value = "";
appendMessage("user", text, true);
// 提前放入一个占位气泡装 AI 回答,不存进 history(等回答完整再存)
appendMessage("assistant", "", false);
const container = document.getElementById("ai-messages");
const msgs = container.querySelectorAll(".ai-message:not(.user-message)");
const contentEl =
msgs.length > 0 ? msgs[msgs.length - 1] : container.lastElementChild;
try {
// 构造带上下文的请求
const rawHistory = JSON.parse(
localStorage.getItem(AI_HISTORY_KEY) || "[]",
).slice(-10);
const contextMessages = rawHistory.map((msg) => ({
role: msg.role,
content: msg.content,
}));
// 提取当前文章文本交作为上下文
const articleContext = {
content:
document.querySelector(".post-content")?.innerText.substring(0, 3000) ||
"",
};
const response = await fetch("/.netlify/functions/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: contextMessages, articleContext }),
});
if (!response.body) throw new Error("您的浏览器不支持 ReadableStream");
// 开始流式解析 SSE (Server-Sent Events)
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let fullReply = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n");
for (const line of lines) {
if (line.trim() === "" || line.trim() === "data: [DONE]") continue;
if (line.startsWith("data: ")) {
try {
const parsed = JSON.parse(line.slice(6));
if (
parsed.choices &&
parsed.choices[0].delta &&
parsed.choices[0].delta.content
) {
fullReply += parsed.choices[0].delta.content;
// 实时逐字更新到界面
contentEl.innerText = fullReply;
container.scrollTop = container.scrollHeight;
}
} catch (e) {
// 包可能被截断导致 JSON 无法解析,暂时忽略等待下一个完整包
}
}
}
}
// 回答完毕,一次性把历史拼回去存进 localStorage
const history = JSON.parse(localStorage.getItem(AI_HISTORY_KEY) || "[]");
history.push({ role: "assistant", content: fullReply });
localStorage.setItem(AI_HISTORY_KEY, JSON.stringify(history));
// 使用 Marked 将最终文本渲染为漂亮的 Markdown
if (typeof marked !== "undefined") {
contentEl.innerHTML = marked.parse(fullReply);
}
} catch (err) {
contentEl.innerText = "出错了:" + err.message;
}
}
5. 总结
在这套设计中,AI 的逻辑被深度融合到了 tool_island.ejs 和 main.js 里,不仅解决了大模型生成速度慢导致的 504 崩溃问题由于有了真正的流式传输,在左下方唤起聊天时便能无缝打字,同时也利用了 localStorage 长久保存你的聊天上下文记录。
跟着上述教程,你也可以亲自改造出一个属于你的定制博客专属 AI 小助手。