用 Vue 3 重构 Dify 聊天前端(上篇):项目搭建与基础架构

本系列教程将带你从零开始,用 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
  }
})

架构决策:

  1. 会话分两组管理(pinned / normal),计算属性合并展示。置顶操作就是数组间的移动。
  2. 消息更新使用 updateLastMessage ,这是 SSE 流式更新的关键。每个 message 事件到达时,只需更新最后一条消息的 content。
  3. addMessage + updateLastMessage 组合使用:发送消息时先 addMessage 一个空的 assistant 消息占位,然后 SSE 回调不断 updateLastMessage 追加内容。

5.3 后端消息 → 前端消息的转换

这个转换发生在 src/api/chat.tsgetMessages 函数中:

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
}

小结

上篇我们完成了:

  1. 项目脚手架 --- Vite + Vue 3 + TypeScript + Element Plus
  2. 类型体系 --- 覆盖消息、文件、工作流、Agent 思考等所有业务实体
  3. 认证流程 --- SSO 单点登录 + 路由守卫
  4. HTTP 封装 --- 自动 Token 注入 + 统一错误处理
  5. 状态管理 --- 用户 Store + 会话 Store,支持 localStorage 持久化

下一篇预告:中篇将深入核心功能------SSE 流式聊天的完整实现、ChatView 组件的核心逻辑、Markdown 渲染、文件上传和会话管理。

相关推荐
计算机学姐2 小时前
基于SpringBoot的新能源充电桩管理系统
java·vue.js·spring boot·后端·mysql·spring·java-ee
Seveny072 小时前
深圳长亮科技面试
javascript·vue.js·科技
慧一居士2 小时前
Nuxt4 项目的约定配置都有哪些,哪些可以自动实现, 详细示例和使用说明
前端·vue.js
D_C_tyu3 小时前
vue3 + vue3-print-nb 插件实现打印功能
前端·javascript·vue.js
paul_chen213 小时前
Vite + Vue SPA 在子路径部署(内外网访问+Nginx 反向代理)
前端·vue.js·nginx
yume_sibai3 小时前
Vue 3 表单设计器实现
vue.js·交互·ux
吴佳浩 Alben4 小时前
Vibe Coding 时代:Vue 消失了还是 React 太强?
前端·vue.js·人工智能·react.js·语言模型·自然语言处理
前端大波4 小时前
Vue 项目中让 AI 更稳:AGENTS.md + Prompt 模板实践
vue.js·人工智能·prompt
紫_龙4 小时前
最新版vue3+TypeScript开发入门到实战教程之组件通信之一
前端·vue.js·typescript