小欣AI·服务台

SSE 事件参考

本文档面向前端开发者,详细说明 AI 平台流式响应的全部 SSE 事件、典型序列和集成方式。

修改记录

日期变更
2026-06-25DeerFlow 工具调用事件序列对齐 OpenAI 规范:模型声明 tool_calls 时立即产出 output_item.addedstatus="in_progress")+ function_call_arguments.done(完整 arguments);工具执行完产出 output_item.donestatus="completed")。前端可监听 function_call_arguments.done 获取结构化参数(含 ask_clarificationquestion/options/clarification_type),不再依赖 node_update:* custom 事件。
2026-06-05修复续话(previousResponseId)场景下 SSE 误透传上一轮历史:不再把 resume 快照里上一轮的附件(container_file_citation)与工具调用(write_file/present_files 等)当本轮内容下发;续话流只携带本轮真实产出,与 DeerFlow 原生前端一致。前端无需再自行按文件名/调用去重。
2026-06-02对齐 API 文档的「取消生成 / 续话」说明(responseId 取值区别);品牌名更新。
2026-05-29新增附件相关 SSE 事件与前端集成(上传识别、生成附件下载 / 预览)。
2026-05-21SSE 事件参考完善;链接适配线上。

什么是 SSE

SSE(Server-Sent Events)是一种从服务端向浏览器推送实时数据的 HTTP 协议。AI 平台在收到用户提问后,将回答逐字生成为 SSE 事件流,前端逐帧渲染,实现"打字机"效果。

数据流向:AI 引擎 → SDK 归一化 → 业务后端透传 → 浏览器 SSE → 前端 UI 渲染。
所有事件已统一为 OpenAI Responses API 标准格式,无论底层接入哪家 AI 引擎,前端收到的都是同一套事件协议。

数据流向

请求阶段
前端
fetch / EventSource
业务后端
调用 SDK API
SDK
转发请求
AI 引擎
生成回答
响应阶段(SSE 流)
AI 引擎
事件流输出
SDK 归一化
ResponseStreamEvent
业务后端
透传 SSE 字节
前端
解析 event/data 帧

请求阶段:浏览器发起 HTTP 请求 → 业务后端接收并调用 SDK API → SDK 转发至 AI 引擎。响应阶段:AI 引擎生成回答事件流 → SDK 归一化为标准 ResponseStreamEvent → 业务后端将 SSE 字节写入 HttpServletResponse → 前端逐帧解析,驱动打字机渲染。

事件全景图

每一次流式调用都从 response.created 开始,到 response.completed(或 failed/incomplete)结束。中间取决于 Agent 行为,可能经历文本输出、工具调用、推理或代码解释器等不同分支。

DeerFlow 注意response.createdresponse.id占位(此时上游 run_id 尚未返回,仅含会话标识);response.completed 的 id 才是完整标识。前端如需稳定关联本轮会话,用 metadata.thread_id;后端如需"停止生成",用 StreamHandle.awaitResponseId() 取含 runId 的 id 再调 cancel(见 API 文档)。
点击任何事件名可跳转到详细说明
response.created response.in_progress

此后根据 Agent 选择进入一个或多个输出项,每个 output_item 由一对 ADDED / DONE 包裹

场景 · 文本回答
output_item.added content_part.added output_text.delta ×N output_text.done content_part.done output_item.done
期间可能出现 output_text.annotation.added(引用/脚注),穿插在 delta 之间
场景 · 安全拒绝(与文本互斥)
output_item.added refusal.delta ×N refusal.done output_item.done
模型判定输入违反安全策略时走此通道,不会同时出现 output_text.delta
场景 · 工具调用
output_item.added function_call_arguments.delta ×N function_call_arguments.done output_item.done
之后可能接另一个工具调用,或回到文本回答分支 → 最终到 completed
场景 · 深度推理
output_item.added 并行两路: reasoning_text.delta ×N (完整版) + reasoning_summary_text.delta ×N (摘要版) reasoning_summary_text.done output_item.done
reasoning_text.delta 是完整思维链(调试用),reasoning_summary_text.delta 是精简摘要(展示给用户)。推理完成后通常接文本回答分支。
内置工具 · 状态通知
web_search_call.searching web_search_call.completed web_search_call.failed file_search_call.searching file_search_call.completed
这些事件穿插在工具调用分支中,通知前端当前搜索/检索进度
DeerFlow 扩展 · updates 通道(node_update:*)
node_update:model node_update:tools node_update:TitleMiddleware node_update:TokenUsageMiddleware node_update:SummarizationMiddleware …等18+种中间件节点
全程伴随:每个图节点执行完触发一次,携带状态增量。驱动标题同步、token统计、摘要刷新、Langfuse追踪。
DeerFlow 扩展 · custom 通道
custom(thinking) custom(task_running) custom(llm_retry)
thinking 被 SDK 转为 REASONING_TEXT_DELTA;task_running 驱动子任务进度卡片;llm_retry 弹出重试提示。
以上分支都可重复、组合,最终收敛到 → response.completed / response.failed / response.incomplete
任一流中均可出现 error 事件(网络/SDK 异常),此时流立即中断

事件格式

SSE 流由逐行的文本帧组成,每个事件包含一个 event 行和一个 data 行,以空行分隔:

SSE 帧格式
// 每个事件由 event 行 + data 行组成,以空行结束
event:response.created
data:{"type":"response.created","response":{"id":"resp_abc123","status":"in_progress",...}}

event:response.output_item.added
data:{"type":"response.output_item.added","output_index":0,"item":{"id":"lc_run--019e0001-abcd-1234-ef00-000000000001","type":"message","status":"in_progress"}}

event:response.content_part.added
data:{"type":"response.content_part.added","output_index":0,"item":{"id":"lc_run--019e0001-abcd-1234-ef00-000000000001","type":"message"}}

event:response.output_text.delta
data:{"type":"response.output_text.delta","output_index":0,"content_index":0,"item_id":"lc_run--019e0001-abcd-1234-ef00-000000000001","delta":"你好"}

event:response.output_text.delta
data:{"type":"response.output_text.delta","output_index":0,"content_index":0,"item_id":"lc_run--019e0001-abcd-1234-ef00-000000000001","delta":",今天"}

event:response.completed
data:{"type":"response.completed","response":{"id":"resp_abc123","status":"completed","output":[...],"usage":{...}}}

关键规则:

快速上手

