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] =?UTF-8?q?snail-ai=E6=B5=8B=E8=AF=95=E7=89=88=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() {