从 0 到 1:把 Dify Chatflow 接进一个 qiankun 中后台

0 阅读14分钟

从 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. 先看最终效果

需求很朴素:给一个海外业务的中后台系统加一个「智能助手」——右下角一个悬浮球,点开是聊天抽屉,能回答业务规则、操作指引之类的问题,回答要逐字流式输出。

最终落地的形态:

  • 右下角圆形悬浮球,所有路由页面常驻
  • 点击从右侧滑出 400px 抽屉
  • 支持流式打字光标、停止生成、新会话
  • 会话 ID 存在 sessionStorage,刷新不丢上下文
  • 生产环境 API 密钥由 Nginx 注入,前端不持有

整体链路只有一条:

浏览器 fetch(SSE)
      ↓ 同域 /dify-api/v1/chat-messages
Nginx(注入 Authorization,关闭缓冲)
      ↓
Dify Cloud  →  Chatflow(知识库检索 → LLM → 直接回复)

1. 选型:Chatflow、Chatbot、Agent 到底选哪个

Dify 现在的应用类型不少,先把边界划清楚,不然后面全是返工。

类型mode 值适用场景嵌入中后台的适配度
Chatflowadvanced-chat需要多步骤编排:检索、条件分支、变量处理⭐⭐⭐⭐⭐ 事件流最完整
Chatbotchat简单问答 + 知识库⭐⭐⭐⭐ 足够用,编排能力弱
Agent(新版)agent自主规划、工具调用⭐⭐ 有硬性限制
Workflowworkflow无对话的批处理任务⭐ 不适用 /chat-messages
文本生成completion单次输入输出⭐ 无多轮上下文

为什么最终选 Chatflow:

  1. 业务问答需要「先检索知识库,检索不到再兜底话术」,这是典型的多步骤编排,Chatbot 做不了条件分支;
  2. Chatflow 的 SSE 事件流最完整,能拿到 workflow_started / node_started / message / message_end,后续想接「显示当前正在检索」这类交互有数据基础;
  3. 它的 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。

有两条官方红线必须遵守:

  1. 密钥只能在服务端使用。官方文档原话是:把密钥嵌进前端代码,它可能被提取和滥用。所以正确做法是 Nginx 反向代理注入,前端只请求自己的域名。
  2. 一个应用一个密钥,不要几个应用共用一串。

Base URL 也要记一下:

部署形态Base URL
Dify Cloudhttps://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. 项目侧:先摸清技术栈约束

动手写代码前,先把项目底细摸清楚,能省掉大量返工。我这次的项目情况:

项值影响
React16.12(CDN 外置加载 16.14)不能用 hooks 生态里依赖 18 特性的库
Umi3.2.17webpack 4,Node 17+ 需要 --openssl-legacy-provider
qiankunslave: {},base: 'atlas'子应用,所有请求的 origin 是主应用
组件库fen-design 2.7.2(基于 antd 二次封装)部分 props 会被丢弃,见第 8 节
类型TypeScript 4.1,strict: true但 umi build 走 babel,不做类型检查
样式CSS Modules + Lessimport 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.tsSSE 请求与解析、会话保持、中断
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.tsrootContainer 全局挂载
config/proxy.ts开发代理
.nginxconfig生产反代 + 密钥注入

如果这篇文章帮你少踩一个坑,欢迎点赞收藏。有微前端下接 AI 能力的其他问题,也欢迎在评论区聊。