最小可用的 SSE 监听代码:

JAVASCRIPT
// 发送请求并读取 SSE 流
const response = await fetch('/api/ai/chat/stream', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ agent: 'my-agent', input: '你好' }),
});

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let currentEvent = '';

while (true) {
  const { done, value } = await reader.read();
  if (done) break;

  buffer += decoder.decode(value, { stream: true });
  const lines = buffer.split('\n');
  buffer = lines.pop() || '';

  for (const line of lines) {
    if (line.startsWith('event:')) {
      currentEvent = line.slice(7);
    } else if (line.startsWith('data:')) {
      const data = JSON.parse(line.slice(6));
      handleEvent(currentEvent, data);  // 你的事件处理函数
    }
  }
}

生命周期事件 6 个

每次流式调用都会完整经历生命周期主线。这 6 个事件描述了响应从创建到结束的全过程。

SSE 事件名触发时机携带字段前端行为
response.created 服务端接收请求、分配 response_id 的瞬间,永远是流的第一个事件 response.id, response.status="in_progress" 记录 response.id 用于多轮对话;显示"加载中"占位
response.in_progress 进入内容生成阶段,位于 created 之后、第一个内容事件之前 response 更新 UI 状态为"生成中",可开始展示骨架屏
response.completed 全部内容生成完毕,流正常结束 response.output[](完整), response.usage 读取 outputusage;隐藏加载动画;保存会话记录
response.failed 服务端内部错误导致流终止 response.status="failed", response.error 展示错误提示;上报错误日志
response.incomplete 触发了 max_tokens 截断或其他提前终止,内容不完整但不报错 response.status="incomplete" 提示用户"回答已截断";可增大 maxOutputTokens 后重试
error 网络层或 SDK 内部捕获的异常 error.message, error.code 展示网络错误提示;根据异常类型判断是否重试

输出项事件 4 个

每轮回答由一个或多个"输出项"组成。一段文字是 message 项,一次工具调用是 function_call 项,一段推理是 reasoning 项。每个输出项内部可再细分为 content part。

Item 生命周期(六阶段)

每个有文本内容的输出项(message/reasoning)完整经历以下六个事件,前端可依此管理 UI 组件的创建与销毁

EVENT SEQUENCE
response.output_item.added      ← 创建气泡占位(item.id 即后续 item_id)
response.content_part.added     ← content part 开始(富文本场景多个 part)
response.output_text.delta × N  ← 逐字追加,item_id 不变
response.output_text.done       ← text 字段含完整文本,可做最终校正
response.content_part.done      ← content part 结束
response.output_item.done       ← 气泡渲染完成,item.item() 含完整内容

注意:流被用户取消时 output_text.donecontent_part.doneoutput_item.done 均可能缺失,前端清理逻辑不能依赖它们必然到达。

SSE 事件名触发时机携带字段前端行为
response.output_item.added 新输出项开始产出 item.id(真实消息 id,DeerFlow 引擎产出 lc_run--* 格式), item.type (message/function_call/reasoning), output_index(该 item 在 output[] 中的真实位置) item.id 为 key 创建 UI 占位(消息气泡/工具卡片/推理面板);多个 item 按 output_index 顺序排列
response.output_item.done 当前输出项的全部内容接收完毕 item(内容完整), output_index 标记该 item 渲染完成;若是 function_call,此时参数完整可展示
response.content_part.added 输出项内部开始新的内容部分 item 富文本渲染时为新的内容块占位(如同一消息含文字+图片)
response.content_part.done 当前内容部分接收完毕 item 标记该 content part 完成

文本与拒绝事件 5 个

模型生成可读文字时触发。这是前端最常处理的事件类别。

SSE 事件名触发时机携带字段前端行为
response.output_text.delta 模型每生成一小段文字触发一次,核心渲染事件 delta(文本片段), item_id(所属 MessageItem 的真实 id,DeerFlow 引擎为 lc_run--* 格式), output_index(item 在 output[] 中的位置), content_index(content part 序号) 追加 delta 到当前活跃气泡;DeerFlow 串行输出,当 item_id 变化时表示进入新一轮消息,重置文本区域(见 React Hook 示例)
response.output_text.done 当前文本 content part 输出结束 text完整文本,无需自行拼接), item_id, output_index, content_index 可选:用完整文本替换自行拼接的结果,做最终渲染;按 item_id 定位对应气泡
response.output_text.annotation.added 文本中出现引用/脚注 annotation(含引用源、位置等元信息) 在文本旁渲染角标或悬浮引用卡片
response.refusal.delta 模型判断请求违反安全策略,走拒绝通道 delta(拒绝文本片段) 展示"该请求无法处理"类提示,而非正常回答
response.refusal.done 拒绝文本输出完毕 delta(完整拒绝文本) 渲染完整拒绝提示
注意:拒绝文本与正常文本 互斥,不会同时出现。收到 refusal.delta 意味着本轮不会再有 output_text.delta

推理事件 3 个

部分模型在输出最终答案前有内部"思维链"过程。推理文本有两种形态:完整版和摘要版。

SSE 事件名触发时机携带字段前端行为
response.reasoning_text.delta 完整推理过程逐段输出(详细、未压缩) delta 通常不展示给终端用户,仅用于调试日志
response.reasoning_summary_text.delta 推理摘要逐段输出(模型自行精简) delta 在可折叠面板中展示"AI 正在思考…",增强用户体感
response.reasoning_summary_text.done 推理摘要结束 隐藏"思考中"动画,切换到正式回答区
使用建议:面向终端用户时,监听 reasoning_summary_text.delta 展示可读的思考过程;开发调试时,监听 reasoning_text.delta 记录完整思维链日志。

函数调用事件 2 个

Agent 决定调用外部工具/API 时触发,此时模型停止生成文字,改为输出工具名和参数 JSON。

SSE 事件名触发时机携带字段前端行为
response.function_call_arguments.delta 工具参数 JSON 逐片生成中,参数尚不完整 item_id(工具调用 id), output_index, delta(参数片段字符串) 展示"正在准备调用…"进度提示;工具名从对应 output_item.addeditem.name 获取
response.function_call_arguments.done 工具参数 JSON 完整,此时可解析 arguments 并可视化 item_id, output_index, arguments(完整 JSON 字符串) 展示工具调用详情卡片(工具名+格式化参数);标记调用完成

内置工具事件 10 个

