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

用 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.1Composition API 风格
UI 库Element Plus 2.5成熟的 Vue 3 组件库
HTTPAxios 1.6请求拦截 + 统一错误处理
Markdownmarked 12 + highlight.js 11GFM 渲染 + 代码高亮
安全DOMPurify 3.0XSS 防护
样式SCSSCSS 变量 + 响应式

项目结构

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 创建项目

npm create vite@latest dify-chat -- --template vue-ts cd dify-chat npminstall

1.2 安装依赖

# 核心依赖npminstall vue-router pinia axios element-plus @element-plus/icons-vue # Markdown 与代码高亮npminstall marked highlight.js dompurify # 开发依赖npminstall-D sass @types/dompurify 

1.3 Vite 配置

vite.config.ts 是整个项目的构建核心:

import{ defineConfig, loadEnv }from'vite'import vue from'@vitejs/plugin-vue'exportdefaultdefineConfig(({ 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 文件:

VITE_APP_TITLE=AI 智能助手 VITE_API_BASE_URL=/api VITE_APP_NGINX_SUB_PATH=/ 

TypeScript 类型声明 src/env.d.ts

interfaceImportMetaEnv{readonlyVITE_APP_TITLE:stringreadonlyVITE_API_BASE_URL:stringreadonlyVITE_APP_NGINX_SUB_PATH:string}

2. TypeScript 类型体系设计

类型设计是整个项目的基础。我们把所有聊天相关的类型集中在 src/api/chat.ts 中定义。

2.1 消息类型

// 前端使用的消息结构exportinterfaceMessage{ id:string role:'user'|'assistant' content:string createAt?:number parentMessageId?:string messageFiles?: MessageFile[] agentThoughts?: AgentThought[] workflow_process?: WorkflowProcess feedback?: Feedback citation?: Citation[]}// 后端返回的消息结构(需要转换)exportinterfaceBackendMessage{ 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 文件与附件

exportinterfaceMessageFile{ id:string type:string// 'image' | 'document' 等 url:string filename?:string size?:number}

2.3 Agent 思考过程

exportinterfaceAgentThought{ id:string thought:string// 思考内容 tool?:string// 调用的工具名称 tool_input?:string// 工具输入参数 observation?:string// 工具执行结果 message_files?: MessageFile[] created_at?:number}

2.4 工作流追踪

exportinterfaceWorkflowProcess{ status:string// 'running' | 'succeeded' | 'failed' tracing: WorkflowNode[]}exportinterfaceWorkflowNode{ id:string node_id:string title:string// 节点名称,如"大模型"、"知识检索" status:string// 'running' | 'succeeded' | 'failed' inputs?:any outputs?:any elapsed_time?:number// 执行耗时(秒)}

2.5 用户反馈

exportinterfaceFeedback{ rating:'like'|'dislike'|null content?:string// 详细反馈内容}

2.6 引用来源

exportinterfaceCitation{ position:number document_id:string document_name:string data_source_type:string source_url?:string}

2.7 请求与响应类型

exportinterfaceChatRequest{ appId:string query:string conversationId?:string// 有值表示继续已有会话 inputs?: Record<string,any> files?:string[]// 已上传文件的 URL 列表}

2.8 SSE 回调接口

这是整个流式聊天的核心类型定义——通过回调函数把不同类型的 SSE 事件分发到对应的处理逻辑:

exportinterfaceSSECallbacks{onMessage:(content:string, taskId?:string, messageId?:string)=>voidonThought:(thought: AgentThought)=>voidonFile:(file: MessageFile)=>voidonMessageEnd:(data:{ messageId:string conversationId:string citation?: Citation[] feedback?: Feedback })=>voidonWorkflowStarted:(data:{ workflowRunId:string; taskId:string; conversationId:string})=>voidonNodeStarted:(node: WorkflowNode)=>voidonNodeFinished:(node: WorkflowNode)=>voidonWorkflowFinished:(data:{ status:string; conversationId:string})=>voidonTTSChunk:(audio:string)=>voidonSuggestedQuestions:(questions:string[])=>voidonError:(error:string)=>voidonDone:()=>void}

这种设计的好处:SSE 解析逻辑与 UI 更新逻辑完全解耦sendChatMessageSSE 函数只负责解析 SSE 流并触发回调,而 ChatView 组件通过回调函数决定如何更新 UI。


3. 路由与认证

3.1 路由设计

src/router/index.ts

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 认证流程:

router.beforeEach(async(to, from, next)=>{const userStore =useUserStore()const ssoApp = to.query.appid asstringconst ssoCode = to.query.code asstringlet token = userStore.token // 开发环境自动 mockif(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 换取 tokenconst newToken =awaitssoLogin(ssoApp, ssoCode) userStore.setToken(newToken)// 2. 获取用户信息const userInfo =awaitgetInfo() 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 登录失败,请重试'}})}}elseif(!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

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

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 响应拦截器

统一处理后端返回的业务错误码:

request.interceptors.response.use((res)=>{const code = res.data.code ||200const 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('无效的会话'))}elseif(code ===500){ ElMessage.error(msg)returnPromise.reject(newError(msg))}elseif(code ===601){ ElMessage.warning(msg)returnPromise.reject('error')}elseif(code !==200){ ElMessage.error(msg)returnPromise.reject('error')}else{return res.data.data // 只返回业务数据}},(error)=>{ ElMessage.error(error)returnPromise.reject(error)})

关键设计:

  • 响应拦截器已经"解包"了后端响应,API 函数拿到的直接就是 data 字段的值。
  • 401 时自动清除用户状态,引导用户重新登录。
  • 二进制数据(文件下载)特殊处理,直接返回完整 response。

4.4 API 函数示例

有了封装好的 request,API 函数写起来非常简洁:

// src/api/app.tsexportconst getAppById =async(id:string):Promise<AppInfo>=>{returnawait request.get(`/app/${id}`)}// src/api/user.tsexportconst ssoLogin =async(ssoApp:string, ssoCode:string):Promise<string>=>{returnawait request.get('/public/login/ssoLogin',{ params:{ ssoApp, ssoCode }})}exportconst getInfo =async():Promise<UserInfo>=>{returnawait request.get('/user/getInfo')}

5. Pinia Store 设计

5.1 用户状态管理

src/stores/user.ts — 使用 Composition API 风格(defineStore + setup 函数):

import{ defineStore }from'pinia'import{ ref }from'vue'exportconstTOKEN_KEY='ai-token'exportconstUSER_INFO_KEY='ai-userInfo'exportconst useUserStore =defineStore('user',()=>{const userInfo =ref<UserInfo |null>(null)const token =ref<string>('')// 设置 Token → 同时持久化到 localStorageconstsetToken=(value:string)=>{ token.value = value localStorage.setItem(TOKEN_KEY, value)}// 设置用户信息 → 同步持久化constsetUserInfo=(info: UserInfo)=>{ userInfo.value = info localStorage.setItem(USER_INFO_KEY,JSON.stringify(info))}// 从 localStorage 恢复(页面刷新后)constinitFromStorage=()=>{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}}}// 登出 → 清空所有constlogout=()=>{ 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 — 管理会话列表和消息:

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'exportconst 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))// === 核心方法 ===// 加载会话列表constloadConversations=async()=>{if(!appId.value)return isLoading.value =truetry{ conversations.value =awaitgetConversations(appId.value,30)}finally{ isLoading.value =false}}// 切换会话 → 加载该会话的消息constswitchConversation=async(conversationId:string)=>{if(conversationId === currentConversationId.value)return currentConversationId.value = conversationId currentMessages.value =[]if(conversationId)awaitloadMessages(conversationId)}// 加载消息 → 关键转换:BackendMessage → MessageconstloadMessages=async(conversationId:string)=>{if(!appId.value)return isLoadingMessages.value =truetry{ currentMessages.value =awaitgetMessages(appId.value, conversationId)}finally{ isLoadingMessages.value =false}}// 创建新会话constcreateNewConversation=()=>{ currentConversationId.value ='' currentMessages.value =[]}// 删除会话 → 从列表移除 + 如果是当前会话则切换constdeleteConversation=async(conversationId:string)=>{awaitdeleteConversationApi(conversationId) conversations.value = conversations.value.filter(c => c.id !== conversationId) pinnedConversations.value = pinnedConversations.value.filter(c => c.id !== conversationId)if(currentConversationId.value === conversationId){createNewConversation()}}// 流式聊天时的实时更新方法constupdateCurrentConversationId=(conversationId:string)=>{ currentConversationId.value = conversationId }constaddMessage=(message:any)=>{ currentMessages.value.push(message)}constupdateLastMessage=(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 函数中:

exportconst 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 渲染、文件上传和会话管理。

Read more

GTC2026前瞻(二)Agentic AI 与开源模型篇+(三)Physical AI 与机器人篇

GTC2026前瞻(二)Agentic AI 与开源模型篇+(三)Physical AI 与机器人篇

(二)Agentic AI 与开源模型篇 Agentic AI与开源模型:英伟达想定义的,不只是“更聪明的模型”,而是“能持续工作的数字劳动力” 如果说过去两年的大模型竞赛,核心问题还是“谁能生成更像人的答案”,那么到了 GTC 2026,问题已经明显变了。英伟达把 Agentic AI 直接列为大会四大核心主题之一,官方对这一主题的定义也很明确:重点不再是单轮问答,而是让 AI agent 能够推理、规划、检索并执行动作,最终把企业数据转化为可投入生产的“数字劳动力”。这说明,Agentic AI 在英伟达的语境里,已经不是一个前沿概念,而是下一阶段 AI 商业化的主战场。(NVIDIA) 一、GTC 2026真正的变化,是 AI 开始从“会回答”走向“会做事”

AI写作避坑指南:用Qwen3-4B-Instruct少走弯路

AI写作避坑指南:用Qwen3-4B-Instruct少走弯路 1. 引言:为何选择Qwen3-4B-Instruct进行AI写作? 在当前生成式AI快速发展的背景下,越来越多的内容创作者、开发者和研究者开始借助大语言模型提升写作效率。然而,面对参数规模从0.5B到70B不等的各类模型,如何在性能、资源消耗与输出质量之间做出平衡,成为实际应用中的关键挑战。 Qwen3-4B-Instruct 作为阿里云通义千问系列中面向指令理解优化的40亿参数模型,凭借其出色的逻辑推理能力、长文本生成稳定性以及对CPU环境的良好支持,成为高性价比AI写作的理想选择。尤其在集成高级WebUI后,该模型不仅适用于技术文档、小说创作、代码生成等复杂任务,还能在无GPU环境下稳定运行,极大降低了使用门槛。 本文将围绕 “AI 写作大师 - Qwen3-4B-Instruct” 镜像 的实际部署与使用经验,系统梳理常见误区,并提供可落地的优化建议,帮助用户高效利用这一工具,避免踩坑。 2. 模型特性解析:为什么4B是AI写作的“黄金平衡点”? 2.1 参数规模与能力边界 相较于更小的0.5B或

Stable Diffusion 3.5-FP8模型是否支持WebGPU加速?未来可期

Stable Diffusion 3.5-FP8模型是否支持WebGPU加速?未来可期 在一台轻薄本上,用浏览器打开一个网页,输入“赛博朋克风格的机械猫,在雨夜城市中跳跃”——几秒后,一幅细节丰富、光影逼真的4K图像跃然屏上。整个过程无需安装任何软件,不上传数据,也不依赖云端服务器。 这听起来像科幻?其实离我们并不遥远。 随着Stable Diffusion 3.5-FP8这类高性能量化模型的推出,以及WebGPU等新一代Web计算标准的成熟,这样的场景正逐步成为现实。关键问题来了:FP8模型能在WebGPU上跑起来吗? 答案是:目前还不行,但——非常接近了。🚀 🔍 为什么是FP8? 先说清楚一件事:FP8不是简单的“砍精度”。它不像早期的INT8量化那样容易导致生成质量断崖式下降。相反,FP8(尤其是E4M3和E5M2格式)通过精心设计的指数-尾数结构,在仅用1字节存储的情况下,依然保留了足够的动态范围来应对扩散模型中复杂的激活分布。 举个例子,原始SD3.5使用FP16时,显存占用大约9GB,推理时间可能要十几秒;而FP8版本直接压缩到约4.5GB,速度提升40%

AIGC时代的网络安全威胁与应急响应机制构建

AIGC时代的网络安全威胁与应急响应机制构建

文章目录 * 一、AIGC时代的网络安全威胁 * 二、应急响应机制的构建 * 三、代码示例 * 《网络安全应急管理与技术实践》 * 编辑推荐 * 内容简介 * 作者简介 * 目录 * 前言/序言 随着人工智能生成内容(AIGC)技术的迅猛发展,我们正步入一个前所未有的创新与变革的新时代。然而,与这一技术革新相伴的,不仅仅是便利和效率的提升,更有日益严峻的网络安全威胁。AIGC技术在显著提升内容生成效率与质量的同时,也悄然带来了新的攻击面与潜在风险,这些风险若不及时应对,将对个人、组织乃至整个社会造成深远的影响。 一、AIGC时代的网络安全威胁 在AIGC时代,数据泄露与隐私侵犯的风险愈发突出。AIGC技术依赖于海量数据,这些数据中不乏敏感信息,一旦数据保护措施出现疏漏,这些信息就可能被不法分子恶意利用,导致个人隐私泄露、财产损失等严重后果。 此外,恶意代码注入也是AIGC系统面临的一大威胁。在系统的训练或推理过程中,如果输入数据未经严格过滤,就可能被注入恶意代码,进而引发系统瘫痪、数据篡改等安全问题。 算法偏见与歧视同样不容忽视。