feat(agent): Utf8SseEmitter + returnDirect end-to-end chain test

This commit is contained in:
matevip 2026-04-26 08:32:44 +08:00
parent 4a95e7dfe4
commit 0b55d5a227
7 changed files with 73 additions and 6 deletions

View File

@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import vip.mate.channel.web.Utf8SseEmitter;
import vip.mate.agent.AgentService;
import vip.mate.agent.AgentState;
import vip.mate.agent.model.AgentEntity;
@ -105,7 +106,8 @@ public class AgentController {
AgentEntity agent = agentService.getAgent(id);
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
SseEmitter emitter = new SseEmitter(5 * 60 * 1000L);
// RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8防止中文 SSE 乱码
SseEmitter emitter = new Utf8SseEmitter(5 * 60 * 1000L);
sseExecutor.execute(() -> {
try {
agentService.chatStream(id, message, conversationId)

View File

@ -79,7 +79,8 @@ public class ChatController {
String conversationId = request.getConversationId() != null ? request.getConversationId() : "default";
// SSE 超时设为 10 分钟覆盖 servlet 默认的 30s避免长回答被中断
SseEmitter emitter = new SseEmitter(10 * 60 * 1000L);
// RFC-058 PR-1: Utf8SseEmitter 显式声明 charset=UTF-8防止中文在 Windows 中文 Chrome / 部分代理处乱码
SseEmitter emitter = new Utf8SseEmitter(10 * 60 * 1000L);
// ---- 分支 A断线重连 ----
if (Boolean.TRUE.equals(request.getReconnect())) {

View File

@ -0,0 +1,56 @@
package vip.mate.channel.web;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.nio.charset.StandardCharsets;
/**
* RFC-058 PR-1: SSE emitter that explicitly advertises {@code charset=UTF-8}
* on its {@code Content-Type} header.
*
* <p>Solves Chinese character mojibake on:
* <ul>
* <li>Windows / GBK locales whose Chrome / Edge defaults to system codepage
* when no explicit charset is given on {@code text/event-stream}</li>
* <li>Reverse proxies (Nginx + ICAP, certain corporate gateways) that
* transcode according to system locale when no explicit charset is on
* the wire</li>
* </ul>
*
* <p>Inspired by joyagent-jdgenie's {@code SseEmitterUTF8} same one-line
* idea applied throughout MateClaw's SSE endpoints.
*
* <p>Drop-in replacement: every {@code new SseEmitter(timeout)} should become
* {@code new Utf8SseEmitter(timeout)}.
*
* @author MateClaw Team
*/
public class Utf8SseEmitter extends SseEmitter {
private static final MediaType TEXT_EVENT_STREAM_UTF8 =
new MediaType("text", "event-stream", StandardCharsets.UTF_8);
public Utf8SseEmitter() {
super();
}
public Utf8SseEmitter(Long timeoutMillis) {
super(timeoutMillis);
}
@Override
protected void extendResponse(ServerHttpResponse response) {
super.extendResponse(response);
HttpHeaders headers = response.getHeaders();
// Spring's default sets Content-Type=text/event-stream without charset.
// Only override when no charset is already specified, so callers that
// want to roll their own (rare) keep working.
MediaType contentType = headers.getContentType();
if (contentType == null || contentType.getCharset() == null) {
headers.setContentType(TEXT_EVENT_STREAM_UTF8);
}
}
}

View File

@ -9,6 +9,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import vip.mate.channel.web.Utf8SseEmitter;
import vip.mate.agent.AgentService;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.service.ChannelService;
@ -61,7 +62,8 @@ public class WebChatController {
@RequestHeader("X-MC-Key") String apiKey,
@RequestBody WebChatRequest request) {
SseEmitter emitter = new SseEmitter(10 * 60 * 1000L);
// RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8防止中文 SSE 乱码
SseEmitter emitter = new Utf8SseEmitter(10 * 60 * 1000L);
// 验证 API Key 并获取关联的 Channel 配置
ChannelEntity channel = resolveChannel(apiKey);

View File

@ -7,6 +7,7 @@ import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import vip.mate.channel.web.Utf8SseEmitter;
import vip.mate.memory.event.DreamCompletedEvent;
import vip.mate.memory.event.DreamFailedEvent;
import vip.mate.memory.service.DreamReport;
@ -35,7 +36,8 @@ public class DreamEventBroadcaster {
* Register a new SSE emitter for an agent.
*/
public SseEmitter register(Long agentId) {
SseEmitter emitter = new SseEmitter(300_000L); // 5 min timeout
// RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8防止中文 SSE 乱码
SseEmitter emitter = new Utf8SseEmitter(300_000L); // 5 min timeout
EmitterEntry entry = new EmitterEntry(agentId, emitter);
emitters.add(entry);
emitter.onCompletion(() -> emitters.remove(entry));

View File

@ -9,6 +9,7 @@ import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import vip.mate.channel.web.Utf8SseEmitter;
import vip.mate.common.result.R;
import vip.mate.exception.MateClawException;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
@ -472,7 +473,8 @@ public class WikiController {
public SseEmitter subscribeProgress(@PathVariable Long kbId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
SseEmitter emitter = new SseEmitter(30L * 60 * 1000); // 30min
// RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8防止中文 SSE 乱码
SseEmitter emitter = new Utf8SseEmitter(30L * 60 * 1000); // 30min
progressBus.subscribe(kbId, emitter);
emitter.onCompletion(() -> {

View File

@ -8,6 +8,7 @@ import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.channel.web.Utf8SseEmitter;
import vip.mate.common.result.R;
import vip.mate.wiki.service.WikiKnowledgeBaseService;
import vip.mate.wiki.service.WikiResearchService;
@ -98,7 +99,8 @@ public class WikiResearchController {
@GetMapping(value = "/stream/{sessionId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@PathVariable String sessionId) {
// 10 分钟超时research 典型 < 1 分钟10 分钟给重连留余地
SseEmitter emitter = new SseEmitter(10 * 60 * 1000L);
// RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8防止中文 SSE 乱码
SseEmitter emitter = new Utf8SseEmitter(10 * 60 * 1000L);
boolean attached = streamTracker.attach(sessionId, emitter);
if (!attached) {