以下 Web 搜索、文件检索、代码解释器三类事件来自 OpenAI Responses API 规范,SDK 已按规范定义对应事件类型。是否实际产出取决于上游 Provider 的能力:当前 DeerFlow / Dify 主要产出生命周期、文本、推理与函数调用事件,内置工具调用通常以函数调用事件response.function_call_arguments.*)或自定义节点形态体现;下列事件在 Provider 支持时才会收到。前端应按"未知事件兼容"原则容错处理。

Web 搜索事件 3 个

Agent 使用联网搜索工具时触发。

SSE 事件名触发时机前端行为
response.web_search_call.searching开始在线搜索显示"正在搜索…"进度条
response.web_search_call.completed搜索完成,结果已注入上下文更新进度为"搜索完成",agent 将基于搜索结果生成回答
response.web_search_call.failed搜索失败(网络问题或超时)展示"搜索失败,将基于已有知识回答"提示

文件检索事件 2 个

Agent 使用向量检索知识库文件时触发。

SSE 事件名触发时机前端行为
response.file_search_call.searching开始向量检索知识库显示"正在检索文档…"提示
response.file_search_call.completed检索完成,相关段落已注入上下文更新进度为"文档检索完成"

代码解释器事件 5 个

Agent 使用沙箱环境执行代码(数学计算、数据分析等)时触发。这是复杂的工具调用流程,涉及代码生成和执行两个阶段。

SSE 事件名触发时机携带字段前端行为
response.code_interpreter_call.in_progress 沙箱环境就绪,代码解释器准备执行 显示"正在运行代码…"提示
response.code_interpreter_call_code.delta 模型生成代码的增量片段(代码尚未执行) delta(代码文本片段) 实时展示代码生成过程(代码高亮预览)
response.code_interpreter_call_code.done 代码生成完毕,此刻代码完整但尚未执行 delta(完整代码) 展示完整代码块,准备进入执行阶段
response.code_interpreter_call.interpreting 沙箱正在执行代码(运行时阶段) 显示"执行中…"动画
response.code_interpreter_call.completed 代码执行完成,输出结果已注入上下文 展示执行结果;更新进度为"代码执行完成",agent 将基于结果生成回答

DeerFlow 自定义事件 概述

以下事件来自 DeerFlow 引擎特有的 LangGraph 执行机制,不属于 OpenAI Responses API 规范。SDK 通过 CustomItemtype="custom")承载这些事件,以 customType 字段区分子类型。这些事件全程伴随流式响应,不进入 response.output,仅供前端消费进度信号。

三类 CustomItem 对照:
node_update:*(updates 通道)→ 对应前端 onUpdateEvent,图执行进度
custom(custom 通道)→ 对应前端 onCustomEvent,即时通知
<原始event名>(未知兜底)→ 无对应 hook,透传日志

node_update:* 事件 18+ 种

来自 LangGraph stream_mode="updates",每个图节点执行完后 emit {"<节点名>": {状态增量}}。SDK 为每个 key 生成一个 CustomItemcustomType = "node_update:节点名"

