源码
1.1 项目介绍
我们要做什么
WorkMind AI 是一个智能办公 Agent 平台,专门为企业日常工作场景设计。整个项目一共 7 个模块:
| 模块 | 功能 | 用到的 AI 技术 |
|---|---|---|
| 智能对话助手 | 多轮对话,记住你的背景 | 流式输出、会话记忆、用户画像 |
| 知识库问答 | 上传文档,基于内容回答 | RAG 检索增强生成 |
| 任务 Agent | 复杂任务自动拆解执行 | Function Call、ReAct Agent |
| 内容工作流 | 生成周报、纪要、邮件 | LangGraph 工作流 |
| ERP 报销请假 | AI 填单、模拟审批流程 | Multi-Agent 协作 |
| Prompt 调试 | 测试和对比 Prompt 效果 | A/B 测试、版本管理 |
| 用量看板 | 监控 API 费用和性能 | 成本追踪 |
这个项目把课程里学过的所有核心技术都用上了,是一个完整的生产级项目。
技术栈一览
前端:Vue3 + Vite + Pinia + Vue Router
后端:Node.js + Express
AI: LangChain.js + LangGraph + DeepSeek
数据:Chroma(向量库)
部署:Docker + docker-compose
为什么选 DeepSeek? 价格是 GPT-4o 的十分之一,中文效果好,API 兼容 OpenAI 格式, 换模型只需改一行配置。
1.2 项目结构设计
拿到一个项目,先搞清楚目录结构,这是读代码的地图。
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
- 打开 platform.deepseek.com
- 注册账号,进入"API Keys",新建一个 Key
- 复制 Key(格式是
sk-xxxxxx)
第二步:配置环境变量
进入 server/ 目录,复制 .env.example 为 .env:
cd server
cp .env.example .env
打开 .env,填入你的 Key:
# 必填,没有这个后端无法启动
DEEPSEEK_API_KEY=sk-你的key
# 如果要用 RAG 功能(第二章)再填
# OPENAI_API_KEY=sk-你的key
第三步:安装依赖
# 安装后端依赖
cd server
npm install
# 安装前端依赖
cd ../frontend
npm install
第四步:启动项目
开两个终端分别运行:
# 终端1:启动后端
cd server
npm run dev
# 终端2:启动前端
cd frontend
npm run dev
看到以下输出说明启动成功:
🚀 WorkMind Server 已启动
地址: http://localhost:3000
健康检查: http://localhost:3000/health
浏览器打开 http://localhost:5173 就能看到界面了。
1.4 全局 CSS 变量系统
在 src/styles/global.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;
}
在组件里使用:
/* 不写死,用变量 */
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text);
}
关键变量说明
/* 颜色系统 */
--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,每个模块对应一条路由:
const routes = [
{ path: '/', redirect: '/chat' },
{ path: '/chat', component: () => import('@/views/ChatView.vue') },
{ path: '/knowledge',component: () => import('@/views/KnowledgeView.vue') },
// ...
]
注意这里用了懒加载
// 懒加载:用到这个页面时才加载对应的 JS
component: () => import('@/views/ChatView.vue')
// 不要这样写(全部一起加载,首屏变慢)
import ChatView from '@/views/ChatView.vue'
component: ChatView
1.6 HTTP 工具封装
src/utils/http.js 里封装了两个东西:
axios 实例(普通 HTTP 请求)
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 读取:
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 数据格式(服务端推送的内容)
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(全局状态)
// 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(对话模块状态)
// 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。
// 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 统一创建模型实例:
// 创建对话模型
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 完全一样, 只需要改 baseURL 和 apiKey 就能用:
// 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:
POST /api/chat/stream
请求体
{
"message": "Vue3 的 ref 怎么用?",
"sessionId": "session_1710000000000",
"role": "tech",
"userId": "user-demo"
}
服务端处理流程
接收请求
↓
1. 拼 system prompt(角色预设 + 用户画像)
↓
2. 查精确缓存(相同问题?直接返回缓存)
↓
3. 获取会话历史(最近 N 条消息)
↓
4. 调用 DeepSeek 流式 API
↓
5. 逐 token 推送给前端(SSE)
↓
6. 流结束:更新会话历史 + 写缓存 + 异步更新用户画像
关键代码段:流式输出
// 用 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`)
}
}
关键代码段:精确缓存
// 查缓存(用 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)。
存什么
{
name: "大伟",
dept: "前端团队",
techLevel: "高级",
primaryStack: ["Vue3", "React", "Node.js"],
currentGoal: "学习 AI 开发",
prefersShort: false,
prefersCode: true,
}
怎么提取
每次对话结束后,异步调用一次模型,用结构化输出提取信息:
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:
// profileToContext() 把画像对象转成文字描述
const profileCtx = `
用户背景:
- 用户姓名:大伟
- 部门:前端团队
- 技术水平:高级
- 技术栈:Vue3, React, Node.js
- 偏好:代码示例`
const systemPrompt = 角色预设 + profileCtx
// 模型就知道这是一个高级前端,会给出有深度的回答
1.12 前端组件拆分
对话页面 ChatView.vue 是这样组合的:
ChatView.vue
├── SessionSidebar.vue 会话列表(左侧)
├── RoleSelector.vue 角色切换(顶部)
├── MessageBubble.vue 单条消息气泡(循环渲染)
├── ChatInput.vue 输入框(底部)
└── ProfilePanel.vue 用户画像(右侧,可折叠)
关键:MessageBubble 的 Markdown 渲染
AI 的回答通常包含 Markdown 格式(代码块、加粗、列表), 我们用 marked 库把 Markdown 转成 HTML,用 highlight.js 高亮代码:
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:
<div class="ai-bubble markdown-body" v-html="renderedContent" />
安全提示:v-html 会执行 HTML,有 XSS 风险。 在实际项目中,要用 DOMPurify 过滤一下 HTML:
import DOMPurify from 'dompurify'
const safeHtml = DOMPurify.sanitize(marked(content))
本项目是内部工具,输入来源可信,暂不做此处理。
1.13 Toast 通知组件
ToastList.vue 用了 Vue3 的 Teleport 特性,把 DOM 渲染到 body 上:
<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/.env 里 ALLOWED_ORIGINS 是否包含 http://localhost:5173。 开发时 Vite proxy 会代理请求,不应该有跨域问题。如果有,检查 proxy 配置。
Q:AI 回复很慢,要等很久才出字?
DeepSeek 的第一个 token 延迟通常在 1-3 秒。 如果要加载动画,在 start 事件到 token 第一次出现之间显示"思考中..."。
Q:用户画像不更新?
画像提取是异步的,对话结束后约 2-3 秒才完成。 可以刷新一下右侧画像面板。 如果完全不更新,检查后端日志有没有报错。