在 Astro Firefly 主题中集成专属 AI 智能助手:从 API 配置到 UI 落地全记录

在 Astro Firefly 主题中集成专属 AI 智能助手:从 API 配置到 UI 落地全记录
✨AI 摘要:
🤖 正在生成摘要...

在搭建独立博客的过程中,为访客提供一个轻量、实时且懂你网站的 AI 助手一直是许多独立开发者的心愿。今天这篇文章,就带大家把一个非intrusive(不唐突)、支持上下文问答与文章摘要的 AI 聊天小组件,完美集成到基于 Astro 的 Firefly 主题中。

技术栈选型#

  • 前端框架:Astro (SSR 动态渲染模式)
  • 部署托管:Vercel Serverless Functions
  • AI 引擎:Groq API (选用高性价比且高效的 openai/gpt-oss-20b 模型)
  • UI 交互:原生 Tailwind CSS + 浮动卡片交互,完美兼容 Swup 无刷新跳转

后端 API 接口实现 (src/pages/api/ai.ts)#

为了保证安全性,我们不能把 API Key 暴露在前端。因此,我们需要在 Astro 中创建一个后端的 Serverless 接口。

这里我们不仅实现了基础的聊天问答,还加入了针对长文章的 AI 摘要功能,以及一套全能文本清洗逻辑——用来彻底过滤掉大模型偶尔“抽风”吐出的 Markdown 表格管道符(|)和多余符号,确保在纯文本聊天框中排版清爽。

在项目的 src/pages/api/ 目录下创建 ai.ts 文件:

