From bb94a6b9a9d4090d6b2a55bd5620808bae734aeb Mon Sep 17 00:00:00 2001 From: ct <164434275@qq.com> Date: Fri, 15 May 2026 09:36:01 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E6=9A=82=E5=AD=98=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 19 ++ ruoyi-admin/pom.xml | 4 + .../src/main/resources/application-dev.yml | 45 +++- .../src/main/resources/application.yml | 2 +- ruoyi-common/pom.xml | 1 + ruoyi-common/ruoyi-common-ai/pom.xml | 41 +++ .../common/ai/config/SnailAiConfig.java | 16 ++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + ruoyi-common/ruoyi-common-bom/pom.xml | 7 + ruoyi-modules/pom.xml | 1 + ruoyi-modules/ruoyi-ai/pom.xml | 31 +++ .../ai/controller/OpenApiDemoController.java | 251 ++++++++++++++++++ .../java/org/dromara/ai/package-info.java | 1 + 13 files changed, 413 insertions(+), 7 deletions(-) create mode 100644 ruoyi-common/ruoyi-common-ai/pom.xml create mode 100644 ruoyi-common/ruoyi-common-ai/src/main/java/org/dromara/common/ai/config/SnailAiConfig.java create mode 100644 ruoyi-common/ruoyi-common-ai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ruoyi-modules/ruoyi-ai/pom.xml create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/OpenApiDemoController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/package-info.java diff --git a/pom.xml b/pom.xml index 02a984b55..81d638836 100644 --- a/pom.xml +++ b/pom.xml @@ -33,6 +33,7 @@ 2.2.7 4.5.0 1.9.0 + 0.0.1 1.5.0 0.2.0 1.18.42 @@ -278,6 +279,18 @@ ${snailjob.version} + + com.aizuda + snail-ai-agent-starter + ${snailai.version} + + + + com.aizuda + snail-ai-openapi-starter + ${snailai.version} + + org.bouncycastle @@ -341,6 +354,12 @@ ${revision} + + org.dromara + ruoyi-ai + ${revision} + + org.dromara ruoyi-gen diff --git a/ruoyi-admin/pom.xml b/ruoyi-admin/pom.xml index 80ef4b3d5..e29d4a656 100644 --- a/ruoyi-admin/pom.xml +++ b/ruoyi-admin/pom.xml @@ -75,6 +75,10 @@ ruoyi-job + + org.dromara + ruoyi-ai + diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index 1b52fab96..f7a9a684e 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -13,7 +13,7 @@ spring.boot.admin.client: --- # snail-job 配置 snail-job: - enabled: true + enabled: false # 需要在 SnailJob 后台组管理创建对应名称的组,然后创建任务的时候选择对应的组,才能正确分派任务 group: "ruoyi_group" # SnailJob 接入验证令牌 详见 script/sql/ry_job.sql `sj_group_config` 表 @@ -28,6 +28,39 @@ snail-job: # 客户端ip指定 host: +--- # snail-ai 配置 +snail-ai: + # 启用客户端模式 + enabled: true + # ==================== Server 连接 ==================== + # Server 端 gRPC 地址(即 snail-ai-starter 的 snail-ai.server.grpc-port) + server: + host: 127.0.0.1 + port: 18888 + # ==================== 客户端配置 ==================== + # 本客户端 gRPC 端口(Server 通过此端口分发 Chat 请求) + # 应用 ID(在 Server「应用管理」页面创建后获取) + app-id: 1 + # 认证令牌(在 Server「应用管理」页面创建时自动生成) + token: SAI_ce6fbc820c50456baecc7cdcf2a14b1b + port: 18889 + # Skill 文件临时目录 + skill-temp-dir: /tmp/snail-ai-agent/skills + # ==================== OpenAPI Client 配置 ==================== + open-api: + # 启用 OpenAPI Client + enabled: true + # Server HTTP 端口 + web-port: 18080 + # 是否使用 HTTPS + https: false + # API 路径前缀 + prefix: snail-ai + # 超时配置(毫秒) + connect-timeout-ms: 5000 + read-timeout-ms: 60000 + chat-timeout-ms: 300000 + --- # 数据源配置 spring: datasource: @@ -47,9 +80,9 @@ spring: driverClassName: com.mysql.cj.jdbc.Driver # jdbc 所有参数配置参考 https://lionli.blog.csdn.net/article/details/122018562 # rewriteBatchedStatements=true 批处理优化 大幅提升批量插入更新删除性能(对数据库有性能损耗 使用批量操作应考虑性能问题) - url: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true + url: jdbc:mysql://8.136.96.136:3306/ry?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true username: root - password: root + password: mysql_5NrZmE # # 从库数据源 # slave: # lazy: true @@ -96,13 +129,13 @@ spring: spring.data: redis: # 地址 - host: localhost + host: 8.136.96.136 # 端口,默认为6379 port: 6379 # 数据库索引 - database: 0 + database: 11 # redis 密码必须配置 - password: ruoyi123 + password: redis_JsjKmr # 连接超时时间 timeout: 10s # 是否开启ssl diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index b8848f036..324f4ab19 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -224,7 +224,7 @@ message: --- # warm-flow工作流配置 warm-flow: # 是否开启工作流,默认true - enabled: true + enabled: false # 是否开启设计器ui ui: true # 是否显示流程图顶部文字 diff --git a/ruoyi-common/pom.xml b/ruoyi-common/pom.xml index f6a30c47f..c88e1fd11 100644 --- a/ruoyi-common/pom.xml +++ b/ruoyi-common/pom.xml @@ -37,6 +37,7 @@ ruoyi-common-encrypt ruoyi-common-push ruoyi-common-mqtt + ruoyi-common-ai diff --git a/ruoyi-common/ruoyi-common-ai/pom.xml b/ruoyi-common/ruoyi-common-ai/pom.xml new file mode 100644 index 000000000..f1eb0712b --- /dev/null +++ b/ruoyi-common/ruoyi-common-ai/pom.xml @@ -0,0 +1,41 @@ + + + + ruoyi-common + org.dromara + ${revision} + + 4.0.0 + + ruoyi-common-ai + + + ruoyi-common-ai AI公共模块 + + + + + + org.springframework.boot + spring-boot-autoconfigure + + + + com.aizuda + snail-ai-agent-starter + + + + com.aizuda + snail-ai-openapi-starter + + + + org.dromara + ruoyi-common-core + + + + diff --git a/ruoyi-common/ruoyi-common-ai/src/main/java/org/dromara/common/ai/config/SnailAiConfig.java b/ruoyi-common/ruoyi-common-ai/src/main/java/org/dromara/common/ai/config/SnailAiConfig.java new file mode 100644 index 000000000..5ade5cbdd --- /dev/null +++ b/ruoyi-common/ruoyi-common-ai/src/main/java/org/dromara/common/ai/config/SnailAiConfig.java @@ -0,0 +1,16 @@ +package org.dromara.common.ai.config; + +import com.aizuda.snail.ai.agent.starter.EnableSnailAiAgent; +import com.aizuda.snail.ai.openapi.client.starter.EnableSnailAiOpenApi; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +/** + * Snail AI 自动配置 + */ +@AutoConfiguration +@ConditionalOnProperty(prefix = "snail-ai", name = "enabled", havingValue = "true") +@EnableSnailAiAgent +@EnableSnailAiOpenApi +public class SnailAiConfig { +} diff --git a/ruoyi-common/ruoyi-common-ai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ruoyi-common/ruoyi-common-ai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..32af79519 --- /dev/null +++ b/ruoyi-common/ruoyi-common-ai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.dromara.common.ai.config.SnailAiConfig diff --git a/ruoyi-common/ruoyi-common-bom/pom.xml b/ruoyi-common/ruoyi-common-bom/pom.xml index b60c35f40..0526082c4 100644 --- a/ruoyi-common/ruoyi-common-bom/pom.xml +++ b/ruoyi-common/ruoyi-common-bom/pom.xml @@ -159,6 +159,13 @@ ${revision} + + + org.dromara + ruoyi-common-ai + ${revision} + + diff --git a/ruoyi-modules/pom.xml b/ruoyi-modules/pom.xml index 971929a54..4a79d2500 100644 --- a/ruoyi-modules/pom.xml +++ b/ruoyi-modules/pom.xml @@ -21,6 +21,7 @@ ruoyi-job ruoyi-system ruoyi-workflow + ruoyi-ai diff --git a/ruoyi-modules/ruoyi-ai/pom.xml b/ruoyi-modules/ruoyi-ai/pom.xml new file mode 100644 index 000000000..1baf5cb8a --- /dev/null +++ b/ruoyi-modules/ruoyi-ai/pom.xml @@ -0,0 +1,31 @@ + + + + org.dromara + ruoyi-modules + ${revision} + + 4.0.0 + jar + ruoyi-ai + + + ai模块 + + + + + + org.dromara + ruoyi-common-ai + + + org.dromara + ruoyi-common-satoken + + + + + diff --git a/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/OpenApiDemoController.java b/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/OpenApiDemoController.java new file mode 100644 index 000000000..5b62e5ed5 --- /dev/null +++ b/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/OpenApiDemoController.java @@ -0,0 +1,251 @@ +package org.dromara.ai.controller; + +import com.aizuda.snail.ai.common.execption.SnailAiException; +import com.aizuda.snail.ai.common.model.PageResult; +import com.aizuda.snail.ai.common.model.Result; +import com.aizuda.snail.ai.common.openapi.dto.*; +import com.aizuda.snail.ai.openapi.client.core.api.OpenApiAgentClient; +import com.aizuda.snail.ai.openapi.client.core.api.OpenApiChatClient; +import com.aizuda.snail.ai.openapi.client.core.api.OpenApiConversationClient; +import com.aizuda.snail.ai.openapi.client.core.api.OpenApiUserClient; +import com.aizuda.snail.ai.openapi.client.core.listener.SseEventListener; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.satoken.utils.LoginHelper; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.List; + +/** + * OpenAPI 使用示例 Controller + *

