现在越来越多的博客开始接入 AI 功能,为读者提供智能摘要和交互式问答。但很多时候现成的插件难以满足高度定制化的需求,或者需要付费订阅才能使用。于是我决定自己动手,打造一个完全免费的博客专属 AI 聊天助手,并且可以在前端实现流式输出,解决大模型生成慢导致的 504 超时问题。

今天我将分享一篇从零开始打造博客专属 AI 聊天助手的完整实战教程!这套方案基于 Netlify Serverless Functions,并在前后端彻底实现了 SSE (Server-Sent Events) 流式输出,完美解决请求超时问题,带来顺滑的打字机对话体验!


0. 前言(API的选择)

本着完全免费的原则,下面推荐几个免费的大模型 API 平台:

  1. Gemini API:

    • 作为 Google Gemini 的官方 API,提供了比较慷慨的免费额度。但是国内需要代理,且必须是美国、新加坡等地区的 IP 才能访问,注册门槛较高。
    • 但是开通过后可以通过开源项目 openai-gemini 把 Gemini API 伪装成 OpenAI 格式,再部署到 Netlify 国内节点,实现免代理,但是门槛还是较高。
  2. OpenRouter

    • 国内直连,是一大优势,带free的是免费模型。
    • 目前每天提供50次免费调用,但你充值10 credits,也就是10刀,就有每天1000次的调用次数。充值后的credits,可以用于其他收费模型的调用
  3. SiliconFlow(硅基流动)

    • 国内直连,注册门槛低,Qwen3-8B等模型免费额度无限(好像是)但是有很大的速率限制,速度会比较慢。
  4. modelScope(魔塔社区)

    • 好像是每天提供2000次免费调用,不是很了解,具体看官网。
  5. 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/functionsstream 方法:

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.ejsmain.js 里,不仅解决了大模型生成速度慢导致的 504 崩溃问题由于有了真正的流式传输,在左下方唤起聊天时便能无缝打字,同时也利用了 localStorage 长久保存你的聊天上下文记录。

跟着上述教程,你也可以亲自改造出一个属于你的定制博客专属 AI 小助手。