Skip to content

前端流式(SSE) ​

适用端:Web | 类型:机制

定制功能

本页讲的 5 条 agent 域 SSE 流全部是本项目自研;上游 enlev-module-ai 另有 3 条 (ai/chat/message、ai/mindmap、ai/write),它们只是共用了同一个客户端库, 事件词表、落库纪律、错误处置都与 agent 域无关。改 agent 的流不要去看那 3 条,反之亦然。

定位:说清「后端一个事件怎么变成界面上一个字」——为什么必须用 fetchEventSource 而不是原生 EventSource、两套事件词表各自长什么样、 前端在哪里把它们收敛成状态、onerror 为什么必须 throw、 以及断连时那句提示为什么非要带上执行编号不可。

前置阅读:ReAct 循环与流式对话(后端怎么产出这些事件)、 工作流引擎(WorkflowEvent 那套词表从哪来)。 本页反复出现的一个区分是「聊天词表 AgentChatEvent」与「执行词表 WorkflowEvent」—— 它们是两套刻意不通用的类型,合并会让某一侧的 switch 静默走 default。

它解决什么问题 ​

一次问答要十几到二十几秒,规划路径甚至五分钟。用普通请求-响应的话, 用户在这五分钟里看到的是「输入完没反应」,判定卡住的阈值远低于二十秒, 于是刷新页面重发一遍——再烧一轮 token、再等五分钟。

所以流式不是体验优化,是成本问题。但流式一旦落地,就带来三个非流式没有的失效形态:

三个失效形态,每一个都在库里留过疤:

  1. 无限重连。fetchEventSource 的 onerror 若不抛异常,库会自动重连。 表现为同一个问题被反复提交、反复计费,而界面上什么都看不出来。
  2. 静默丢弃。后端新增一种事件类型而前端没跟上,那种事件就消失了,控制台毫无报错。
  3. 直播与刷新不一致。同一条回复,流式过程中看到的样子和刷新后从库里重建的样子对不上。

第 3 条是本页最要紧的:它有一条已经被违反的纪律,见下面的「分歧 D5」。

代码在哪 ​

客户端库 ​

