本系列教程将带你从零开始,用 Vue 3 + TypeScript 复刻一个类似 Dify 的 AI 聊天前端。上篇聚焦项目搭建、类型设计、路由认证、HTTP 封装和状态管理。


项目简介
背景
Dify 是一个开源的 LLM 应用开发平台,提供了对话式 AI 的后端服务。在实际项目中,我们往往需要自建前端来对接Dify后端 API或LLM后端服务,实现定制化的聊天界面。
本项目的目标:用 Vue 3 构建一个生产级的 AI 聊天前端,具备以下能力:
- SSE 流式输出(打字机效果)
- Markdown 渲染 + 代码高亮
- 用户认证
- 文件/图片上传
- 聊天会话历史管理
- 工作流执行可视化
- Agent 思考过程展示
- 移动端响应式适配
技术栈
| 类别 | 技术选型 | 说明 |
|---|---|---|
| 框架 | Vue 3.4 + Composition API | 使用 <script setup> 语法 |
| 语言 | TypeScript 5.3 | 全量类型覆盖 |
| 构建 | Vite 5.1 | 极速 HMR |
| 状态管理 | Pinia 2.1 | Composition API 风格 |
| UI 库 | Element Plus 2.5 | 成熟的 Vue 3 组件库 |
| HTTP | Axios 1.6 | 请求拦截 + 统一错误处理 |
| Markdown | marked 12 + highlight.js 11 | GFM 渲染 + 代码高亮 |
| 安全 | DOMPurify 3.0 | XSS 防护 |
| 样式 | SCSS | CSS 变量 + 响应式 |
项目结构
src/
├── api/ # API 层
│ ├── app.ts # 应用信息接口
│ ├── chat.ts # 聊天 API + SSE 流式
│ └── user.ts # 用户认证接口
├── assets/ # 静态资源
├── components/ # 可复用组件(9 个)
│ ├── AgentThought.vue # Agent 思考过程
│ ├── ChatSidebar.vue # 侧边栏
│ ├── ConversationItem.vue# 会话条目
│ ├── FeedbackButtons.vue # 反馈按钮
│ ├── FileUpload.vue # 文件上传
│ ├── MarkdownRenderer.vue# Markdown 渲染
│ ├── MessageActions.vue # 消息操作
│ ├── SuggestedQuestions.vue # 建议问题
│ └── WorkflowTracing.vue # 工作流可视化
├── router/ # 路由配置
│ └── index.ts
├── stores/ # Pinia 状态管理
│ ├── conversation.ts # 会话状态
│ └── user.ts # 用户状态
├── styles/ # 全局样式
├── utils/ # 工具函数
│ └── request.ts # Axios 封装
├── views/ # 页面组件
│ ├── ChatView.vue # 主聊天页面
│ └── ErrorView.vue # 错误页面
├── main.ts # 入口文件
└── App.vue # 根组件
整体数据流
用户输入 → ChatView → sendChatMessageSSE() → Dify API
↑ ↓
messages 数组 ← SSE 事件流(message/agent_thought/workflow)
↓
MarkdownRenderer → 实时渲染 AI 回复
1. 项目初始化
1.1 创建项目
bash
npm create vite@latest dify-chat -- --template vue-ts
cd dify-chat
npm install
1.2 安装依赖
bash
# 核心依赖
npm install vue-router pinia axios element-plus @element-plus/icons-vue
# Markdown 与代码高亮
npm install marked highlight.js dompurify
# 开发依赖
npm install -D sass @types/dompurify
1.3 Vite 配置
vite.config.ts 是整个项目的构建核心:
typescript
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd())
return {
// 支持子路径部署(如 /chat-app/)
base: env.VITE_APP_NGINX_SUB_PATH || '/',
plugins: [vue()],
css: {
preprocessorOptions: {
scss: {
api: 'modern-compiler' // 使用现代 SCSS 编译器
}
}
},
resolve: {
alias: {
'@': '/src' // 路径别名,import xx from '@/utils/xx'
}
},
server: {
port: 3001,
proxy: {
// 开发环境将 /api 请求代理到后端
'/api': {
target: 'http://localhost:9000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
}
})
关键设计决策:
- 代理配置 :前端 3001 端口,后端 9000 端口。开发时所有
/api/*请求自动转发到后端,避免跨域问题。 - 路径别名 :
@/映射到src/,让导入路径更清晰。 - 子路径部署 :通过环境变量
VITE_APP_NGINX_SUB_PATH控制 base path,适配不同部署环境。
1.4 环境变量
创建 .env 文件:
env
VITE_APP_TITLE=AI 智能助手
VITE_API_BASE_URL=/api
VITE_APP_NGINX_SUB_PATH=/
TypeScript 类型声明 src/env.d.ts:
typescript
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
readonly VITE_API_BASE_URL: string
readonly VITE_APP_NGINX_SUB_PATH: string
}
2. TypeScript 类型体系设计
类型设计是整个项目的基础。我们把所有聊天相关的类型集中在 src/api/chat.ts 中定义。
2.1 消息类型
typescript
// 前端使用的消息结构
export interface Message {
id: string
role: 'user' | 'assistant'
content: string
createAt?: number
parentMessageId?: string
messageFiles?: MessageFile[]
agentThoughts?: AgentThought[]
workflow_process?: WorkflowProcess
feedback?: Feedback
citation?: Citation[]
}
// 后端返回的消息结构(需要转换)
export interface BackendMessage {
id: string
conversationId: string
parentMessageId: string
inputs: Record<string, any>
query: string // 用户问题
answer: string // AI 回答
messageFiles: MessageFile[]
feedback: Feedback | null
retrieverResources: any[]
agentThoughts: AgentThought[]
createdAt: number
status: string
error: string | null
}
为什么要区分 Message 和 BackendMessage?
后端返回的是"一轮对话"的结构(包含 query + answer),而前端渲染需要拆分为 user 和 assistant 两条独立消息。这个转换在 getMessages 函数中完成。
2.2 文件与附件
typescript
export interface MessageFile {
id: string
type: string // 'image' | 'document' 等
url: string
filename?: string
size?: number
}
2.3 Agent 思考过程
typescript
export interface AgentThought {
id: string
thought: string // 思考内容
tool?: string // 调用的工具名称
tool_input?: string // 工具输入参数
observation?: string // 工具执行结果
message_files?: MessageFile[]
created_at?: number
}
2.4 工作流追踪
typescript
export interface WorkflowProcess {
status: string // 'running' | 'succeeded' | 'failed'
tracing: WorkflowNode[]
}
export interface WorkflowNode {
id: string
node_id: string
title: string // 节点名称,如"大模型"、"知识检索"
status: string // 'running' | 'succeeded' | 'failed'
inputs?: any
outputs?: any
elapsed_time?: number // 执行耗时(秒)
}
2.5 用户反馈
typescript
export interface Feedback {
rating: 'like' | 'dislike' | null
content?: string // 详细反馈内容
}
2.6 引用来源
typescript
export interface Citation {
position: number
document_id: string
document_name: string
data_source_type: string
source_url?: string
}
2.7 请求与响应类型
typescript
export interface ChatRequest {
appId: string
query: string
conversationId?: string // 有值表示继续已有会话
inputs?: Record<string, any>
files?: string[] // 已上传文件的 URL 列表
}
2.8 SSE 回调接口
这是整个流式聊天的核心类型定义------通过回调函数把不同类型的 SSE 事件分发到对应的处理逻辑:
typescript
export interface SSECallbacks {
onMessage: (content: string, taskId?: string, messageId?: string) => void
onThought: (thought: AgentThought) => void
onFile: (file: MessageFile) => void
onMessageEnd: (data: {
messageId: string
conversationId: string
citation?: Citation[]
feedback?: Feedback
}) => void
onWorkflowStarted: (data: { workflowRunId: string; taskId: string; conversationId: string }) => void
onNodeStarted: (node: WorkflowNode) => void
onNodeFinished: (node: WorkflowNode) => void
onWorkflowFinished: (data: { status: string; conversationId: string }) => void
onTTSChunk: (audio: string) => void
onSuggestedQuestions: (questions: string[]) => void
onError: (error: string) => void
onDone: () => void
}
这种设计的好处:SSE 解析逻辑与 UI 更新逻辑完全解耦 。sendChatMessageSSE 函数只负责解析 SSE 流并触发回调,而 ChatView 组件通过回调函数决定如何更新 UI。
3. 路由与认证
3.1 路由设计
src/router/index.ts:
typescript
import { createRouter, createWebHistory } from 'vue-router'
import { getInfo, ssoLogin } from '@/api/user'
import { useUserStore } from '@/stores/user'
const router = createRouter({
history: createWebHistory(import.meta.env.VITE_APP_NGINX_SUB_PATH),
routes: [
{
path: '/',
redirect: '/error?message=请从安溪专区访问'
},
{
path: '/chat/:appId', // 动态路由,支持多应用
name: 'Chat',
component: () => import('@/views/ChatView.vue') // 懒加载
},
{
path: '/error',
name: 'Error',
component: () => import('@/views/ErrorView.vue')
}
]
})
设计要点:
/chat/:appId使用动态路由,同一个前端可以对接不同的 AI 应用(每个应用有不同的 prompt、模型配置等)。- 路由懒加载(
() => import()),首屏不会加载所有页面代码。 - 根路径重定向到错误页,强制用户从指定入口访问。
3.2 SSO 单点登录
通过路由守卫 beforeEach 实现 SSO 认证流程:
typescript
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
const ssoApp = to.query.appid as string
const ssoCode = to.query.code as string
let token = userStore.token
// 开发环境自动 mock
if (import.meta.env.DEV) {
const devToken = 'eyJhbGciOiJIUzI1NiJ9...'
userStore.setToken(devToken)
userStore.setUserInfo({
ssoUserName: 'dev_user',
nickName: '开发测试用户'
})
token = devToken
}
if (ssoApp && ssoCode && !token) {
try {
// 1. 用 SSO code 换取 token
const newToken = await ssoLogin(ssoApp, ssoCode)
userStore.setToken(newToken)
// 2. 获取用户信息
const userInfo = await getInfo()
userStore.setUserInfo(userInfo)
// 3. 去掉 URL 中的 code 参数,刷新路由
const query = { ...to.query }
delete query.code
next({ ...to, query, replace: true })
} catch (error) {
next({ path: '/error', query: { message: 'SSO 登录失败,请重试' } })
}
} else if (!token && to.path !== '/error') {
next({ path: '/error', query: { message: '未登录,请从门户网站进入' } })
} else {
next()
}
})
SSO 认证流程图:
门户入口 → 带 appid + code 参数访问 /chat/xxx
↓
beforeEach 拦截 → 检测到 code
↓
ssoLogin(appid, code) → 获取 token
↓
getInfo() → 获取用户信息
↓
存储 token + userInfo → 重定向(去掉 code 参数)
↓
进入 ChatView 正常聊天
4. HTTP 客户端封装
4.1 Axios 实例
src/utils/request.ts:
typescript
import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { TOKEN_KEY, useUserStore } from '@/stores/user'
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL, // '/api'
timeout: 120000 // 2 分钟超时,AI 响应可能较慢
})
4.2 请求拦截器
核心功能:自动注入 Bearer Token。
typescript
request.interceptors.request.use(
(config) => {
const token = localStorage.getItem(TOKEN_KEY)
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
这里直接从 localStorage 读取 token,而不是从 Pinia store 读取,是为了避免循环依赖问题(store 还未初始化时就需要发请求)。
4.3 响应拦截器
统一处理后端返回的业务错误码:
typescript
request.interceptors.response.use(
(res) => {
const code = res.data.code || 200
const msg = errorCode[code] || res.data.msg || errorCode['default']
// 二进制数据直接返回
if (res.request.responseType === 'blob' || res.request.responseType === 'arraybuffer') {
return res.data
}
const userStore = useUserStore()
if (code === 401) {
// 认证过期 → 清除登录状态 + 弹窗提示
userStore.logout()
return ElMessageBox.alert('登录状态已过期,请重新从门户点击访问!', '认证失败')
.then(() => Promise.reject('无效的会话'))
} else if (code === 500) {
ElMessage.error(msg)
return Promise.reject(new Error(msg))
} else if (code === 601) {
ElMessage.warning(msg)
return Promise.reject('error')
} else if (code !== 200) {
ElMessage.error(msg)
return Promise.reject('error')
} else {
return res.data.data // 只返回业务数据
}
},
(error) => {
ElMessage.error(error)
return Promise.reject(error)
}
)
关键设计:
- 响应拦截器已经"解包"了后端响应,API 函数拿到的直接就是
data字段的值。 - 401 时自动清除用户状态,引导用户重新登录。
- 二进制数据(文件下载)特殊处理,直接返回完整 response。
4.4 API 函数示例
有了封装好的 request,API 函数写起来非常简洁:
typescript
// src/api/app.ts
export const getAppById = async (id: string): Promise<AppInfo> => {
return await request.get(`/app/${id}`)
}
// src/api/user.ts
export const ssoLogin = async (ssoApp: string, ssoCode: string): Promise<string> => {
return await request.get('/public/login/ssoLogin', { params: { ssoApp, ssoCode } })
}
export const getInfo = async (): Promise<UserInfo> => {
return await request.get('/user/getInfo')
}
5. Pinia Store 设计
5.1 用户状态管理
src/stores/user.ts --- 使用 Composition API 风格(defineStore + setup 函数):
typescript
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const TOKEN_KEY = 'ai-token'
export const USER_INFO_KEY = 'ai-userInfo'
export const useUserStore = defineStore('user', () => {
const userInfo = ref<UserInfo | null>(null)
const token = ref<string>('')
// 设置 Token → 同时持久化到 localStorage
const setToken = (value: string) => {
token.value = value
localStorage.setItem(TOKEN_KEY, value)
}
// 设置用户信息 → 同步持久化
const setUserInfo = (info: UserInfo) => {
userInfo.value = info
localStorage.setItem(USER_INFO_KEY, JSON.stringify(info))
}
// 从 localStorage 恢复(页面刷新后)
const initFromStorage = () => {
const storedToken = localStorage.getItem(TOKEN_KEY)
const storedUserInfo = localStorage.getItem(USER_INFO_KEY)
if (storedToken) token.value = storedToken
if (storedUserInfo) {
try { userInfo.value = JSON.parse(storedUserInfo) }
catch { userInfo.value = null }
}
}
// 登出 → 清空所有
const logout = () => {
userInfo.value = null
token.value = ''
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_INFO_KEY)
}
return { userInfo, token, setToken, setUserInfo, initFromStorage, logout }
})
设计要点:
- Token 和 UserInfo 同时存在内存(ref)和 localStorage 中。内存保证响应式,localStorage 保证刷新后不丢失。
initFromStorage在应用启动时调用(main.ts 中),确保路由守卫能拿到 token。
5.2 会话状态管理
src/stores/conversation.ts --- 管理会话列表和消息:
typescript
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import {
getConversations, getMessages,
deleteConversation as deleteConversationApi,
pinConversation as pinConversationApi,
unpinConversation as unpinConversationApi,
updateConversationName
} from '@/api/chat'
export const useConversationStore = defineStore('conversation', () => {
// === 状态 ===
const appId = ref<string>('')
const conversations = ref<ConversationItem[]>([])
const pinnedConversations = ref<ConversationItem[]>([])
const currentConversationId = ref<string>('')
const currentMessages = ref<any[]>([])
const isLoading = ref(false)
const isLoadingMessages = ref(false)
// === 计算属性 ===
// 置顶 + 普通合并后的完整列表
const allConversations = computed(() => [
...pinnedConversations.value,
...conversations.value
])
// 当前选中的会话对象
const currentConversation = computed(() =>
allConversations.value.find(c => c.id === currentConversationId.value)
)
// === 核心方法 ===
// 加载会话列表
const loadConversations = async () => {
if (!appId.value) return
isLoading.value = true
try {
conversations.value = await getConversations(appId.value, 30)
} finally {
isLoading.value = false
}
}
// 切换会话 → 加载该会话的消息
const switchConversation = async (conversationId: string) => {
if (conversationId === currentConversationId.value) return
currentConversationId.value = conversationId
currentMessages.value = []
if (conversationId) await loadMessages(conversationId)
}
// 加载消息 → 关键转换:BackendMessage → Message
const loadMessages = async (conversationId: string) => {
if (!appId.value) return
isLoadingMessages.value = true
try {
currentMessages.value = await getMessages(appId.value, conversationId)
} finally {
isLoadingMessages.value = false
}
}
// 创建新会话
const createNewConversation = () => {
currentConversationId.value = ''
currentMessages.value = []
}
// 删除会话 → 从列表移除 + 如果是当前会话则切换
const deleteConversation = async (conversationId: string) => {
await deleteConversationApi(conversationId)
conversations.value = conversations.value.filter(c => c.id !== conversationId)
pinnedConversations.value = pinnedConversations.value.filter(c => c.id !== conversationId)
if (currentConversationId.value === conversationId) {
createNewConversation()
}
}
// 流式聊天时的实时更新方法
const updateCurrentConversationId = (conversationId: string) => {
currentConversationId.value = conversationId
}
const addMessage = (message: any) => {
currentMessages.value.push(message)
}
const updateLastMessage = (content: string, extras?: any) => {
const lastMsg = currentMessages.value[currentMessages.value.length - 1]
if (lastMsg) {
lastMsg.content = content
Object.assign(lastMsg, extras)
}
}
return {
// 状态
appId, conversations, pinnedConversations,
currentConversationId, currentMessages,
isLoading, isLoadingMessages,
// 计算属性
currentConversation, allConversations,
// 方法
setAppId, loadConversations, switchConversation,
createNewConversation, deleteConversation,
updateCurrentConversationId, addMessage, updateLastMessage,
renameConversation, pinConversation, unpinConversation
}
})
架构决策:
- 会话分两组管理(pinned / normal),计算属性合并展示。置顶操作就是数组间的移动。
- 消息更新使用
updateLastMessage,这是 SSE 流式更新的关键。每个message事件到达时,只需更新最后一条消息的 content。 addMessage+updateLastMessage组合使用:发送消息时先addMessage一个空的 assistant 消息占位,然后 SSE 回调不断updateLastMessage追加内容。
5.3 后端消息 → 前端消息的转换
这个转换发生在 src/api/chat.ts 的 getMessages 函数中:
typescript
export const getMessages = async (appId: string, conversationId: string): Promise<Message[]> => {
const backendMessages: BackendMessage[] = await request.post('/chat/message/messages', {
appId, conversationId, limit: 50
})
const messages: Message[] = []
backendMessages.forEach(m => {
// 后端一轮对话 = 一条 BackendMessage
// 前端需要拆成 user + assistant 两条 Message
messages.push({
id: `${m.id}-user`,
role: 'user',
content: m.query, // 用户问题
createAt: m.createdAt,
messageFiles: m.messageFiles
})
messages.push({
id: `${m.id}-assistant`,
role: 'assistant',
content: m.answer, // AI 回答
createAt: m.createdAt,
agentThoughts: m.agentThoughts,
feedback: m.feedback || undefined,
messageFiles: m.messageFiles
})
})
return messages
}
小结
上篇我们完成了:
- 项目脚手架 --- Vite + Vue 3 + TypeScript + Element Plus
- 类型体系 --- 覆盖消息、文件、工作流、Agent 思考等所有业务实体
- 认证流程 --- SSO 单点登录 + 路由守卫
- HTTP 封装 --- 自动 Token 注入 + 统一错误处理
- 状态管理 --- 用户 Store + 会话 Store,支持 localStorage 持久化
下一篇预告:中篇将深入核心功能------SSE 流式聊天的完整实现、ChatView 组件的核心逻辑、Markdown 渲染、文件上传和会话管理。