+ * 演示如何使用 OpenAPI Client 调用 Snail AI 服务端接口 + * + * @author opensnail + * @date 2026-04-25 + */ +@Slf4j +@RestController +@RequestMapping("/snail-ai") +@RequiredArgsConstructor +@Tag(name = "OpenAPI Demo", description = "OpenAPI 客户端使用示例") +public class OpenApiDemoController { + + private final OpenApiAgentClient agentClient; + private final OpenApiChatClient chatClient; + private final OpenApiConversationClient conversationClient; + private final OpenApiUserClient userClient; + + // ==================== User 相关接口 ==================== + + @PostMapping("/user/register") + @Operation(summary = "注册当前登录用户", description = "使用当前系统登录用户自动注册,返回 openId") + public Result registerCurrentUser() { + OpenApiUserVO user = ensureOpenApiUser(); + return Result.ok(user); + } + + @GetMapping("/user") + @Operation(summary = "获取当前登录用户的 OpenAPI 信息", description = "自动解析当前用户 openId 并查询详情") + public Result getUser() { + String openId = ensureOpenId(); + OpenApiUserQueryRequest request = new OpenApiUserQueryRequest(); + request.setOpenId(openId); + return userClient.getUser(request); + } + + // ==================== Agent 相关接口 ==================== + + @GetMapping("/agents") + @Operation(summary = "获取所有 Agent 列表", description = "查询当前用户可访问的所有智能体") + public Result> listAgents() { + return agentClient.listAgents(); + } + + @GetMapping("/agent/{agentId}") + @Operation(summary = "获取 Agent 详情", description = "根据 ID 查询智能体详细信息") + public Result getAgent( + @Parameter(description = "Agent ID", required = true, example = "1") + @PathVariable Long agentId) { + OpenApiAgentIdentityRequest request = new OpenApiAgentIdentityRequest(); + request.setAgentId(agentId); + return agentClient.getAgent(request); + } + + // ==================== Conversation 相关接口 ==================== + + @PostMapping("/agent/{agentId}/conversation") + @Operation(summary = "创建会话", description = "为指定 Agent 创建一个新的对话会话") + public Result createConversation( + @Parameter(description = "Agent ID", required = true, example = "1") + @PathVariable Long agentId, + @Parameter(description = "创建会话请求") + @RequestBody OpenApiCreateConversationRequest request) { + request.setAgentId(agentId); + request.setOpenId(ensureOpenId()); + return conversationClient.createConversation(request); + } + + @GetMapping("/agent/{agentId}/conversations") + @Operation(summary = "获取会话列表", description = "查询指定 Agent 的所有会话(分页)") + public PageResult> listConversations( + @Parameter(description = "Agent ID", required = true, example = "1") + @PathVariable Long agentId, + @Parameter(description = "页码", example = "1") + @RequestParam(defaultValue = "1") int page, + @Parameter(description = "每页数量", example = "10") + @RequestParam(defaultValue = "10") int size) { + OpenApiConversationQueryRequest request = new OpenApiConversationQueryRequest(); + request.setAgentId(agentId); + request.setOpenId(ensureOpenId()); + request.setPage(page); + request.setSize(size); + return conversationClient.listConversations(request); + } + + @GetMapping("/agent/{agentId}/conversation/{conversationId}/messages") + @Operation(summary = "获取会话消息", description = "查询指定会话的所有消息记录") + public Result> getMessages( + @Parameter(description = "Agent ID", required = true, example = "1") + @PathVariable Long agentId, + @Parameter(description = "会话 ID", required = true, example = "conv-123") + @PathVariable String conversationId) { + OpenApiConversationIdentityRequest request = new OpenApiConversationIdentityRequest(); + request.setAgentId(agentId); + request.setConversationId(conversationId); + request.setOpenId(ensureOpenId()); + return conversationClient.getMessages(request); + } + + @DeleteMapping("/agent/{agentId}/conversation/{conversationId}") + @Operation(summary = "删除会话", description = "删除指定的对话会话") + public Result deleteConversation( + @Parameter(description = "Agent ID", required = true, example = "1") + @PathVariable Long agentId, + @Parameter(description = "会话 ID", required = true, example = "conv-123") + @PathVariable String conversationId) { + OpenApiConversationIdentityRequest request = new OpenApiConversationIdentityRequest(); + request.setAgentId(agentId); + request.setConversationId(conversationId); + request.setOpenId(ensureOpenId()); + return conversationClient.deleteConversation(request); + } + + // ==================== Chat 相关接口 ==================== + + @PostMapping("/agent/{agentId}/chat/sync") + @Operation(summary = "同步对话", description = "发送消息并等待 AI 回复(非流式)") + public Result chatSync( + @Parameter(description = "Agent ID", required = true, example = "1") + @PathVariable Long agentId, + @Parameter(description = "对话请求") + @RequestBody OpenApiChatRequest request) { + request.setAgentId(agentId); + request.setOpenId(ensureOpenId()); + log.info("Sync chat request: agentId={}, content={}", agentId, request.getContent()); + return chatClient.chatSync(request); + } + + @GetMapping("/agent/{agentId}/chat/stream") + @Operation(summary = "流式对话", description = "发送消息并以 SSE 流式接收 AI 回复") + public SseEmitter chatStream( + @Parameter(description = "Agent ID", required = true, example = "1") + @PathVariable Long agentId, + @Parameter(description = "用户消息", required = true, example = "你好") + @RequestParam String content, + @Parameter(description = "会话 ID(可选)", example = "conv-123") + @RequestParam(required = false) String conversationId) { + + SseEmitter emitter = new SseEmitter(300000L); // 5 分钟超时 + + OpenApiChatRequest request = new OpenApiChatRequest(); + request.setAgentId(agentId); + request.setOpenId(ensureOpenId()); + request.setContent(content); + request.setConversationId(conversationId); + + log.info("Stream chat request: agentId={}, content={}", agentId, content); + try { + chatClient.chatStream(request, new SseEventListener() { + @Override + public void onText(String text) { + try { + emitter.send(SseEmitter.event() + .name("text") + .data(text)); + } catch (IOException e) { + log.error("Failed to send SSE text", e); + emitter.completeWithError(e); + } + } + + @Override + public void onThinking(String thinking) { + try { + emitter.send(SseEmitter.event() + .name("thinking") + .data(thinking)); + } catch (IOException e) { + log.error("Failed to send SSE thinking", e); + } + } + + @Override + public void onComplete(String data) { + try { + emitter.send(SseEmitter.event() + .name("done") + .data(data)); + emitter.complete(); + log.info("Stream chat completed"); + } catch (IOException e) { + log.error("Failed to send SSE completion", e); + emitter.completeWithError(e); + } + } + + @Override + public void onError(String errorMessage) { + log.error("Stream chat error: {}", errorMessage); + try { + emitter.send(SseEmitter.event() + .name("error") + .data(errorMessage)); + } catch (IOException e) { + log.error("Failed to send SSE error", e); + } + emitter.completeWithError(new SnailAiException(errorMessage)); + } + }); + } catch (Exception e) { + log.error("Stream chat exception", e); + emitter.completeWithError(e); + } + + return emitter; + } + + private String ensureOpenId() { + return ensureOpenApiUser().getOpenId(); + } + + private OpenApiUserVO ensureOpenApiUser() { + Long userId = LoginHelper.getUserId(); + String username = LoginHelper.getLoginUser().getNickname(); + + OpenApiUserRegisterRequest registerRequest = new OpenApiUserRegisterRequest(); + registerRequest.setExternalId(String.valueOf(userId)); + registerRequest.setNickname(username); + Result registerResult = userClient.register(registerRequest); + if (registerResult == null || registerResult.getData() == null) { + throw new SnailAiException("注册 OpenAPI 用户失败,返回为空"); + } + OpenApiUserVO user = registerResult.getData(); + return user; + } +} diff --git a/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/package-info.java b/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/package-info.java new file mode 100644 index 000000000..d97a8cbe5 --- /dev/null +++ b/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/package-info.java @@ -0,0 +1 @@ +package org.dromara.ai; From 4eacdc54ca09f121571bb8d56a74ff61183def88 Mon Sep 17 00:00:00 2001 From: ct <164434275@qq.com> Date: Mon, 18 May 2026 16:53:21 +0800 Subject: [PATCH 2/8] =?UTF-8?q?snail-ai=E6=B5=8B=E8=AF=95=E7=89=88?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application-dev.yml | 2 + .../src/main/resources/application-prod.yml | 35 ++++++++ .../ai/controller/OpenApiDemoController.java | 88 ++++++++++++++----- 3 files changed, 102 insertions(+), 23 deletions(-) diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index f7a9a684e..e9a59c6af 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -32,6 +32,8 @@ snail-job: snail-ai: # 启用客户端模式 enabled: true + # 聊天发送模式: stream(流式) / sync(同步) + chat-mode: stream # ==================== Server 连接 ==================== # Server 端 gRPC 地址(即 snail-ai-starter 的 snail-ai.server.grpc-port) server: diff --git a/ruoyi-admin/src/main/resources/application-prod.yml b/ruoyi-admin/src/main/resources/application-prod.yml index d77ddf57c..4a78dcb3b 100644 --- a/ruoyi-admin/src/main/resources/application-prod.yml +++ b/ruoyi-admin/src/main/resources/application-prod.yml @@ -31,6 +31,41 @@ snail-job: # 客户端ip指定 host: +--- # snail-ai 配置 +snail-ai: + # 启用客户端模式 + enabled: true + # 聊天发送模式: stream(流式) / sync(同步) + chat-mode: stream + # ==================== Server 连接 ==================== + # Server 端 gRPC 地址(即 snail-ai-starter 的 snail-ai.server.grpc-port) + server: + host: 127.0.0.1 + port: 18888 + # ==================== 客户端配置 ==================== + # 本客户端 gRPC 端口(Server 通过此端口分发 Chat 请求) + # 应用 ID(在 Server「应用管理」页面创建后获取) + app-id: 1 + # 认证令牌(在 Server「应用管理」页面创建时自动生成) + token: SAI_ce6fbc820c50456baecc7cdcf2a14b1b + port: 18889 + # Skill 文件临时目录 + skill-temp-dir: /tmp/snail-ai-agent/skills + # ==================== OpenAPI Client 配置 ==================== + open-api: + # 启用 OpenAPI Client + enabled: true + # Server HTTP 端口 + web-port: 18080 + # 是否使用 HTTPS + https: false + # API 路径前缀 + prefix: snail-ai + # 超时配置(毫秒) + connect-timeout-ms: 5000 + read-timeout-ms: 60000 + chat-timeout-ms: 300000 + --- # 数据源配置 spring: datasource: diff --git a/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/OpenApiDemoController.java b/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/OpenApiDemoController.java index 5b62e5ed5..f429ea797 100644 --- a/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/OpenApiDemoController.java +++ b/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/OpenApiDemoController.java @@ -12,14 +12,20 @@ import com.aizuda.snail.ai.openapi.client.core.listener.SseEventListener; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.dromara.common.satoken.utils.LoginHelper; +import org.springframework.http.MediaType; +import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; +import java.io.PrintWriter; import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; /** * OpenAPI 使用示例 Controller @@ -40,6 +46,8 @@ public class OpenApiDemoController { private final OpenApiChatClient chatClient; private final OpenApiConversationClient conversationClient; private final OpenApiUserClient userClient; + @Value("${snail-ai.chat-mode:stream}") + private String chatMode; // ==================== User 相关接口 ==================== @@ -138,6 +146,13 @@ public class OpenApiDemoController { // ==================== Chat 相关接口 ==================== + @GetMapping("/chat/mode") + @Operation(summary = "获取聊天发送模式", description = "返回 stream(流式) 或 sync(同步)") + public Result> getChatMode() { + String mode = "sync".equalsIgnoreCase(chatMode) ? "sync" : "stream"; + return Result.ok(Map.of("mode", mode)); + } + @PostMapping("/agent/{agentId}/chat/sync") @Operation(summary = "同步对话", description = "发送消息并等待 AI 回复(非流式)") public Result chatSync( @@ -153,15 +168,19 @@ public class OpenApiDemoController { @GetMapping("/agent/{agentId}/chat/stream") @Operation(summary = "流式对话", description = "发送消息并以 SSE 流式接收 AI 回复") - public SseEmitter chatStream( + public void chatStream( @Parameter(description = "Agent ID", required = true, example = "1") @PathVariable Long agentId, @Parameter(description = "用户消息", required = true, example = "你好") @RequestParam String content, @Parameter(description = "会话 ID(可选)", example = "conv-123") - @RequestParam(required = false) String conversationId) { - - SseEmitter emitter = new SseEmitter(300000L); // 5 分钟超时 + @RequestParam(required = false) String conversationId, + HttpServletResponse response) { + response.setStatus(HttpServletResponse.SC_OK); + response.setCharacterEncoding("UTF-8"); + response.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE); + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Connection", "keep-alive"); OpenApiChatRequest request = new OpenApiChatRequest(); request.setAgentId(agentId); @@ -170,26 +189,26 @@ public class OpenApiDemoController { request.setConversationId(conversationId); log.info("Stream chat request: agentId={}, content={}", agentId, content); + CountDownLatch latch = new CountDownLatch(1); + final boolean[] completed = {false}; try { + PrintWriter writer = response.getWriter(); chatClient.chatStream(request, new SseEventListener() { @Override public void onText(String text) { try { - emitter.send(SseEmitter.event() - .name("text") - .data(text)); + writeSseEvent(writer, "text", text); } catch (IOException e) { log.error("Failed to send SSE text", e); - emitter.completeWithError(e); + completed[0] = true; + latch.countDown(); } } @Override public void onThinking(String thinking) { try { - emitter.send(SseEmitter.event() - .name("thinking") - .data(thinking)); + writeSseEvent(writer, "thinking", thinking); } catch (IOException e) { log.error("Failed to send SSE thinking", e); } @@ -198,14 +217,13 @@ public class OpenApiDemoController { @Override public void onComplete(String data) { try { - emitter.send(SseEmitter.event() - .name("done") - .data(data)); - emitter.complete(); + writeSseEvent(writer, "done", data); log.info("Stream chat completed"); } catch (IOException e) { log.error("Failed to send SSE completion", e); - emitter.completeWithError(e); + } finally { + completed[0] = true; + latch.countDown(); } } @@ -213,21 +231,45 @@ public class OpenApiDemoController { public void onError(String errorMessage) { log.error("Stream chat error: {}", errorMessage); try { - emitter.send(SseEmitter.event() - .name("error") - .data(errorMessage)); + writeSseEvent(writer, "error", errorMessage); } catch (IOException e) { log.error("Failed to send SSE error", e); + } finally { + completed[0] = true; + latch.countDown(); } - emitter.completeWithError(new SnailAiException(errorMessage)); } }); + latch.await(5, TimeUnit.MINUTES); + if (!completed[0]) { + writeSseEvent(writer, "error", "SSE stream timeout"); + writeSseEvent(writer, "done", ""); + } + writer.flush(); } catch (Exception e) { log.error("Stream chat exception", e); - emitter.completeWithError(e); + try { + PrintWriter writer = response.getWriter(); + writeSseEvent(writer, "error", "stream exception: " + e.getMessage()); + writeSseEvent(writer, "done", ""); + writer.flush(); + } catch (IOException ex) { + log.error("Failed to write stream exception", ex); + } } + } - return emitter; + private void writeSseEvent(PrintWriter writer, String event, String data) throws IOException { + synchronized (writer) { + writer.write("event: " + event + "\n"); + String payload = data == null ? "" : data; + String[] lines = payload.split("\\R", -1); + for (String line : lines) { + writer.write("data: " + line + "\n"); + } + writer.write("\n"); + writer.flush(); + } } private String ensureOpenId() { From 2b534dd98722410d0c4d3d7b81d5894d066d5025 Mon Sep 17 00:00:00 2001 From: ct <164434275@qq.com> Date: Mon, 18 May 2026 17:03:10 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E9=85=8D=E7=BD=AE=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ruoyi-admin/src/main/resources/application-dev.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index e9a59c6af..3192472b7 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -82,9 +82,9 @@ spring: driverClassName: com.mysql.cj.jdbc.Driver # jdbc 所有参数配置参考 https://lionli.blog.csdn.net/article/details/122018562 # rewriteBatchedStatements=true 批处理优化 大幅提升批量插入更新删除性能(对数据库有性能损耗 使用批量操作应考虑性能问题) - url: jdbc:mysql://8.136.96.136:3306/ry?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true + url: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true username: root - password: mysql_5NrZmE + password: root # # 从库数据源 # slave: # lazy: true @@ -131,13 +131,13 @@ spring: spring.data: redis: # 地址 - host: 8.136.96.136 + host: localhost # 端口,默认为6379 port: 6379 # 数据库索引 - database: 11 + database: 0 # redis 密码必须配置 - password: redis_JsjKmr + password: ruoyi123 # 连接超时时间 timeout: 10s # 是否开启ssl From 4bf9004f7ffc0c1e5ee99fd89b2cb40f3b11f135 Mon Sep 17 00:00:00 2001 From: ct <164434275@qq.com> Date: Mon, 18 May 2026 17:05:51 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=E9=85=8D=E7=BD=AE=E8=BF=98=E5=8E=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ruoyi-admin/src/main/resources/application-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index 3192472b7..f003f32b8 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -13,7 +13,7 @@ spring.boot.admin.client: --- # snail-job 配置 snail-job: - enabled: false + enabled: true # 需要在 SnailJob 后台组管理创建对应名称的组,然后创建任务的时候选择对应的组,才能正确分派任务 group: "ruoyi_group" # SnailJob 接入验证令牌 详见 script/sql/ry_job.sql `sj_group_config` 表 From 57f731492ff01fe34e429dbfa7bdc199fd7fe6f8 Mon Sep 17 00:00:00 2001 From: ct <164434275@qq.com> Date: Tue, 19 May 2026 10:46:35 +0800 Subject: [PATCH 5/8] =?UTF-8?q?=E8=8F=9C=E5=8D=95sql?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- script/sql/ry_ai.sql | 1 + 1 file changed, 1 insertion(+) create mode 100644 script/sql/ry_ai.sql diff --git a/script/sql/ry_ai.sql b/script/sql/ry_ai.sql new file mode 100644 index 000000000..d52ff0db7 --- /dev/null +++ b/script/sql/ry_ai.sql @@ -0,0 +1 @@ +INSERT INTO sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `active_menu`, `ext`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2056263986068676609, 'AI聊天', 0, 1, 'ai/chat', 'ai/chat/index', NULL, 'N', 'Y', 'C', '0', '0', NULL, 'checkbox', '', '', 1761000000000000103, 1761100000000000001, '2026-05-18 14:41:52', 1761100000000000001, '2026-05-18 14:43:41', ''); \ No newline at end of file From 21548f8170276c41eb988ae57b301b5af9e0280b Mon Sep 17 00:00:00 2001 From: ct <164434275@qq.com> Date: Tue, 19 May 2026 10:48:49 +0800 Subject: [PATCH 6/8] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E7=AB=AF=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ruoyi-admin/src/main/resources/application-dev.yml | 2 +- ruoyi-admin/src/main/resources/application-prod.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index f003f32b8..c808767fa 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -53,7 +53,7 @@ snail-ai: # 启用 OpenAPI Client enabled: true # Server HTTP 端口 - web-port: 18080 + web-port: 8080 # 是否使用 HTTPS https: false # API 路径前缀 diff --git a/ruoyi-admin/src/main/resources/application-prod.yml b/ruoyi-admin/src/main/resources/application-prod.yml index 4a78dcb3b..76274c81d 100644 --- a/ruoyi-admin/src/main/resources/application-prod.yml +++ b/ruoyi-admin/src/main/resources/application-prod.yml @@ -56,7 +56,7 @@ snail-ai: # 启用 OpenAPI Client enabled: true # Server HTTP 端口 - web-port: 18080 + web-port: 8080 # 是否使用 HTTPS https: false # API 路径前缀 From 5b26ae9b1c135a0d8f7c0d78981cdaed834895b1 Mon Sep 17 00:00:00 2001 From: ct <164434275@qq.com> Date: Tue, 19 May 2026 10:50:06 +0800 Subject: [PATCH 7/8] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ruoyi-admin/src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 324f4ab19..b8848f036 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -224,7 +224,7 @@ message: --- # warm-flow工作流配置 warm-flow: # 是否开启工作流,默认true - enabled: false + enabled: true # 是否开启设计器ui ui: true # 是否显示流程图顶部文字 From 7b81f13866e1a7daeb04e9c5f0968434fc4fde27 Mon Sep 17 00:00:00 2001 From: ct <164434275@qq.com> Date: Tue, 19 May 2026 14:03:01 +0800 Subject: [PATCH 8/8] =?UTF-8?q?1=E3=80=81=E4=BF=AE=E6=94=B9=E4=BD=BF?= =?UTF-8?q?=E7=94=A8Spring=20sse=E6=96=B9=E5=BC=8F=202=E3=80=81=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0security=E6=8E=92=E9=99=A4=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application.yml | 1 + .../ai/controller/OpenApiDemoController.java | 176 ++++++++---------- 2 files changed, 74 insertions(+), 103 deletions(-) diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index b8848f036..9b28f219b 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -109,6 +109,7 @@ security: - /*/api-docs - /*/api-docs/** - /warm-flow-ui/config + - /snail-ai/agent/*/chat/stream # MyBatisPlus配置 # https://baomidou.com/config/ diff --git a/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/OpenApiDemoController.java b/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/OpenApiDemoController.java index f429ea797..b27d9be6a 100644 --- a/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/OpenApiDemoController.java +++ b/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/OpenApiDemoController.java @@ -9,27 +9,19 @@ import com.aizuda.snail.ai.openapi.client.core.api.OpenApiChatClient; import com.aizuda.snail.ai.openapi.client.core.api.OpenApiConversationClient; import com.aizuda.snail.ai.openapi.client.core.api.OpenApiUserClient; import com.aizuda.snail.ai.openapi.client.core.listener.SseEventListener; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.dromara.common.satoken.utils.LoginHelper; -import org.springframework.http.MediaType; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; -import java.io.PrintWriter; import java.util.List; import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; /** * OpenAPI 使用示例 Controller - *

* 演示如何使用 OpenAPI Client 调用 Snail AI 服务端接口 * * @author opensnail @@ -39,7 +31,6 @@ import java.util.concurrent.TimeUnit; @RestController @RequestMapping("/snail-ai") @RequiredArgsConstructor -@Tag(name = "OpenAPI Demo", description = "OpenAPI 客户端使用示例") public class OpenApiDemoController { private final OpenApiAgentClient agentClient; @@ -51,15 +42,19 @@ public class OpenApiDemoController { // ==================== User 相关接口 ==================== + /** + * 注册当前登录用户并返回 OpenAPI 用户信息。 + */ @PostMapping("/user/register") - @Operation(summary = "注册当前登录用户", description = "使用当前系统登录用户自动注册,返回 openId") public Result registerCurrentUser() { OpenApiUserVO user = ensureOpenApiUser(); return Result.ok(user); } + /** + * 查询当前登录用户对应的 OpenAPI 用户信息。 + */ @GetMapping("/user") - @Operation(summary = "获取当前登录用户的 OpenAPI 信息", description = "自动解析当前用户 openId 并查询详情") public Result getUser() { String openId = ensureOpenId(); OpenApiUserQueryRequest request = new OpenApiUserQueryRequest(); @@ -69,16 +64,19 @@ public class OpenApiDemoController { // ==================== Agent 相关接口 ==================== + /** + * 查询当前用户可访问的智能体列表。 + */ @GetMapping("/agents") - @Operation(summary = "获取所有 Agent 列表", description = "查询当前用户可访问的所有智能体") public Result> listAgents() { return agentClient.listAgents(); } + /** + * 根据智能体 ID 查询智能体详情。 + */ @GetMapping("/agent/{agentId}") - @Operation(summary = "获取 Agent 详情", description = "根据 ID 查询智能体详细信息") public Result getAgent( - @Parameter(description = "Agent ID", required = true, example = "1") @PathVariable Long agentId) { OpenApiAgentIdentityRequest request = new OpenApiAgentIdentityRequest(); request.setAgentId(agentId); @@ -87,26 +85,25 @@ public class OpenApiDemoController { // ==================== Conversation 相关接口 ==================== + /** + * 为指定智能体创建新会话。 + */ @PostMapping("/agent/{agentId}/conversation") - @Operation(summary = "创建会话", description = "为指定 Agent 创建一个新的对话会话") public Result createConversation( - @Parameter(description = "Agent ID", required = true, example = "1") @PathVariable Long agentId, - @Parameter(description = "创建会话请求") @RequestBody OpenApiCreateConversationRequest request) { request.setAgentId(agentId); request.setOpenId(ensureOpenId()); return conversationClient.createConversation(request); } + /** + * 分页查询指定智能体下的会话列表。 + */ @GetMapping("/agent/{agentId}/conversations") - @Operation(summary = "获取会话列表", description = "查询指定 Agent 的所有会话(分页)") public PageResult> listConversations( - @Parameter(description = "Agent ID", required = true, example = "1") @PathVariable Long agentId, - @Parameter(description = "页码", example = "1") @RequestParam(defaultValue = "1") int page, - @Parameter(description = "每页数量", example = "10") @RequestParam(defaultValue = "10") int size) { OpenApiConversationQueryRequest request = new OpenApiConversationQueryRequest(); request.setAgentId(agentId); @@ -116,12 +113,12 @@ public class OpenApiDemoController { return conversationClient.listConversations(request); } + /** + * 查询指定会话的消息历史。 + */ @GetMapping("/agent/{agentId}/conversation/{conversationId}/messages") - @Operation(summary = "获取会话消息", description = "查询指定会话的所有消息记录") public Result> getMessages( - @Parameter(description = "Agent ID", required = true, example = "1") @PathVariable Long agentId, - @Parameter(description = "会话 ID", required = true, example = "conv-123") @PathVariable String conversationId) { OpenApiConversationIdentityRequest request = new OpenApiConversationIdentityRequest(); request.setAgentId(agentId); @@ -130,12 +127,12 @@ public class OpenApiDemoController { return conversationClient.getMessages(request); } + /** + * 删除指定会话。 + */ @DeleteMapping("/agent/{agentId}/conversation/{conversationId}") - @Operation(summary = "删除会话", description = "删除指定的对话会话") public Result deleteConversation( - @Parameter(description = "Agent ID", required = true, example = "1") @PathVariable Long agentId, - @Parameter(description = "会话 ID", required = true, example = "conv-123") @PathVariable String conversationId) { OpenApiConversationIdentityRequest request = new OpenApiConversationIdentityRequest(); request.setAgentId(agentId); @@ -146,19 +143,21 @@ public class OpenApiDemoController { // ==================== Chat 相关接口 ==================== + /** + * 获取当前聊天发送模式。 + */ @GetMapping("/chat/mode") - @Operation(summary = "获取聊天发送模式", description = "返回 stream(流式) 或 sync(同步)") public Result> getChatMode() { String mode = "sync".equalsIgnoreCase(chatMode) ? "sync" : "stream"; return Result.ok(Map.of("mode", mode)); } + /** + * 同步对话接口。 + */ @PostMapping("/agent/{agentId}/chat/sync") - @Operation(summary = "同步对话", description = "发送消息并等待 AI 回复(非流式)") public Result chatSync( - @Parameter(description = "Agent ID", required = true, example = "1") @PathVariable Long agentId, - @Parameter(description = "对话请求") @RequestBody OpenApiChatRequest request) { request.setAgentId(agentId); request.setOpenId(ensureOpenId()); @@ -166,21 +165,21 @@ public class OpenApiDemoController { return chatClient.chatSync(request); } + /** + * 流式对话接口,按 SSE 事件返回消息分片。 + */ @GetMapping("/agent/{agentId}/chat/stream") - @Operation(summary = "流式对话", description = "发送消息并以 SSE 流式接收 AI 回复") - public void chatStream( - @Parameter(description = "Agent ID", required = true, example = "1") + public SseEmitter chatStream( @PathVariable Long agentId, - @Parameter(description = "用户消息", required = true, example = "你好") @RequestParam String content, - @Parameter(description = "会话 ID(可选)", example = "conv-123") - @RequestParam(required = false) String conversationId, - HttpServletResponse response) { - response.setStatus(HttpServletResponse.SC_OK); - response.setCharacterEncoding("UTF-8"); - response.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE); - response.setHeader("Cache-Control", "no-cache"); - response.setHeader("Connection", "keep-alive"); + @RequestParam(required = false) String conversationId) { + SseEmitter emitter = new SseEmitter(300000L); + emitter.onTimeout(() -> { + safeSend(emitter, "error", "SSE stream timeout"); + safeSend(emitter, "done", ""); + emitter.complete(); + }); + emitter.onError(error -> log.warn("SSE emitter error: {}", error.getMessage())); OpenApiChatRequest request = new OpenApiChatRequest(); request.setAgentId(agentId); @@ -189,93 +188,64 @@ public class OpenApiDemoController { request.setConversationId(conversationId); log.info("Stream chat request: agentId={}, content={}", agentId, content); - CountDownLatch latch = new CountDownLatch(1); - final boolean[] completed = {false}; try { - PrintWriter writer = response.getWriter(); chatClient.chatStream(request, new SseEventListener() { @Override public void onText(String text) { - try { - writeSseEvent(writer, "text", text); - } catch (IOException e) { - log.error("Failed to send SSE text", e); - completed[0] = true; - latch.countDown(); - } + safeSend(emitter, "text", text); } @Override public void onThinking(String thinking) { - try { - writeSseEvent(writer, "thinking", thinking); - } catch (IOException e) { - log.error("Failed to send SSE thinking", e); - } + safeSend(emitter, "thinking", thinking); } @Override public void onComplete(String data) { - try { - writeSseEvent(writer, "done", data); - log.info("Stream chat completed"); - } catch (IOException e) { - log.error("Failed to send SSE completion", e); - } finally { - completed[0] = true; - latch.countDown(); - } + safeSend(emitter, "done", data); + log.info("Stream chat completed"); + emitter.complete(); } @Override public void onError(String errorMessage) { log.error("Stream chat error: {}", errorMessage); - try { - writeSseEvent(writer, "error", errorMessage); - } catch (IOException e) { - log.error("Failed to send SSE error", e); - } finally { - completed[0] = true; - latch.countDown(); - } + safeSend(emitter, "error", errorMessage); + safeSend(emitter, "done", ""); + emitter.complete(); } }); - latch.await(5, TimeUnit.MINUTES); - if (!completed[0]) { - writeSseEvent(writer, "error", "SSE stream timeout"); - writeSseEvent(writer, "done", ""); - } - writer.flush(); } catch (Exception e) { log.error("Stream chat exception", e); - try { - PrintWriter writer = response.getWriter(); - writeSseEvent(writer, "error", "stream exception: " + e.getMessage()); - writeSseEvent(writer, "done", ""); - writer.flush(); - } catch (IOException ex) { - log.error("Failed to write stream exception", ex); - } - } - } - - private void writeSseEvent(PrintWriter writer, String event, String data) throws IOException { - synchronized (writer) { - writer.write("event: " + event + "\n"); - String payload = data == null ? "" : data; - String[] lines = payload.split("\\R", -1); - for (String line : lines) { - writer.write("data: " + line + "\n"); - } - writer.write("\n"); - writer.flush(); + safeSend(emitter, "error", "stream exception: " + e.getMessage()); + safeSend(emitter, "done", ""); + emitter.complete(); + } + + return emitter; + } + + /** + * 输出一条 SSE 事件。 + */ + private void safeSend(SseEmitter emitter, String event, String data) { + try { + emitter.send(SseEmitter.event().name(event).data(data == null ? "" : data)); + } catch (IOException e) { + log.warn("SSE send failed, event={}", event, e); } } + /** + * 获取当前登录用户对应的 openId,不存在时会自动注册。 + */ private String ensureOpenId() { return ensureOpenApiUser().getOpenId(); } + /** + * 确保当前登录用户已注册为 OpenAPI 用户。 + */ private OpenApiUserVO ensureOpenApiUser() { Long userId = LoginHelper.getUserId(); String username = LoginHelper.getLoginUser().getNickname();