AI 生成摘要
本文介绍如何为博客打造专属AI聊天助手,利用Netlify Serverless Functions实现SSE流式输出,解决大模型调用时的超时问题。教程基于支持OpenAI接口的SiliconFlow平台,推荐其免费模型如Qwen3-8B,适用于知识问答和摘要功能。通过服务端中转,避免暴露API Key,并确保响应流畅,提供打字机式对话体验。

现在越来越多的博客开始接入 AI 功能,为读者提供智能摘要和交互式问答。但很多时候现成的插件难以满足高度定制化的需求,或者在免费 Serverless 平台上部署时因为大模型的响应太慢而经常引发 504 Gateway Timeout

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

1. 架构思路与准备工作

由于直接在前端页面发起 AI API 请求会暴露敏感的 API Key,我们必须通过服务端的 Serverless 云函数作为中转。
而大多数 Serverless 的免费额度限制为每次请求最多 10 秒,一旦大模型思考超过 10 秒,链接就会被强行切断导致报错。因此,核心解法是利用 HTTP 流(Stream),让后端在拿到 AI 的第一个字时就开始往前端吐数据,从而保持 HTTP 连接常开。

本教程需要用到的工具:

  • 前端页面: 任何静态博客(Hexo / Hugo 等均可,提供原生的 HTML / CSS / JS 代码)
  • 中间层: Netlify Functions
  • 大模型 API: 支持 OpenAI 接口格式的 AI 平台(本教程以注册即送额度且部分模型免费的 SiliconFlow 为例)

API 选择建议

对于个人博客而言,如果我们只是为了让 AI 回答一些博客内的简单问题,完全不需要花钱。
强烈推荐大家去注册 SiliconFlow (硅基流动)。它原生兼容 OpenAI 的接口格式,接入极其方便,而且平台上的 Qwen/Qwen3-8BDeepSeek-R1-0528-Qwen3-8B 的较小参数等知名大模型都是永久免费无限调用的!本教程就是在这个免费模型的基础上跑起来的,响应速度也是秒级,用来做知识问答和摘要绰绰有余。


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 = 'https://api.siliconflow.cn/v1/chat/completions'; // 你也可以换成其他支持 OpenAI 接口的模型提供商的 API 地址
const DEFAULT_MODEL = 'Qwen/Qwen3-8B'; // 你也可以换成其他支持 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.SILICONFLOW_API_KEY; 
  const API_URL = process.env.SILICONFLOW_API_URL || DEFAULT_API_URL;
  const MODEL = process.env.SILICONFLOW_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 后台配置环境变量 SILICONFLOW_API_KEY


3. 前端 UI:Tool Island 悬浮窗

在我的主题中,聊天框被集成在了右下角的悬浮工具岛 (Tool Island) 内。我们利用了主题的全局变量 SUPER_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.SUPER_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 = "gogei_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 小助手。

    for (const line of lines) {
      if (line.trim() === "" || line.trim() === "data: [DONE]") continue;
      
      if (line.startsWith("data: ")) {
        try {
          // 剥离 data: 前缀,解析纯 JSON
          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;
            // 实时更新到正在输出的 DIV 中
            aiMsgEl.innerText = fullReply;
            document.getElementById("ai-messages").scrollTop = document.getElementById("ai-messages").scrollHeight;
          }
        } catch (e) {
          // 注意:偶尔有切包不完整的状况导致 JSON 无法解析,这里必须 catch 忽略以等待下一个完整的包
        }
      }
    }
  }

  // 4. 全部接收完毕后,我们可以利用 marked.js 将回复渲染为丰富的 Markdown 加持格式(代码高亮/表格等)
  if (typeof marked !== "undefined") {
    aiMsgEl.innerHTML = marked.parse(fullReply);
  }

} catch (err) {
  aiMsgEl.innerText = "出错了:" + err.message;
}

}


---

## 5. 总结

至此,你已经拥有了一个完整、定制化且具备完美打字机交互体验的博客 AI 助手。这套架构带来了这三个好处:
1. **防止白嫖:** 前端抓不到 API Key;
2. **极佳体验:** 原生 SSE 数据流,和商业大模型网页没有任何区别;
3. **结合博客内容:** 虽然没有花大价钱做知识库,我们也可以巧妙地通过读取 `document.querySelector` 强行投喂了当页文章内容,简单粗暴且好用。

赶快按照这套教程,开启你的博客智能化之旅吧!如果有任何疑问,欢迎留言交流。