从 0 到 1:把 Dify Chatflow 接进一个 qiankun 中后台
摘要:本文完整记录了一次真实落地:在 Dify Cloud 上编排一个 Chatflow、拿到 API 密钥、把流式对话接进一个 React 16 + Umi 3 的 qiankun 子应用,并踩平了推理模型思考过程外泄、微前端绝对路径打偏、组件库丢弃样式 prop、Nginx 缓冲吞掉 SSE 这四个坑。文中所有数据都来自实际抓包与构建结果。
技术栈:React 16.12 · Umi 3.2.17 · qiankun · TypeScript 4.1 · Nginx · Dify Cloud 1.18 · DeepSeek 适合读者:需要在已有中后台系统里接入 AI 对话能力,且项目运行在微前端架构下的同学。
目录
- 0. 先看最终效果
- 1. 选型:Chatflow、Chatbot、Agent 到底选哪个
- 2. Dify 侧:搭一个 Chatflow
- 3. 拿 API 密钥(最容易卡住的一步)
- 4. 关于 .ifpkg:Dify 1.18 的导出变化
- 5. 项目侧:先摸清技术栈约束
- 6. 前端实现:一个自包含组件
- 7. 打通链路:开发代理与生产反代
- 8. 四个真实的坑
- 9. 验收清单
- 10. 沉淀
0. 先看最终效果
需求很朴素:给一个海外业务的中后台系统加一个「智能助手」——右下角一个悬浮球,点开是聊天抽屉,能回答业务规则、操作指引之类的问题,回答要逐字流式输出。
最终落地的形态:
- 右下角圆形悬浮球,所有路由页面常驻
- 点击从右侧滑出 400px 抽屉
- 支持流式打字光标、停止生成、新会话
- 会话 ID 存在
sessionStorage,刷新不丢上下文 - 生产环境 API 密钥由 Nginx 注入,前端不持有
整体链路只有一条:
浏览器 fetch(SSE)
↓ 同域 /dify-api/v1/chat-messages
Nginx(注入 Authorization,关闭缓冲)
↓
Dify Cloud → Chatflow(知识库检索 → LLM → 直接回复)
1. 选型:Chatflow、Chatbot、Agent 到底选哪个
Dify 现在的应用类型不少,先把边界划清楚,不然后面全是返工。
| 类型 | mode 值 | 适用场景 | 嵌入中后台的适配度 |
|---|---|---|---|
| Chatflow | advanced-chat | 需要多步骤编排:检索、条件分支、变量处理 | ⭐⭐⭐⭐⭐ 事件流最完整 |
| Chatbot | chat | 简单问答 + 知识库 | ⭐⭐⭐⭐ 足够用,编排能力弱 |
| Agent(新版) | agent | 自主规划、工具调用 | ⭐⭐ 有硬性限制 |
| Workflow | workflow | 无对话的批处理任务 | ⭐ 不适用 /chat-messages |
| 文本生成 | completion | 单次输入输出 | ⭐ 无多轮上下文 |
为什么最终选 Chatflow:
- 业务问答需要「先检索知识库,检索不到再兜底话术」,这是典型的多步骤编排,Chatbot 做不了条件分支;
- Chatflow 的 SSE 事件流最完整,能拿到
workflow_started/node_started/message/message_end,后续想接「显示当前正在检索」这类交互有数据基础; - 它的 API 走标准
POST /v1/chat-messages,和 Chatbot 完全一致,迁移成本为零。
为什么没选新版 Agent,这三点一定要提前知道,否则会白做:
- 新版 Agent 在独立的 Agents 页面创建,不在「工作室 Studio」里,很多人找不到入口;
- 它不能挂经典知识库,必须先把知识库升级成 Agent 知识库;
- 它的沙箱运行在 Dify 云端,无法访问你私有网络里的服务。如果你的知识依赖内网 API 实时取数,这条路直接堵死。
另外新版 Agent 目前是 Beta,且 API 只支持流式(阻塞模式会直接 400)。
结论:中后台嵌入式问答,优先 Chatflow;只想快速验证,用 Chatbot;需要自主调用外部工具,才考虑 Agent。
2. Dify 侧:搭一个 Chatflow
2.1 创建应用
进入 Dify Cloud 的工作室(Studio) → 创建空白应用 → 选择 Chatflow,填写名称和图标。
注意区分:工作室 里创建的是 Chatbot / Chatflow / Workflow;Agents 是另一个独立页面。
2.2 编排工作流
一个实用的 Chatflow 通常长这样:
[开始]
↓
[知识库检索] ← 挂载你的知识库,设置 Top K 和 score 阈值
↓
[条件分支] ← 检索结果为空时走兜底话术
├─ 命中 → [LLM] ← 系统提示词里引用检索结果变量
└─ 未命中 → [LLM](另一套提示词)
↓
[直接回复] ← 把 LLM 输出作为回答返回
几个容易忽略的配置点:
- 知识库检索节点:
Top K不要设太大,3~5 足够;score 阈值设 0.5 以下容易召回噪声。 - LLM 节点:系统提示词里建议显式约束「只根据检索内容回答,不要编造」,否则模型会自由发挥。
- 直接回复节点:这里是流式输出的关键,不接这个节点,前端拿不到
message事件。 - 开场白与建议问题:在「编排 → 功能」里配置,对应 API 返回的
opening_statement和suggested_questions。如果这两个字段返回空数组,说明你还没配。
2.3 定义应用变量(可选但推荐)
如果希望按国家、语言做差异化回答,在应用里定义输入变量,比如 country、language,然后在提示词里用 {{#country#}} 引用。
前端调用时通过 inputs 传入:
{
"inputs": { "country": "jpn", "language": "en-US" },
"query": "东京哪些区域支持智能屏?",
"response_mode": "streaming",
"user": "user-123"
}
有一个细节值得实测一下:如果应用里没有定义这些变量,多传的 inputs 会被 Dify 忽略,不会报错。实测 user_input_form 为空数组时,带上 inputs 依然返回 200。所以不必为了「变量对不上」而反复改请求体。
2.4 调试与发布
在「调试与预览」里把典型问题都问一遍,确认:
- 回答能正常流式输出
- 检索结果符合预期(可以在回答下方展开引用来源)
- 兜底分支真的能触发
确认无误后点发布。未发布的应用,API 调用会失败。
3. 拿 API 密钥(最容易卡住的一步)
这一步几乎人人踩过:在工作室里翻遍了也找不到「API 密钥」。
现在的入口是:
打开应用 → 右上角「发布」→「访问 API」→ 左侧「API 密钥」→ 创建密钥
拿到一串 app- 开头的字符串,形如 app-xxxxxxxxxxxxxxxxxxxx。
有两条官方红线必须遵守:
- 密钥只能在服务端使用。官方文档原话是:把密钥嵌进前端代码,它可能被提取和滥用。所以正确做法是 Nginx 反向代理注入,前端只请求自己的域名。
- 一个应用一个密钥,不要几个应用共用一串。
Base URL 也要记一下:
| 部署形态 | Base URL |
|---|---|
| Dify Cloud | https://api.dify.ai/v1 |
| 私有化部署 | http(s)://你的域名/v1 |
4. 关于 .ifpkg:Dify 1.18 的导出变化
如果你是从别的环境导应用过来,会发现导出得到的只有一个 .ifpkg 文件,没有 .yml DSL。
这不是导出坏了。Dify 1.18.0 起,「导出 DSL」升级成了「导出应用包」,产物是一个 ZIP(扩展名 .ifpkg),里面包含:
app.yaml ← 真正的 DSL
manifest.json
icon.png
skills/
导入时分两种情况:
- 目标环境 ≥ 1.18.0:直接导入
.ifpkg即可。 - 目标环境 < 1.18.0:把
.ifpkg改名为.zip解压,只导入其中的app.yaml。官方文档明确说了这个回退路径。
还有一个必须知道的事实:应用包不含知识库、API 密钥、模型凭据、日志、历史版本。换环境后这些都要重新配。所以别指望导出一个包就能在另一台机器上完整复现。
顺带一提,Sandbox 套餐下,版权信息和输入框提示语也不会被导入。
5. 项目侧:先摸清技术栈约束
动手写代码前,先把项目底细摸清楚,能省掉大量返工。我这次的项目情况:
| 项 | 值 | 影响 |
|---|---|---|
| React | 16.12(CDN 外置加载 16.14) | 不能用 hooks 生态里依赖 18 特性的库 |
| Umi | 3.2.17 | webpack 4,Node 17+ 需要 --openssl-legacy-provider |
| qiankun | slave: {},base: 'atlas' | 子应用,所有请求的 origin 是主应用 |
| 组件库 | fen-design 2.7.2(基于 antd 二次封装) | 部分 props 会被丢弃,见第 8 节 |
| 类型 | TypeScript 4.1,strict: true | 但 umi build 走 babel,不做类型检查 |
| 样式 | CSS Modules + Less | import styles from './index.less' |
有三个约束直接决定了实现方案:
① 不能用官方 embed.js
Dify 提供的嵌入脚本 embed.min.js 依赖运行时动态注入 <script>。而 qiankun 的 ProxySandbox 会把动态 script 注入拦截并改写成 fetch 请求,直接触发 CORS 失败。
所以只能走自研组件 + 纯 HTTP/SSE 接口这条路。这反而更好:样式可控、埋点可控、错误可捕捉。
② 全局 UI 只能挂在 rootContainer
要做一个所有路由页面都常驻的悬浮球,唯一可靠的落点是 src/app.ts 导出的 rootContainer 生命周期:
export function rootContainer(container: any) {
return React.createElement(
ConfigProvider,
{ locale: getPackage() },
container,
// 智能助手挂成 container 的兄弟节点,覆盖所有路由
React.createElement(AiAssistant),
);
}
注意这个位置不能用 useModel,它不在路由上下文内。
③ 会话用户标识要能兜底
用户的 id 从 localStorage.User 解析,但未登录时不能每次刷新都变成新用户,否则 Dify 侧会话全乱。兜底方案是生成一个浏览器维度的匿名 id 存起来:
export const getUserId = () => {
const id = getCurrentUserId();
if (id) return id;
let anon = window.localStorage.getItem('atlas_ai_anon_id') || '';
if (!anon) {
anon = `anon-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
window.localStorage.setItem('atlas_ai_anon_id', anon);
}
return anon;
};
6. 前端实现:一个自包含组件
6.1 目录结构
src/common/components/aiAssistant/
├── index.tsx # 悬浮球 + Drawer
├── index.less # 全部样式
├── config.ts # 常量、用户标识、请求前缀
├── hooks/
│ └── useDifyChat.ts # SSE 封装(核心)
└── components/
├── messageItem.tsx # 单条气泡
├── inputBar.tsx # 输入区
└── markdown.tsx # 轻量 Markdown 渲染
组件完全自包含,只有 3 处外部改动:src/app.ts 挂载、config/proxy.ts 开发代理、.nginxconfig 生产反代。
6.2 请求体
Dify Chat API 的核心只有五个字段:
const res = await fetch(`${DIFY_API_PREFIX}/v1/chat-messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal, // 用于「停止生成」
body: JSON.stringify({
inputs: buildChatInputs(), // 应用变量,没有就传 {}
query: text,
response_mode: 'streaming', // 必须是 streaming 才能逐字输出
conversation_id: conversationIdRef.current, // 空字符串表示新会话
user: getUserId(),
}),
});
conversation_id 是会话保持的关键:首轮传空,从响应里拿到后存起来,后续每轮带上,Dify 就会延续上下文。
6.3 SSE 解析(核心代码)
浏览器原生 EventSource 只支持 GET,而 Dify 是 POST,所以必须手写解析。核心就三件事:按空行切块、取 data: 行、按事件类型分发。
const reader = res.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
buffer = buffer.replace(/\r\n/g, '\n');
// SSE 以空行分隔事件,最后一段可能是半截,留到下次
const blocks = buffer.split('\n\n');
buffer = blocks.pop() || '';
for (const block of blocks) {
const line = block.split('\n').find((item) => item.indexOf('data:') === 0);
if (!line) continue;
const raw = line.slice(5).trim();
if (!raw || raw === '[DONE]') continue;
const payload = JSON.parse(raw);
// ...按 event 分发
}
}
// 关键:流结束后,缓冲区里可能还压着最后一段没有结尾空行的数据
if (buffer.trim()) {
// 再消费一次,否则最后一部分会被丢掉
}
这一段有两个细节是真正踩出来的:
\r\n要先归一化。有些代理会返回 CRLF,split('\n\n')会全部失效。- 流结束必须再消费一次 buffer。部分代理不会给最后一条事件补末尾空行,如果只处理循环内的完整块,最后一段数据会永久留在缓冲区里。
事件分发只关心这几种:
switch (payload.event) {
case 'message':
case 'agent_message':
// 增量文本,追加
break;
case 'message_replace':
// 内容审核拦截后整体替换,不是追加
break;
case 'message_end':
// 结束
break;
case 'error':
throw new Error(payload.message);
}
实测一次完整问答的事件分布(hello 一句话):
1 workflow_started
4 node_started
4 node_finished
361 message ← 正文按 token 切片
1 message_end
1 workflow_finished
361 个 message 事件意味着一句话被切了 361 次。每次追加都要做函数式状态更新,否则 React 16 的批处理会让内容错乱:
setMessages((list) => {
const next = list.slice();
const last = next[next.length - 1];
next[next.length - 1] = { ...last, content: last.content + chunk };
return next;
});
6.4 消息渲染:为什么自己写 Markdown
项目里没装 react-markdown,兼容 React 16 的版本体积也不小。所以写了一个 150 行的极简渲染器,只支持段落、粗体、行内代码、链接、有序/无序列表、标题和代码块。
关键是全程用 React 元素组装,不用 dangerouslySetInnerHTML。AI 输出是不可信内容,一旦用 innerHTML,就等着 XSS 吧。
6.5 接口前缀集中管理
export const DIFY_API_PREFIX = resolveApiPrefix();
这一个常量同时服务三个环境(开发独立访问、开发被嵌入、生产),具体逻辑见第 8.2 节,它是本次最大的一个坑。
7. 打通链路:开发代理与生产反代
7.1 开发环境:本地代理
前端请求 /dify-api/v1/chat-messages,由 devServer 转发到 Dify:
'/dify-api': {
target: 'https://api.dify.ai',
changeOrigin: true,
pathRewrite: { '^/dify-api': '' },
// 本地没有 Nginx 注入密钥,联调时临时放开(切勿提交)
headers: { Authorization: 'Bearer app-xxxxxxxx' },
}
⚠️ 改完 proxy 配置必须重启 devServer。这类配置只在启动时读取一次,热更新对它无效。我这次绕了不少弯路,最后发现就是这一条。
7.2 生产环境:Nginx 反代注入密钥
location /dify-api/ {
proxy_pass https://api.dify.ai/;
proxy_set_header Host api.dify.ai;
# 密钥在这里注入,前端代码和网络面板都看不到
proxy_set_header Authorization "Bearer app-xxxxxxxxxxxxxxxx";
# ↓ 下面四行是 SSE 的命门
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
chunked_transfer_encoding on;
}
几个要点:
proxy_pass末尾必须有斜杠,否则路径拼接会多出一段。proxy_buffering off是 SSE 的命门。Nginx 默认会攒够缓冲再发,开启缓冲后前端看到的不是逐字输出,而是「转圈十几秒 → 一次性蹦出全文」。proxy_http_version 1.1+Connection ""才能让上游保持长连接分块返回。- 如果上游是 HTTPS,还要加
proxy_ssl_server_name on;。不加会导致 TLS 握手被对端直接关闭——因为 Nginx 默认不发 SNI,多域名共享 IP 的场景下上游不知道你要访问哪个站点。
8. 四个真实的坑
8.1 推理模型的 <think> 被当正文渲染了
接上 DeepSeek 之后,界面上直接出现了这种东西:
<think><!--dify-deepseek-reasoning-->We need answer in English? User asks...
...(一千多字的内部推理)...
</think>
I can't confirm a specific district list...
第一个反应不能是「对每个 chunk 做正则替换」。 因为流式场景下标签会被切到不同 chunk 里,<think> 可能被拆成 <thi 和 nk>,任何针对单块的替换都会漏。
正确做法是:累积原始文本,每次重新计算可展示内容。
const stripReasoning = (text: string) => {
// 1. 移除已经闭合的完整推理段
let out = text.replace(/<think(?:\s[^>]*)?>[\s\S]*?<\/think>/gi, '');
// 2. 移除 HTML 注释(Dify 会插入 <!--dify-deepseek-reasoning--> 标记)
out = out.replace(/<!--[\s\S]*?-->/g, '');
// 3. 仍未闭合的开标签,说明正在思考,后面的内容一律不展示
const openIndex = out.toLowerCase().indexOf('<think');
if (openIndex !== -1) out = out.slice(0, openIndex);
// 4. 只剩闭合标签,前面都是推理内容
const closeIndex = out.toLowerCase().lastIndexOf('</think>');
if (closeIndex !== -1) out = out.slice(closeIndex + 8);
// 5. 末尾可能是被切断的半个标签,先不展示,等下一个 chunk 补齐
out = out.replace(/<\/?[a-z][^>]*$/i, '');
return out.replace(/^[\s\u200b]+/, '');
};
调用处:
if (payload.answer) {
rawContent += payload.answer;
const display = stripReasoning(rawContent); // 每次都基于全量重算
patchLast((msg) => ({ ...msg, content: display }));
}
第 5 步那个正则要小心,<\/?[a-z][^>]*$ 只匹配字母开头的标签,所以正文里的 1 < 2 不会被误伤。
这套逻辑我写了 9 个用例验证,包括 <thi、</thi、大小写混写、TOKYO<THINK>x</THINK>done、以及 1 < 2 这种误伤边界,全部通过。
另外一个体验细节:思考阶段 display 为空,此时气泡会自动回落到「思考中…」的占位文案,用户不会看到空白。
8.2 qiankun 下,以 / 开头的请求打到了主应用
这是最隐蔽的一个,因为它不报错。
现象是:请求 200、无异常、但回复内容为空。
原因链条:
子应用跑在 8006,被主应用(8000)通过 qiankun 嵌入
↓
fetch('/dify-api/v1/chat-messages') ← 绝对路径
↓
浏览器解析为「当前页面 origin + 路径」
↓
当前页面 origin 是主应用的 localhost:8000,不是子应用的 8006
↓
主应用没有这条 proxy,命中 umi 的 history fallback
↓
返回 200 + text/html + 976 字节的 index.html
↓
res.ok === true,catch 不进,控制台无报错
↓
SSE 解析器在 HTML 里找不到任何 data: 行
↓
回复内容为空
验证方式很简单:
# 主应用
curl -i http://localhost:8000/dify-api/v1/parameters
# → 200, Content-Type: text/html ← 这是 index.html
# 子应用
curl -i http://localhost:8006/dify-api/v1/parameters
# → 200, Content-Type: application/json
这类「软 404」是排查流式问题时的头号陷阱:状态码骗人,res.ok 也骗人。
解决方案是让子应用自己识别是否被嵌入,动态决定请求前缀:
const resolveApiPrefix = () => {
const prefix = config.DIFY_API_PREFIX; // '/dify-api'
// 生产/沙盒/预发:同域反代,直接相对路径
if (process.env.PROJECT_ENV !== 'development') return prefix;
// 开发环境:qiankun 会把子应用的 publicPath 注入到 window
const injected = (window as any).__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
const matched = injected ? /^(https?:\/\/[^/]+)/.exec(String(injected)) : null;
// 拿不到注入地址时,用当前主机名 + 子应用端口兜底
const ownOrigin = matched
? matched[1]
: `http://${window.location.hostname}:${config.DIFY_DEV_PORT}`;
// 独立访问子应用(origin 相同)时保持相对路径,避免多余跨域
if (!ownOrigin || ownOrigin === window.location.origin) return prefix;
return `${ownOrigin}${prefix}`; // → http://localhost:8006/dify-api
};
这里有几个设计考虑:
- 用
window.location.hostname而不是硬编码localhost,这样通过局域网 IP 访问主应用时也能正确工作; - 独立访问子应用时不走绝对地址,避免不必要的跨域;
- 生产环境完全不受影响,仍是同域相对路径。
跨域能不能行?要提前验证两件事,我用 curl 确认过:
# 普通请求
access-control-allow-origin: http://localhost:8000
# 预检 OPTIONS
STATUS=200
access-control-allow-headers: content-type
access-control-allow-methods: DELETE, GET, OPTIONS, PATCH, POST, PUT
因为请求带 Content-Type: application/json 会触发预检,allow-headers 里必须有 content-type,否则一样失败。umi 的 qiankun 子应用 devServer 默认就放开了 CORS(否则主应用连子应用的 JS 都加载不了),所以这里通常不用额外配置。
如果不想走跨域,还有一个方案:在主应用的 proxy 里也加一条 /dify-api。但那要改主应用仓库,跨团队协作成本更高,子应用侧自适应更内聚。
8.3 fen-design 的 Drawer 丢弃了 bodyStyle
想把抽屉内容区改成 flex 布局做「头部固定 + 中间滚动 + 底部输入」,于是写了:
<Drawer bodyStyle={{ padding: 0, display: 'flex', flexDirection: 'column' }}>
结果样式完全没生效,TypeScript 还报错。
翻组件库源码才明白:
// fen-design/es/Drawer/index.d.ts
export type DrawerProps = Omit<AntdDrawerProps,
'className' | 'style' | 'bodyStyle' | 'closeIcon' | ... >;
类型层就把它 Omit 掉了,运行时也确认:
// fen-design/es/Drawer/index.js
var newProps = omitProps(props, ['afterOpenChange', 'bodyStyle', 'closeIcon', ...]);
也就是说,二次封装时被显式剔除的 props,传了不仅无效,连类型都过不去。
替代方案:不碰 body 样式,改用「底部锚点 + scrollIntoView」实现自动滚动到底:
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!visible) return;
const timer = setTimeout(() => {
endRef.current?.scrollIntoView({ block: 'end' });
}, 60);
return () => clearTimeout(timer);
}, [messages, visible]);
// 列表末尾放一个空 div 作为锚点
<div ref={endRef} />
block: 'end' 会滚动最近的可滚动祖先,也就是 Drawer 的 body,不需要自己控制容器高度。
排查经验:用二次封装的组件库时,如果「样式/属性完全没反应」且控制台无报错,先去
node_modules里翻.d.ts和编译后的.js,看有没有Omit或omitProps。这比怀疑自己的 CSS 快得多。
8.4 SSE 必须关掉 Nginx 缓冲
这个前面提过,但值得单独强调,因为它的症状特别有迷惑性:
- 前端代码完全正确,逻辑没问题
- 本地开发一切正常(devServer 不缓冲)
- 一上线就变成「转圈十几秒 → 全文一次性弹出」
根因是 Nginx 默认 proxy_buffering on,会把上游响应攒够缓冲区才下发。流式场景必须:
proxy_buffering off;
proxy_cache off;
proxy_http_version 1.1;
proxy_set_header Connection "";
另外 proxy_read_timeout 也要调大。LLM 生成一段长回答可能超过默认的 60 秒,超时就直接断了。
9. 验收清单
上线前建议逐条过一遍:
Dify 侧
- 应用已发布(未发布调 API 会失败)
- 「访问 API」里能看到
app-开头的密钥 - 调试预览里流式输出正常
- 兜底分支真的能被触发
开发环境
- 改了
proxy.ts后重启过 devServer - Network 里
chat-messages的Content-Type是text/event-stream - 响应里能看到连续的
data: {"event":"message"...} - 微前端场景下请求 URL 指向的是子应用的 origin
生产环境
-
curl -I你的/dify-api/v1/parameters,返回application/json而不是text/html - Nginx 已配
proxy_buffering off - 密钥只存在于 Nginx,前端产物里搜不到
app- - 长回答不会被 60 秒超时切断
代码侧
- SSE 解析处理了
\r\n - 流结束后消费了剩余 buffer
- 状态更新用的是函数式
setState - Markdown 渲染没有用
dangerouslySetInnerHTML - 推理内容已被过滤
10. 沉淀
回头看,这次真正花时间的不是写代码,而是判断问题出在哪一层。总结几条可复用的方法:
① 让失败可见,比让功能可用更重要
最初的收尾逻辑里,SSE 块解析失败是静默 continue 的:
try { payload = JSON.parse(raw); } catch (e) { continue; } // ← 元凶
只要有一个环节出问题,用户看到的是「没有回复」,开发者看到的是一片安静。
后来改成:
- JSON 解析失败 →
console.warn打出原始内容 - 流结束但没有内容 →
console.error打出content-type、数据块数量、最后一条原始数据 - 响应不是
text/event-stream→ 直接读全文并抛出前 300 字符
改完之后,同样的故障会直接在气泡里显示:
Service temporarily unavailable, please retry later
(未收到回答(数据块 2 个))
那「数据块 2 个」就是 index.html 里空行的数量——一眼就能定位。
② 分层验证,一层一层摘
不要一上来就读代码。按链路顺序验证,每验证一层就排除一层:
服务端 API 正常吗? → curl 直连,看有没有 message 事件
代理层正常吗? → 请求自己的 devServer,看 Content-Type 和事件数
浏览器路径对吗? → 看 Network 里的真实 URL 和响应体前 300 字符
我这次的顺序是:直连 API 拿到 361 个 message 事件 → 经代理拿到 302 个 → 说明前两层都没问题 → 那么问题只可能在浏览器解析或路径。范围一下就收窄了。
③ 状态码不是证据,内容才是
200 只代表 HTTP 层成功。返回的是 index.html 还是 text/event-stream,必须看 Content-Type 和响应体。history fallback、网关默认页、登录跳转,全都能返回 200。
④ 微前端里,一切 / 开头的路径都是相对「主应用」的
子应用里所有 API 请求都要想一遍:这个路径会打到谁?开发态和子应用独立运行时可能表现完全不同,必须两边都验。
⑤ 流式协议自己解析,别指望现成库兜住所有边界
\r\n、跨 chunk 切断、末尾无空行、event: ping 这种没有 data: 行的块——每一个都能让你的解析器在特定环境下静默失效。写完后拿真实接口打一遍,把事件类型统计出来,比看文档可靠。
附:关键文件清单
| 文件 | 职责 |
|---|---|
src/common/components/aiAssistant/index.tsx | 悬浮球 + Drawer 容器 |
src/common/components/aiAssistant/hooks/useDifyChat.ts | SSE 请求与解析、会话保持、中断 |
src/common/components/aiAssistant/config.ts | 接口前缀解析、用户标识、应用变量 |
src/common/components/aiAssistant/components/markdown.tsx | 轻量安全渲染 |
src/common/components/aiAssistant/components/messageItem.tsx | 消息气泡 |
src/common/components/aiAssistant/components/inputBar.tsx | 输入区 |
src/app.ts | rootContainer 全局挂载 |
config/proxy.ts | 开发代理 |
.nginxconfig | 生产反代 + 密钥注入 |
如果这篇文章帮你少踩一个坑,欢迎点赞收藏。有微前端下接 AI 能力的其他问题,也欢迎在评论区聊。