import type { APIRoute } from 'astro';
export const prerender = false;
// 🧹 全能文本清洗函数:确保在纯文本气泡中格式清爽、多余 Markdown 符号彻底被清除
function cleanAiResponse(text: string): string {
if (!text) return "";
let cleaned = text;
// 1. 转换表格管道符为竖向列表
if (cleaned.includes('|')) {
const lines = cleaned.split('\n');
const formattedLines: string[] = [];
for (const line of lines) {
if (line.replace(/[\s|:\-]+/g, '') === '') continue;
if (line.includes('|')) {
const parts = line.split('|').map(p => p.trim()).filter(Boolean);
if (parts.length > 0) {
formattedLines.push(`• ${parts.join(':')}`);
}
} else {
formattedLines.push(line);
}
}
cleaned = formattedLines.join('\n');
}
// 2. 清除裸露的反引号和粗体符号
cleaned = cleaned.replace(/`/g, '');
cleaned = cleaned.replace(/\*\*/g, '');
// 3. 确保列表项前自带换行,防止粘连
cleaned = cleaned.replace(/([^\n])\s*[-•]\s*/g, '$1\n• ');
cleaned = cleaned.replace(/([^\n])\s*(\d+\.)\s*/g, '$1\n$2 ');
return cleaned.trim();
}
export const POST: APIRoute = async ({ request }) => {
try {
const apiKey = import.meta.env.GROQ_API_KEY || process.env.GROQ_API_KEY;
if (!apiKey) {
return new Response(
JSON.stringify({ result: "服务器未配置 API Key,请检查 Vercel 环境变量设置。" }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
const body = await request.json();
const { action, prompt, text } = body;
let systemPrompt = "";
let userContent = "";
if (action === 'summary') {
systemPrompt = "你是一个专业的博客文章摘要助手。请为以下文章生成一段结构清晰、内容充实的摘要,字数控制在 150~200 字左右,包含文章的核心观点和主要干货,语言亲切自然,不要使用表格。";
userContent = (text || "").slice(0, 3000);
} else {
systemPrompt = `你是「喵栈 AI」小助手,专为独立博客「早安喵栈 (929255.xyz)」服务。
【核心规则】:
1. 语言亲切可爱,控制在 100 字以内。
2. 遇到列表或多项说明时,每条内容必须换行输出(使用 - 或 • 开头)。
3. 严禁使用表格,严禁输出反引号或粗体符号。
【本站背景与精准指引】:
- 站点核心技术:Astro 静态博客框架、Tailwind CSS 自定义样式、Vercel 自动化部署、GitHub 托管、Cloudflare CDN 加速、Markdown/MDX 写作。
- 若询问“有哪些文章推荐”:请推荐本站关于 Astro 博客配置、Vercel 部署实践、Docker/Gatus 自建服务和独立博客搭建的探索记录。
- 若询问“怎么搭建类似博客”:简要列出使用 Astro、Tailwind CSS、Vercel 与 GitHub 部署的步骤。`;
userContent = prompt || "你好";
}
// 封装带自动重试的 Groq 请求函数(应对 429 或 500 抖动)
async function fetchWithRetry(retries = 2, delay = 1000): Promise<any> {
try {
const response = await fetch('https://api.groq.com/openai/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'openai/gpt-oss-20b',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userContent }
],
temperature: 0.2,
max_tokens: 800
})
});
if (!response.ok) {
const errText = await response.text();
if ((response.status === 429 || response.status >= 500) && retries > 0) {
await new Promise(resolve => setTimeout(resolve, delay));
return fetchWithRetry(retries - 1, delay * 2);
}
throw new Error(`Groq 状态码 ${response.status}: ${errText}`);
}
return await response.json();
} catch (error) {
if (retries > 0) {
await new Promise(resolve => setTimeout(resolve, delay));
return fetchWithRetry(retries - 1, delay * 2);
}
throw error;
}
}
const data = await fetchWithRetry();
const rawResult = data.choices?.[0]?.message?.content || "";
const aiResult = cleanAiResponse(rawResult);
if (!aiResult.trim()) {
return new Response(
JSON.stringify({ result: "喵~ AI 刚才走神了,请再点一次发送试试!" }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
return new Response(
JSON.stringify({ result: aiResult }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error: any) {
console.error("❌ AI API 内部错误:", error);
return new Response(
JSON.stringify({ result: "网络开小差啦,请稍后再提问~" }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
};

前端悬浮挂件与 UI 落地 (Layout.astro 或根布局)#

为了让所有页面都能在左下角展示这个可爱的 AI 悬浮球,我们在全局布局文件(例如 Firefly 主题的根 Layout 中)加入了挂件的 HTML 结构与交互脚本。

1. HTML 与样式组件#

在布局文件的底部(</body> 之前)加入悬浮窗 DOM:

<!-- 🤖 全站固定悬浮“喵栈 AI”小助手(固定左下角) -->
<div id="ai-widget" class="fixed bottom-6 left-6 z-50">
<!-- 聊天对话框(默认隐藏) -->
<div id="ai-box" class="hidden w-[360px] h-[460px] mb-4 rounded-2xl border border-black/10 dark:border-white/10 card-base shadow-2xl flex flex-col overflow-hidden text-neutral-800 dark:text-neutral-200">
<!-- 头部 -->
<div class="px-4 py-3 border-b border-black/10 dark:border-white/10 flex justify-between items-center bg-black/5 dark:bg-white/5">
<div class="flex items-center gap-2.5">
<div class="w-7 h-7 rounded-lg bg-teal-500/20 border border-teal-500/30 flex items-center justify-center text-teal-600 dark:text-teal-400 text-sm">🐱</div>
<span class="font-semibold text-sm text-neutral-900 dark:text-white">喵栈 AI</span>
<span class="text-[10px] px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 font-medium">在线</span>
</div>
<div class="flex items-center gap-3 text-neutral-500 dark:text-neutral-400 text-xs">
<button id="ai-clear" class="hover:text-black dark:hover:text-white transition-colors" title="清空对话">🗑️</button>
<button id="ai-close" class="hover:text-black dark:hover:text-white text-base leading-none transition-colors">✕</button>
</div>
</div>
<!-- 消息展示区(关键点:加入 whitespace-pre-wrap 确保换行生效) -->
<div id="ai-messages" class="flex-1 p-4 overflow-y-auto space-y-3.5 text-xs leading-relaxed">
<div class="flex gap-2">
<div class="w-6 h-6 rounded-md bg-teal-500/20 text-teal-600 dark:text-teal-400 flex items-center justify-center text-xs shrink-0">🐱</div>
<div class="space-y-3">
<div class="bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/5 p-3 rounded-2xl rounded-tl-none max-w-[90%] text-neutral-800 dark:text-neutral-200 whitespace-pre-wrap">
喵~ 我是「喵栈 AI」小助手,专门为早安喵栈 (929255.xyz) 服务。有什么想问的吗?
</div>
<!-- 快捷提问按钮 -->
<div class="flex flex-wrap gap-2 pt-1" id="ai-quick-prompts">
<button class="quick-btn px-3 py-1.5 rounded-full bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10 border border-black/10 dark:border-white/10 text-neutral-700 dark:text-neutral-300 text-[11px] transition-all hover:scale-[1.02] active:scale-95">
这个博客用了什么技术?
</button>
<button class="quick-btn px-3 py-1.5 rounded-full bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10 border border-black/10 dark:border-white/10 text-neutral-700 dark:text-neutral-300 text-[11px] transition-all hover:scale-[1.02] active:scale-95">
有哪些文章推荐?
</button>
<button class="quick-btn px-3 py-1.5 rounded-full bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10 border border-black/10 dark:border-white/10 text-neutral-700 dark:text-neutral-300 text-[11px] transition-all hover:scale-[1.02] active:scale-95">
怎么搭建类似的博客?
</button>
</div>
</div>
</div>
</div>
<!-- 底部输入框 -->
<div class="p-3 border-t border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 flex gap-2 items-center">
<input id="ai-input" type="text" placeholder="向喵栈 AI 提问..." class="flex-1 bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-xl px-3.5 py-2 text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 outline-none focus:border-black/30 dark:focus:border-white/30 text-xs transition-colors" />
<button id="ai-send" class="bg-black/10 hover:bg-black/20 dark:bg-white/15 dark:hover:bg-white/25 text-neutral-900 dark:text-white px-3.5 py-2 rounded-xl text-xs font-medium transition-all active:scale-95">发送</button>
</div>
</div>
<!-- 悬浮触发圆球 -->
<button id="ai-toggle" class="w-12 h-12 rounded-full card-base border border-black/10 dark:border-white/10 shadow-2xl flex items-center justify-center text-xl hover:scale-110 active:scale-95 transition-transform">
🐱
</button>
</div>

2. 交互与无刷新挂载脚本 (<script>)#

由于 Astro 主题通常支持 Swup 无刷新路由切换,我们需要确保每次页面跳转后脚本都能正确初始化:

<script is:inline>
function initGlobalAIFeatures() {
const toggleBtn = document.getElementById('ai-toggle');
const closeBtn = document.getElementById('ai-close');
const clearBtn = document.getElementById('ai-clear');
const box = document.getElementById('ai-box');
const sendBtn = document.getElementById('ai-send');
const input = document.getElementById('ai-input');
const msgContainer = document.getElementById('ai-messages');
if (toggleBtn && !toggleBtn.dataset.inited) {
toggleBtn.dataset.inited = 'true';
toggleBtn.addEventListener('click', () => {
box.classList.toggle('hidden');
});
closeBtn?.addEventListener('click', () => box.classList.add('hidden'));
clearBtn?.addEventListener('click', () => {
msgContainer.innerHTML = `
<div class="flex gap-2">
<div class="w-6 h-6 rounded-md bg-teal-500/20 text-teal-600 dark:text-teal-400 flex items-center justify-center text-xs shrink-0">🐱</div>
<div class="space-y-3">
<div class="bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/5 p-3 rounded-2xl rounded-tl-none max-w-[90%] text-neutral-800 dark:text-neutral-200 whitespace-pre-wrap">
对话已清空,我是「喵栈 AI」,随时准备为你解答!
</div>
</div>
</div>`;
});
async function handleSend(textToSend) {
const text = textToSend || input.value.trim();
if (!text) return;
msgContainer.innerHTML += `
<div class="flex justify-end">
<div class="bg-teal-600/20 border border-teal-500/30 text-teal-900 dark:text-teal-100 p-2.5 px-3.5 rounded-2xl rounded-tr-none max-w-[85%] text-xs">
${text}
</div>
</div>`;
if (!textToSend) input.value = '';
msgContainer.scrollTop = msgContainer.scrollHeight;
const loadingId = 'loading-' + Date.now();
msgContainer.innerHTML += `
<div id="${loadingId}" class="flex gap-2">
<div class="w-6 h-6 rounded-md bg-teal-500/20 text-teal-600 dark:text-teal-400 flex items-center justify-center text-xs shrink-0">🐱</div>
<div class="bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/5 p-2.5 px-3.5 rounded-2xl rounded-tl-none max-w-[85%] text-neutral-500 dark:text-neutral-400 animate-pulse text-xs">
正在思考...
</div>
</div>`;
msgContainer.scrollTop = msgContainer.scrollHeight;
try {
const res = await fetch('/api/ai/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'chat', prompt: text })
});
const data = await res.json();
const targetEl = document.getElementById(loadingId);
if (targetEl) {
// 💡 注意:必须加上 whitespace-pre-wrap 类名,否则换行符会被浏览器忽略
targetEl.outerHTML = `
<div class="flex gap-2">
<div class="w-6 h-6 rounded-md bg-teal-500/20 text-teal-600 dark:text-teal-400 flex items-center justify-center text-xs shrink-0">🐱</div>
<div class="bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/5 p-3 rounded-2xl rounded-tl-none max-w-[85%] text-neutral-800 dark:text-neutral-200 text-xs whitespace-pre-wrap">
${data.result || data.error || '出错了,请稍后重试。'}
</div>
</div>`;
}
} catch (err) {
const targetEl = document.getElementById(loadingId);
if (targetEl) {
targetEl.outerHTML = `
<div class="flex gap-2">
<div class="w-6 h-6 rounded-md bg-teal-500/20 text-teal-600 dark:text-teal-400 flex items-center justify-center text-xs shrink-0">🐱</div>
<div class="bg-red-500/10 border border-red-500/20 text-red-600 dark:text-red-300 p-2.5 px-3.5 rounded-2xl rounded-tl-none text-xs">
网络连接失败,请稍后重试。
</div>
</div>`;
}
}
msgContainer.scrollTop = msgContainer.scrollHeight;
}
sendBtn?.addEventListener('click', () => handleSend());
input?.addEventListener('keydown', (e) => e.key === 'Enter' && handleSend());
msgContainer.addEventListener('click', (e) => {
const target = e.target;
if (target && target.classList.contains('quick-btn')) {
handleSend(target.innerText.trim());
}
});
}
}
document.addEventListener('DOMContentLoaded', initGlobalAIFeatures);
document.addEventListener('swup:pageView', initGlobalAIFeatures);
document.addEventListener('astro:page-load', initGlobalAIFeatures);
</script>

避坑指南与核心优化总结#

在开发和调试这个小组件时,我们踩过并解决了以下几个非常关键的痛点:

  1. 解决纯文本聊天框“排版乱七八糟”的问题: 大模型经常喜欢输出 Markdown 表格(带有 | 管道符)或长段落。我们在后端编写了 cleanAiResponse 清洗函数,强行将表格转换为漂亮的带点列表(•),并移除了所有反引号和粗体符号。同时在前端容器上设置了 CSS whitespace-pre-wrap,完美解决了换行符失效、文本挤成一团的痛点。
  2. 防止回答“答非所问”或泛泛而谈: 通过在后端 System Prompt 中加入强力的站点背景锚点(明确告知其专属早安喵栈 929255.xyz 的技术栈),AI 现在能够精准、可爱地回答关于本博客的架构、搭建指南和文章推荐。
  3. 环境变量与 Vercel 部署: 确保在 Vercel 项目后台正确配置了 GROQ_API_KEY 环境变量,并将 Astro 接口设置为 export const prerender = false;(SSR 动态路由模式),接口即可稳定运行。

希望这篇分享能给同样使用 Astro 搭建独立博客的小伙伴们带来一些灵感!快去给你的小站也装上一个专属 AI 助手吧~ 🐱✨

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
在 Astro Firefly 主题中集成专属 AI 智能助手:从 API 配置到 UI 落地全记录
https://929255.xyz/posts/add-ai-to-astro/
作者
ZaoCat
发布于
2026-10-31
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
ZaoCat
A digital momad
最新动态
站点统计
文章
12
分类
2
标签
25
总字数
13,129
运行时长
0 天
最后活动
0 天前