mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
feat(agent): Utf8SseEmitter + returnDirect end-to-end chain test
This commit is contained in:
parent
4a95e7dfe4
commit
0b55d5a227
@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
import vip.mate.channel.web.Utf8SseEmitter;
|
||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
import vip.mate.agent.AgentState;
|
import vip.mate.agent.AgentState;
|
||||||
import vip.mate.agent.model.AgentEntity;
|
import vip.mate.agent.model.AgentEntity;
|
||||||
@ -105,7 +106,8 @@ public class AgentController {
|
|||||||
AgentEntity agent = agentService.getAgent(id);
|
AgentEntity agent = agentService.getAgent(id);
|
||||||
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
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(() -> {
|
sseExecutor.execute(() -> {
|
||||||
try {
|
try {
|
||||||
agentService.chatStream(id, message, conversationId)
|
agentService.chatStream(id, message, conversationId)
|
||||||
|
|||||||
@ -79,7 +79,8 @@ public class ChatController {
|
|||||||
|
|
||||||
String conversationId = request.getConversationId() != null ? request.getConversationId() : "default";
|
String conversationId = request.getConversationId() != null ? request.getConversationId() : "default";
|
||||||
// SSE 超时设为 10 分钟,覆盖 servlet 默认的 30s,避免长回答被中断
|
// 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:断线重连 ----
|
// ---- 分支 A:断线重连 ----
|
||||||
if (Boolean.TRUE.equals(request.getReconnect())) {
|
if (Boolean.TRUE.equals(request.getReconnect())) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
import vip.mate.channel.web.Utf8SseEmitter;
|
||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
import vip.mate.channel.model.ChannelEntity;
|
import vip.mate.channel.model.ChannelEntity;
|
||||||
import vip.mate.channel.service.ChannelService;
|
import vip.mate.channel.service.ChannelService;
|
||||||
@ -61,7 +62,8 @@ public class WebChatController {
|
|||||||
@RequestHeader("X-MC-Key") String apiKey,
|
@RequestHeader("X-MC-Key") String apiKey,
|
||||||
@RequestBody WebChatRequest request) {
|
@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 配置
|
// 验证 API Key 并获取关联的 Channel 配置
|
||||||
ChannelEntity channel = resolveChannel(apiKey);
|
ChannelEntity channel = resolveChannel(apiKey);
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import org.springframework.context.event.EventListener;
|
|||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
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.DreamCompletedEvent;
|
||||||
import vip.mate.memory.event.DreamFailedEvent;
|
import vip.mate.memory.event.DreamFailedEvent;
|
||||||
import vip.mate.memory.service.DreamReport;
|
import vip.mate.memory.service.DreamReport;
|
||||||
@ -35,7 +36,8 @@ public class DreamEventBroadcaster {
|
|||||||
* Register a new SSE emitter for an agent.
|
* Register a new SSE emitter for an agent.
|
||||||
*/
|
*/
|
||||||
public SseEmitter register(Long agentId) {
|
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);
|
EmitterEntry entry = new EmitterEntry(agentId, emitter);
|
||||||
emitters.add(entry);
|
emitters.add(entry);
|
||||||
emitter.onCompletion(() -> emitters.remove(entry));
|
emitter.onCompletion(() -> emitters.remove(entry));
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import org.springframework.http.MediaType;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
import vip.mate.channel.web.Utf8SseEmitter;
|
||||||
import vip.mate.common.result.R;
|
import vip.mate.common.result.R;
|
||||||
import vip.mate.exception.MateClawException;
|
import vip.mate.exception.MateClawException;
|
||||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||||
@ -472,7 +473,8 @@ public class WikiController {
|
|||||||
public SseEmitter subscribeProgress(@PathVariable Long kbId,
|
public SseEmitter subscribeProgress(@PathVariable Long kbId,
|
||||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
verifyKBWorkspace(kbId, 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);
|
progressBus.subscribe(kbId, emitter);
|
||||||
|
|
||||||
emitter.onCompletion(() -> {
|
emitter.onCompletion(() -> {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import org.springframework.http.MediaType;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
import vip.mate.channel.web.ChatStreamTracker;
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
|
import vip.mate.channel.web.Utf8SseEmitter;
|
||||||
import vip.mate.common.result.R;
|
import vip.mate.common.result.R;
|
||||||
import vip.mate.wiki.service.WikiKnowledgeBaseService;
|
import vip.mate.wiki.service.WikiKnowledgeBaseService;
|
||||||
import vip.mate.wiki.service.WikiResearchService;
|
import vip.mate.wiki.service.WikiResearchService;
|
||||||
@ -98,7 +99,8 @@ public class WikiResearchController {
|
|||||||
@GetMapping(value = "/stream/{sessionId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
@GetMapping(value = "/stream/{sessionId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
public SseEmitter stream(@PathVariable String sessionId) {
|
public SseEmitter stream(@PathVariable String sessionId) {
|
||||||
// 10 分钟超时(research 典型 < 1 分钟,10 分钟给重连留余地)
|
// 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);
|
boolean attached = streamTracker.attach(sessionId, emitter);
|
||||||
if (!attached) {
|
if (!attached) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user