返回笔记首页

第一章:项目搭建 + 智能对话助手

主题配置

源码

workmind-01.zip


1.1 项目介绍

我们要做什么

WorkMind AI 是一个智能办公 Agent 平台,专门为企业日常工作场景设计。整个项目一共 7 个模块:

模块 功能 用到的 AI 技术
智能对话助手 多轮对话,记住你的背景 流式输出、会话记忆、用户画像
知识库问答 上传文档,基于内容回答 RAG 检索增强生成
任务 Agent 复杂任务自动拆解执行 Function Call、ReAct Agent
内容工作流 生成周报、纪要、邮件 LangGraph 工作流
ERP 报销请假 AI 填单、模拟审批流程 Multi-Agent 协作
Prompt 调试 测试和对比 Prompt 效果 A/B 测试、版本管理
用量看板 监控 API 费用和性能 成本追踪

这个项目把课程里学过的所有核心技术都用上了,是一个完整的生产级项目。

技术栈一览

markdown
前端:Vue3 + Vite + Pinia + Vue Router
后端:Node.js + Express
AI:  LangChain.js + LangGraph + DeepSeek
数据:Chroma(向量库)
部署:Docker + docker-compose

为什么选 DeepSeek? 价格是 GPT-4o 的十分之一,中文效果好,API 兼容 OpenAI 格式, 换模型只需改一行配置。


1.2 项目结构设计

拿到一个项目,先搞清楚目录结构,这是读代码的地图。

markdown
workmind/
├── frontend/                   ← Vue3 前端
│   ├── src/
│   │   ├── views/              ← 7个模块的页面组件
│   │   ├── components/         ← 可复用 UI 组件
│   │   │   ├── layout/         ← 布局组件(侧边栏、顶栏)
│   │   │   ├── chat/           ← 对话模块组件
│   │   │   ├── rag/            ← 知识库模块组件
│   │   │   └── common/         ← 通用组件(Toast 等)
│   │   ├── stores/             ← Pinia 状态管理
│   │   ├── composables/        ← 组合式函数(可复用逻辑)
│   │   ├── utils/              ← 工具函数(http、token计算)
│   │   ├── router/             ← 路由配置
│   │   └── styles/             ← 全局样式(CSS 变量)
│   ├── index.html
│   ├── vite.config.js
│   └── package.json
│
├── server/                     ← Node.js 后端
│   ├── src/
│   │   ├── routes/             ← API 路由(chat.js / knowledge.js...)
│   │   ├── services/           ← 业务逻辑(chat/ / rag/ / agent/...)
│   │   ├── middleware/         ← 中间件(限流、校验、安全)
│   │   ├── utils/              ← 工具(日志、错误处理)
│   │   ├── config/             ← 配置管理(统一读取环境变量)
│   │   └── index.js            ← 服务入口
│   ├── .env.example
│   ├── Dockerfile
│   └── package.json
│
├── docker-compose.yml          ← 一键启动所有服务
└── README.md

设计原则:职责分离

  • routes/ 只负责接收请求、校验参数、返回响应,不写业务逻辑
  • services/ 只负责业务逻辑,不知道 HTTP 是什么
  • middleware/ 只负责通用处理,比如限流、日志、安全检查
  • config/ 统一管理环境变量,业务代码不直接用 process.env

1.3 环境配置

