前言
ByteFisher 博客已发布 95+ 篇文章,一个痛点越来越明显:静态博客缺少即时互动能力。
读者经常遇到的问题场景:
"有没有 Unity 相关的入门教程?"
"C# 委托的用法在哪篇文章讲过?"
"你们用的这个图床是什么?"
在之前,这些问题的唯一回答渠道是文章底部的评论区——读者留言,博主看到后回复,周期可能是几小时甚至几天。如果读者没有留下联系方式,即使回复了对方也看不到。
为了解决这个问题,我决定在博客右下角加一个 AI 问答助手悬浮球:读者点击即可提问,基于 DeepSeek 大模型实时获得回复。
本文记录完整的实现过程:后端代理、前端交互、Hexo 集成、Vercel 部署。
一、整体架构
AI 问答助手的架构分为三层:
┌─────────────────────────────────────────────────────────┐
│ 浏览器 (Frontend) │
│ ┌───────────────────┐ ┌───────────────────────────┐ │
│ │ source/js/ │ │ body-end.swig │ │
│ │ ai-assistant.js │ ← │ <script data-pjax> │ │
│ │ │ │ │ │
│ │ createBtn() │ │ styles.styl │ │
│ │ createPanel() │ │ main.css 编译注入 │ │
│ │ sendMessage() │ │ │ │
│ └────────┬──────────┘ └───────────────────────────┘ │
└───────────┼─────────────────────────────────────────────┘
│ POST https://bytefisher-ai.vercel.app/api/chat
│ JSON { messages: [...] }
▼
┌─────────────────────────────────────────────────────────┐
│ Vercel Serverless (Proxy Layer) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ api/chat.js │ │
│ │ ├── setCorsHeaders() ← 动态 origin 检测 │ │
│ │ ├── 验证请求格式 ← messages 存在性检查 │ │
│ │ ├── 读取环境变量 ← DEEPSEEK_API_KEY │ │
│ │ ├── 转发到 DeepSeek ← POST /v1/chat/... │ │
│ │ └── 返回响应 ← JSON + CORS Header │ │
│ └──────────────────────────────────────────────────┘ │
└───────────────────────┬─────────────────────────────────┘
│ POST https://api.deepseek.com/v1/chat/completions
│ Authorization: Bearer sk-xxx
▼
┌─────────────────────────────────────────────────────────┐
│ DeepSeek API │
│ ┌──────────────────────────────────────────────────┐ │
│ │ model: deepseek-chat │ │
│ │ messages: [system, user] │ │
│ │ temperature: 0.7 │ │
│ │ max_tokens: 2000 │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘技术选型对比:
| 方案 | 优点 | 缺点 | 选择理由 |
|---|---|---|---|
| DeepSeek API | 中文友好,¥0.14/百万tokens | — | 成本极低,博客用得起 |
| OpenAI API | 生态完善 | ¥2.5/百万tokens,贵 18 倍 | — |
| 本地 Ollama | 免费 | 需要服务器资源 | — |
| Vercel | 免费额度,自动 HTTPS | 冷启动 1-3s | 已有 Vercel 账号(Waline) |
| Cloudflare Workers | 免费额度更高 | 需额外注册 | — |
二、后端:Vercel 代理 API
2.1 为什么需要代理层
直接在前端调用 DeepSeek API 有两个问题:
API Key 泄露 — 前端代码所有人可见,Key 会被盗用
无法干预 — 请求频率、日志、错误处理都不可控
解决方案:在 Vercel 上部署一个 Serverless Function 作为代理。API Key 存储在 Vercel 环境变量中,前端只与代理交互。代理层还可以处理 CORS、格式校验、错误标准化。
2.2 api/chat.js 完整代码
// api/chat.js — Vercel Serverless Function
// CORS 预处理:动态检测来源域名
function setCorsHeaders(req, res) {
var origin = req.headers.origin || '';
if (
origin === 'https://www.bytefisher.top' ||
origin.indexOf('localhost') !== -1 ||
origin.indexOf('127.0.0.1') !== -1
) {
res.setHeader('Access-Control-Allow-Origin', origin);
} else {
res.setHeader('Access-Control-Allow-Origin', 'https://www.bytefisher.top');
}
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
}
module.exports = async (req, res) => {
setCorsHeaders(req, res);
// OPTIONS 预检请求
if (req.method === 'OPTIONS') {
return res.status(204).end();
}
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
var { messages } = req.body;
if (!messages || !messages.length) {
return res.status(400).json({ error: 'Messages required' });
}
var apiKey = process.env.DEEPSEEK_API_KEY;
if (!apiKey) {
return res.status(500).json({ error: 'API key not configured' });
}
try {
var response = await fetch('https://api.deepseek.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + apiKey
},
body: JSON.stringify({
model: 'deepseek-chat',
messages: messages,
temperature: 0.7,
max_tokens: 2000
})
});
var data = await response.json();
if (response.ok) {
res.status(200).json(data);
} else {
res.status(response.status).json({ error: data.error || 'API error' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
};2.3 关键细节说明
CORS 动态 origin:本地开发时前端运行在 http://localhost:4000,如果 CORS 头写死为 https://www.bytefisher.top,浏览器会拦截请求。通过检测 req.headers.origin 可以实现本地开发和生产环境同时可用。
⚠️ 踩坑:module.exports 不是 export default:Vercel 默认按 CommonJS 解析 .js 文件。如果写成 export default async function handler(...),部署后会报语法错误,API 返回 500。
OPTIONS 预检:浏览器在跨域 POST 请求前会先发一个 OPTIONS 预检。如果不处理 OPTIONS,浏览器直接报 CORS 错误,真正的 POST 不会发出。
2.4 vercel.json
在项目根目录创建 vercel.json,控制 Serverless Function 的超时时间:
{
"functions": {
"api/*.js": {
"maxDuration": 10
}
}
}Vercel 免费 Hobby 计划的 Serverless Function 最长执行 10 秒,超过会超时断开。
三、前端:悬浮球按钮
3.1 设计目标
右下角固定定位,不干扰主内容
默认显示 🎣 emoji
悬停展开显示文字 "AI 助手"
品牌色渐变背景
点击后面板弹出,按钮隐藏
3.2 创建按钮
function createBtn() {
var btn = document.createElement('div');
btn.id = 'ai-assistant-btn';
btn.title = 'AI 问答助手';
btn.innerHTML = '<span class="ai-btn-icon">🎣</span><span class="ai-btn-text">AI 助手</span>';
btn.addEventListener('click', toggle);
document.body.appendChild(btn);
}3.3 悬停展开动画
#ai-assistant-btn
display: flex
align-items: center
gap: 4px
padding: 0 6px 0 14px
height: 44px
border-radius: 22px
background: linear-gradient(135deg, #37c6c0, #32b2ad)
color: #fff
cursor: pointer
z-index: 9998
box-shadow: 0 4px 16px rgba(55,198,192,0.35)
transition: padding 0.3s, opacity 0.3s, box-shadow 0.3s
.ai-btn-icon
font-size: 22px
line-height: 1
.ai-btn-text
font-size: 14px
white-space: nowrap
overflow: hidden
max-width: 0
opacity: 0
transition: max-width 0.3s, opacity 0.3s
&:hover
padding: 0 14px 0 14px
box-shadow: 0 6px 24px rgba(55,198,192,0.45)
.ai-btn-text
max-width: 80px
opacity: 1
&.hidden
opacity: 0
pointer-events: none核心技巧:用 max-width + opacity 的 transition 实现文字展开。默认 max-width: 0 隐藏文字,悬停时设为 80px 并淡入。同时配合 padding 动画,药丸形状从紧凑变为舒展。
四、前端:聊天面板
4.1 面板布局
面板固定定位在右下角,与按钮同位置:
function createPanel() {
var panel = document.createElement('div');
panel.id = 'ai-assistant-panel';
panel.innerHTML =
'<div class="ai-header">' +
'<span>🎣 ' + CONFIG.botName + '</span>' +
'<button class="ai-close">×</button>' +
'</div>' +
'<div class="ai-messages" id="ai-msgs"></div>' +
'<div class="ai-input-area">' +
'<textarea id="ai-input" rows="1" placeholder="' + CONFIG.placeholder + '"></textarea>' +
'<button id="ai-send">发送</button>' +
'</div>';
document.body.appendChild(panel);
}4.2 消息气泡样式
采用对话式 UI 的经典风格:用户消息右对齐,机器人消息左对齐。
.ai-message
max-width: 85%
padding: 10px 14px
border-radius: 12px
font-size: 14px
line-height: 1.6
&.ai-message-user
background: #37c6c0
color: #fff
margin-left: auto
border-bottom-right-radius: 4px
&.ai-message-bot
background: #f0f4f8
color: #333
margin-right: auto
border-bottom-left-radius: 4px
code
background: rgba(0,0,0,0.06)
padding: 2px 6px
border-radius: 4px
pre
background: #1e1e1e
color: #d4d4d4
padding: 12px
border-radius: 8px
overflow-x: auto4.3 Markdown 渲染引擎
不引入任何第三方库,纯前端正则实现:
function render(text) {
// 第一步:XSS 防护 — 转义 HTML 特殊字符
text = text.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
// 第二步:代码块
text = text.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
// 第三步:行内代码
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
// 第四步:加粗
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// 第五步:换行
text = text.replace(/\n/g, '<br>');
return text;
}转义顺序很重要:先转义 HTML,再渲染 Markdown。否则用户输入 <script> 会绕过转义。
4.4 打字机加载动画
三点弹跳,错峰延迟:
@keyframes ai-bounce
0%, 60%, 100%
transform: translateY(0)
30%
transform: translateY(-6px)
.ai-message-typing
background: #f0f4f8
display: flex
gap: 4px
align-items: center
span
width: 6px
height: 6px
border-radius: 50%
background: #ccc
animation: ai-bounce 1.4s infinite
&:nth-child(2)
animation-delay: 0.2s
&:nth-child(3)
animation-delay: 0.4s4.5 键盘快捷键
// Enter 发送,Shift+Enter 换行
document.getElementById('ai-input').addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
});
// Escape 关闭
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && isOpen) toggle();
});五、System Prompt 工程
5.1 提示词设计原则
System Prompt 是 AI 助手的"人格设定",直接影响回答质量。设计时遵循三个原则:
| 原则 | 说明 | 实现 |
|---|---|---|
| 身份明确 | 让模型知道自己是谁 | "你是一个博客助手" |
| 上下文充分 | 提供足够背景信息 | 作者、内容领域、文章数 |
| 约束清晰 | 限制回答风格和范围 | "简洁中文、不确定不编造" |
5.2 完整 Prompt
var system = [
'你是一个博客助手,帮助访客了解 ByteFisher 博客。',
'',
'## 博客基本信息',
'作者:淡水鱼(Unity 游戏开发者 + 钓鱼爱好者)',
'内容领域:Unity3D、C#、Lua、Python、钓鱼技巧、游戏开发教程',
'文章总数:95+ 篇',
'博客地址:https://www.bytefisher.top',
'',
'## 回答规则',
'- 使用简洁的中文回复,可以适当使用 emoji',
'- 不知道的内容不要编造',
'- 如果用户查找文章,引导他们使用搜索功能',
'- 回答控制在 200 字以内'
].join('\n');5.3 边界情况处理
| 场景 | 处理方式 |
|---|---|
| 无关问题(如"今天天气怎么样") | 友好表示能力有限,引导回博客主题 |
| 敏感话题 | 礼貌拒绝 |
| 找不到相关信息 | "抱歉没找到相关内容,换个问法试试?" |
| 连续追问 | 当前设计为单轮问答(不保留历史),每次独立 |
六、Hexo / NexT 集成
6.1 样式注入
NexT 主题支持通过 source/_data/styles.styl 注入自定义样式。所有 AI 助手相关的 CSS 都追加在此文件末尾,会自动编译到 main.css 中。
6.2 脚本加载
在 source/_data/body-end.swig 末尾追加一行:
<!-- AI 博客助手 -->
<script src="/js/ai-assistant.js" data-pjax></script>data-pjax 属性是关键:NexT 使用 PJAX 实现无刷新页面切换,加了此属性的脚本会在每次 PJAX 渲染时重新执行。
6.3 ⚠️ 踩坑:PJAX 重复创建
首次加载页面正常。但在 PJAX 导航到其他页面后,脚本重新执行,每次都创建一个新的悬浮球和面板,欢迎语跟着叠加。
function init() {
+ if (document.getElementById('ai-assistant-btn')) return;
createBtn();
createPanel();
addMsg('bot', CONFIG.welcomeMessage);
autoResizeInput();
}加一行防重复检查即可:如果按钮已存在,跳过创建。
6.4 文件清单
| 文件 | 操作 | 行数 | 说明 |
|---|---|---|---|
api/chat.js | 新建 | 62 | Vercel Serverless Function |
vercel.json | 新建 | 6 | Vercel 配置 |
source/js/ai-assistant.js | 新建 | 167 | 前端全部逻辑 |
source/_data/styles.styl | 追加 | ~120 | AI 助手样式 |
source/_data/body-end.swig | 追加 | 1 | 加载脚本标签 |
七、Vercel 部署指南
7.1 创建 Vercel 项目
1. 登录 https://vercel.com(与 Waline 同一个账号)
2. Dashboard → Add New → Project
3. 选择 BlogCode 仓库 → Import
4. Configure Project:
├── Framework Preset: Other
├── Root Directory: ./
├── Build Command: (留空)
└── Output Directory: (留空)
5. 点击 Environment Variables
├── Name: DEEPSEEK_API_KEY
└── Value: sk-xxxxxxxxxxxxxxxxx
6. 点击 Deploy,等待 1-2 分钟7.2 配置前端 API 地址
部署完成后,Vercel 会分配一个域名,如 https://bytefisher-ai.vercel.app。
将 source/js/ai-assistant.js 中的 API 地址更新为实际地址:
var CONFIG = {
apiEndpoint: 'https://bytefisher-ai.vercel.app/api/chat',
// ↑ 替换为你的 Vercel 项目 URL
};7.3 验证 API
用 curl 测试 API 是否正常工作:
curl -X POST https://bytefisher-ai.vercel.app/api/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"你好"}]}'期望返回格式:
{
"choices": [
{
"message": {
"content": "你好!欢迎来到 ByteFisher 博客...",
"role": "assistant"
}
}
]
}浏览器直接 GET 访问 API 应返回 {"error":"Method not allowed"},这是正常的(只接受 POST)。
7.4 触发重新部署
Vercel 默认自动连接 GitHub,推送代码后自动重新部署。如果初始项目创建时 api/ 目录还不存在,可能不会自动检测变化,需要手动 Redeploy:
Vercel Dashboard → 项目 → Deployments → 最新 commit → Redeploy八、完整交互流程
用户操作链路:
打开博客首页
→ 右下角出现 🎣 悬浮球
→ 鼠标悬停 → 展开显示 "AI 助手"
→ 点击悬浮球 → 面板弹出,按钮隐藏
→ 显示欢迎语:
"🎣 欢迎来到 ByteFisher 博客!
我是 ByteBot,可以帮你:
📖 推荐文章
💡 解答技术问题
🎯 了解博客内容
有什么想了解的?"
→ 输入 "你们博客有哪些 Unity 文章?"
→ 按 Enter → 三点跳动加载
→ AI 回复(支持代码块、加粗等 Markdown 渲染)
→ 按 Escape 或点击 × 关闭面板
→ 按钮恢复显示数据流转时序:
Frontend Vercel Proxy DeepSeek API
│ │ │
├── POST /api/chat ────────┤ │
│ { messages: [...] } │ │
│ ├── POST /v1/chat/completions ──┤
│ │ Authorization: Bearer │
│ │ Body: { messages, ... } │
│ │ │
│ │ ←── 200 JSON ──────────┤
│ ←── 200 JSON ──────────┤ { choices: [...] } │
│ { choices: [...] } │ │
│ │ │
│ 渲染消息到面板 │ │错误处理路径:
| 故障场景 | 表现 | 用户看到 |
|---|---|---|
| 网络断开 | fetch 超时 | "网络开小差了,请稍后重试 🐟" |
| API Key 无效 | DeepSeek 返回 401 | "抱歉没理解,换个问法试试?" |
| 请求超时 | Vercel 10s 超时 | "网络开小差了,请稍后重试 🐟" |
| 参数错误 | 前端校验 | 不发送请求,提示用户输入内容 |
九、费用与性能评估
费用估算
| 项目 | 计算公式 | 月费 |
|---|---|---|
| DeepSeek 输入 | ¥0.14/百万tokens × 4.5万tokens/月 | ≈ ¥0.0063 |
| DeepSeek 输出 | ¥0.28/百万tokens × 0.5万tokens/月 | ≈ ¥0.0014 |
| Vercel 托管 | Hobby 计划免费额度 | ¥0 |
| 总计 | 日均 30 次问答 | ≈ ¥0.01/月 |
按日均 30 次问答,每次约 1500 tokens(含 system prompt)计算。DeepSeek 的价格极低,几乎可以忽略不计。
性能指标
| 阶段 | 耗时 | 说明 |
|---|---|---|
| Vercel 冷启动 | 0.5-2s | 闲置 15 分钟后首次请求 |
| DeepSeek 推理 | 0.8-2s | 模型响应时间 |
| 网络传输 | 0.2-0.5s | 客户端 → Vercel → DeepSeek |
| 总耗时 | 1.5-4.5s | 冷启动时更慢,后续请求更快 |
对于个人博客的流量,冷启动不可避免。Vercel Hobby 计划在 15 分钟无请求后会回收实例。但 1-3s 的等待对问答场景来说可以接受。
十、总结
改造前后对比
| 维度 | 之前 | 之后 |
|---|---|---|
| 互动方式 | 评论区留言,等待回复 | AI 即时问答 |
| 覆盖范围 | 仅文章底部 | 全站右下角悬浮球 |
| 回答问题 | 博主自己回复 | DeepSeek 大模型 |
| 技术栈 | — | DeepSeek + Vercel + 原生 JS |
| 月运营成本 | — | ≈ ¥0.01 |
技术收获
整个实现过程中的几个关键经验:
Vercel Serverless 函数要用 CommonJS:
module.exports而非export defaultCORS 要动态检测 origin:本地开发(localhost)和生产环境(bytefisher.top)需要不同允许来源
PJAX 兼容要防重复创建:
data-pjax脚本每次导航都执行,需要在init()中加守卫检查纯前端 Markdown 渲染:不引入第三方库也能满足基本需求,HTML 转义顺序至关重要
可以做的下一步扩展
对话历史:用 localStorage 持久化对话记录,刷新不丢失
RAG 检索增强:结合博客的
search.json做文章检索,AI 可以基于博客内容回答,不依赖模型训练数据多人格切换:钓鱼助手、编程助手、闲聊助手三种模式
语音输入:集成 Web Speech API,支持语音提问
本文所有代码托管在 [GitHub](https://github.com/grj1981/BlogCode),欢迎 Star 和交流。
分类:Hexo博客搭建
标签:series:Hexo博客搭建、Hexo、Next主题、AI、DeepSeek、博客优化

夜雨聆风