customType来源节点携带数据前端/后端行为
node_update:model 核心 LLM 调用节点 messages[] 含 AIMessage(含 usage_metadatamodel_nametool_calls SDK 内部触发 Langfuse step 生命周期追踪;前端无需处理
node_update:tools 核心工具执行节点 messages[] 含 ToolMessage(含 tool_call_idnamecontentstatus SDK 生成 ToolCallItem → 前端渲染工具调用卡片(onToolEnd
node_update:TitleMiddleware 标题中间件(after_model) title 字段 前端自动更新会话标题
node_update:TokenUsageMiddleware Token 统计中间件(after_model) token 用量数据 前端累加 token 统计展示
node_update:SummarizationMiddleware 摘要中间件(before_model) 历史消息转移通知 超长会话触发摘要时,前端刷新消息缓存
node_update:TodoMiddleware.* 计划模式中间件(before_agent / before_model / after_model / after_agent) todo 列表状态更新 前端渲染/更新计划面板
node_update:LoopDetectionMiddleware.* 循环检测中间件(before_agent / after_model / after_agent) 循环触发标志 前端可展示"检测到循环,正在跳出"提示
node_update:MemoryMiddleware 记忆中间件(after_agent) 更新后的记忆内容 前端可刷新记忆面板(如有)
node_update:ThreadDataMiddleware 线程数据中间件(before_agent) 用户消息写入状态 通常无需前端处理
node_update:SandboxMiddleware.* 沙箱中间件(before_agent / after_agent) 文件/环境初始化状态 通常无需前端处理
注意:节点名 = {MiddlewareClass.__name__}.{hook_name},hook 名固定为 before_agent / before_model / after_model / after_agent。具体哪些节点出现取决于启用的中间件组合(plan_mode、subagent、loop_detection 等配置)。

updates 通道中 messages[] 的子类型

上游 updates payload 的 messages[] 数组中每条消息有 type 字段,对应 LangChain 消息类名:

msg.typeLangChain 类含义SDK 处理方式
humanHumanMessage用户输入消息忽略
aiAIMessageLLM 回复消息提取 usage_metadatamodel_nametool_calls.args
toolToolMessage工具执行结果去重后生成 ToolCallItemtask 工具重命名为 sub_agent

custom 通道事件 3 种

来自 LangGraph stream_mode="custom",由工具/中间件通过 get_stream_writer() 主动 emit。SDK 注册了 "custom" 作为五种 stream_mode 之一,底层 SSE 帧格式为:

SSE 帧格式(custom 通道)
event:custom
data:{"type":"task_running","task_id":"uuid-xxx","message":{...}}

// 或
event:custom
data:{"type":"llm_retry","attempt":2,"max_attempts":3,"message":"..."}

// thinking 也走 custom 通道,但被 SDK 拦截转换
event:custom
data:{"type":"thinking","data":"让我想一想..."}

SDK 处理流程(DeerflowSseNormalizer.handleCustom())

PROCESSING FLOW
SSE event="custom" 到达
  ↓
normalize() → 匹配 case "custom" → handleCustom(data)
  ↓
解析 JSON,取 root.get("type")
  ├── type="thinking"
  │     ↓ 拦截,提取 data 字段作为推理文本
  │     ↓ appendReasoningDelta(text)
  │     ↓ return REASONING_TEXT_DELTA  ← 不生成 CustomItem,走推理流程
  │
  └── type="task_running"
        ↓ 累加子代理 token(从 message.response_metadata.token_usage 提取)不 return,继续往下

  其余任意 type 值(包括 task_running / llm_retry / 未来的新类型)
        ↓
        ↓ new CustomItem(null, "custom")
        ↓ 将整个 JSON 展开写入 raw(所有的 key-value 对)
        ↓ emit OUTPUT_ITEM_ADDED  ← CustomItem,不进入 Response.output

核心要点:

事件详细说明

type="thinking"
此事件会被 SDK 拦截,不生成 CustomItem。前端应监听 response.reasoning_text.delta 接收推理内容,而不是在 CustomItem 中查找 thinking。
字段何时详情
发射源DeerFlow LLM 中间件在推理模式(reasoning_effort)下的内部处理
触发条件模型启用了 reasoning_effort 或模型原生输出 thinking 块
SDK 入口handleCustom() 中提前 return,走 appendReasoningDelta()
SDK 产出REASONING_TEXT_DELTAResponseStreamEvent),前端通过 ev.type() 或 SSE event 名识别
共用 ReasoningItem与 messages-tuple 通道中 <think> 标签走的 appendReasoningDelta() 是同一个实例——两条路径的推理文本会合并到同一个 ReasoningItem
SSE event 名response.reasoning_text.delta
data JSON{"type":"response.reasoning_text.delta","delta":"..."}
前端行为逐次追加渲染推理面板,与标准推理流程一致(见 推理事件
type="task_running"

子代理(sub-agent)运行中,每当子代理产出一条新的 AI 消息就 emit 一次。一批 task_running 最后接一个 task_completed 事件(也是 custom 通道,见表后说明)。

上游 JSON payload(deer-flow → SDK 原始帧)

RAW JSON
{
  "type": "task_running",
  "task_id": "uuid-of-subagent-task",   // 子代理任务 ID,前端按此去重/分组
  "message": {                          // 完整的 LangChain AIMessage 对象
    "type": "ai",                       // LangChain 消息类型
    "content": "正在查询猪场数据...",
    "id": "lc_run--019e0001-...",
    "response_metadata": {
      "model_name": "deepseek-v3",
      "finish_reason": "stop",
      "token_usage": {                  // ← SDK 从此处提取 token 累计到全局 usage
        "prompt_tokens": 150,
        "completion_tokens": 35,
        "total_tokens": 185
      }
    }
  },
  "message_index": 1,                  // 第几条消息(1-based),前端可展示序号
  "total_messages": 3                  // 当前已累积的消息总数
}

SDK 对 task_running 的特殊处理

前端接收

TYPESCRIPT 前端判断
case 'response.output_item.added': {
  const item = data.item;
  if (item.type !== 'custom') break;

  // method 1: raw 通过 SSE data 中的 item.raw 字段取得
  // method 2: 将 data.item 作为 CustomItem,取 getRaw()["type"]
  const rawType = data.item.raw?.type;  // 或 item.raw["type"]

  if (rawType === 'task_running') {
    const { task_id, message, message_index } = data.item.raw;
    // 更新子任务进度卡片
    updateSubtask(task_id, {
      latestMessage: message,
      messageIndex: message_index,
    });
  }
  break;
}
字段何时详情
发射源task_tool.py:355task 工具(子代理调度器)在轮询子代理结果时
触发条件lead agent 使用了 task 工具派发子代理;子代理运行中每次产出新 AI 消息时 emit 一次
频率子代理每完成一轮 LLM 调用就 emit 一次;如果子代理调了多个工具,每条 AI 消息各 emit 一次
SDK 产出OUTPUT_ITEM_ADDED,item 为 CustomItem(type="custom", customType="custom"),raw 含全部字段
SSE event 名response.output_item.added
前端行为task_id 找到对应子任务卡片,用 message.content 更新显示的最新消息预览
type="llm_retry"

LLM 调用失败后,LLMErrorHandlingMiddleware 自动重试时 emit。前端应弹出即时通知而非静默忽略。

上游 JSON payload(deer-flow → SDK 原始帧)

RAW JSON
{
  "type": "llm_retry",
  "attempt": 2,               // 当前是第几次重试(从 1 开始)
  "max_attempts": 3,          // 最多重试次数
  "wait_ms": 2000,            // 距离上一次失败等待的毫秒数
  "reason": "rate_limit",     // 重试原因(rate_limit / timeout / server_error)
  "message": "LLM 调用失败(限流),正在第 2/3 次重试(等待 2.0s)..."  // 人类可读提示
}

SDK 处理:不拦截,完整保留 raw,生成 CustomItem(null, "custom"),emit OUTPUT_ITEM_ADDED

前端接收

TYPESCRIPT 前端判断
const rawType = data.item.raw?.type;

if (rawType === 'llm_retry') {
  const { attempt, max_attempts, message } = data.item.raw;
  toast.warning(message, { duration: 3000 });  // 弹出提示但不要阻断用户操作
}
字段何时详情
发射源llm_error_handling_middleware.py:197
触发条件LLM API 调用返回可重试错误(限流 429、服务端 5xx、超时)且 retry_max_attempts > 1
SDK 产出OUTPUT_ITEM_ADDED,item 为 CustomItem(type="custom", customType="custom"),raw 含全部字段
SSE event 名response.output_item.added
前端行为toast.warning(message),告知用户"AI 服务暂时不稳定,正在自动重试"
最终失败超过 max_attempts 后中间件抛出异常 → SDK emit response.failed;前端从 toast 切换到错误横幅
补充:type="task_completed"(非核心,现状未处理)

子代理运行结束后,task_tool.py 还会 emit 一条 task_completed 事件(含 resultusage 等字段),同样走 custom 通道。当前 SDK 将其作为普通 custom 事件透传,raw.type = "task_completed"。前端可通过 data.item.raw 取得结果摘要和用量信息。

兜底 CustomItem 生成场景

除上述 custom 通道外,以下场景也会产生 CustomItem:

场景触发条件customType 值raw 内容
未知 SSE event 名 新版 deer-flow 引入新 stream_mode,SDK 的 normalize()default 分支 <原始event名> 原始 data JSON(若可解析)或原始字符串
JSON 解析异常 handleCustom()normalize()readTree() 抛 IOException <原始event名> {"data": "原始字符串"}
Jackson 反序列化回退 OutputItem@JsonSubTypes 不匹配(如未来 OpenAI 新 type) JSON 中 "type" 的值 完整反序列化后的 JSON 对象
前端兼容原则:OutputItem.type === "custom" 且不认识 customType / raw.type 的事件,记录日志后忽略,不抛异常不中断流。

role 字段映射

Message/MessageItem 中,role 字段遵循 OpenAI Messages API 规范,标识消息发言者身份:

role 值deer-flow 来源含义
userHumanMessage用户输入
assistantAIMessageAI/LLM 的回复
systemSystemMessage系统提示词
toolToolMessage工具执行结果
与 LangChain type 的区别:type 是 updates 通道 messages[] 内的 LangChain 类名(human/ai/tool),仅出现在 raw payload 中;role 是 OpenAI 规范字段,对外暴露在 MessageItem 中供业务方使用。两者语义对应但字段名不同。

前端消费对照表

SSE 通道customType前端事件分发UI 表现
updatesnode_update:model产出 OUTPUT_ITEM_ADDED(ToolCallItem) + FUNCTION_CALL_ARGUMENTS_DONE工具声明 → 前端获知工具名和参数
updatesnode_update:tools产出 OUTPUT_ITEM_DONE(ToolCallItem)工具完成 → 含 status 和 result
updatesnode_update:TitleMiddlewareonUpdateEvent自动更新会话标题
updatesnode_update:TokenUsageMiddlewareonUpdateEventToken 用量更新
updatesnode_update:SummarizationMiddlewareonUpdateEvent刷新消息缓存
updatesnode_update:*(其他中间件)onUpdateEvent状态同步(按需处理)
customcustom(raw.type=thinking)ReasoningItem推理面板
customcustom(raw.type=task_running)onCustomEvent子任务进度卡片
customcustom(raw.type=llm_retry)onCustomEvent重试 Toast 提示

场景:纯文本回答

最常见场景——用户提问,模型直接生成文字回复,不涉及工具调用或推理。

response.created ← 分配 response_id,status=in_progress
response.in_progress ← 进入生成阶段
response.output_item.added ← item.type="message"
response.content_part.added
response.output_text.delta ×N ← 逐字追加渲染
response.output_text.done ← 完整文本,可选替换
response.content_part.done
response.output_item.done ← 当前 item 完成
response.completed ← output 完整列表 + usage 统计

要点:

场景:工具调用 → 生成回答

Agent 先调用工具获取数据,再基于工具返回结果生成文字回答。典型如查询数据库、调 API。

response.created
response.in_progress
response.output_item.added ← item.type="function_call"
response.function_call_arguments.delta ×N ← 参数片段
response.function_call_arguments.done ← 参数完整,服务端执行工具
response.output_item.done
response.output_item.added ← item.type="message"(基于工具结果生成)
response.content_part.added
response.output_text.delta ×N
response.output_text.done
response.content_part.done
response.output_item.done
response.completed

要点:

场景:澄清问题(ask_clarification)

Agent 发现用户输入信息不足时,调用 ask_clarification 工具向用户提问。前端收到结构化参数后渲染澄清卡片,用户选择/补充后继续对话。

response.output_item.added ← item.type="function_call", name="ask_clarification", status="in_progress"
response.function_call_arguments.done ← arguments: {"question":"…","options":["A","B"],"clarification_type":"missing_info"}
response.output_item.done ← item.status="completed"

前端处理:

场景:深度推理 → 生成回答

模型先进行内部推理(思维链),再输出正式回答。用户可见推理摘要。

response.created
response.in_progress
response.output_item.added ← item.type="reasoning"
response.reasoning_summary_text.delta ×N ← 推理摘要片段
response.reasoning_summary_text.done
response.output_item.done
response.output_item.added ← item.type="message"
response.output_text.delta ×N ← 正式回答
response.output_text.done
response.output_item.done
response.completed

要点:

场景:代码解释器

Agent 在沙箱中生成并执行代码(如数据分析、数学计算)。这是最复杂的内置工具流程。

response.created
response.in_progress
response.output_item.added ← item.type="function_call"(代码解释器)
response.code_interpreter_call.in_progress
response.code_interpreter_call_code.delta ×N ← 代码生成中
response.code_interpreter_call_code.done ← 代码完整,即将执行
response.code_interpreter_call.interpreting← 沙箱执行中
response.code_interpreter_call.completed ← 执行完成
response.output_item.done
response.output_item.added ← item.type="message"(基于执行结果生成)
response.output_text.delta ×N
response.output_text.done
response.output_item.done
response.completed

要点:

使用 fetch + ReadableStream

这是推荐的方式,支持 POST 传参、自定义 Header、AbortController 中断。兼容所有现代浏览器。

TYPESCRIPT
// 通用 SSE 流解析器
async function streamChat(
  body: { agent: string; input: string; previous_response_id?: string },
  handlers: {
    onDelta?: (delta: string) => void;
    onReasoning?: (delta: string) => void;
    onToolCall?: (name: string, args: string) => void;
    onCompleted?: (response: any) => void;
    onError?: (message: string) => void;
  },
  signal?: AbortSignal
): Promise<void> {
  const response = await fetch('/api/ai/chat/stream', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
    signal,
  });

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  let currentEvent = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop() || '';

    for (const line of lines) {
      if (line.startsWith('event:')) {
        currentEvent = line.slice(7);
      } else if (line.startsWith('data:')) {
        const data = JSON.parse(line.slice(6));

        switch (currentEvent) {
          case 'response.output_text.delta':
            handlers.onDelta?.(data.delta);
            break;
          case 'response.output_text.done':
            if (data.text) handlers.onDelta?.(data.text);  // 完整文本替换增量拼接结果
            break;
          case 'response.reasoning_summary_text.delta':
            handlers.onReasoning?.(data.delta);
            break;
          case 'response.function_call_arguments.done':
            // item_id 对应 output_item.added 中的 item.id,name 需从彼处缓存
            handlers.onToolCall?.(data.item_id, data.arguments);
            break;
          case 'response.completed':
            handlers.onCompleted?.(data.response);
            break;
          case 'response.failed':
            handlers.onError?.(data.response?.error?.message || 'AI 服务异常');
            break;
          case 'error':
            handlers.onError?.(data.error?.message || '连接异常');
            break;
          default:
            // 未知事件类型,记录日志后忽略,不抛异常
            console.debug('未处理的事件:', currentEvent, data);
        }
      }
    }
  }
}

使用 EventSource(仅 GET 场景)

如果业务后端支持 GET 请求传递参数(query string),可以用浏览器原生 EventSource,代码更简洁。但 不支持 POST、自定义 Header 和中断

JAVASCRIPT
const params = new URLSearchParams({ agent: 'my-agent', input: '你好' });
const source = new EventSource(`/api/ai/chat/stream?${params}`);

let fullText = '';

source.addEventListener('response.output_text.delta', (e) => {
  const { delta } = JSON.parse(e.data);
  fullText += delta;
  renderText(fullText);  // 追加渲染
});

source.addEventListener('response.output_text.done', (e) => {
  const { text } = JSON.parse(e.data);
  if (text) renderText(text);  // text 字段含完整文本,以服务端结果为准
});

source.addEventListener('response.completed', (e) => {
  const { response } = JSON.parse(e.data);
  console.log('完成,tokens:', response.usage?.total_tokens);
  source.close();
});

source.addEventListener('response.failed', (e) => {
  const { response } = JSON.parse(e.data);
  showError(response.error?.message || 'AI 服务异常');
  source.close();
});

source.onerror = () => {
  showError('连接异常,请重试');
  source.close();
};
EventSource 的局限:不支持 POST 方法、不支持自定义 HTTP Header(如 Authorization)、不支持 AbortController 中断。生产环境建议使用 fetch + ReadableStream

React Hook 完整示例

以下 Hook 覆盖全部 30 种 SSE 事件,包含文本渲染、推理面板、工具调用、内置工具状态和错误处理。

TYPESCRIPT (REACT)
import { useState, useRef, useCallback } from 'react';

// ── 类型定义 ──
type Status = 'idle' | 'streaming' | 'completed' | 'failed' | 'incomplete';

interface ToolCallState {
  id: string; name: string; arguments: string; done: boolean;
}

interface BuiltinStatus {
  webSearch?: 'searching' | 'completed' | 'failed';
  fileSearch?: 'searching' | 'completed';
  codeInterpreter?: 'in_progress' | 'coding' | 'interpreting' | 'completed';
}

interface AiChatState {
  status: Status;
  answerText: string;           // 当前活跃 item 的累积文本
  currentItemId: string | null;  // 正在流式输出的 item_id
  reasoningText: string;
  refusalText: string;
  toolCalls: ToolCallState[];
  builtinStatus: BuiltinStatus;
  responseId: string | null;
  usage: { inputTokens: number; outputTokens: number } | null;
  error: string | null;
}

// ── Hook ──
export function useAiChat(agent: string) {
  const [state, setState] = useState<AiChatState>({
    status: 'idle', answerText: '', currentItemId: null, reasoningText: '', refusalText: '',
    toolCalls: [], builtinStatus: {}, responseId: null, usage: null, error: null,
  });

  const abortRef = useRef<AbortController | null>(null);
  // 用 ref 持有最新 responseId,避免 send 闭包因 React batching 读到旧 state
  const responseIdRef = useRef<string | null>(null);

  const cancel = useCallback(() => {
    abortRef.current?.abort();
    setState(prev => ({ ...prev, status: 'idle' }));
  }, []);

  const send = useCallback(async (input: string) => {
    cancel();
    setState(prev => ({ ...prev,
      status: 'streaming', answerText: '', currentItemId: null, reasoningText: '',
      refusalText: '', toolCalls: [], builtinStatus: {}, usage: null, error: null,
    }));

    const abortController = new AbortController();
    abortRef.current = abortController;

    try {
      const resp = await fetch('/api/ai/chat/stream', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        // previous_response_id 为业务 DTO 字段名,可自定义;SDK Builder 对应 previousResponseId()
        // 读 ref 而非 state,确保拿到最新值(绕过 React 18 batching 闭包陈旧问题)
        body: JSON.stringify({ agent, input, previous_response_id: responseIdRef.current }),
        signal: abortController.signal,
      });
      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);

      const reader = resp.body!.getReader();
      const decoder = new TextDecoder();
      let buffer = '', currentEvent = '';

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split('\n');
        buffer = lines.pop() || '';

        for (const line of lines) {
          if (line.startsWith('event:')) {
            currentEvent = line.slice(7);
          } else if (line.startsWith('data:')) {
            const data = JSON.parse(line.slice(6));
            // ref 在 setState 外更新(updater 内不应有副作用)
            if (currentEvent === 'response.created' && data.response?.id) {
              responseIdRef.current = data.response.id;
            }
            setState(prev => {
              const s = { ...prev };

              switch (currentEvent) {
                // ── 生命周期 ──
                case 'response.created':
                  s.responseId = data.response?.id; // 同步到 state 供 UI 展示
                  break;
                case 'response.in_progress':
                  s.status = 'streaming';
                  break;

                // ── 文本增量 ──
                // item_id 串行出现(DeerFlow 顺序执行),新 item 直接覆盖旧文本
                case 'response.output_text.delta': {
                  const id = data.item_id;
                  if (id && id !== s.currentItemId) {
                    s.currentItemId = id; // 新 item:切换并重置文本
                    s.answerText = data.delta || '';
                  } else {
                    s.answerText += data.delta || '';
                  }
                  break;
                }
                case 'response.output_text.done':
                  // 用服务端校正后的完整文本覆盖增量累积结果(可选)
                  if (data.text) s.answerText = data.text;
                  break;
                case 'response.output_text.annotation.added':
                  // 可在此收集引用/脚注列表,与文本位置关联渲染
                  break;

                // ── 拒绝 ──
                case 'response.refusal.delta':
                  s.refusalText += data.delta || '';
                  break;
                case 'response.refusal.done':
                  if (data.delta) s.refusalText = data.delta;
                  break;

                // ── 推理 ──
                case 'response.reasoning_text.delta':
                  // 完整思维链(调试用),生产环境可跳过渲染
                  break;
                case 'response.reasoning_summary_text.delta':
                  s.reasoningText += data.delta || '';
                  break;
                case 'response.reasoning_summary_text.done':
                  s.reasoningText += '\n\n——思考完毕——';
                  break;

                // ── 输出项 ──
                case 'response.output_item.added':
                  if (data.item?.type === 'function_call') {
                    s.toolCalls = [...s.toolCalls, {
                      id: data.item.id, name: data.item.name || '...',
                      arguments: '', done: false,
                    }];
                  }
                  break;
                case 'response.output_item.done':
                  if (data.item?.type === 'function_call') {
                    s.toolCalls = s.toolCalls.map(tc =>
                      tc.id === data.item.id ? { ...tc, done: true } : tc
                    );
                  }
                  break;
                case 'response.content_part.added':
                case 'response.content_part.done':
                  // 富文本场景可按需处理
                  break;

                // ── 工具调用 ──
                // item_id = 工具调用 id(与 output_item.added 的 item.id 对应)
                // name 在 output_item.added 时已写入 toolCalls,此处只追加 arguments
                case 'response.function_call_arguments.delta':
                  s.toolCalls = s.toolCalls.map(tc =>
                    tc.id === data.item_id
                      ? { ...tc, arguments: tc.arguments + (data.delta || '') }
                      : tc
                  );
                  break;
                case 'response.function_call_arguments.done':
                  s.toolCalls = s.toolCalls.map(tc =>
                    tc.id === data.item_id
                      ? { ...tc, arguments: data.arguments, done: true }
                      : tc
                  );
                  break;

                // ── 内置工具状态 ──
                case 'response.web_search_call.searching':   s.builtinStatus = { ...s.builtinStatus, webSearch: 'searching' }; break;
                case 'response.web_search_call.completed':  s.builtinStatus = { ...s.builtinStatus, webSearch: 'completed' }; break;
                case 'response.web_search_call.failed':      s.builtinStatus = { ...s.builtinStatus, webSearch: 'failed' }; break;
                case 'response.file_search_call.searching':  s.builtinStatus = { ...s.builtinStatus, fileSearch: 'searching' }; break;
                case 'response.file_search_call.completed': s.builtinStatus = { ...s.builtinStatus, fileSearch: 'completed' }; break;
                case 'response.code_interpreter_call.in_progress':   s.builtinStatus = { ...s.builtinStatus, codeInterpreter: 'in_progress' }; break;
                case 'response.code_interpreter_call_code.delta':    s.builtinStatus = { ...s.builtinStatus, codeInterpreter: 'coding' }; break;
                case 'response.code_interpreter_call_code.done':     // 代码完整,即将执行 break;
                case 'response.code_interpreter_call.interpreting':  s.builtinStatus = { ...s.builtinStatus, codeInterpreter: 'interpreting' }; break;
                case 'response.code_interpreter_call.completed':     s.builtinStatus = { ...s.builtinStatus, codeInterpreter: 'completed' }; break;

                // ── 终止 ──
                case 'response.completed':
                  s.status = 'completed';
                  s.usage = {
                    inputTokens: data.response?.usage?.input_tokens || 0,
                    outputTokens: data.response?.usage?.output_tokens || 0,
                  };
                  break;
                case 'response.failed':
                  s.status = 'failed';
                  s.error = data.response?.error?.message || 'AI 服务异常';
                  break;
                case 'response.incomplete':
                  s.status = 'incomplete';
                  break;
                case 'error':
                  s.status = 'failed';
                  s.error = data.error?.message || '连接异常';
                  break;

                default:
                  console.debug('未处理事件:', currentEvent, data);
              }
              return s;
            });
          }
        }
      }
    } catch (err: any) {
      if (err.name !== 'AbortError') {
        setState(prev => ({ ...prev, status: 'failed', error: err.message }));
      }
    }
  }, [agent, cancel]);  // responseIdRef 不需要列入 deps(ref 变化不触发重渲染)

  return { ...state, send, cancel };
}

