在 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>避坑指南与核心优化总结
在开发和调试这个小组件时,我们踩过并解决了以下几个非常关键的痛点:
- 解决纯文本聊天框“排版乱七八糟”的问题: 大模型经常喜欢输出 Markdown 表格(带有
|管道符)或长段落。我们在后端编写了cleanAiResponse清洗函数,强行将表格转换为漂亮的带点列表(•),并移除了所有反引号和粗体符号。同时在前端容器上设置了 CSSwhitespace-pre-wrap,完美解决了换行符失效、文本挤成一团的痛点。 - 防止回答“答非所问”或泛泛而谈: 通过在后端 System Prompt 中加入强力的站点背景锚点(明确告知其专属早安喵栈
929255.xyz的技术栈),AI 现在能够精准、可爱地回答关于本博客的架构、搭建指南和文章推荐。 - 环境变量与 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/
早安喵栈