第一步:申请 DeepSeek API Key

  1. 打开 platform.deepseek.com
  2. 注册账号,进入"API Keys",新建一个 Key
  3. 复制 Key(格式是 sk-xxxxxx

第二步:配置环境变量

进入 server/ 目录,复制 .env.example.env

bash
cd server
cp .env.example .env

打开 .env,填入你的 Key:

bash
# 必填,没有这个后端无法启动
DEEPSEEK_API_KEY=sk-你的key

# 如果要用 RAG 功能(第二章)再填
# OPENAI_API_KEY=sk-你的key

第三步:安装依赖

bash
# 安装后端依赖
cd server
npm install

# 安装前端依赖
cd ../frontend
npm install

第四步:启动项目

开两个终端分别运行:

bash
# 终端1:启动后端
cd server
npm run dev

# 终端2:启动前端
cd frontend
npm run dev

看到以下输出说明启动成功:

plain
🚀 WorkMind Server 已启动
   地址: http://localhost:3000
   健康检查: http://localhost:3000/health

浏览器打开 http://localhost:5173 就能看到界面了。


1.4 全局 CSS 变量系统

src/styles/global.css 里,我们用 CSS 变量定义了整套设计系统。

为什么要用 CSS 变量?

写死颜色值的问题:颜色散落在几百个组件里,换主题时要改几百处。 用 CSS 变量只需改一处,所有组件自动跟着变。

css
/* 定义变量 */
:root {
  --color-primary: #4f46e5;
  --color-bg:      #f8f9fb;
  --color-surface: #ffffff;
  --color-text:    #111827;
}

/* 深色模式:只需覆盖变量值 */
[data-theme="dark"] {
  --color-bg:      #0f0f13;
  --color-surface: #1a1a24;
  --color-text:    #f1f0ff;
}

在组件里使用:

css
/* 不写死,用变量 */
.card {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  color: var(--color-text);
}

关键变量说明

css
/* 颜色系统 */
--color-primary        靛蓝,所有主操作按钮用这个
--color-primary-bg     主色的浅背景,用于激活状态
--color-surface        卡片、面板的背景色
--color-bg             页面整体背景色
--color-border         边框颜色
--color-text           主文字
--color-text-sub       次要文字(标签、描述)
--color-text-muted     辅助文字(placeholder)

/* 间距系统 */
--space-sm   8px
--space-md   16px
--space-lg   24px
--space-xl   32px

/* 圆角系统 */
--radius-md   8px   普通卡片
--radius-lg   12px  大卡片
--radius-full 9999px  药丸形状(Tag、按钮)

1.5 路由配置

路由文件在 src/router/index.js,每个模块对应一条路由:

javascript
const routes = [
  { path: '/',         redirect: '/chat' },
  { path: '/chat',     component: () => import('@/views/ChatView.vue') },
  { path: '/knowledge',component: () => import('@/views/KnowledgeView.vue') },
  // ...
]

注意这里用了懒加载

javascript
// 懒加载:用到这个页面时才加载对应的 JS
component: () => import('@/views/ChatView.vue')

// 不要这样写(全部一起加载,首屏变慢)
import ChatView from '@/views/ChatView.vue'
component: ChatView

1.6 HTTP 工具封装

src/utils/http.js 里封装了两个东西:

axios 实例(普通 HTTP 请求)

javascript
const http = axios.create({
  baseURL: '/api',    // 配合 Vite proxy,自动代理到后端
  timeout: 30000,
})

// 响应拦截:统一处理错误,弹出 Toast 提示
http.interceptors.response.use(
  (res) => res.data,
  (err) => {
    if (err.response?.status === 429) toast.warning('请求太频繁')
    else if (err.response?.status >= 500) toast.error('服务器异常')
    return Promise.reject(err)
  }
)

fetchStream(SSE 流式请求)

AI 的流式输出不能用 axios,因为 axios 会等响应结束后才返回。 我们要用浏览器原生的 fetch + ReadableStream 逐 chunk 读取:

javascript
export async function fetchStream(url, body, { onToken, onEvent, onDone, onError }) {
  const response = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })

  const reader  = response.body.getReader()
  const decoder = new TextDecoder()
  let buffer    = ''

  while (true) {
    const { value, done } = await reader.read()
    if (done) break

    buffer += decoder.decode(value, { stream: true })

    // SSE 格式:每条消息用 \n\n 分隔
    const parts = buffer.split('\n\n')
    buffer = parts.pop() ?? ''

    for (const part of parts) {
      // 解析 event 和 data 字段
      const lines = part.split('\n')
      let event = 'message', dataStr = ''
      for (const line of lines) {
        if (line.startsWith('event: ')) event = line.slice(7)
        if (line.startsWith('data: '))  dataStr = line.slice(6)
      }

      const data = JSON.parse(dataStr)
      if (event === 'token') onToken?.(data.token)
      if (event === 'done')  onDone?.(data)
    }
  }
}

