Browse Source

fix(app): SSE 流式输出保留行尾空白避免 Markdown 标题损坏

新增 preserveTrailingWhitespace 方法,缓冲以空白结尾的 chunk 与下一 chunk 合并后发出,规避前端 SSE 逐行 trim 导致的行尾空格丢失(如 "## " 标题标记被拼成 "##标题")。

- 捕获内部订阅并注册 onCancel/onDispose,下游取消时同步取消上游,避免资源泄漏
- 尾部空白缓冲设 256 上限,防止纯空白输出导致 buffer 无界增长
- onError 先 flush 已缓冲内容再传播,避免已生成文本丢失
- 空白判定改用 Character.isWhitespace,对齐前端 trim 语义
TDesign-Vue-Next-1.20.6
wanghanlin 2 weeks ago
parent
commit
0205af7b72
  1. 66
      src/main/java/com/wok/supportbot/app/AssistantApp.java

66
src/main/java/com/wok/supportbot/app/AssistantApp.java

@ -18,7 +18,9 @@ import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import reactor.core.Disposable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.FluxSink;
import java.util.ArrayList;
import java.util.Collections;
@ -97,6 +99,9 @@ public class AssistantApp {
private static final String SYSTEM_PROMPT = "";
/** 尾部空白缓冲上限:超过后强制发出,避免纯空白输出导致 buffer 无界增长 */
private static final int MAX_TRAILING_WHITESPACE_BUFFER = 256;
/**
* 初始化 ChatClient
*
@ -235,7 +240,8 @@ public class AssistantApp {
}
ChatRequest req = chatPipeline.buildRequest(ctx);
if (req.faqHit()) {
// FAQ 命中直接返回答案文本不附加 SSE 协议行Spring 会自动做 data: 帧包装
// FAQ 命中整段答案原样输出 SSE 编码器处理内部换行
// 后端不做任何格式增删不拆行不加换行不补空格
return Flux.just(req.faqAnswer().get());
}
McpToolCallback.resetEvents();
@ -249,7 +255,7 @@ public class AssistantApp {
}
// 原始文本流推荐问题已不再由主回复同步生成改由 SuggestionGenerator 异步按需生成
Flux<String> rawStream = spec.stream().content();
return rawStream
return preserveTrailingWhitespace(rawStream)
.doOnComplete(() -> aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY))
.doOnError(e -> {
aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY);
@ -263,6 +269,62 @@ public class AssistantApp {
.onErrorResume(e -> Flux.just("抱歉,AI 服务调用失败:" + e.getMessage()));
}
/**
* 缓冲以空白字符结尾的 chunk将其与下一个 chunk 合并后再发出
* <p>
* 原因前端 SSE 解析会对每行执行 trim()如果 chunk 以空白结尾 Markdown 标题 "## "
* 行尾空白会被削掉导致 "## " + "一、..." 拼成 "##一、..."破坏 Markdown 渲染
* 本方法不改变大模型输出的文本内容只调整 chunk 边界以避开 SSE 的空白截断
*
* @param source 原始大模型输出流
* @return 调整后的流每个元素均不以空白字符结尾流末尾除外
*/
private static Flux<String> preserveTrailingWhitespace(Flux<String> source) {
return Flux.create(sink -> {
StringBuilder buffer = new StringBuilder();
// 捕获内部订阅下游取消/释放时同步取消上游避免资源泄漏
Disposable subscription = source.subscribe(
chunk -> {
if (chunk == null) {
return;
}
buffer.append(chunk);
String current = buffer.toString();
if (current.isEmpty()) {
return;
}
char last = current.charAt(current.length() - 1);
// 当前累积内容不以空白结尾可以安全发出
if (!Character.isWhitespace(last)) {
sink.next(current);
buffer.setLength(0);
} else if (buffer.length() >= MAX_TRAILING_WHITESPACE_BUFFER) {
// 尾部空白过长如纯空白输出强制发出以限制内存占用
sink.next(current);
buffer.setLength(0);
}
// 若以空白结尾继续缓存等下一个 chunk
},
e -> {
// 出错前先把已缓冲内容发出避免已生成文本丢失
if (buffer.length() > 0) {
sink.next(buffer.toString());
buffer.setLength(0);
}
sink.error(e);
},
() -> {
if (buffer.length() > 0) {
sink.next(buffer.toString());
}
sink.complete();
}
);
sink.onCancel(subscription);
sink.onDispose(subscription);
}, FluxSink.OverflowStrategy.BUFFER);
}
/**
* 统一检索引用来源新入口委托 {@link ChatPipeline#retrieveSources}
*

Loading…
Cancel
Save