使用示例

TSX
function ChatPanel() {
  const {
    status, answerText, reasoningText, refusalText,
    toolCalls, builtinStatus, usage, error, send, cancel,
  } = useAiChat('pig-farm-agent');

  const isStreaming = status === 'streaming';

  return (
    <div className="chat-panel">
      {/* 错误提示 */}
      {error && <div className="error-banner">{error}</div>}

      {/* 内置工具状态 */}
      {builtinStatus.webSearch === 'searching' && <Spinner label="搜索中..." />}
      {builtinStatus.webSearch === 'failed'    && <Hint>搜索失败,基于已有知识回答</Hint>}
      {builtinStatus.fileSearch === 'searching' && <Spinner label="检索文档..." />}
      {builtinStatus.codeInterpreter && <CodeStatusBadge status={builtinStatus.codeInterpreter} />}

      {/* 拒绝回答 */}
      {refusalText && <div className="refusal-box">{refusalText}</div>}

      {/* 推理面板 */}
      {reasoningText && (
        <details open>
          <summary>AI 思考中...</summary>
          <p>{reasoningText}</p>
        </details>
      )}

      {/* 回答区 */}
      <Markdown content={answerText} />

      {/* 工具调用 */}
      {toolCalls.map(tc => (
        <ToolCard key={tc.id} name={tc.name} args={tc.arguments} loading={!tc.done} />
      ))}

      {/* 底部状态 */}
      {isStreaming && <Spinner />}
      {status === 'incomplete' && <Hint>回答因长度限制被截断</Hint>}
      {usage && <div className="usage">消耗 {usage.inputTokens + usage.outputTokens} tokens</div>}

      {/* 操作 */}
      <InputBox onSend={send} disabled={isStreaming} />
      <button onClick={cancel} disabled={!isStreaming}>取消</button>
    </div>
  );
}