项值位置
库@microsoft/fetch-event-sourcepackage.json:43,版本 ^2.0.1
原生 EventSource零处使用src/ 全仓唯一命中是 components/FormScript/sandbox.spec.ts:54 的一个字符串夹具
import 站点5 处api/agent/assistant/index.ts:2、api/agent/workflow/index.ts:2、api/ai/chat/message/index.ts:2、api/ai/mindmap/index.ts:2、api/ai/write/index.ts:1
fetchEventSource( 调用点8 处5 处在 agent 域,3 处在上游 ai 域

为什么不用原生 EventSource

api/agent/assistant/index.ts:129-135 的原话:原生 EventSource 只能发 GET、不能带 Authorization 头。把 token 拼在 query 上确实能绕过去,但那会让它进到网关日志与浏览器历史里。 这不是「哪个库更好看」的选型,是「token 会不会被写进日志」的选型。

5 条 agent 域流 ​

#前端方法端点权限点事件词表
1AgentAssistantApi.sendStream
api/agent/assistant/index.ts:145
POST /agent/assistant/send-streamagent:assistant:use聊天
2AgentAssistantApi.resumeWorkflowStream
:185
POST /agent/assistant/resume-workflow-streamagent:assistant:use聊天
3AgentAssistantApi.runWorkflowStream
:229
POST /agent/assistant/run-workflow-streamagent:assistant:use聊天
4AgentWorkflowApi.runSandboxStream
api/agent/workflow/index.ts:893
POST /agent/workflow-def/sandbox-streamagent:workflow:run执行
5AgentWorkflowApi.resumeStream
:936
POST /agent/workflow/resume-streamagent:workflow:run执行

5 条全部带 openWhenHidden: true。理由写在 :152-153:一次问答要十几二十秒, 用户很可能切走去干别的,回来发现对话断了会以为系统不稳定。

第 3 条与第 5 条刻意不通用

api/agent/workflow/index.ts:910-927 明写:两条流的事件词表不同,合并会让某一侧的 switch 静默走 default。更要紧的是 feedback 的键名必须按挂起节点类型给—— 只给 hitl_approved,bpm 审批节点会读到 null,把「通过」判成「驳回」, 而且状态还报 SUCCEEDED。

4 个消费方 ​

文件行数消费哪条流事件收敛方式
views/agent/assistant/components/AssistantChat.vue21501 / 2 / 3applyChatEvent(流 1、2 共用)+ 流 3 另有一份手写 switch
views/agent/workflow/components/ExecutionHistoryDrawer.vue6055内联 switch,只认 ERROR/INTERRUPTED/DONE 三态
views/agent/workflow/editor/components/SandboxPanel.vue6114reduceSandboxEvent(run, event, now) —— 纯函数 reducer
views/agent/workflow/resumeRoute.ts49不消费,供文案executionResumeHint(id),被上面第 1、2 个文件共 4 处引用

JSON.parse(event.data) 全仓 5 处:AssistantChat.vue:1429、:1583、:1881, ExecutionHistoryDrawer.vue:438,SandboxPanel.vue:344。

后端对应位置 ​

位置作用
controller/admin/assistant/AgentAssistantController.java:67 / :116 / :152三个聊天流端点
service/chat/AgentAssistantService.java:126-203EventType 枚举(9 值)
service/chat/AgentAssistantService.java:209-329AgentChatEvent 内嵌类(17 个字段)
service/chat/AgentAssistantServiceImpl.java:672-706bridgeWorkflowRun——三个入口共用的桥接骨架
service/chat/AgentAssistantServiceImpl.java:818-903bridgeWorkflowEvent——WorkflowEvent → AgentChatEvent 的逐类型映射
service/workflow/AgentWorkflowExecutionService.java:130-146执行侧 EventType(5 值)
service/workflow/AgentWorkflowExecutionService.java:152-164WorkflowEvent 内嵌类(6 个字段)
framework/agent/config/AgentChatProperties.java:96streamTimeoutSeconds = 120

两个 EventType 都是内嵌类,不是独立文件

AgentChatEvent 嵌在 AgentAssistantService 接口里,WorkflowEvent 嵌在 AgentWorkflowExecutionService 接口里。按文件名搜 AgentChatEvent.java 会一无所获—— 这是找这两个类时最容易浪费十分钟的地方。

核心机制 ​

1. 「类型 + 数据」走一个通道,而不是每种事件一个 event name ​

后端在 AgentAssistantService.java:121-124 写下了理由:

用「类型 + 数据」而不是给每种事件单开一个 SSE event name: 前端的 @microsoft/fetch-event-source 按 event name 分发时, 漏注册一种就会静默丢弃那类事件 —— 而漏掉哪种取决于后端什么时候新增, 前端不会有任何报错。统一走一个通道,前端 switch 的 default 分支能兜住未知类型。

前端把同一段话抄在了 api/agent/assistant/index.ts:67-74,并加了一句纪律: 消费时 switch 一定要留 default 分支。5 个消费点全部留了。

default 分支的注释在 3 处逐字相同:

ts
default:
  // 后端新增了事件类型而前端还没跟上 —— 忽略即可,不要炸掉整个流
  break

2. 两套词表,字段级对照 ​

聊天词表实测:后端 AgentChatEvent 17 个字段,前端 AgentChatEventVO 15 个。

后端字段前端 AgentChatEventVO备注
typetype9 值联合,与后端 EventType 逐一对上
contentcontent?
conversationIdconversationId?
messageIdmessageId?
referencesreferences?类型是 AgentReferenceVO[],与后端 DTO 不同形,见分歧 D2
suggestionssuggestions?
errorMsgerrorMsg?
workflowDefIdworkflowDefId?
workflowCodeworkflowCode?
workflowNameworkflowName?
summarysummary?
warningswarnings?
roundsrounds?: number | null🔴 可为 null 且不落库,见下
executionIdexecutionId?
nodeIdnodeId?
nodeName缺🔴 分歧 D1
manifest缺🔴 分歧 D1

执行词表实测:后端 WorkflowEvent 6 个字段,前端 WorkflowEventVO 6 个, 5 个 type 逐一对上,零分歧。

这次对照是全量的,不是抽查

除上面两套词表外,同链路的其余载荷也逐个核对过,结论都是零分歧:

载荷后端前端结果
历史消息AgentMessageRespVO(12 字段)AgentMessageVO(12 字段)逐一对上
对话后建议AgentAnswerStreamSplitter.Suggestion(record,text + tool)AgentSuggestionVO(text + tool?)逐一对上
执行事件WorkflowEvent(6 字段 / 5 类型)WorkflowEventVO(6 字段 / 5 类型)逐一对上
事件类型枚举EventType(9 值)AgentChatEventVO['type'](9 值联合)逐一对上

所以下面 D1 / D2 / D5 三条就是全部,不是「已发现的三条」。

rounds 那条纪律值得单说,因为它是「null 不能当 0」的教科书案例 (api/agent/assistant/index.ts:106-112):

🔴 可为 null,且不落库:刷新页面后卡片凭 workflowDefId 重建,那时它就是 null。 消费端必须「null 不显示」而不是「null 当 0」——0 会被读成「一轮都没跑」。 同理这里不能写 data.rounds || 0,那会把两种情况压成同一个值。

运行时确实照做了(AssistantChat.vue:1344):

ts
msg.rounds = data.rounds ?? null

3. 分歧 D1:两个字段在运行时被读,却没在类型里声明 ​

后端 nodeName(AgentAssistantService.java:310)与 manifest(:329) 都随 WORKFLOW_CONFIRM 下发,前端确实在读:

读取点读的字段
AssistantChat.vue:1358data.nodeName
AssistantChat.vue:1362data.manifest ?? null
AssistantChat.vue:1903data.nodeName
AssistantChat.vue:1922data.nodeName
AssistantChat.vue:1924data.manifest ?? null

而 AgentChatEventVO 里两个都没有。TypeScript 一声不吭,因为入口签名是 applyChatEvent(msg: any, data: any)(:1301)——any 把整条链路的类型检查关掉了。

失效方向:静默。后端把 manifest 改名或去掉,前端不会编译报错, 只会让确认卡片从「清单不可用」变成永远转圈(manifestLoading: !data.manifest 恒为 true)。

manifest 为什么必须搭事件这班车

AgentAssistantService.java:318-322 的原话:刷新后确认卡片是凭 agent_message.execution_id 重建的,而重建路径刻意不恢复 nodeId (AgentWorkflowExecutionRespVO.currentNode 是「最后一个跑完的节点」, 而 SAA 是在 hitl 节点之前挂起的,拿它当闸口会把用户指错地方)。 没有 nodeId 就没有 fromNodeId,也就无法重现这份裁剪过的清单。

4. 分歧 D2:同一份引用出处,前端有三个互不相同的形状 ​

后端一份 AiRagSegmentRespDTO 有 7 个字段(id、documentId、documentName、 knowledgeId、content、tokens、score)。前端为它声明了两个接口:

声明处字段问题
AgentReferenceVO
api/agent/assistant/index.ts:24-30
id、documentName?、content、score(必填)score 声明为必填,但全仓零处读取
MessageKnowledge props
views/ai/chat/index/components/message/MessageKnowledge.vue:58-65
id、documentId(必填)、documentName(必填)、content没有 score;而 documentId 正是它自己的分组键(:83)

AgentReferenceVO 声明了没人读的 score,却漏掉了渲染必需的 documentId。 两者都不会被发现,因为数据是从 JSON.parse 出来的,运行时没有任何校验。

这个组件是从上游 ai 域借来的

AssistantChat.vue:435 写着 import MessageKnowledge from '@/views/ai/chat/index/components/message/MessageKnowledge.vue'。 它只渲染 documentName、segments.length、segment.id、segment.content 四项 (:16、:44、:47),score 与 tokens 从来不显示。

5. 分歧 D5:草稿卡运行时,引用出处直播不显示、刷新后才出现 ​

这是本页唯一一条已经生效的缺陷。三段代码放在一起才看得全:

后端(AgentAssistantServiceImpl.java:871-883,DONE 分支):

java
List<AiRagSegmentRespDTO> references = extractReferences(we.getState());
Long messageId = settleWorkflowAnswer(settled, context, conversationId,
        answer, STATUS_SUCCESS, null, null, references);
List<AgentChatEvent> events = new ArrayList<>();
if (!references.isEmpty()) {
    events.add(AgentChatEvent.meta(conversationId, messageId, references));
}

:874-876 的注释把意图说得很清楚:引用出处「既随一条迟到 META 事件下发 (前端 META 分支 msg.references=… 直接渲染,零改动),也落进 agent_message.referenceJson(刷新后仍能重建引用)」。

前端 applyChatEvent(:1303-1310)确实兑现了:

ts
case 'META':
  if (data.conversationId && !innerConversationId.value) { /* … */ }
  msg.references = data.references || []
  break

前端草稿卡运行那条流(:1891-1894)没有:

ts
case 'META':
  // 会话编号本来就已知;引用出处归回复气泡(后端在 DONE 那一支另发一条带 references 的 META)
  break

这条注释说对了后端的行为,然后把它丢掉了:它明写「后端在 DONE 那一支另发一条带 references 的 META」,而紧跟其后的代码是 break。applyChatEvent 那份是 「零改动就能渲染」的正解,这一份是空分支。

后果:草稿卡「确认并运行」跑完,若流程往 state 里写了 citations, settleWorkflowAnswer 已经把它落进 agent_message.referenceJson, 但那条迟到 META 在前端被丢弃 ⟹ 直播看不到引用出处,刷新页面后它出现了。 这直接违反该文件自己的「直播渲染 == 刷新渲染」纪律 (:1840-1841 的原话:「塞回卡片的话,直播看到『卡片变成了答案』、 刷新后看到『卡片 + 答案两条』,两边对不上」)。

失效方向:静默 + 只对刷新后可见。功能不算坏,但用户会以为「刚才那次没检索到资料」。

修法只有一种

把 :1892-1894 的空分支换成 applyChatEvent 里那两行——即 if (answerMessage) answerMessage.references = data.references || []。 不要改成让这条流也调 applyChatEvent:那份 switch 里的 MESSAGE 分支会驱动 typewriter,而这条流刻意不用 typewriter(理由见下条)。

6. 为什么会有第三份 switch ​

applyChatEvent 自己的 javadoc(:1295-1299)把抽函数的理由写得很硬:

早先这里只有一份 switch 写死在 handleSend 里,续跑那份如果复制粘贴, 以后后端每加一种事件都得记得改两处 —— 漏掉的那处表现是「点了同意、卡片消失了, 然后什么都没发生」,而控制台毫无报错。所以抽成一份,两条路径共用。

而库里现在有三份:applyChatEvent(流 1、2 共用)、草稿卡运行的内联 switch (:1891-1966)、ExecutionHistoryDrawer 的内联 switch(:449-465)。 第三份 switch 正是分歧 D5 的所在。

草稿卡那份不能直接合并,理由在 ensureAnswerMessage 的 javadoc(:1843-1845):

🔴 不用 typewriter:DONE 那一支后端是一次性给全量回复(不是增量分片), 匀速打字只是多等一会儿;而 typewriter 是单例、只往「最后一条 assistant」写、 并靠 streaming 收口 —— 在这里驱动它等于把「停止」按钮与空流判定也一并拽进这条路径。

typewriter 是 AssistantChat.vue:940-990 的一个内联单例对象, 直接读写 messageList.value、streaming.value、autoFollow.value。 它不是一个可复用的模块,所以「共用一份 switch」在这条流上确实做不到—— 但 META 那一支与 typewriter 毫无关系,空着它没有任何技术理由。

ExecutionHistoryDrawer 那份则是故意只认三个类型(:464): default: break // META / NODE 只是过程事件,本抽屉不渲染流。

7. onerror 必须 throw ​

throw err 在 agent 域出现 5 次,恰好一条流一次: AssistantChat.vue:1460、:1623、:1990,ExecutionHistoryDrawer.vue:474, SandboxPanel.vue:370。而「必须 throw」这条纪律的说明在源码里出现 8 次 (AssistantChat.vue:446、:1440、:1603、:1969;ExecutionHistoryDrawer.vue:468; SandboxPanel.vue:10、:364;resumeRoute.ts:43)。

说明比实现多,是刻意的:这条纪律的失效形态太安静。AssistantChat.vue:1440-1441 的原话:

🔴 必须 throw:fetchEventSource 的 onerror 不抛异常就会自动无限重连, 表现为同一个问题被反复提交、反复计费,而界面上什么都看不出来。

resumeRoute.ts:43-44 补了最关键的一句——这个函数不豁免这条纪律:

🔴 本函数不改变「必须 throw」这条纪律:fetchEventSource 的 onerror 不抛异常就会 自动无限重连,表现为同一个问题被反复提交、反复计费。调用方是「先把编号写到页面上,再 throw」。

SandboxPanel.vue:364 的说法最直白:不 throw 会「反复真跑大模型计费」。

8. 断连时必须把执行编号写到页面上 ​

resumeRoute.ts:4-7 记了这条纪律的诞生背景:

🔴 诞生背景:AssistantChat.vue 的三处 SSE onerror 都只留一句「连接中断,请重试」就 throw err 走人。执行编号(executionId)当时明明就在组件 state 里 (WORKFLOW_CONFIRM / run-stream 的 META 带回来的),却一个字都不往页面上写 —— 用户手上没有任何凭据,既认不出有哪条执行、也不知道去哪儿恢复, 只能重发一遍问题(于是又跑一遍、又计一次费)。

处置是拿不到编号就返回空串,而不是退化成一句没有编号的提示(:35-37):

🔴 拿不到 executionId 时返回空串,不退化成一句没有编号的「可在执行历史恢复」: 没有编号,用户到了抽屉里认不出是哪一条(同一个定义可能跑过几十次,列表按时间倒序, 断连那次未必在第一行),那句提示就从「出路」变成「又一个死路」。

ts
export const executionResumeHint = (executionId?: number | null): string =>
  executionId == null
    ? ''
    : `(本次已产生执行 #${executionId}:聊天里恢复不了时,可凭这个编号到${EXECUTION_HISTORY_ROUTE}里查看它的状态;若仍停在「等待确认」,可在那里恢复)`

三处调用点:AssistantChat.vue:1457、:1615、:1979。 但三处不够——AssistantChat.vue:1445-1454 记了一条更难看出来的约束:

⚠️ 编号只可能来自 WORKFLOW_CONFIRM;🔴 hitl 有值时那条 el-alert 不渲染 (因为 v-if="!item.cardStatus && !item.hitl"),所以 HitlConfirmCard 必须也印一遍 —— 两边都改才叫「页面上留着编号」。

第 4 处因此在 HitlConfirmCard.vue:56,它直接渲染 EXECUTION_HISTORY_ROUTE。

菜单路径已实测,但注释里的出处已失效

EXECUTION_HISTORY_ROUTE = '「AI 助手 → 流程编排」页的「执行历史」'。 本次实测 sql/mysql/ainb-init.sql 的 system_menu: 791 AI(status=0)→ 9000050908 AI 助手(status=0,path=agent)→ 9000050931 流程编排(status=0,path=workflow),拼出 /ai/agent/workflow, 「执行历史」是该页列表行上的按钮而非独立菜单。结论正确。

但 resumeRoute.ts:16-17 声称这个结论「据 sql/mysql/xpy-2026-09-29-agent-menu-under-ai.sql 头部那棵侧栏树」——那个文件不存在。sql/mysql/ 下只有 6 个文件: ai-2026-04-16.sql、ainb-init.sql、bpm-2026-04-18.sql、im-2026-06-20.sql、 quartz.sql、ruoyi-vue-pro.sql。前端全仓引用了 7 个不同的 xpy-*.sql(共 8 处), 没有一个存在。改菜单名时照注释去找依据会找不到。

9. 非 JSON 行静默丢弃,但后端并不发心跳 ​

5 处 JSON.parse(event.data) 全部包在 try/catch 里,catch 一律 return。 其中 2 处带注释(AssistantChat.vue:1431「心跳之类的非 JSON 行,忽略」、 SandboxPanel.vue:346「心跳等非 JSON 行」),3 处是裸 catch { return } (AssistantChat.vue:1583、:1881、ExecutionHistoryDrawer.vue:438)。

后端没有心跳

本次对 enlev-module-agent/src/main 全量检索 heartbeat / 心跳 / keepAlive / keep-alive / interval(),只有 2 处命中,且都在 tool/impl/form/FormPublishBundleTool.java:181 与 :196——那是 HttpUtil 请求的 req.setKeepAlive(false),与 SSE 心跳无关。后端 5 个 SSE 端点一个心跳都不发。

那两处注释因此点名了一个不存在的机制。catch 本身仍然是对的 (任何非 JSON 行都不该炸掉整条流),但理由写错了: 真正会走进这个 catch 的是 SSE 注释行与代理注入的空行,不是心跳。

10. 身份必须在 Servlet 线程上快照 ​

后端三个聊天流端点的第一句都是同一个(AgentAssistantController.java:120、:156):

java
// 🔴 快照必须在这里捕获(此刻还在 Servlet 线程上,ThreadLocal 里有身份),晚一步就可能是 null
AgentExecutionContext context = AgentExecutionContext.snapshot();

紧接着是三层 catch,把同步段异常接住转成流内元素(:125-133)。 :109-111 说了不接的后果:

身份在 Servlet 线程快照、同步段的 ServiceException 与任何异常都接住转成流内元素 (否则 Accept=text/event-stream 与全局处理器的 JSON 内容协商失败, 用户只看到「账号未登录」或空 body,真实原因全丢)。

前端配合的方式是 payload.code !== 0 那一支(5 处消费点全都有), 例如 ExecutionHistoryDrawer.vue:442-447:

ts
if (payload.code !== 0) {
  // 后端把 ServiceException 也塞进流里(AgentWorkflowController#resumeStream),
  // 最常见的是这条执行已被轮询器/审批回调抢先恢复 → STATE_INVALID
  outcome = 'ERROR'
  detail = payload.msg || '恢复被后端拒绝'
  return
}

11. 超时是「相邻两事件间隔」,不是总时长 ​

bridgeWorkflowRun:693-694:

java
// ⚠️ timeout 是相邻两事件间隔上限:工作流每过一个节点发一次 NODE,长时间无进度=某节点挂起
.timeout(Duration.ofSeconds(chatProperties.getStreamTimeoutSeconds()))

streamTimeoutSeconds 默认 120(AgentChatProperties.java:96), enlev-server/src/main/resources/ 下没有任何 yaml 覆盖,所以跑的就是 120 秒。

:87-95 解释了为什么必须显式兜底:

AiModelApi.chatStream 的注释里写明了:厂商连接挂起时(连接还在、就是不发数据), doOnComplete/doOnError/doOnCancel 三条都不触发。那样这次调用既不会结束、 也不会落计量日志,前端界面会一直转圈 —— 而且一个挂死的 SSE 连接会一直占着线程。

推论:一条跑 10 分钟但每 90 秒过一个节点的流程不会被超时掐断; 一个卡在单个节点上 121 秒的流程会。

12. Flux.create(...).subscribeOn(...) 会把整条流憋到最后 ​

这是本项目在流式上踩过最深的一个坑,AgentAssistantServiceImpl.java:439-456 逐字记了下来:

🔴 这里绝不能用 Flux.create(sink -> {阻塞规划}).subscribeOn(boundedElastic)。

subscribeOn 的预取(prefetch=32)不是直接调 request,而是 worker.schedule(() -> s.request(n)) —— 派到那个唯一的 worker 线程上去执行。 而 create 的 lambda 也跑在同一个 worker 上,一进去就是五分钟的阻塞规划。 于是 request 排在队列里永远轮不到执行,下游的「需求」补不上来, FluxCreate 只能把事件一直攒在缓冲区里,直到规划结束、worker 空出来才一次性涌出。

实测就是这个形态:客户端收到 META 和一条「正在理解需求…」之后静默 4 分 54 秒, 最后 1 秒里 8915 字节全部灌出来。而同一时刻后端日志显示规划一直在正常推进 (第 2、3 轮在查表、查控件契约、校验、自修正)。对照实验里问答路径 32KB / 436 个事件在 20 秒内持续增量到达 —— 说明 Servlet 层逐事件 flush 是好的, 堵点只在 Reactor 这一层。

正解是 Sinks.many().unicast().onBackpressureBuffer() + doOnSubscribe 里 另派一个 worker 跑阻塞逻辑(:457、:464-466)。

这条与前端无关,但它决定了前端能不能做打字机

前端 typewriter(AssistantChat.vue:940-990)按积压量动态调速: pending > 60 ? 6 : pending > 20 ? 3 : 1,间隔 pending > 60 ? 8 : 24 毫秒。 这套调速的前提是「增量真的是一阵一阵地到」。后端一旦憋成一次性涌出, 调速就退化成「等五分钟、然后用 8 毫秒一跳快速放完」——比不做流式还难看。

13. A1:草稿卡运行改走聊天通道 ​

api/agent/assistant/index.ts:201-219 是这次改动最完整的一段记录:

🔴 差别不在跑法,在回复落到哪:/agent/workflow/run-stream 只写 agent_workflow_execution,全程不碰 agent_message;而聊天历史与 LLM 的记忆都来自 agent_message。于是那一击的结果刷新即消失,用户追问一句,模型完全不知道刚才跑过什么 (原话:「连个记忆功能都没有吗」)。

后端 bridgeWorkflowRun:657-660 从另一个角度说了同一件事:

🔴 抽出来不是为了少写几行,是因为落库那一段漏一处就是一个「刷新即失忆」的缺陷: 草稿卡运行原先另走 /agent/workflow/run-stream(只写 agent_workflow_execution、 全程不碰 agent_message),于是回复在刷新后消失、LLM 也读不到。 三份拷贝的代价是「记得改三处」,漏掉的那处不报任何错。

AgentPlannerApi 现在只剩 2 个方法(api/agent/planner/index.ts:58-71: getWorkflowDraft、publishWorkflow)——旧的 runWorkflowStream 已删除。 这是对的:留两个同名方法、只有一个会落库,正是当初踩坑的原因。

换通道带来一处权限不对称

端点权限点前端还调吗
POST /agent/workflow/run-streamagent:workflow:run不调了
POST /agent/assistant/run-workflow-streamagent:assistant:use调

agent:workflow:run 是一个独立可授的按钮权限(system_menu 里 9000050937 运行流程,挂在 9000050931 流程编排 下)。A1 之后前端的唯一调用方 只要 agent:assistant:use ⟹ 只被授予「智能助手」菜单的用户,能在聊天里跑本租户 任意一条已发布的工作流,包括带写节点的那条,而不需要「运行流程」权限。

失效方向:fail-open。旧端点仍然保留、权限点仍然生效,所以从后端看一切正常; api/agent/planner/index.ts 里 publishWorkflow 的 javadoc 那句 「真正执行走 run-stream(增量14a 起需 agent:workflow:run)」在 A1 之后已过期。

14. 沙箱通道:真调大模型,但不落任何痕迹 ​

api/agent/workflow/index.ts:873-884 列全了差异:LLM 与只读工具真跑、 写工具 mock(返回 __sandboxMock)、不落执行实例、不写 trace、不计配额、 命中人工确认/等待即停(v1 不支持沙箱内 resume)。 请求体是 { dsl, input, mockWrites: true }(:902)。

权限点是 agent:workflow:run 而不是 query——因为真调大模型,会花钱。

前端这一侧是唯一被抽成纯函数的:SandboxPanel.vue:355-356 只做 reduceSandboxEvent(run.value, data),584 行的 sandboxModel.ts 不 import Vue 也不 import axios, 37 条用例直接喂事件对象。

数据模型 ​

本页没有数据库表——它的「数据模型」是上面第 2 节那两套事件词表。 这里只补一件与之直接相关的事:SSE 里的事件不落库,落库的是消息。

通道直播靠什么刷新后靠什么重建
普通问答MESSAGE / REASONING / SUGGESTIONS 事件agent_message 一行
引用出处迟到 META 的 referencesagent_message.referenceJson
草稿卡WORKFLOW_DRAFT 事件(只带 defId/code/name/摘要)agent_message.workflowDefId → 另调 /agent/planner/workflow-draft 取 DSL
确认卡WORKFLOW_CONFIRM 事件(带 executionId/nodeId/nodeName/manifest)agent_message.executionId → 查执行状态;nodeId 与裁剪过的 manifest 重建不出来

这就是「单一装配路径」纪律的落点:后端 AgentAssistantService.java:169-171 说 「不把 DSL 塞进 SSE 事件的理由和当初 PLAN 一样:刷新页面之后前端照样要能把卡片 重建出来,那条路径无论如何都得存在。在流里再塞一份 DSL,等于同一个结构维护两条组装逻辑, 迟早不一致」。

manifest 是这条纪律的唯一例外,而且是被迫的——见上面第 3 节那个 warning。

接口清单 ​

见「5 条 agent 域流」那张表。补三条非流式但同链路的:

端点方法权限点用途
/agent/planner/workflow-draftGETagent:assistant:use草稿卡取 DSL 正文
/agent/planner/publish-workflowPOSTagent:assistant:use草稿卡「确认并运行」第一步
/agent/workflow-def/manifestGETagent:workflow:query确认卡在事件没带清单时的兜底

草稿卡的「确认并运行」是两次独立请求:先 publishWorkflow(幂等)→ setPrimaryVersion, 再 runWorkflowStream。中间没有任何令牌,凭的是 code。

后端 SSE 端点的错误响应也是 200

三层 catch 把异常转成了 CommonResult.error(...) 塞进流里 (AgentAssistantController.java:125-133、:161-169)。所以前端不能靠 HTTP 状态码判失败, 必须读 payload.code。5 处消费点都这么做了。

怎么扩展 ​

加一种聊天事件类型 ​

  1. 后端 AgentAssistantService.EventType 加枚举值,并在 AgentChatEvent 上加字段 + 一个静态工厂。
  2. 前端 api/agent/assistant/index.ts 的 AgentChatEventVO:type 联合加一项、字段加一个可选属性。 这一步不做也不会有任何报错,但 nodeName/manifest 就是这么丢的(分歧 D1)。
  3. AssistantChat.vue 的 applyChatEvent 加一个 case。
  4. 🔴 同时检查另外两份 switch::1891-1966(草稿卡运行)与 ExecutionHistoryDrawer.vue:449-465。它们不会报错,只会静默走 default。 分歧 D5 就是第 4 步漏了。
  5. 若该事件需要刷新后重建,还要在 agent_message 上加列并写进 AgentMessageRespVO——否则直播能看到、刷新就没了。

加一条新的 SSE 流 ​

复制 api/agent/assistant/index.ts:220-243 那一份(runWorkflowStream), 它是 5 条里最新的、纪律最全的:fetchEventSource + POST + 三个头 + openWhenHidden: true + signal: ctrl.signal。

四条不可省的纪律:

  • onerror 里 throw err(否则无限重连、反复计费)
  • 抛之前先把执行编号写到页面上(executionResumeHint)
  • onclose 里先 flush 再置完成态——AssistantChat.vue:1462-1470 的理由是 复制按钮 v-if="status === 'SUCCESS'",早置完成态会让人复制到打了一半的文本
  • 组件 onBeforeUnmount 里 ctrl?.abort()(SandboxPanel.vue:390-393: 「关抽屉即销毁:掐掉 SSE,否则它在后台继续真跑、继续计费」)

让事件消费可测 ​

照 sandboxModel.ts 的样子做:把 switch 抽成一个 reduce(state, event, now) => state 的纯函数,.vue 里只留 reduceXxx(run.value, data) 一行。584 行 / 37 条用例就是这么来的。

注意 now 要当参数传,不要在纯函数里读 Date.now()—— sandboxModel.spec.ts:65 正是靠传入 1000 / 1200 才能断言耗时。

坑与注意 ​

静默 / fail-open 方向 ​

  1. 🔴 分歧 D5 已生效:草稿卡运行那条流的 META 分支是空的 (AssistantChat.vue:1892-1894),后端 :881-883 那条迟到 META 携带的 references 被丢弃,而 referenceJson 已落库 ⟹ 直播看不到引用、刷新后出现。 该行注释说对了后端行为却没实现它。
  2. 🔴 applyChatEvent 签名是 (msg: any, data: any)(:1301)。 整条事件链路的类型检查因此关闭,nodeName/manifest 缺失(分歧 D1)不会报错。
  3. 🔴 三份 switch 而不是两份。applyChatEvent 的 javadoc 说抽函数就是为了避免复制粘贴, 而库里现有三份;后端加事件类型时漏改的那份不会有任何提示。
  4. AgentReferenceVO.score 声明为必填却零处读取;同时它漏了 documentId, 而 documentId 是 MessageKnowledge:83 的分组键。两个方向都不会被发现(分歧 D2)。
  5. rounds 不能写 || 0。:1344 用的是 ?? null;写成 || 会把 「刷新后取不到」和「真跑过 0 轮」压成同一个值。
  6. onerror 不 throw = 无限重连 + 反复计费,界面上什么都看不出来。 5 处 throw err 一处都不能少。
  7. 断连提示不带执行编号 = 又一个死路。用户认不出是哪条执行,只能重发问题, 于是又跑一遍又计一次费。executionResumeHint 拿不到编号时返回空串是刻意的。
  8. HitlConfirmCard 那一行不能省。AssistantChat.vue 里那条 el-alert 的 v-if="!item.cardStatus && !item.hitl" 决定了 hitl 有值时它不渲染, 所以编号必须由卡片自己再印一遍。
  9. 注释里的「心跳」不存在。后端 5 个 SSE 端点一个心跳都不发, 2 处 catch 的注释点名了一个虚构机制。catch 本身是对的,理由错了。
  10. 前端 8 处引用的 7 个 sql/mysql/xpy-*.sql 全部不存在,其中 resumeRoute.ts:17 把它当作菜单路径的权威出处。结论对,出处失效。
  11. 🔴 A1 之后的权限不对称:/agent/assistant/run-workflow-stream 只要 agent:assistant:use,而前端只调它 ⟹ 只有「智能助手」权限的用户能跑本租户 任意已发布工作流(含写节点),绕过独立可授的 agent:workflow:run。
  12. publishWorkflow 的 javadoc 已过期:它仍写着「真正执行走 run-stream (增量14a 起需 agent:workflow:run)」,而那条端点前端已不再调用。
  13. typewriter 是内联单例(AssistantChat.vue:940-990),直接读写 messageList / streaming / autoFollow,零测试。它也是「第三份 switch 无法合并」的真实原因。
  14. applyChatEvent 同样零测试。产品里最要命的那份事件词表没有单测; 有单测的那份(reduceSandboxEvent)恰好是不碰钱的沙箱。
  15. sandboxModel.ts 之外,nameCatalog.ts 与 useWorkflowEditor.ts 也没有测试 (两者都 import 了 Vue / axios,是 16 个纯模块之外的 2 个「不纯」文件,共 1506 行)。
  16. X_GAP 与 .vue 的 CSS 之间没有任何编译期或运行期联系。 谁把 max-width 调大都不会有东西变红,只会在浏览器里表现为「+看得见却点不着」。 layout.spec.ts:160-187 用 readFileSync 读 CSS 把这条约束钉住了(见「测试」一节)。

fail-closed 方向 ​

  1. 后端不接住同步段异常 ⟹ 用户看到「账号未登录」或空 body。 Accept=text/event-stream 与全局处理器的 JSON 内容协商会失败,真实原因全丢。 三层 catch 是必需的,不是防御性冗余。
  2. .timeout(120 秒) 是相邻事件间隔上限。单个节点卡住 121 秒会被掐断, 而总时长 10 分钟但持续推进的流程不会。
  3. Flux.create(...).subscribeOn(...) 会把整条流憋到最后一次性涌出。 实测静默 4 分 54 秒、最后 1 秒灌 8915 字节。必须用 Sinks + doOnSubscribe 另派 worker。
  4. resumeStream 的 feedback 键名给错 ⟹ bpm 审批节点读到 null, 把「通过」判成「驳回」,状态还报 SUCCEEDED。这是两套词表刻意不通用的最硬理由。
  5. 沙箱权限是 agent:workflow:run 而不是 query,因为它真调大模型、真花钱。
  6. 组件卸载不 abort() ⟹ SSE 在后台继续真跑、继续计费(SandboxPanel.vue:390-393)。
  7. streamTimeoutSeconds 在 yaml 里没有覆盖,跑的就是代码默认的 120。 想调必须显式加配置,不能以为「线上已经配过了」。
  8. layout.spec.ts 的 CSS 读取器抓不到就抛,不回落默认值 (:166「绝不回落默认值让断言假通过」、:175「请改本用例的读法,不要放宽断言」)。 改 WorkflowDesigner.vue 的类名会让这条用例红,那是设计意图。

→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。

测试 ​

前端 agent 域的分层是为了可测性做的,实测数字如下。

分类文件数行数有 spec 的
纯模块(不 import Vue / axios / element-plus)16409514
不纯模块215060

不纯的那 2 个是 editor/useWorkflowEditor.ts(1179)与 nameCatalog.ts(327)。 纯模块里唯二没有 spec 的是 editor/defaults.ts(59)与 assistant/useLastConversation.ts(72)。

15 个 spec 文件 / 249 条用例 / 51 个 describe / 4820 行,全部在 src/views/agent/workflow/ 下:

spec用例行被测模块
editor/dslGateFixtures.spec.ts161539nodeIoContract + starterDsl + triggerCases(跨模块夹具)
editor/sandboxModel.spec.ts37365sandboxModel(reduceSandboxEvent)
editor/triggerCases.spec.ts25436triggerCases
editor/nodeIoContract.spec.ts22381nodeIoContract
editorModel.spec.ts29346editorModel
editor/ioReference.spec.ts14265ioReference
editor/starterDsl.spec.ts14160starterDsl
editor/graphEdit.spec.ts14150graphEdit
nodeSummary.spec.ts19262nodeSummary
editor/editorHistory.spec.ts12125editorHistory
publishGate.spec.ts11113publishGate
nodeConfigSchema.spec.ts10213editorModel(NODE_TYPE_OPTIONS / defaultConfig)
layout.spec.ts9188layout(含那条读 CSS 的)
replayModel.spec.ts8190replayModel
resumeRoute.spec.ts887resumeRoute

跑法:pnpm test:unit(vitest,匹配 src/**/*.spec.ts,全仓 59 个 spec,agent 域占 15 个)。 ⚠️ pnpm test:e2e 是坏的——playwright.config.ts 与 tests/e2e/ 都不存在。

值得单独讲的一条:用单测去读 .vue 的 CSS ​

layout.spec.ts:160-187。X_GAP 在 layout.ts:67(值 300), 而 max-width: 240px 与 right: -34px 在 editor/components/WorkflowDesigner.vue:468 与 :507 的 scoped style 里。 两者之间没有任何编译期或运行期联系,于是这个 spec 直接 readFileSync 那个 .vue:

ts
it('层间距 ≥ 节点最大宽度 + 悬停「+」的外凸量 + 余量,否则+会被下一个节点盖住', () => {
  const maxWidth = pxOf(ruleBody('.wf-node'), 'max-width')
  const plusProtrusion = Math.abs(pxOf(ruleBody('.wf-node__plus'), 'right'))
  const MIN_CLEARANCE = 20
  expect(X_GAP).toBeGreaterThanOrEqual(maxWidth + plusProtrusion + MIN_CLEARANCE)
})

实测:300 >= 240 + 34 + 20 = 294,余 6px。

:155-158 记了这条约束怎么从潜伏变成必现故障:

🔴 S1 之前 X_GAP 恰好等于 max-width(240),即「节点一长满,两个卡片边贴边、+整颗被下一个节点盖住」。 只是那时 tool 节点的摘要都是 调用工具「form_list」 这种短英文键,节点停在 160px 的 min-width 上, 永远碰不到 max-width,所以没人发现;S1 把摘要变成 调用工具「查询表单列表」(form_list), 每个 tool 节点都顶到 240px,旧账立刻变成必现故障。

声明值 vs 生效值:一组刻意反向的判据 ​

nameCatalog.ts 里两个函数读的是不同源的字段,这不是笔误:

函数读什么为什么
gateRiskOf(t)
:213-216
t.declaredRisk ?? t.risk——声明值运行期各道风险闸读 getDeclaredRisk(),在线降险推不动它们
disabledToolBlock(shown, enabled)
:252-264
enabled === false——生效值AgentSecurityGateway#checkTool 读 isEnabled(),在线停用当次就真生效

:234-238 的原话:「判『能不能用』按生效值,判『算不算只读』按声明值」。

两处细节同样是踩过坑的:

  • 判 === false 而不是 !enabled(:240-242):字段缺失(后端未升级、目录加载失败、 自定义工具那一行压根没下发)时不置灰。「宁可少拦一个,也不要把整个下拉全灰掉」。
  • 参数是两个位置参数而不是一个对象(:224-230):第一版收的是全可选字段的对象, 面板传进去的对象里 displayName/name 都不存在,title 于是渲染成 「undefined 已被停用」,而 pnpm ts:check 全绿、e2e 也全绿 (断言只查了「已被停用」在不在,没查工具名在不在)。拆成位置参数后, 缺标识串就是编译期少传一个实参。

两处「已停用」文案刻意不合并

:247-250:def/editor/index.vue 的 disabledToolTitle 说的是「保存和发布都不会拦你」 (AgentDefServiceImpl.validateToolKeys 确实不看 enabled), 而本句说的是「体检和发布也会拦下来」(checkToolRef 确实报 error)。 两道闸不同,合并成一份就等于在其中一处说谎。

未证实 ​

读源码读到、但没有运行取证 ​

  1. 分歧 D5 的用户可见后果未实测。三段代码已逐字核对(后端 AgentAssistantServiceImpl.java:871-883、前端 applyChatEvent:1303-1310、 前端空分支 :1891-1894),逻辑上必然导致「直播不显示、刷新后显示」, 但没有真的跑一条带 citations 的流程去观察界面。
  2. extractReferences(we.getState()) 读的键名未取证。:874 的注释说是 「约定的 citations 键」,但该方法本体未读,键名与结构未核实。
  3. reduceSandboxEvent 的 584 行本体未逐行读。只核对了它的调用方式 (SandboxPanel.vue:355-356)与 spec 的入参形状((run, event, now))。
  4. 上游 ai 域那 3 条流未读。本页所有结论只覆盖 agent 域的 5 条; api/ai/chat/message、api/ai/mindmap、api/ai/write 是否遵守同一套 onerror 纪律,未取证。

推理得出、未取证 ​

  1. 「后端不发心跳」是基于一次全量 grep 的否定结论。检索了 heartbeat / 心跳 / keepAlive / keep-alive / interval() 五个词, 命中 2 处且都与 SSE 无关。但心跳也可能由网关、Nginx 或 Spring 的 SseEmitter 默认行为产生——那一层不在本模块内,未查。
  2. A1 权限不对称的实际影响面未量化。端点权限点已核对 (AgentAssistantController.java:153 是 agent:assistant:use), 但「有多少租户真的把『运行流程』当成一个独立授权项在用」没有数据支撑; system_menu 里 9000050937 这一行存在,不代表有人授过它。
  3. X_GAP 那条 spec 能否真拦住 Playwright 报的那个错,未复现。 layout.spec.ts:152 说旧账的表现是 subtree intercepts pointer events、 曾让 agent-workflow-editor.spec.ts 挂 3 分钟,但那个 e2e 文件在当前仓库里 不存在(pnpm test:e2e 是坏的),所以这段历史无法复核。
  4. typewriter 的调速参数(60/20 与 6/3/1、8ms/24ms)是否仍然合适,未实测。 注释说做法与 ai/chat 的 textRoll 一致,但没有对照两边当前的参数值。

相关页面 ​