Appearance
AI 聊天助手实现计划
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: 为 VitePress 文档站添加 AI 聊天浮窗,能搜索文档内容并通过 Vercel Edge Function 调用 AI HUB API 回答用户问题。
Architecture: 构建时用 VitePress buildEnd hook 遍历 md 文件生成 docs-index.json;前端 ChatWidget.vue 加载索引,用 fuse.js 检索相关文档片段,POST 到 /api/chat;Vercel Edge Function 持有 API Key,透传流式响应给前端。
Tech Stack: VitePress 1.6.4、Vue 3、fuse.js、gray-matter、Vercel Edge Functions(TypeScript)
Task 1: 安装依赖
Files:
- Modify:
package.json
Step 1: 安装 fuse.js 和 gray-matter
bash
npm install fuse.js
npm install --save-dev gray-matterfuse.js 是前端运行时依赖;gray-matter 只在构建时用,放 devDependencies。
Step 2: 验证安装成功
bash
node -e "import('fuse.js').then(() => console.log('fuse.js ok'))"
node -e "import('gray-matter').then(() => console.log('gray-matter ok'))"Expected: 两行均输出 ok
Step 3: Commit
bash
git add package.json package-lock.json
git commit -m "chore: 安装 fuse.js 和 gray-matter"Task 2: 构建时生成文档索引
Files:
- Modify:
docs/.vitepress/config.ts
Step 1: 在 config.ts 顶部添加 import
在文件第 1 行已有的 import 后,追加:
typescript
import fs from "fs";
import path from "path";
import matter from "gray-matter";Step 2: 在 defineConfig 中添加 buildEnd hook
在 ignoreDeadLinks: true, 之后,markdown: 之前插入:
typescript
// 构建完成后生成文档索引供 AI 聊天搜索使用
async buildEnd(siteConfig) {
const docs: { path: string; title: string; content: string; category: string }[] = [];
const docsDir = path.resolve(__dirname, "../");
function walkDir(dir: string, baseDir = "") {
const files = fs.readdirSync(dir);
for (const file of files) {
const fullPath = path.join(dir, file);
const relativePath = path.join(baseDir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory() && !file.startsWith(".")) {
walkDir(fullPath, relativePath);
} else if (file.endsWith(".md") && file !== "index.md") {
const raw = fs.readFileSync(fullPath, "utf8");
const { data, content: body } = matter(raw);
docs.push({
path: relativePath.replace(/\.md$/, ""),
title: data.title || file.replace(".md", ""),
content: body.slice(0, 1500),
category: path.dirname(relativePath),
});
}
}
}
walkDir(docsDir);
const outPath = path.resolve(siteConfig.outDir, "docs-index.json");
fs.writeFileSync(outPath, JSON.stringify({ docs }));
console.log(`✓ 生成文档索引:${docs.length} 篇`);
},Step 3: 构建并验证索引文件生成
bash
npm run docs:build 2>&1 | grep "生成文档索引"Expected: ✓ 生成文档索引:N 篇(N > 0)
bash
node -e "const d = JSON.parse(require('fs').readFileSync('docs/.vitepress/dist/docs-index.json','utf8')); console.log(d.docs.length, 'docs,', JSON.stringify(d.docs[0]))"Expected: 输出文档数量和第一篇文档的结构(含 path、title、content、category)
Step 4: Commit
bash
git add docs/.vitepress/config.ts
git commit -m "feat: 构建时生成文档索引 docs-index.json"Task 3: 创建 Vercel Edge Function
Files:
- Create:
api/chat.ts
Step 1: 创建 api 目录和 chat.ts
bash
mkdir -p api创建 api/chat.ts:
typescript
export const config = {
runtime: "edge",
};
export default async function handler(req: Request): Promise<Response> {
// 只允许 POST
if (req.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
let question: string;
let context: string;
try {
const body = await req.json();
question = String(body.question ?? "").slice(0, 500);
context = String(body.context ?? "").slice(0, 6000);
} catch {
return new Response("Bad Request", { status: 400 });
}
if (!question) {
return new Response("question is required", { status: 400 });
}
const apiKey = process.env.AI_CHAT_API_KEY;
const apiUrl = process.env.AI_CHAT_API_URL;
const model = process.env.AI_CHAT_MODEL ?? "gpt-4o-mini";
if (!apiKey || !apiUrl) {
return new Response("Server configuration error", { status: 500 });
}
const systemPrompt = context
? `你是一个文档助手,根据以下文档内容回答用户问题。如果文档中没有相关信息,请如实说明。\n\n${context}`
: "你是一个文档助手,请回答用户的问题。";
const upstream = await fetch(`${apiUrl}/v1/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
stream: true,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: question },
],
}),
});
if (!upstream.ok) {
const err = await upstream.text();
return new Response(`Upstream error: ${err}`, { status: 502 });
}
// 直接透传流式响应
return new Response(upstream.body, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Access-Control-Allow-Origin": "*",
},
});
}Step 2: 验证文件结构正确
bash
node --input-type=module <<'EOF'
import { readFileSync } from 'fs'
const content = readFileSync('api/chat.ts', 'utf8')
console.log(content.includes('edge') ? 'runtime: edge ✓' : '❌ missing edge runtime')
console.log(content.includes('AI_CHAT_API_KEY') ? 'env vars ✓' : '❌ missing env vars')
console.log(content.includes('stream: true') ? 'streaming ✓' : '❌ missing stream')
EOFExpected: 三行全部 ✓
Step 3: Commit
bash
git add api/chat.ts
git commit -m "feat: 添加 Vercel Edge Function 代理 AI 请求"Task 4: 创建 ChatWidget Vue 组件
Files:
- Create:
docs/.vitepress/theme/components/ChatWidget.vue
Step 1: 创建组件文件
创建 docs/.vitepress/theme/components/ChatWidget.vue:
vue
<template>
<!-- 悬浮触发按钮 -->
<button
class="chat-trigger"
:class="{ active: isOpen }"
@click="isOpen = !isOpen"
aria-label="AI 文档助手"
>
<span v-if="!isOpen">💬</span>
<span v-else>✕</span>
</button>
<!-- 聊天面板 -->
<Transition name="chat-slide">
<div v-if="isOpen" class="chat-panel">
<div class="chat-header">
<span class="chat-title">📚 店小二-有问题随时咨询我</span>
<span class="chat-hint">基于本站文档内容回答</span>
</div>
<div class="chat-messages" ref="messagesRef">
<div v-if="messages.length === 0" class="chat-empty">
有什么关于文档的问题?尽管问吧 👋
</div>
<div
v-for="msg in messages"
:key="msg.id"
class="chat-message"
:class="msg.role"
>
<div class="message-content">{{ msg.content }}</div>
</div>
<div v-if="streaming" class="chat-message assistant">
<div class="message-content">{{ streamingText }}<span class="cursor">▍</span></div>
</div>
</div>
<div class="chat-input-area">
<input
v-model="input"
@keydown.enter.prevent="sendMessage"
:disabled="streaming"
placeholder="输入问题,按 Enter 发送..."
ref="inputRef"
class="chat-input"
/>
<button
@click="sendMessage"
:disabled="streaming || !input.trim()"
class="chat-send"
>
{{ streaming ? '…' : '发送' }}
</button>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, onMounted } from 'vue'
import Fuse from 'fuse.js'
interface DocItem {
path: string
title: string
content: string
category: string
}
interface Message {
id: number
role: 'user' | 'assistant'
content: string
}
const isOpen = ref(false)
const input = ref('')
const messages = ref<Message[]>([])
const streaming = ref(false)
const streamingText = ref('')
const messagesRef = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
let fuse: Fuse<DocItem> | null = null
// 加载文档索引
onMounted(async () => {
try {
const res = await fetch('/docs-index.json')
const data = await res.json()
fuse = new Fuse<DocItem>(data.docs, {
keys: [
{ name: 'title', weight: 2 },
{ name: 'content', weight: 1 },
],
threshold: 0.4,
includeScore: true,
})
} catch {
console.warn('[ChatWidget] 加载文档索引失败')
}
})
// 打开面板时聚焦输入框
watch(isOpen, async (val) => {
if (val) {
await nextTick()
inputRef.value?.focus()
}
})
// 消息更新时滚动到底部
async function scrollToBottom() {
await nextTick()
if (messagesRef.value) {
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
}
}
async function sendMessage() {
const question = input.value.trim()
if (!question || streaming.value) return
// 添加用户消息
messages.value.push({ id: Date.now(), role: 'user', content: question })
input.value = ''
await scrollToBottom()
// 检索相关文档
let context = ''
if (fuse) {
const results = fuse.search(question).slice(0, 3)
context = results
.map((r) => `# ${r.item.title}\n${r.item.content}`)
.join('\n\n---\n\n')
}
// 调用 Edge Function,流式接收
streaming.value = true
streamingText.value = ''
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question, context }),
})
if (!res.ok || !res.body) {
throw new Error(`HTTP ${res.status}`)
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
// SSE 格式:每行 "data: {...}" 或 "data: [DONE]"
for (const line of chunk.split('\n')) {
if (!line.startsWith('data: ')) continue
const payload = line.slice(6).trim()
if (payload === '[DONE]') continue
try {
const json = JSON.parse(payload)
const delta = json.choices?.[0]?.delta?.content ?? ''
streamingText.value += delta
await scrollToBottom()
} catch {
// 忽略解析错误的行
}
}
}
// 流结束,将内容加入消息列表
messages.value.push({
id: Date.now(),
role: 'assistant',
content: streamingText.value,
})
} catch (err) {
messages.value.push({
id: Date.now(),
role: 'assistant',
content: '抱歉,请求失败,请稍后再试。',
})
} finally {
streaming.value = false
streamingText.value = ''
await scrollToBottom()
}
}
</script>
<style scoped>
.chat-trigger {
position: fixed;
bottom: 24px;
right: 24px;
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--vp-c-brand-1);
color: #fff;
border: none;
font-size: 20px;
cursor: pointer;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
z-index: 1001;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s, background 0.2s;
}
.chat-trigger:hover {
transform: scale(1.08);
}
.chat-trigger.active {
background: var(--vp-c-text-2);
}
.chat-panel {
position: fixed;
bottom: 84px;
right: 24px;
width: 360px;
height: 520px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 16px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
z-index: 1000;
overflow: hidden;
}
.chat-header {
padding: 14px 16px 10px;
border-bottom: 1px solid var(--vp-c-divider);
display: flex;
flex-direction: column;
gap: 2px;
}
.chat-title {
font-weight: 600;
font-size: 15px;
}
.chat-hint {
font-size: 12px;
color: var(--vp-c-text-3);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 14px 14px 8px;
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-empty {
color: var(--vp-c-text-3);
font-size: 14px;
text-align: center;
margin-top: 40px;
}
.chat-message {
max-width: 88%;
padding: 8px 12px;
border-radius: 12px;
font-size: 14px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.chat-message.user {
background: var(--vp-c-brand-1);
color: #fff;
align-self: flex-end;
border-bottom-right-radius: 4px;
}
.chat-message.assistant {
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-1);
align-self: flex-start;
border-bottom-left-radius: 4px;
}
.cursor {
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
.chat-input-area {
padding: 10px 12px;
border-top: 1px solid var(--vp-c-divider);
display: flex;
gap: 8px;
}
.chat-input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-1);
font-size: 14px;
outline: none;
transition: border-color 0.15s;
}
.chat-input:focus {
border-color: var(--vp-c-brand-1);
}
.chat-input:disabled {
opacity: 0.6;
}
.chat-send {
padding: 8px 14px;
background: var(--vp-c-brand-1);
color: #fff;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: opacity 0.15s;
white-space: nowrap;
}
.chat-send:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* 面板出入动画 */
.chat-slide-enter-active,
.chat-slide-leave-active {
transition: opacity 0.2s, transform 0.2s;
}
.chat-slide-enter-from,
.chat-slide-leave-to {
opacity: 0;
transform: translateY(12px) scale(0.97);
}
/* 移动端适配 */
@media (max-width: 480px) {
.chat-panel {
right: 8px;
left: 8px;
width: auto;
bottom: 80px;
}
}
</style>Step 2: 验证文件存在且语法无明显问题
bash
node -e "
const fs = require('fs')
const content = fs.readFileSync('docs/.vitepress/theme/components/ChatWidget.vue', 'utf8')
console.log('script setup:', content.includes('script setup') ? '✓' : '❌')
console.log('fuse.js import:', content.includes('fuse.js') ? '✓' : '❌')
console.log('SSE parsing:', content.includes('data: ') ? '✓' : '❌')
console.log('streaming:', content.includes('streaming') ? '✓' : '❌')
"Expected: 四行全部 ✓
Step 3: Commit
bash
git add docs/.vitepress/theme/components/ChatWidget.vue
git commit -m "feat: 添加 ChatWidget.vue AI 聊天浮窗组件"Task 5: 注册 ChatWidget 到主题
Files:
- Modify:
docs/.vitepress/theme/index.ts
Step 1: 添加 import
在 import ScrollProgress from './components/ScrollProgress.vue' 行之后,插入:
typescript
import ChatWidget from './components/ChatWidget.vue'Step 2: 添加插槽注册
在 Layout 的插槽对象中,'layout-top': () => h(ScrollProgress) 行之后,添加:
typescript
// 在所有页面底部插入 AI 聊天浮窗
'layout-bottom': () => h(ChatWidget)修改后 index.ts 完整内容应为:
typescript
import { h } from 'vue'
import DefaultTheme from 'vitepress/theme'
import type { Theme } from 'vitepress'
import HomeHero from './components/HomeHero.vue'
import HomeFeatures from './components/HomeFeatures.vue'
import ScrollProgress from './components/ScrollProgress.vue'
import ChatWidget from './components/ChatWidget.vue'
import './custom.css'
import './custom-animations.css'
import './custom-components.css'
export default {
extends: DefaultTheme,
Layout: () => {
return h(DefaultTheme.Layout, null, {
// 在首页插入自定义组件
'home-hero-before': () => h(HomeHero),
'home-features-before': () => h(HomeFeatures),
// 在所有页面顶部插入滚动进度条
'layout-top': () => h(ScrollProgress),
// 在所有页面底部插入 AI 聊天浮窗
'layout-bottom': () => h(ChatWidget)
})
}
} satisfies ThemeStep 3: 验证开发服务器能正常启动
bash
npm run docs:dev 2>&1 &
sleep 5
curl -s http://localhost:5173 | grep -q "AI精品店" && echo "dev server ✓" || echo "❌ dev server failed"
kill %1 2>/dev/nullExpected: dev server ✓
Step 4: Commit
bash
git add docs/.vitepress/theme/index.ts
git commit -m "feat: 注册 ChatWidget 到 VitePress 主题 layout-bottom 插槽"Task 6: 配置 Vercel 环境变量
Step 1: 在 Vercel 控制台配置环境变量
进入 Vercel 项目 → Settings → Environment Variables,添加:
| Key | Value | Environment |
|---|---|---|
AI_CHAT_API_KEY | 你的 AI HUB API Key | Production, Preview |
AI_CHAT_API_URL | 你的 AI HUB API 基础地址(不含 /v1) | Production, Preview |
AI_CHAT_MODEL | 使用的模型名(如 gpt-4o-mini) | Production, Preview |
Step 2: 确认 .gitignore 中已有 .env 相关规则
bash
grep -E "\.env" .gitignore || echo "⚠️ 建议在 .gitignore 中添加 .env.local"如果没有,执行:
bash
echo ".env.local" >> .gitignore
git add .gitignore
git commit -m "chore: 添加 .env.local 到 .gitignore"Task 7: 端到端验证
Step 1: 完整构建,确认索引生成
bash
npm run docs:build 2>&1 | tail -5Expected: 包含 ✓ 生成文档索引:N 篇 且无错误
Step 2: 检查 dist 中有索引文件
bash
node -e "
const d = JSON.parse(require('fs').readFileSync('docs/.vitepress/dist/docs-index.json','utf8'))
console.log('文档数量:', d.docs.length)
console.log('示例:', JSON.stringify(d.docs[0], null, 2))
"Expected: 文档数量 > 0,结构包含 path / title / content / category
Step 3: 本地预览验证组件渲染
bash
npm run docs:preview &
sleep 3
curl -s http://localhost:4173 | grep -c "vitepress" && echo "preview ok ✓"
kill %1 2>/dev/null打开浏览器访问 http://localhost:4173,确认右下角有聊天按钮。
Step 4: 推送并触发 Vercel 部署
bash
git push origin main在 Vercel 控制台确认部署成功后,打开生产站点,测试聊天功能。
Step 5: Commit(如有未提交内容)
bash
git status
# 确认 clean,所有内容已在前面各 task commit 中提交