多轮对话

多轮对话的关键字段是 previousResponseId。每轮完成后保存 response.id,下一轮请求时传入,服务端自动关联历史上下文。

JAVASCRIPT
// 首轮对话
let lastResponseId = null;

async function chat(userInput) {
  const body = {
    agent: 'my-agent',
    input: userInput,
    // 非首轮时传入上一轮 responseId
    ...(lastResponseId && { previous_response_id: lastResponseId }),
  };

  const response = await fetch('/api/ai/chat/stream', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });

  // ... 解析 SSE 流 ...

  // 在 response.created 事件中更新:
  // lastResponseId = data.response.id;
}

要点:

错误处理

前端需要区分两类错误:

错误类型来源SSE 事件处理方式
服务端业务错误 AI 引擎返回错误(如超过限流、输入违规) response.failed 展示 response.error.message;建议用户修改输入后重试
连接/网络错误 HTTP 请求失败、网络断开 error 或 fetch 异常 展示"连接异常"提示;可实现指数退避重连
回答被截断 超过 max_tokens 限制 response.incomplete 提示用户"回答已截断";后端可增大 maxOutputTokens
用户主动取消 前端 AbortController.abort() AbortError 静默处理,不展示错误提示
JAVASCRIPT
try {
  await streamChat(body, {
    onCompleted(response) {
      if (response.status === 'incomplete') {
        showWarning('回答因长度限制被截断,建议精简提问');
      }
    },
    onError(message) {
      // 区分是否可重试
      if (message.includes('超时') || message.includes('连接')) {
        showRetryable('网络异常,请重试');
      } else {
        showError(message);
      }
    },
  }, abortSignal);
} catch (err) {
  if (err.name === 'AbortError') {
    // 用户取消,不做任何处理
  } else {
    showError('请求失败: ' + err.message);
  }
}