SSE 数据格式(服务端推送的内容)

javascript
event: token
data: {"token": "Vue"}

event: token
data: {"token": "3 的"}

event: token
data: {"token": "响应式"}

event: done
data: {"fromCache": false, "inputTokens": 45, "outputTokens": 120}

1.7 Pinia 状态管理

app store(全局状态)

javascript
// src/stores/app.js
export const useAppStore = defineStore('app', () => {
  // 主题(持久化到 localStorage)
  const theme = ref(localStorage.getItem('theme') || 'light')

  function toggleTheme() {
    theme.value = theme.value === 'light' ? 'dark' : 'light'
    localStorage.setItem('theme', theme.value)
  }

  // Toast 消息队列
  const toasts = ref([])

  function showToast(message, type = 'info', duration = 3000) {
    const id = ++toastId
    toasts.value.push({ id, message, type })
    setTimeout(() => {
      toasts.value = toasts.value.filter(t => t.id !== id)
    }, duration)
  }

  // 暴露便捷方法
  const toast = {
    success: (msg) => showToast(msg, 'success'),
    error:   (msg) => showToast(msg, 'error'),
  }

  return { theme, toggleTheme, toasts, toast }
})

chat store(对话模块状态)

javascript
// src/stores/chat.js
export const useChatStore = defineStore('chat', () => {
  const sessions  = ref([])    // 所有会话
  const currentId = ref(null)  // 当前会话 ID

  // computed:当前会话的消息列表
  const messages = computed(() =>
    sessions.value.find(s => s.id === currentId.value)?.messages || []
                           )

  // 发送消息(核心逻辑)
  async function sendMessage(text) {
    // 1. 添加用户消息到列表
    // 2. 添加 AI 消息占位
    // 3. 调用 fetchStream 流式填充 AI 消息
    // 4. 完成后更新用量统计
  }

  return { sessions, currentId, messages, sendMessage }
})

1.8 服务端配置管理

所有环境变量集中在 server/src/config/index.js,业务代码通过这里读取,不直接访问 process.env

javascript
// server/src/config/index.js
export const config = {
  app: {
    port: Number(process.env.PORT) || 3000,
    env:  process.env.NODE_ENV || 'development',
  },
  ai: {
    deepseekKey:  process.env.DEEPSEEK_API_KEY,
    primaryModel: process.env.PRIMARY_MODEL || 'deepseek-chat',
    baseURL:      'https://api.deepseek.com/v1',
  },
  cache: {
    ttl: Number(process.env.CACHE_TTL) || 1800000,  // 30 分钟
  },
}

// 启动时校验必填项
export function validateConfig() {
  if (!config.ai.deepseekKey) {
    console.error('❌ 缺少 DEEPSEEK_API_KEY')
    process.exit(1)    // 缺少配置直接退出,不继续运行
  }
}

为什么要集中管理配置?

  • 方便:业务代码写 config.ai.deepseekKey,比 process.env.DEEPSEEK_API_KEY 简洁
  • 安全:可以在这里做类型转换(Number)、默认值、校验
  • 可测试:测试时可以 mock 整个 config 模块

1.9 模型工厂

server/src/services/model.js 统一创建模型实例:

javascript
// 创建对话模型
export function createChatModel({ temperature = 0.7, streaming = false } = {}) {
  return new ChatOpenAI({
    model:         config.ai.primaryModel,   // 从配置读取
    apiKey:        config.ai.deepseekKey,
    configuration: { baseURL: config.ai.baseURL },  // 指向 DeepSeek
    temperature,
    streaming,
  })
}

// 全局单例,复用同一个实例
export const chatModel = createChatModel({ temperature: 0.7 })

