fix: 使用终态答案校正流式输出

- DONE 事件携带后端汇总的完整最终正文

- 前端完成时替换可能受损的流式文本

- 补充文件 URL 终态校正回归测试
This commit is contained in:
2026-07-23 19:49:45 +08:00
parent 417e846cbd
commit 41545fcef0
4 changed files with 65 additions and 3 deletions

View File

@@ -930,7 +930,7 @@ public class AgentRunService {
buildAssistantRuntimeMessage(chatContext, finalAnswer, assistantAccumulator, citations)); buildAssistantRuntimeMessage(chatContext, finalAnswer, assistantAccumulator, citations));
chatRuntimeManager.recordCompleted(chatContext); chatRuntimeManager.recordCompleted(chatContext);
} }
sendDone(chatSseEmitter); sendDone(chatSseEmitter, finalAnswer);
} }
private void handleRuntimeError(Throwable error, private void handleRuntimeError(Throwable error,
@@ -1184,10 +1184,30 @@ public class AgentRunService {
return chatSseEmitter.send(envelope); return chatSseEmitter.send(envelope);
} }
/**
* 发送不携带最终正文的完成事件。
*
* @param chatSseEmitter SSE 发送器
* @return 发送成功时为 {@code true}
*/
private boolean sendDone(ChatSseEmitter chatSseEmitter) { private boolean sendDone(ChatSseEmitter chatSseEmitter) {
return sendDone(chatSseEmitter, null);
}
/**
* 发送完成事件,并提供最终正文供前端校正流式增量。
*
* @param chatSseEmitter SSE 发送器
* @param finalText 最终完整正文;取消场景可为空
* @return 发送成功时为 {@code true}
*/
private boolean sendDone(ChatSseEmitter chatSseEmitter, String finalText) {
ChatEnvelope<Map<String, Object>> envelope = new ChatEnvelope<>(); ChatEnvelope<Map<String, Object>> envelope = new ChatEnvelope<>();
envelope.setDomain(ChatDomain.SYSTEM); envelope.setDomain(ChatDomain.SYSTEM);
envelope.setType(ChatType.DONE); envelope.setType(ChatType.DONE);
if (finalText != null) {
envelope.setPayload(Map.of("finalText", finalText));
}
return chatSseEmitter.sendDone(envelope); return chatSseEmitter.sendDone(envelope);
} }

View File

@@ -229,8 +229,14 @@ public class AgentRunServiceDraftAndHitlTest {
Assert.assertEquals("先分析", reasoning.toString()); Assert.assertEquals("先分析", reasoning.toString());
Assert.assertEquals("\n最终回答", content.toString()); Assert.assertEquals("\n最终回答", content.toString());
Assert.assertEquals("\n最终回答", answer.toString()); Assert.assertEquals("\n最终回答", answer.toString());
Assert.assertTrue(emitter.envelopes.stream().anyMatch(envelope -> ChatEnvelope<?> done = emitter.envelopes.stream()
envelope.getDomain() == ChatDomain.SYSTEM && envelope.getType() == ChatType.DONE)); .filter(envelope -> envelope.getDomain() == ChatDomain.SYSTEM
&& envelope.getType() == ChatType.DONE)
.findFirst()
.orElseThrow();
@SuppressWarnings("unchecked")
Map<String, Object> donePayload = (Map<String, Object>) done.getPayload();
Assert.assertEquals("\n最终回答", donePayload.get("finalText"));
} }
/** /**

View File

@@ -298,6 +298,38 @@ describe('agentTimelineAdapter', () => {
}); });
}); });
it('reconciles streamed text with the canonical final answer', () => {
const items: any[] = [];
for (const delta of [
'http://127.0.0.1:39',
'0',
'/easyflow/file.docx',
]) {
applyAgentSseEnvelope(items, {
domain: 'LLM',
type: 'MESSAGE',
payload: { delta },
});
}
applyAgentSseEnvelope(items, {
domain: 'SYSTEM',
type: 'DONE',
payload: {
finalText: 'http://127.0.0.1:39000/easyflow/file.docx',
},
});
const assistant = items.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' && item.role === 'assistant',
);
expect(assistant?.parts[0]?.content).toBe(
'http://127.0.0.1:39000/easyflow/file.docx',
);
expect(assistant?.status).toBe('done');
});
it('applies streaming text, HITL approval and error envelopes', () => { it('applies streaming text, HITL approval and error envelopes', () => {
const items: any[] = []; const items: any[] = [];

View File

@@ -534,6 +534,10 @@ export function applyAgentSseEnvelope(
return; return;
} }
if (domain === 'SYSTEM' && type === 'DONE') { if (domain === 'SYSTEM' && type === 'DONE') {
const finalText = asText(payload.finalText ?? payload.text);
if (finalText) {
ChatTimelineBuilder.replaceMessageContent(items, finalText);
}
ChatTimelineBuilder.finalize(items); ChatTimelineBuilder.finalize(items);
return; return;
} }