防抖渲染

output_text.delta 可能每 10-30ms 触发一次。如果渲染 Markdown 或做 DOM 操作,建议做防抖:

JAVASCRIPT
function debounce(fn, ms) {
  let timer;
  return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
}

const renderMarkdown = debounce((text) => {
  element.innerHTML = marked.parse(text);  // 或其他 Markdown 渲染库
}, 50);  // 50ms 防抖,平衡流畅度与性能

未知事件兼容

SDK 可能在 minor 版本中新增事件类型(如未来接入新 AI 引擎引入新事件)。前端代码 必须能处理未知事件,不应因为不认识某个事件就报错或中断流处理。

JAVASCRIPT
// ✅ 推荐:按事件名监听,自动兼容新事件
source.addEventListener(eventName, handler);

// ✅ 也可以:switch 带 default 分支
switch (currentEvent) {
  case 'response.output_text.delta': /* ... */ break;
  case 'response.completed':       /* ... */ break;
  default:
    console.debug('未知事件:', currentEvent);  // 记录日志后忽略,不抛异常
}

// ❌ 错误:switch 没有 default,未知事件导致静默丢弃
// ❌ 错误:default 分支 throw error,一条未知事件就中断整个流
策略:未知事件视为无害的进度信号,记录日志后忽略,不抛异常、不中断流处理。

