前言
ByteFisher 博客已发布 95+ 篇文章,一个痛点越来越明显:静态博客缺少即时互动能力。
读者经常遇到的问题场景:
- “有没有 Unity 相关的入门教程?”
- “C# 委托的用法在哪篇文章讲过?”
- “你们用的这个图床是什么?”
在之前,这些问题的唯一回答渠道是文章底部的评论区——读者留言,博主看到后回复,周期可能是几小时甚至几天。如果读者没有留下联系方式,即使回复了对方也看不到。
为了解决这个问题,我决定在博客右下角加一个 AI 问答助手悬浮球:读者点击即可提问,基于 DeepSeek 大模型实时获得回复。
本文记录完整的实现过程:后端代理、前端交互、Hexo 集成、Vercel 部署。
一、整体架构
AI 问答助手的架构分为三层:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| ┌─────────────────────────────────────────────────────────┐ │ 浏览器 (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 完整代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
|
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);
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 的超时时间:
1 2 3 4 5 6 7
| { "functions": { "api/*.js": { "maxDuration": 10 } } }
|
Vercel 免费 Hobby 计划的 Serverless Function 最长执行 10 秒,超过会超时断开。
三、前端:悬浮球按钮
3.1 设计目标
- 右下角固定定位,不干扰主内容
- 默认显示 🎣 emoji
- 悬停展开显示文字 “AI 助手”
- 品牌色渐变背景
- 点击后面板弹出,按钮隐藏
3.2 创建按钮
1 2 3 4 5 6 7 8
| 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 悬停展开动画
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| #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 面板布局
面板固定定位在右下角,与按钮同位置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 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 的经典风格:用户消息右对齐,机器人消息左对齐。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| .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: auto
|
4.3 Markdown 渲染引擎
不引入任何第三方库,纯前端正则实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| function render(text) { 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 打字机加载动画
三点弹跳,错峰延迟:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| @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.4s
|
4.5 键盘快捷键
1 2 3 4 5 6 7 8 9 10 11 12
| document.getElementById('ai-input').addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } });
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && isOpen) toggle(); });
|
五、System Prompt 工程
5.1 提示词设计原则
System Prompt 是 AI 助手的”人格设定”,直接影响回答质量。设计时遵循三个原则:
| 原则 |
说明 |
实现 |
| 身份明确 |
让模型知道自己是谁 |
“你是一个博客助手” |
| 上下文充分 |
提供足够背景信息 |
作者、内容领域、文章数 |
| 约束清晰 |
限制回答风格和范围 |
“简洁中文、不确定不编造” |
5.2 完整 Prompt
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 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 末尾追加一行:
1 2
| <script src="/js/ai-assistant.js" data-pjax></script>
|
data-pjax 属性是关键:NexT 使用 PJAX 实现无刷新页面切换,加了此属性的脚本会在每次 PJAX 渲染时重新执行。
6.3 ⚠️ 踩坑:PJAX 重复创建
首次加载页面正常。但在 PJAX 导航到其他页面后,脚本重新执行,每次都创建一个新的悬浮球和面板,欢迎语跟着叠加。
1 2 3 4 5 6 7
| 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 2 3 4 5 6 7 8 9 10 11 12
| 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 地址更新为实际地址:
1 2 3 4
| var CONFIG = { apiEndpoint: 'https://bytefisher-ai.vercel.app/api/chat', };
|
7.3 验证 API
用 curl 测试 API 是否正常工作:
1 2 3
| curl -X POST https://bytefisher-ai.vercel.app/api/chat \ -H "Content-Type: application/json" \ -d '{"messages":[{"role":"user","content":"你好"}]}'
|
期望返回格式:
1 2 3 4 5 6 7 8 9 10
| { "choices": [ { "message": { "content": "你好!欢迎来到 ByteFisher 博客...", "role": "assistant" } } ] }
|
浏览器直接 GET 访问 API 应返回 {"error":"Method not allowed"},这是正常的(只接受 POST)。
7.4 触发重新部署
Vercel 默认自动连接 GitHub,推送代码后自动重新部署。如果初始项目创建时 api/ 目录还不存在,可能不会自动检测变化,需要手动 Redeploy:
1
| Vercel Dashboard → 项目 → Deployments → 最新 commit → Redeploy
|
八、完整交互流程
用户操作链路:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| 打开博客首页 → 右下角出现 🎣 悬浮球 → 鼠标悬停 → 展开显示 "AI 助手" → 点击悬浮球 → 面板弹出,按钮隐藏 → 显示欢迎语: "🎣 欢迎来到 ByteFisher 博客!
我是 ByteBot,可以帮你: 📖 推荐文章 💡 解答技术问题 🎯 了解博客内容
有什么想了解的?" → 输入 "你们博客有哪些 Unity 文章?" → 按 Enter → 三点跳动加载 → AI 回复(支持代码块、加粗等 Markdown 渲染) → 按 Escape 或点击 × 关闭面板 → 按钮恢复显示
|
数据流转时序:
1 2 3 4 5 6 7 8 9 10 11 12 13
| 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 default
- CORS 要动态检测 origin:本地开发(localhost)和生产环境(bytefisher.top)需要不同允许来源
- PJAX 兼容要防重复创建:
data-pjax 脚本每次导航都执行,需要在 init() 中加守卫检查
- 纯前端 Markdown 渲染:不引入第三方库也能满足基本需求,HTML 转义顺序至关重要
可以做的下一步扩展
- 对话历史:用 localStorage 持久化对话记录,刷新不丢失
- RAG 检索增强:结合博客的
search.json 做文章检索,AI 可以基于博客内容回答,不依赖模型训练数据
- 多人格切换:钓鱼助手、编程助手、闲聊助手三种模式
- 语音输入:集成 Web Speech API,支持语音提问
本文所有代码托管在 GitHub,欢迎 Star 和交流。