Skip to content

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-matter

fuse.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')
EOF

Expected: 三行全部 ✓

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 Theme

Step 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/null

Expected: 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,添加:

KeyValueEnvironment
AI_CHAT_API_KEY你的 AI HUB API KeyProduction, Preview
AI_CHAT_API_URL你的 AI HUB API 基础地址(不含 /v1Production, Preview
AI_CHAT_MODEL使用的模型名(如 gpt-4o-miniProduction, 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 -5

Expected: 包含 ✓ 生成文档索引: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 中提交