断线重连

网络不稳定导致 SSE 连接断开时,通过 previousResponseId 实现无缝续接:

JAVASCRIPT
async function chatWithRetry(agent, input, maxRetries = 3) {
  let lastResponseId = null;
  let collectedText = '';

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      await streamChat(
        {
          agent,
          input: attempt === 0 ? input : collectedText || input,
          previous_response_id: lastResponseId,  // 续接上下文
        },
        {
          onCreated(id) { lastResponseId = id; },
          onDelta(delta) { collectedText += delta; render(collectedText); },
          onCompleted() { return; },  // 成功,退出重试循环
        }
      );
      break;  // 正常完成,退出重试
    } catch (err) {
      if (attempt === maxRetries - 1) {
        showError('多次重试失败,请稍后再试');
      } else {
        // 指数退避:1s, 2s, 4s...
        await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000));
      }
    }
  }
}

附件:上传、识别生成附件、下载/预览(照着实现)

前端只跟业务后端的两个接口打交道,不直连 DeerFlow、不解析 file_id

接口说明
POST /ai/chatmultipart:message + 可选 file + 可选 previousResponseId;返回 text/event-stream
GET /ai/file?fileId=&mode=preview|download返回附件字节,预览用 inline、下载用 attachment

附件信号在 SSE 流里:assistant 文本的注解事件 response.output_text.annotation.added,其 annotationcontainer_file_citation,含 file_id + filename

① 发起带附件的提问 + 接收流(含附件信号)

带文件的 POST 必须用 fetch + ReadableStream 解析 SSE(EventSource 只支持 GET、不能传文件)。

JAVASCRIPT · 原生 fetch
async function ask(message, file, previousResponseId, ui) {
  const form = new FormData();
  form.append('message', message);
  if (file) form.append('file', file);                 // <input type=file> 选中的 File
  if (previousResponseId) form.append('previousResponseId', previousResponseId);

  const res = await fetch('/ai/chat', { method: 'POST', body: form });
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buf = '';

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buf += decoder.decode(value, { stream: true });

    // SSE 以空行分隔事件块
    let idx;
    while ((idx = buf.indexOf('\n\n')) >= 0) {
      const block = buf.slice(0, idx); buf = buf.slice(idx + 2);
      const line = block.split('\n').find(l => l.startsWith('data:'));
      if (!line) continue;
      const data = JSON.parse(line.slice(5).trim());
      handle(data, ui);
    }
  }
}

function handle(data, ui) {
  switch (data.type) {
    case 'response.created':
      ui.previousResponseId = data.response?.id; break;       // 存起来供续话
    case 'response.output_text.delta':
      ui.appendText(data.delta); break;                       // 增量正文
    case 'response.output_text.annotation.added': {        // 附件信号!
      const a = data.annotation;
      if (a && a.file_id) ui.addAttachment(a.file_id, a.filename);
      break;
    }
    case 'response.completed':
      ui.done(); break;
    case 'error':
      ui.error(data.message); break;
    default: break;                                          // 未知事件忽略(前向兼容)
  }
}

② 渲染附件卡片 + 预览/下载

拿到 file_id/filename 后渲染一个条目;预览用 inline URL(新标签/iframe),下载用 attachment URL。file_id 原样 URL 编码透传,不解析。

JAVASCRIPT
function addAttachment(fileId, filename) {
  const q = 'fileId=' + encodeURIComponent(fileId);
  const previewUrl  = '/ai/file?' + q + '&mode=preview';
  const downloadUrl = '/ai/file?' + q + '&mode=download';

  const el = document.createElement('div');
  el.className = 'attachment';
  el.innerHTML =
    '📎 ' + escapeHtml(filename) +
    ' <a target="_blank" href="' + previewUrl  + '">预览</a>' +
    ' <a href="' + downloadUrl + '" download>下载</a>';
  document.getElementById('attachments').appendChild(el);
}
预览:<a target="_blank"> 或放进 <iframe src=previewUrl>(PDF/图片/文本可内联;HTML/SVG 后端会强制下载防 XSS)。 下载:<a download> 触发浏览器另存。两者都打到业务后端 /ai/file,由后端经 SDK 取字节。

对应的业务后端

/ai/chat/ai/file 两个接口的服务端实现(金蝶云苍穹)见 SDK 文档 · 附件集成——后端用 createStreamAsSse 直通 SSE、用 files().download(fileId) 回吐字节并设 Content-Disposition。前后端契约即上方两接口表,无需额外约定