关键:DeepSeek 兼容 OpenAI API

LangChain 的 ChatOpenAI 类本来是给 OpenAI 用的, 但 DeepSeek 的 API 格式和 OpenAI 完全一样, 只需要改 baseURLapiKey 就能用:

javascript
// OpenAI 的配置
new ChatOpenAI({
  model: 'gpt-4o',
  apiKey: 'sk-openai-key',
  // baseURL 默认是 https://api.openai.com/v1
})

// DeepSeek 的配置(只改这两处)
new ChatOpenAI({
  model: 'deepseek-chat',
  apiKey: 'sk-deepseek-key',
  configuration: { baseURL: 'https://api.deepseek.com/v1' },  // ← 只改这里
})

1.10 核心接口:流式对话

这是整个项目最重要的接口,在 server/src/routes/chat.js

javascript
POST /api/chat/stream

请求体

json
{
  "message": "Vue3 的 ref 怎么用?",
  "sessionId": "session_1710000000000",
  "role": "tech",
  "userId": "user-demo"
}

服务端处理流程

javascript
接收请求
↓
1. 拼 system prompt(角色预设 + 用户画像)
↓
2. 查精确缓存(相同问题?直接返回缓存)
↓
3. 获取会话历史(最近 N 条消息)
↓
4. 调用 DeepSeek 流式 API
↓
5. 逐 token 推送给前端(SSE)
↓
6. 流结束:更新会话历史 + 写缓存 + 异步更新用户画像

关键代码段:流式输出

javascript
// 用 model.stream() 得到异步迭代器
const stream = await chatModel.stream(messages)

// for await 逐个处理每个 chunk
for await (const chunk of stream) {
  if (chunk.content) {
    fullReply += chunk.content
    // 推送给前端
    res.write(`event: token\ndata: ${JSON.stringify({ token: chunk.content })}\n\n`)
  }
}

关键代码段:精确缓存

javascript
// 查缓存(用 MD5(system+message) 作为 key)
const cached = cache.get(systemPrompt, message)

if (cached) {
  // 缓存命中:模拟流式输出,不调 API
  for (let i = 0; i < cached.content.length; i += 3) {
    res.write(`event: token\ndata: ${JSON.stringify({ token: cached.content.slice(i, i+3) })}\n\n`)
    await sleep(6)   // 加一点延迟,让前端看起来也是流式的
  }
  return
}

// 缓存未命中:调 API,写入缓存
const stream = await chatModel.stream(messages)
// ...
cache.set(systemPrompt, message, { content: fullReply })

1.11 用户画像

用户画像是跨会话的记忆,存在服务端内存里(生产换 Redis)。

存什么

javascript
{
  name: "大伟",
    dept: "前端团队",
    techLevel: "高级",
    primaryStack: ["Vue3", "React", "Node.js"],
    currentGoal: "学习 AI 开发",
    prefersShort: false,
    prefersCode: true,
    }

怎么提取

每次对话结束后,异步调用一次模型,用结构化输出提取信息:

javascript
const result = await extractModel.invoke([
  {
    role: 'system',
    content: `从对话中提取用户信息,只填写有明确依据的字段。
              如果没有新信息,hasInfo 返回 false。`,
  },
  {
    role: 'user',
    content: `用户说:${userMsg}\nAI回复:${aiReply.slice(0, 200)}`,
  },
])

// result 是结构化的 JSON
if (result.hasInfo) {
  // 增量更新画像(不覆盖,只更新有变化的字段)
  if (result.name)      profile.name = result.name
  if (result.techStack) profile.primaryStack = [...new Set([...profile.primaryStack, ...result.techStack])]
}

怎么用

对话时把画像注入 system prompt:

javascript
// profileToContext() 把画像对象转成文字描述
const profileCtx = `
用户背景:
- 用户姓名:大伟
- 部门:前端团队
- 技术水平:高级
- 技术栈:Vue3, React, Node.js
- 偏好:代码示例`

