From 0b55d5a227a7364d74d7772affb331fb36cf5c57 Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 26 Apr 2026 08:32:44 +0800 Subject: [PATCH] feat(agent): Utf8SseEmitter + returnDirect end-to-end chain test --- .../agent/controller/AgentController.java | 4 +- .../vip/mate/channel/web/ChatController.java | 3 +- .../vip/mate/channel/web/Utf8SseEmitter.java | 56 +++++++++++++++++++ .../channel/webchat/WebChatController.java | 4 +- .../controller/DreamEventBroadcaster.java | 4 +- .../mate/wiki/controller/WikiController.java | 4 +- .../controller/WikiResearchController.java | 4 +- 7 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/web/Utf8SseEmitter.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java b/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java index ee2ffa19..825f1e2b 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java @@ -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) diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index 3bd30743..da6ec97f 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -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())) { diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/Utf8SseEmitter.java b/mateclaw-server/src/main/java/vip/mate/channel/web/Utf8SseEmitter.java new file mode 100644 index 00000000..f3196997 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/Utf8SseEmitter.java @@ -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. + * + *

Solves Chinese character mojibake on: + *

+ * + *

Inspired by joyagent-jdgenie's {@code SseEmitterUTF8} — same one-line + * idea applied throughout MateClaw's SSE endpoints. + * + *

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); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java index 7e543009..4cd0f572 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java @@ -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); diff --git a/mateclaw-server/src/main/java/vip/mate/memory/controller/DreamEventBroadcaster.java b/mateclaw-server/src/main/java/vip/mate/memory/controller/DreamEventBroadcaster.java index 44d5bf37..628b9555 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/controller/DreamEventBroadcaster.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/controller/DreamEventBroadcaster.java @@ -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)); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java index c33183d2..b7a5ed0b 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -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(() -> { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiResearchController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiResearchController.java index 79b55ed8..d34b952b 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiResearchController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiResearchController.java @@ -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) {