简介面向需要将 Dify 聊天能力嵌入自有 Web 系统的前端开发者尤其是 Vue 项目中的集成场景。资源方案围绕 iframe 实现用户会话隔离针对 Dify 原生对话框无法传递用户 ID、清缓存或更换浏览器后历史记录丢失的痛点通过接口获取当前登录用户 token写入 localStorage 并强制刷新 iframe 内容窗口确保会话跨环境保持一致同时保留原生聊天窗口样式及其会话历史切换追问能力。包体为 1 个 docx 文档压缩包约 237KB内容涵盖 Vue 组件编写、axios API 调用、localStorage 嵌套读写、计算属性生成 iframe 源地址以及样式配置与同源部署注意事项可帮助开发者规避跨域加载失败等常见问题。目前已有 304 人学习适合需要快速接入带历史追溯能力的 Dify 聊天窗口、兼顾用户体验与第三方服务限制的前端开发者参考学习。1. Dify 聊天窗口接入自家 Vue 项目iframe 只是第一步用户隔离才是硬骨头把 Dify 的聊天窗口嵌进自己的后台系统最直觉的做法就是复制官方那段 iframe 代码贴到 Vue 页面上。这个动作三分钟能完成但你会发现两个绕不过去的问题第一所有访问者共享同一个 Dify 应用A 用户问过的问题B 用户刷新后可能直接看到上下文第二Dify 官方页面记住的是浏览器里的匿名身份跟你自己的用户体系完全没有关系。本文要解决的正是「Dify 通过 Iframe 接入会话并实现用户隔离」这条完整链路——在 Vue 工程里用 iframe 嵌一个自研聊天子应用所有聊天请求走自己的服务端中继用 Authorization 头和 user 参数把每个用户的会话彻底隔开。适合正在做企业内部助手、SaaS 客服工作台、或者任何需要把 AI 对话挂进既有业务系统的开发者照着落地。2. Dify WebApp 的接入链路拆解带式嵌入与 API 模式的隔离能力边界2.1 为什么直接贴官方 iframe 代码做不了真正的用户隔离Dify 发布 Web 应用时后台会给你两样东西一段 iframe 嵌入代码和一组 API 访问凭证。那段 iframe 代码指向一个公网聊天页面形如https://xxx.team.dify.ai/chatbot/应用ID。把这段代码贴进 Vue 页面的index.html或者某个组件的template里聊天框能出来消息也能发出去但隔离是完全不存在的——这个 URL 背后是 Dify 托管的匿名会话它只会给同一个浏览器分配同一个conversation_id压根不知道你系统里那个用户是谁。更关键的是iframe 本质上是把 Dify 自己的前端页面请进来你和它之间只有 URL 查询参数可以传递信息。Dify 虽然支持在 URL 上拼接?userxxx这类参数但这只能做到名义上的标识服务端不会校验 user 参数和真实身份的对应关系。只要你把应用链接发给用户任何人都可以换一个user参数来冒名访问。真要落地多租户隔离官方 iframe 这条路在架构上就是死的。2.2 隔离的正确姿势API 模式 自研聊天子应用在 Dify 的 Web 应用配置里切到「API 访问」模式你会拿到一个以app-开头的密钥。调用POST /chat-messages接口时在请求头里放Authorization: Bearer app-xxx在请求体里传两个关键字段user和conversation_id。user是业务侧对用户的唯一标识Dify 会用它来做对话上下文的分桶conversation_id则是会话的延续凭证拿到它才能继续上一次对话。这就给了我们一个干净的隔离方案自己用 Vue 写一个聊天窗口页面部署在自己的域名下把这个页面的地址放进 iframe。因为 iframe 加载的是你自己的站点页面里的聊天逻辑可以自由调用你自己后端的接口再由后端去调 Dify。浏览器里不出现任何app-密钥的痕迹Dify 那边的user字段由后端统一替换成当前登录用户的主键。这样一来两个用户即便同时用同一个 Dify 应用在 Dify 侧拿到的也是完全不同的user值和conversation_id上下文互不穿透。// 自研聊天子应用的核心请求密钥不落地浏览器 const res await fetch(/api/dify/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ message: 帮我查一下上月订单, conversationId: props.conversationId || }) })这里的/api/dify/chat必须是你自己域下的同源接口不能直接写成 Dify 的https://xxx.team.dify.ai/v1/chat-messages。原因在于密钥只允许出现在服务端。浏览器那段代码里没有app-前缀的字符串只有一个业务侧的会话凭证由后端解析成 Dify API Key。2.3 完整请求链路谁在什么时候拿到什么数据从用户在聊天框里敲下一句话到 Dify 返回流式回答整条链路是这样走的Vue 聊天子应用把消息 POST 给自己的服务端中继接口中继接口带上当前用户的身份信息向 Dify 的/chat-messages发送请求请求头带着Authorization: Bearer app-key请求体里带user: 用户主键和上次保存下来的conversation_idDify 返回流式响应中继把文本流透传给前端。前端拿到回答之后中继还会把 Dify 返回的新的conversation_id一并交给前端由父页面负责持久化。# 一次完整的对话流转简化路径 Vue 子应用 - POST /api/dify/chat (自研后端携带用户会话凭证) - POST https://api.dify.ai/v1/chat-messages Headers: Authorization: Bearer app-xxxxx Body: { inputs: {}, query: 你好, user: user-2407, conversation_id: abc-123 } - 200 OK, application/json - SSE / JSON 流式返回注意 Dify 的chat-messages默认响应可能是阻塞式的 JSON也可以在参数里开response_mode: streaming拿 SSE 流。实践中我一般开 streaming配合前端的fetch流式读取体验上更接近原生聊天。用户隔离这件事本质就是在user参数上做文章——只要这个值在你的用户体系里全局唯一Dify 的会话上下文就不会串。3. Vue 端落地聊天窗口子应用组件、Token 分发与 conversation_id 闭环3.1 最小可跑通的 ChatFrame 子应用组件先做一个能直接抄走的 ChatFrame 组件。这个组件是一个独立的 Vue 页面它会被嵌进父页面也就是你实际的业务页面的 iframe 里。组件对外接收authToken和userId两个 props用来向自己后端换聊天凭证。父页面与 iframe 的通讯走postMessage不搞复杂的状态同步。template div classchat-frame div classmsg-list reflistRef div v-for(msg, i) in messages :keyi :classmsg.role {{ msg.content }} /div /div input v-modeldraft keyup.entersend placeholder输入消息... / /div /template script setup import { ref, onMounted, onBeforeUnmount } from vue const props defineProps({ authToken: { type: String, required: true }, userId: { type: String, required: true }, initialConversationId: { type: String, default: } }) const draft ref() const messages ref([]) const conversationId ref(props.initialConversationId) const listRef ref(null) // 监听父页面的 postMessage拿到路由跳转等事件 const onMessage (e) { if (e.data?.type CHAT_RESET) { messages.value [] conversationId.value } } async function send() { const text draft.value.trim() if (!text) return messages.value.push({ role: user, content: text }) draft.value // 同一份 authToken 由父页面下发子应用不持有业务密钥 const resp await fetch(/api/dify/chat, { method: POST, headers: { Content-Type: application/json, X-Auth-Token: props.authToken // 业务侧凭证不是 Dify 密钥 }, body: JSON.stringify({ message: text, conversationId: conversationId.value, user: props.userId }) }) const data await resp.json() messages.value.push({ role: assistant, content: data.answer }) conversationId.value data.conversationId // 把会话 ID 回传给父页面父页面负责持久化 window.parent.postMessage({ type: CONVERSATION_UPDATED, conversationId: data.conversationId }, *) } onMounted(() window.addEventListener(message, onMessage)) onBeforeUnmount(() window.removeEventListener(message, onMessage)) /script这段代码里有两个容易被忽略的点。第一个是X-Auth-Token它不是 Dify 的app-密钥而是你自己服务端签发的业务凭证后端拿到后用来换 Dify API Key。第二个是postMessage的第二个参数生产环境不能写*要指定父页面的具体来源否则任何站点都能给你发消息伪造CHAT_RESET等控制事件。3.2 URL 参数与动态域名处理父页面引入 ChatFrame 时有两种方式。一种是把 ChatFrame 作为一个普通组件引入直接依赖打包这适合聊天窗口和你主应用同构的场景。另一种是部署成独立子应用通过 iframe URL 加载适合聊天模块独立发版或者跨团队协作的场景。用 iframe 方式的话父页面的代码量极小template iframe :srcframeSrc classchat-iframe refframeRef / /template script setup import { computed, onMounted, onBeforeUnmount } from vue import { useUserStore } from /store/user const userStore useUserStore() const conversationId ref() // 从父页面 localStorage / 后端恢复 const frameSrc computed(() { const base import.meta.env.VITE_CHAT_APP_URL const params new URLSearchParams({ authToken: userStore.chatToken, // 来自服务端的一次性/短期凭证 userId: userStore.profile.id, conversation: conversationId.value }) return ${base}/chat?${params.toString()} }) // 监听子应用回传的会话 ID const onMessage (e) { if (e.data?.type CONVERSATION_UPDATED) { conversationId.value e.data.conversationId localStorage.setItem(chat_cid_ userStore.profile.id, e.data.conversationId) } } onMounted(() window.addEventListener(message, onMessage)) onBeforeUnmount(() window.removeEventListener(message, onMessage)) /script从这段能看到整个会话管理的闭环子应用每次拿到新的conversationId就抛给父页面父页面按用户维度存到localStorage或者推到后端下次进入时父页面再把保存的值通过 URL 查询参数传回子应用。查询参数用URLSearchParams构造避免手拼字符串漏了encodeURIComponent导致中文消息把 URL 搞坏。3.3 会话连续conversation_id 的保存与回传很多人接入 Dify 后问一个高频问题为什么刷新页面聊天记录就没了AI 也忘了之前说过的内容因为浏览器和 Dify 之间唯一的桥梁就是conversation_id。这个值每次对话后都会由 Dify 返回你可以理解为它内部维护的会话文件句柄。保存conversation_id时有三个细节值得注意。第一不要只存一个全局变量多用户环境下要按用户主键分桶存否则第二个人进来会把第一个人的会话 ID 覆盖掉。第二conversation_id没有固定的过期时间但它属于 Dify 应用维度的资源如果你在服务端重启或调整了 Dify 应用旧 ID 可能失效前端要处理 404 类型的错误并主动开启新会话。第三历史消息的加载有两种做法——用 Dify 的GET /messages接口回拉或者自己把聊天记录落库。我倾向于后者因为 Dify 只存 AI 侧的消息你很难把它和你业务系统里已有的工单、订单信息关联起来。// 中继接口返回的会话数据结构约定 { code: 0, data: { conversationId: 6a3f7c9e-..., answer: 你好我是你的助手..., createdAt: 1735000000000 } }前端拿到这个结构后conversationId必须无条件覆盖本地旧值哪怕这次对话只是问了一句「在吗」。因为 Dify 每次对老会话追加消息都会返回同一个 ID但如果你开的是新会话返回的是新 ID。覆盖是唯一安全策略。4. 服务端会话管理用户维度映射表、一次性凭证与多实例一致性4.1 中继接口设计前端只认自己的会话 Token前面几章的代码演示了前端怎么传消息但真正的隔离大坝建在服务端中继这一层。一个典型的中继服务需要做三件事收下前端发来的业务请求定位当前真实用户再用 Dify API Key 去请求 Dify。先看用户怎么定位。前端发出的X-Auth-Token不能直接是用户 ID因为用户 ID 一旦泄露任何人都能以这个身份聊天。做法是服务端在用户登录后签发一个短期凭证比如用 HMAC 对userId 过期时间签名。中继接口拿到凭证后先验签验签通过再从 Token 里解出用户主键用主键去映射 Dify 的user参数。// Node.js 中继接口的最小实现Express 风格 const crypto require(crypto) const SECRET process.env.CHAT_TOKEN_SECRET // 与登录签发时相同的密钥 function verifyChatToken(token) { const [payload, sig] token.split(.) const expectSig crypto .createHmac(sha256, SECRET) .update(payload) .digest(base64url) if (sig ! expectSig) return null const { userId, exp } JSON.parse(Buffer.from(payload, base64url).toString()) if (Date.now() exp) return null return { userId } } app.post(/api/dify/chat, async (req, res) { const info verifyChatToken(req.headers[x-auth-token]) if (!info) { return res.status(401).json({ code: 401, message: 会话凭证失效 }) } // 用 userId 调 Dify而不是前端传的原始字符串 const difyResp await callDify({ userId: info.userId, message: req.body.message, conversationId: req.body.conversationId }) res.json(difyResp) })这里最反直觉的一点是前端传上来的userId字段在中继层直接被忽略真正用的用户身份永远来自 Token 解签结果。前端传userId只是为了日志排查方便如果你完全信任它而不校验就回到了官网页面的安全水平。4.2 用户维度会话列表与历史记录加载聊天窗口放进业务系统后用户大概率会问一个需求我昨天跟 AI 聊的内容今天打开还想看到。靠 Dify 的接口回拉是可以的但有两个毛病一是无法做分页和搜索二是 Dify 应用删了或版本升级后历史记录可能消失。自己维护一张会话表是更可靠的选择。-- 会话归属表简化结构 CREATE TABLE chat_session ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id VARCHAR(64) NOT NULL, dify_conversation_id VARCHAR(128) NOT NULL, title VARCHAR(255) DEFAULT , last_message TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_user_updated (user_id, updated_at) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4每次 Dify 返回新conversation_id时中继层就 upsert 这张表。upsert逻辑按(user_id, dify_conversation_id)唯一键判断是新增还是更新。展示侧就简单了用户打开聊天面板时父页面先调一个/api/dify/sessions?userIdxxx接口拿到该用户的所有历史会话渲染成左侧菜单。点某一条时把对应的dify_conversation_id传给 ChatFrame并调用 Dify 的GET /messages把历史消息拉回来。这块的隐藏价值在于你可以把chat_session表和工单表、CRM 客户表做关联实现「这个客户问过 AI 什么问题销售跟进时能直接看到」。这个联动是单纯用 Dify 托管页面完全做不到的。4.3 多实例部署时的会话一致性当你的后端服务部署了多个实例比如 Kubernetes 里跑三个副本会面临一个经典问题用户 A 的请求被负载均衡分到了实例 1下一次被分到了实例 2两个实例各自维护自己的内存状态登录凭证验签没问题但聊天服务里如果有本地缓存一致性就崩溃了。对策是让中继服务保持无状态。chat_session表放在共享数据库里所有实例都查同一张表Dify 的conversation_id本身由 Dify 服务端持有中继层只是透传天然无状态。唯一要注意的是限流和审计日志不能只落在实例本地。我一般把审计日志直接写消息队列异步入库避免聊天高峰期把数据库写崩。至于前端iframe 里加载的子应用也要能容忍「后端实例换人」的情况。这个其实是静默的因为子应用每次请求都是全量带authToken和conversationId后端实例换人不影响会话连续性。5. Dify iframe 接入的五个高频坑从串号到白屏的排查实录5.1 坑一聊天框接上了但每个用户看到的对话完全一样现象两个不同的账号在同一个浏览器里先后打开聊天页面后打开的人能看到前一个人的聊天记录。原因前端把conversation_id存成了全局变量或者后端中继接口没有做用户维度区分所有用户共用一个 Dify 会话。解决第一conversation_id的存取一律带userId前缀第二中继接口强制用 Token 里解出的身份去调 Dify前端传什么参数都不改变用户归属。这个坑最迷惑的地方在于开发环境往往只有一个测试账号Bug 根本暴露不出来。建议从第一天起就用两个不同账号并联测试。我在某公司做过一次复盘这类串号问题大多不是 Dify 的问题而是开发者在会话存储键上偷懒统一写死了一个chat_cid。5.2 坑二iframe 里加载的子应用白屏控制台报跨域错误现象父页面能打开iframe 区域却是空白打开 DevTools 看到类似Refused to display ... in a frame because it set X-Frame-Options的错误。原因Dify 官方聊天页面设了X-Frame-Options: DENY不允许被嵌入或者你自研子应用所在域名的响应头没配置允许被父页面嵌套。解决自研子应用的响应头显式设置Content-Security-Policy: frame-ancestors self https://你的父页面域名同时父页面的 CSP 里给frame-src加上子应用域名。如果是直接嵌 Dify 官方页面遇到这个问题那就死心自研子应用是唯一的正确路径。5.3 坑三iframe 高度固定 600px内容长了出现双重滚动条现象聊天消息一多iframe 内部滚动条和外部页面滚动条一起出现交互非常割裂。原因iframe 高度是写死的没有跟随子应用内容高度变化。解决子应用用ResizeObserver监控根节点高度变化后postMessage给父页面父页面更新 iframe 高度。// 子应用内监听高度变化并通知父页面 const el document.documentElement const observer new ResizeObserver(() { window.parent.postMessage({ type: CHAT_RESIZE, height: el.scrollHeight }, *) }) observer.observe(el)// 父页面接收高度并更新 iframe const onResize (e) { if (e.data?.type CHAT_RESIZE) { frameRef.value.style.height e.data.height px } } window.addEventListener(message, onResize)注意ResizeObserver是异步的消息列表渲染后高度是跳变的前端要做好过渡动画否则每次收到消息 iframe 都会猛地窜一下。另一处细节是父页面里 iframe 初始高度给一个最小值比如400px否则子应用还没加载完成时 iframe 区域是塌陷的。5.4 坑四用户停了一会儿再回来发消息提示 401刷新也没用现象聊天窗口长时间挂着下次发消息时后端返回 401 Unauthorized。原因你签发的authToken过期了而前端没有捕获 401 的重试逻辑。解决在子应用的请求封装里加一层 401 拦截先去父页面要新 Token拿到后重放原请求。async function fetchWithRetry(url, options) { let resp await fetch(url, options) if (resp.status 401) { // 通知父页面刷新业务凭证 const newToken await requestNewTokenFromParent() options.headers[X-Auth-Token] newToken resp await fetch(url, options) } return resp }这里有个容易踩的次级坑requestNewTokenFromParent如果也是异步的子应用不能直接await一个 postMessage 结果因为 postMessage 是单向通知。常见做法是父页面维护一个「Token 刷新中」的 Promise子应用多次调用只会触发一次实际刷新所有等着的请求都挂到同一个 Promise 上。5.5 坑五Dify 返回超时前端一直转圈整个页面卡住现象偶尔一次网络抖动Dify 接口 15 秒没返回聊天输入框锁死连父页面的其他功能都不流畅了。原因中继接口没有设置超时兜底Node.js 的默认请求超时又很长资源被长时间占用。解决中继层给所有 Dify 请求加AbortController超时默认 30 秒切断同时把超时错误码透传给前端提示用户重试。const controller new AbortController() const timer setTimeout(() controller.abort(), 30000) try { const resp await fetch(difyUrl, { signal: controller.signal, method: POST, // ... }) } catch (e) { if (e.name AbortError) { // 记录日志返回 504 给前端 } } finally { clearTimeout(timer) }另外一个容易被忽略的点是流式模式下fetch拿到了响应但流的中间中断前端需要做「半截消息」的处理要么丢弃最后不完整的半句要么标记为「回答被中断」。这个体验问题在 Dify 接入后一定会遇到提前处理比用户投诉后修要好得多。6. 生产加固SSO 对接、并发削峰与会话目录回收把前面的链路跑通后离上线还差三件收尾工作。第一件事是 SSO 对接。中继接口现在用的X-Auth-Token是独立签发的如果用户改了密码或账号被禁用这个 Token 仍然有效直到过期。正确做法是让中继服务对接你已有的单点登录体系——每次校验 Token 时顺带确认用户状态仍是活跃的而不只是验签。你看代码里verifyChatToken只做了 HMAC 和过期校验没查用户库。这一步补上SELECT status FROM user WHERE id ?就能在账号被封禁的瞬间让该用户的聊天能力立刻失效不必等 Token 自然过期。第二件事是并发削峰。你的 Dify 应用如果绑定了付费模型几个人同时狂按回车API 费用会非常难看。中继层做一个简单限流按userId维度每秒最多放行一次对话请求其余请求排队。排队别用内存队列多实例部署下要放在 Redis 里。用户侧的提示语也很重要限流命中时不要只返回一个冷冰冰的 429给出「您上一个问题还在处理中请稍等」会比报错体验好很多。第三件事是会话目录回收。Dify 侧不主动清理长时间未活动的会话如果业务上允许用户无限创建新会话chat_session表会越来越臃肿。我一般会在中继服务里加一个定时任务每个用户最多保留最近 30 条会话记录更早的历史可以归档到冷表或对象存储但保留dify_conversation_id以便用户真需要时还能找回。注意绝对不能直接删掉 Dify 侧的会话因为只有 Dify 服务端有完整上下文。最后说一个我自己吃过亏的教训最开始我只把 Dify 当成一个聊天窗口接入图省事用了共享的匿名会话结果上线第二周就有用户反馈「别人能看到我之前问 AI 的内容」整个客服团队被迫停工排查。从那以后我再接任何对话类 AI 服务第一件事就是画清楚「用户身份从哪里来、会话 ID 存在哪里、谁有资格读取」这三条线。隔离这件事设计十分钟返工一个月。希望这次梳理的路子能帮你在接 Dify 时一次走对。本文还有配套的精品资源点击获取