const systemPrompt = 角色预设 + profileCtx
// 模型就知道这是一个高级前端,会给出有深度的回答

1.12 前端组件拆分

对话页面 ChatView.vue 是这样组合的:

javascript
ChatView.vue
├── SessionSidebar.vue    会话列表(左侧)
├── RoleSelector.vue      角色切换(顶部)
├── MessageBubble.vue     单条消息气泡(循环渲染)
├── ChatInput.vue         输入框(底部)
└── ProfilePanel.vue      用户画像(右侧,可折叠)

关键:MessageBubble 的 Markdown 渲染

AI 的回答通常包含 Markdown 格式(代码块、加粗、列表), 我们用 marked 库把 Markdown 转成 HTML,用 highlight.js 高亮代码:

javascript
import { marked } from 'marked'
import hljs from 'highlight.js'

// 配置 marked:代码块自动高亮
marked.setOptions({
  highlight(code, lang) {
    if (lang && hljs.getLanguage(lang)) {
      return hljs.highlight(code, { language: lang }).value
    }
    return hljs.highlightAuto(code).value
  },
})

// 渲染
const renderedContent = computed(() => marked(props.message.content))

然后用 v-html 渲染 HTML:

html
<div class="ai-bubble markdown-body" v-html="renderedContent" />

安全提示:v-html 会执行 HTML,有 XSS 风险。 在实际项目中,要用 DOMPurify 过滤一下 HTML:

javascript
import DOMPurify from 'dompurify'
const safeHtml = DOMPurify.sanitize(marked(content))

本项目是内部工具,输入来源可信,暂不做此处理。


1.13 Toast 通知组件

ToastList.vue 用了 Vue3 的 Teleport 特性,把 DOM 渲染到 body 上:

html
<Teleport to="body">
  <div class="toast-container">
    <TransitionGroup name="toast">
      <div v-for="toast in appStore.toasts" :key="toast.id" class="toast">
        ...
      </div>
    </TransitionGroup>
  </div>
</Teleport>

为什么用 Teleport?

Toast 应该显示在页面最顶层,不受父组件的 overflow: hidden 影响。 如果直接写在组件树里,可能被父元素裁剪掉。 Teleport to="body" 把 DOM 节点移到 body 直接子元素,不受任何父级影响。


1.14 本章作业

完成本章后,请确认以下功能都能运行:

✅****基础功能

  • 前端 http://localhost:5173 能打开
  • 侧边栏 7 个菜单能切换
  • 主题切换(深色/浅色)能用

✅****对话功能

  • 能发送消息并看到 AI 流式回复
  • 新建/切换/删除会话能用
  • 4 种角色切换生效(技术顾问回答代码问题,HR 助理回答政策问题)
✅****进阶功能
  • 多聊几轮后,右侧用户画像面板开始出现信息
  • 发同一个问题两次,第二次秒回(缓存命中,标签显示"⚡ 缓存")

1.15 常见问题

Q:启动后端报错 **DEEPSEEK_API_KEY** 未配置?

检查 server/.env 文件是否存在,Key 是否填写正确。 注意:.env.example 是模板,要复制一份改名为 .env

Q:前端发请求报 404?

确认后端是否正常启动(http://localhost:3000/health 能访问)。 Vite 的 proxy 只在 npm run dev 时生效,生产部署需要 Nginx 配置。

Q:前端报跨域错误?

检查 server/.envALLOWED_ORIGINS 是否包含 http://localhost:5173。 开发时 Vite proxy 会代理请求,不应该有跨域问题。如果有,检查 proxy 配置。

Q:AI 回复很慢,要等很久才出字?

DeepSeek 的第一个 token 延迟通常在 1-3 秒。 如果要加载动画,在 start 事件到 token 第一次出现之间显示"思考中..."。

Q:用户画像不更新?

画像提取是异步的,对话结束后约 2-3 秒才完成。 可以刷新一下右侧画像面板。 如果完全不更新,检查后端日志有没有报错。