diff --git a/.run/ruoyi-ai.run.xml b/.run/ruoyi-ai.run.xml new file mode 100644 index 000000000..c62041da4 --- /dev/null +++ b/.run/ruoyi-ai.run.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/.run/ruoyi-snailai-server.run.xml b/.run/ruoyi-snailai-server.run.xml new file mode 100644 index 000000000..1bf2102e1 --- /dev/null +++ b/.run/ruoyi-snailai-server.run.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/pom.xml b/pom.xml index 05f8ea5ef..1340fe27e 100644 --- a/pom.xml +++ b/pom.xml @@ -34,6 +34,7 @@ 4.4.0 2.2.7 2.0.0 + 0.0.3 1.45.0 1.18.42 7.4 @@ -61,7 +62,7 @@ 2.3.4 - 2.0.0-M6 + 2.0.0-M8 3.14.0 @@ -278,6 +279,19 @@ ${snailjob.version} + + + com.aizuda + snail-ai-agent-starter + ${snailai.version} + + + + com.aizuda + snail-ai-openapi-starter + ${snailai.version} + + org.bouncycastle diff --git a/ruoyi-common/pom.xml b/ruoyi-common/pom.xml index 9087d2514..bd9a57132 100644 --- a/ruoyi-common/pom.xml +++ b/ruoyi-common/pom.xml @@ -40,6 +40,7 @@ ruoyi-common-social ruoyi-common-nacos ruoyi-common-bus + ruoyi-common-ai ruoyi-common-mqtt ruoyi-common-mcp diff --git a/ruoyi-common/ruoyi-common-ai/pom.xml b/ruoyi-common/ruoyi-common-ai/pom.xml new file mode 100644 index 000000000..0d3b4852b --- /dev/null +++ b/ruoyi-common/ruoyi-common-ai/pom.xml @@ -0,0 +1,39 @@ + + + + org.dromara + ruoyi-common + ${revision} + + 4.0.0 + + ruoyi-common-ai + + + ruoyi-common-ai AI公共模块 + + + + + + + org.dromara + ruoyi-common-core + + + + + com.aizuda + snail-ai-agent-starter + + + + + com.aizuda + snail-ai-openapi-starter + + + + 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 b4b1deb32..1f1fc1094 100644 --- a/ruoyi-common/ruoyi-common-bom/pom.xml +++ b/ruoyi-common/ruoyi-common-bom/pom.xml @@ -223,6 +223,13 @@ ${revision} + + + org.dromara + ruoyi-common-ai + ${revision} + + org.dromara ruoyi-common-mqtt diff --git a/ruoyi-modules/pom.xml b/ruoyi-modules/pom.xml index c90636271..6533c0357 100644 --- a/ruoyi-modules/pom.xml +++ b/ruoyi-modules/pom.xml @@ -14,6 +14,7 @@ ruoyi-job ruoyi-resource ruoyi-workflow + ruoyi-ai ruoyi-modules diff --git a/ruoyi-modules/ruoyi-ai/Dockerfile b/ruoyi-modules/ruoyi-ai/Dockerfile new file mode 100644 index 000000000..e87df88a0 --- /dev/null +++ b/ruoyi-modules/ruoyi-ai/Dockerfile @@ -0,0 +1,26 @@ +# 贝尔实验室 Spring 官方推荐镜像 JDK下载地址 https://bell-sw.com/pages/downloads/ +FROM bellsoft/liberica-openjdk-rocky:17.0.16-cds +#FROM bellsoft/liberica-openjdk-rocky:21.0.8-cds +#FROM findepi/graalvm:java17-native + +LABEL maintainer="Lion Li" + +RUN mkdir -p /ruoyi/ai/logs \ + /ruoyi/skywalking/agent + +WORKDIR /ruoyi/ai + +ENV SERVER_PORT=9206 LANG=C.UTF-8 LC_ALL=C.UTF-8 JAVA_OPTS="" + +EXPOSE ${SERVER_PORT} +EXPOSE 18889 + +ADD ./target/ruoyi-ai.jar ./app.jar + +SHELL ["/bin/bash", "-c"] + +ENTRYPOINT java -Djava.security.egd=file:/dev/./urandom -Dserver.port=${SERVER_PORT} \ + #-Dskywalking.agent.service_name=ruoyi-ai \ + #-javaagent:/ruoyi/skywalking/agent/skywalking-agent.jar \ + -XX:+HeapDumpOnOutOfMemoryError -XX:+UseZGC ${JAVA_OPTS} \ + -jar app.jar diff --git a/ruoyi-modules/ruoyi-ai/pom.xml b/ruoyi-modules/ruoyi-ai/pom.xml new file mode 100644 index 000000000..ae8636c28 --- /dev/null +++ b/ruoyi-modules/ruoyi-ai/pom.xml @@ -0,0 +1,103 @@ + + + + org.dromara + ruoyi-modules + ${revision} + + 4.0.0 + jar + ruoyi-ai + + + ai模块 + + + + + + + com.google.protobuf + protobuf-java + 4.35.0 + + + + + io.grpc + grpc-protobuf + 1.81.0 + + + io.grpc + grpc-stub + 1.81.0 + + + io.grpc + grpc-netty-shaded + 1.81.0 + + + + org.dromara + ruoyi-common-nacos + + + + org.dromara + ruoyi-common-ai + + + + org.dromara + ruoyi-common-doc + + + + org.dromara + ruoyi-common-satoken + + + + org.dromara + ruoyi-common-dubbo + + + + org.dromara + ruoyi-common-web + + + + org.dromara + ruoyi-common-security + + + + org.dromara + ruoyi-api-system + + + + + ${project.artifactId} + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + repackage + + + + + + + + diff --git a/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/RuoYiAiApplication.java b/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/RuoYiAiApplication.java new file mode 100644 index 000000000..00ac65fde --- /dev/null +++ b/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/RuoYiAiApplication.java @@ -0,0 +1,22 @@ +package org.dromara.ai; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup; + +/** + * AI模块 + * + * @author opensnail + */ +@SpringBootApplication +public class RuoYiAiApplication { + + public static void main(String[] args) { + SpringApplication application = new SpringApplication(RuoYiAiApplication.class); + application.setApplicationStartup(new BufferingApplicationStartup(2048)); + application.run(args); + System.out.println("(♥◠‿◠)ノ゙ AI模块启动成功 ლ(´ڡ`ლ)゙ "); + } + +} diff --git a/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/SnailAiController.java b/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/SnailAiController.java new file mode 100644 index 000000000..cc50e21ae --- /dev/null +++ b/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/SnailAiController.java @@ -0,0 +1,364 @@ +package org.dromara.ai.controller; + +import com.aizuda.snail.ai.common.execption.SnailAiException; +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 jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.domain.PageResult; +import org.dromara.common.core.domain.R; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.web.core.BaseController; +import org.dromara.system.api.model.LoginUser; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Snail AI OpenAPI 控制器 + * + * @author opensnail + * @date 2026-04-25 + */ +@Slf4j +@Validated +@RestController +@RequestMapping +@RequiredArgsConstructor +public class SnailAiController extends BaseController { + + /** + * Snail AI 成功状态码。 + */ + private static final int SNAIL_AI_SUCCESS = 1; + /** + * SSE 超时时间。 + */ + private static final long SSE_TIMEOUT = 300000L; + + private final OpenApiAgentClient agentClient; + private final OpenApiChatClient chatClient; + private final OpenApiConversationClient conversationClient; + private final OpenApiUserClient userClient; + /** + * 聊天模式。 + */ + @Value("${snail-ai.chat-mode:stream}") + private String chatMode; + + /** + * 注册当前登录用户并返回 OpenAPI 用户信息。 + */ + @PostMapping("/user/register") + public R registerCurrentUser() { + return R.ok(ensureOpenApiUser()); + } + + /** + * 查询当前登录用户对应的 OpenAPI 用户信息。 + */ + @GetMapping("/user") + public R getUser() { + OpenApiUserQueryRequest request = new OpenApiUserQueryRequest(); + request.setOpenId(ensureOpenId()); + return toR(userClient.getUser(request)); + } + + /** + * 查询当前用户可访问的智能体列表。 + */ + @GetMapping("/agents") + public R> listAgents() { + return toR(agentClient.listAgents()); + } + + /** + * 根据智能体 ID 查询智能体详情。 + */ + @GetMapping("/agent/{agentId}") + public R getAgent(@NotNull(message = "智能体ID不能为空") @PathVariable Long agentId) { + OpenApiAgentIdentityRequest request = new OpenApiAgentIdentityRequest(); + request.setAgentId(agentId); + return toR(agentClient.getAgent(request)); + } + + /** + * 为指定智能体创建新会话。 + */ + @PostMapping("/agent/{agentId}/conversation") + public R createConversation( + @NotNull(message = "智能体ID不能为空") @PathVariable Long agentId, + @RequestBody OpenApiCreateConversationRequest request) { + request.setAgentId(agentId); + request.setOpenId(ensureOpenId()); + return toR(conversationClient.createConversation(request)); + } + + /** + * 分页查询指定智能体下的会话列表。 + */ + @GetMapping("/agent/{agentId}/conversations") + public R> listConversations( + @NotNull(message = "智能体ID不能为空") @PathVariable Long agentId, + @Min(value = 1, message = "页码不能小于1") @RequestParam(defaultValue = "1") int page, + @Min(value = 1, message = "每页条数不能小于1") @RequestParam(defaultValue = "10") int size) { + OpenApiConversationQueryRequest request = new OpenApiConversationQueryRequest(); + request.setAgentId(agentId); + request.setOpenId(ensureOpenId()); + request.setPage(page); + request.setSize(size); + return toPageR(conversationClient.listConversations(request)); + } + + /** + * 查询指定会话的消息历史。 + */ + @GetMapping("/agent/{agentId}/conversation/{conversationId}/messages") + public R> getMessages( + @NotNull(message = "智能体ID不能为空") @PathVariable Long agentId, + @NotBlank(message = "会话ID不能为空") @PathVariable String conversationId) { + OpenApiConversationIdentityRequest request = new OpenApiConversationIdentityRequest(); + request.setAgentId(agentId); + request.setConversationId(conversationId); + request.setOpenId(ensureOpenId()); + return toR(conversationClient.getMessages(request)); + } + + /** + * 删除指定会话。 + */ + @DeleteMapping("/agent/{agentId}/conversation/{conversationId}") + public R deleteConversation( + @NotNull(message = "智能体ID不能为空") @PathVariable Long agentId, + @NotBlank(message = "会话ID不能为空") @PathVariable String conversationId) { + OpenApiConversationIdentityRequest request = new OpenApiConversationIdentityRequest(); + request.setAgentId(agentId); + request.setConversationId(conversationId); + request.setOpenId(ensureOpenId()); + return toR(conversationClient.deleteConversation(request)); + } + + /** + * 获取当前聊天发送模式。 + */ + @GetMapping("/chat/mode") + public R> getChatMode() { + String mode = "sync".equalsIgnoreCase(chatMode) ? "sync" : "stream"; + return R.ok(Map.of("mode", mode)); + } + + /** + * 同步对话接口。 + */ + @PostMapping("/agent/{agentId}/chat/sync") + public R chatSync( + @NotNull(message = "智能体ID不能为空") @PathVariable Long agentId, + @RequestBody OpenApiChatRequest request) { + request.setAgentId(agentId); + request.setOpenId(ensureOpenId()); + log.info("Sync chat request: agentId={}", agentId); + return toR(chatClient.chatSync(request)); + } + + /** + * 流式对话接口,按 SSE 事件返回消息分片。 + */ + @PostMapping(value = "/agent/{agentId}/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter chatStream( + @NotNull(message = "智能体ID不能为空") @PathVariable Long agentId, + @RequestBody OpenApiChatRequest request, + HttpServletResponse response) { + prepareSseResponse(response); + SseEmitter emitter = new SseEmitter(SSE_TIMEOUT); + AtomicBoolean closed = new AtomicBoolean(false); + emitter.onTimeout(() -> { + if (!closed.get()) { + safeSend(emitter, closed, "error", "SSE stream timeout"); + safeSend(emitter, closed, "done", ""); + if (closed.compareAndSet(false, true)) { + emitter.complete(); + } + } + }); + emitter.onCompletion(() -> closed.set(true)); + emitter.onError(error -> { + closed.set(true); + log.warn("SSE emitter error: {}", error.getMessage()); + }); + + request.setAgentId(agentId); + request.setOpenId(ensureOpenId()); + + log.info("Stream chat request: agentId={}", agentId); + try { + chatClient.chatStream(request, new SseEventListener() { + /** + * 推送普通文本片段。 + * + * @param text 文本片段 + */ + @Override + public void onText(String text) { + safeSend(emitter, closed, "text", text); + } + + /** + * 推送思考内容片段。 + * + * @param thinking 思考内容 + */ + @Override + public void onThinking(String thinking) { + safeSend(emitter, closed, "thinking", thinking); + } + + /** + * 处理流式对话完成事件。 + * + * @param data 完成数据 + */ + @Override + public void onComplete(String data) { + if (!closed.get()) { + safeSend(emitter, closed, "done", data); + log.info("Stream chat completed"); + if (closed.compareAndSet(false, true)) { + emitter.complete(); + } + } + } + + /** + * 处理流式对话异常事件。 + * + * @param errorMessage 错误信息 + */ + @Override + public void onError(String errorMessage) { + log.error("Stream chat error: {}", errorMessage); + if (!closed.get()) { + safeSend(emitter, closed, "error", errorMessage); + safeSend(emitter, closed, "done", ""); + if (closed.compareAndSet(false, true)) { + emitter.complete(); + } + } + } + }); + } catch (Exception e) { + log.error("Stream chat exception", e); + if (!closed.get()) { + safeSend(emitter, closed, "error", "stream exception: " + e.getMessage()); + safeSend(emitter, closed, "done", ""); + if (closed.compareAndSet(false, true)) { + emitter.complete(); + } + } + } + + return emitter; + } + + /** + * 转换 Snail AI 普通响应为项目统一响应。 + */ + private R toR(Result result) { + if (result == null) { + return R.fail("Snail AI 服务返回为空"); + } + if (result.getStatus() != SNAIL_AI_SUCCESS) { + return R.fail(result.getMessage()); + } + return R.ok(result.getData()); + } + + /** + * 转换 Snail AI 分页响应为项目统一分页响应。 + */ + private R> toPageR(com.aizuda.snail.ai.common.model.PageResult> result) { + R> response = toR(result); + if (R.isError(response)) { + return R.fail(response.getMsg()); + } + return R.ok(PageResult.build(response.getData(), result.getTotal())); + } + + /** + * 输出一条 SSE 事件。 + */ + private boolean safeSend(SseEmitter emitter, AtomicBoolean closed, String event, String data) { + if (closed.get()) { + return false; + } + try { + emitter.send(SseEmitter.event().name(event).data(data == null ? "" : data)); + return true; + } catch (IOException e) { + closed.set(true); + log.warn("SSE send failed, event={}", event, e); + emitter.completeWithError(e); + return false; + } + } + + /** + * 设置 SSE 响应头,覆盖统一鉴权成功路径中的默认 JSON 响应类型。 + * + * @param response 当前响应 + */ + private void prepareSseResponse(HttpServletResponse response) { + response.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE); + response.setCharacterEncoding("UTF-8"); + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("X-Accel-Buffering", "no"); + } + + /** + * 获取当前登录用户对应的 openId,不存在时会自动注册。 + */ + private String ensureOpenId() { + return ensureOpenApiUser().getOpenId(); + } + + /** + * 确保当前登录用户已注册为 OpenAPI 用户。 + */ + private OpenApiUserVO ensureOpenApiUser() { + Long userId = LoginHelper.getUserId(); + LoginUser loginUser = LoginHelper.getLoginUser(); + if (loginUser == null || userId == null) { + throw new SnailAiException("当前登录用户为空"); + } + + OpenApiUserRegisterRequest registerRequest = new OpenApiUserRegisterRequest(); + registerRequest.setExternalId(String.valueOf(userId)); + registerRequest.setNickname(loginUser.getNickname()); + Result registerResult = userClient.register(registerRequest); + if (registerResult == null) { + throw new SnailAiException("注册 OpenAPI 用户失败,返回为空"); + } + if (registerResult.getStatus() != SNAIL_AI_SUCCESS) { + throw new SnailAiException(registerResult.getMessage()); + } + if (registerResult.getData() == null) { + throw new SnailAiException("注册 OpenAPI 用户失败,返回为空"); + } + return registerResult.getData(); + } +} diff --git a/ruoyi-modules/ruoyi-ai/src/main/resources/application.yml b/ruoyi-modules/ruoyi-ai/src/main/resources/application.yml new file mode 100644 index 000000000..778ce6e5f --- /dev/null +++ b/ruoyi-modules/ruoyi-ai/src/main/resources/application.yml @@ -0,0 +1,33 @@ +# Tomcat +server: + port: 9206 + +# Spring +spring: + application: + # 应用名称 + name: ruoyi-ai + profiles: + # 环境配置 + active: @profiles.active@ + +--- # nacos 配置 +spring: + cloud: + nacos: + # nacos 服务地址 + server-addr: @nacos.server@ + username: @nacos.username@ + password: @nacos.password@ + discovery: + # 注册组 + group: DEFAULT_GROUP + namespace: public + config: + # 配置组 + group: DEFAULT_GROUP + namespace: public + config: + import: + - optional:nacos:application-common.yml + - optional:nacos:${spring.application.name}.yml diff --git a/ruoyi-modules/ruoyi-ai/src/main/resources/logback-plus.xml b/ruoyi-modules/ruoyi-ai/src/main/resources/logback-plus.xml new file mode 100644 index 000000000..ce17b5bc9 --- /dev/null +++ b/ruoyi-modules/ruoyi-ai/src/main/resources/logback-plus.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + ${console.log.pattern} + utf-8 + + + + + + + + + + + + diff --git a/ruoyi-visual/pom.xml b/ruoyi-visual/pom.xml index 4a8276070..3fd96b5b0 100644 --- a/ruoyi-visual/pom.xml +++ b/ruoyi-visual/pom.xml @@ -10,6 +10,7 @@ ruoyi-monitor + ruoyi-snailai-server ruoyi-snailjob-server diff --git a/ruoyi-visual/ruoyi-snailai-server/Dockerfile b/ruoyi-visual/ruoyi-snailai-server/Dockerfile new file mode 100644 index 000000000..c4aca4b75 --- /dev/null +++ b/ruoyi-visual/ruoyi-snailai-server/Dockerfile @@ -0,0 +1,22 @@ +# 贝尔实验室 Spring 官方推荐镜像 JDK下载地址 https://bell-sw.com/pages/downloads/ +FROM bellsoft/liberica-openjdk-rocky:21.0.8-cds +#FROM findepi/graalvm:java21-native + +LABEL maintainer="Lion Li" + +RUN mkdir -p /ruoyi/snailai/logs + +WORKDIR /ruoyi/snailai + +ENV LANG=C.UTF-8 LC_ALL=C.UTF-8 JAVA_OPTS="" + +EXPOSE 8900 +EXPOSE 18888 + +ADD ./target/ruoyi-snailai-server.jar ./app.jar + +SHELL ["/bin/bash", "-c"] + +ENTRYPOINT java -Djava.security.egd=file:/dev/./urandom \ + -XX:+HeapDumpOnOutOfMemoryError -XX:+UseZGC ${JAVA_OPTS} \ + -jar app.jar diff --git a/ruoyi-visual/ruoyi-snailai-server/pom.xml b/ruoyi-visual/ruoyi-snailai-server/pom.xml new file mode 100644 index 000000000..16150d876 --- /dev/null +++ b/ruoyi-visual/ruoyi-snailai-server/pom.xml @@ -0,0 +1,53 @@ + + + + org.dromara + ruoyi-visual + ${revision} + + 4.0.0 + + ruoyi-snailai-server + + + + + com.aizuda + snail-ai-starter + ${snailai.version} + + + + + de.codecentric + spring-boot-admin-starter-client + ${spring-boot-admin.version} + + + + org.dromara + ruoyi-common-nacos + + + + + ${project.artifactId} + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + repackage + + + + + + + + diff --git a/ruoyi-visual/ruoyi-snailai-server/src/main/java/com/aizuda/snail/ai/starter/filter/ActuatorAuthFilter.java b/ruoyi-visual/ruoyi-snailai-server/src/main/java/com/aizuda/snail/ai/starter/filter/ActuatorAuthFilter.java new file mode 100644 index 000000000..2dd564c2d --- /dev/null +++ b/ruoyi-visual/ruoyi-snailai-server/src/main/java/com/aizuda/snail/ai/starter/filter/ActuatorAuthFilter.java @@ -0,0 +1,97 @@ +package com.aizuda.snail.ai.starter.filter; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * Actuator Basic Auth 认证过滤器。 + * + * @author Lion Li + */ +public class ActuatorAuthFilter implements Filter { + + /** + * 认证用户名。 + */ + private final String username; + /** + * 认证密码。 + */ + private final String password; + + /** + * 创建 Actuator 认证过滤器。 + * + * @param username 认证用户名 + * @param password 认证密码 + */ + public ActuatorAuthFilter(String username, String password) { + this.username = username; + this.password = password; + } + + /** + * 校验 Actuator Basic Auth 请求。 + * + * @param servletRequest 原始请求 + * @param servletResponse 原始响应 + * @param filterChain 过滤器链 + * @throws IOException IO 异常 + * @throws ServletException Servlet 异常 + */ + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { + HttpServletRequest request = (HttpServletRequest) servletRequest; + HttpServletResponse response = (HttpServletResponse) servletResponse; + + String authHeader = request.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith("Basic ")) { + response.setHeader("WWW-Authenticate", "Basic realm=\"realm\""); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); + return; + } + + String base64Credentials = authHeader.substring("Basic ".length()); + byte[] credDecoded = Base64.getDecoder().decode(base64Credentials); + String credentials = new String(credDecoded, StandardCharsets.UTF_8); + String[] split = credentials.split(":"); + if (split.length != 2) { + response.setHeader("WWW-Authenticate", "Basic realm=\"realm\""); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); + return; + } + if (!username.equals(split[0]) || !password.equals(split[1])) { + response.setHeader("WWW-Authenticate", "Basic realm=\"realm\""); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); + return; + } + filterChain.doFilter(request, response); + } + + /** + * 初始化过滤器。 + * + * @param filterConfig 过滤器配置 + */ + @Override + public void init(FilterConfig filterConfig) { + } + + /** + * 销毁过滤器。 + */ + @Override + public void destroy() { + } + +} diff --git a/ruoyi-visual/ruoyi-snailai-server/src/main/java/com/aizuda/snail/ai/starter/filter/SecurityConfig.java b/ruoyi-visual/ruoyi-snailai-server/src/main/java/com/aizuda/snail/ai/starter/filter/SecurityConfig.java new file mode 100644 index 000000000..3bac9f308 --- /dev/null +++ b/ruoyi-visual/ruoyi-snailai-server/src/main/java/com/aizuda/snail/ai/starter/filter/SecurityConfig.java @@ -0,0 +1,40 @@ +package com.aizuda.snail.ai.starter.filter; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 权限安全配置 + * + * @author Lion Li + */ +@Configuration +public class SecurityConfig { + + /** + * 认证用户名。 + */ + @Value("${spring.cloud.nacos.discovery.metadata.username}") + private String username; + /** + * 认证密码。 + */ + @Value("${spring.cloud.nacos.discovery.metadata.userpassword}") + private String password; + + /** + * 注册 Actuator Basic Auth 过滤器。 + * + * @return Actuator 过滤器注册对象 + */ + @Bean + public FilterRegistrationBean actuatorFilterRegistrationBean() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new ActuatorAuthFilter(username, password)); + registrationBean.addUrlPatterns("/actuator", "/actuator/*"); + return registrationBean; + } + +} diff --git a/ruoyi-visual/ruoyi-snailai-server/src/main/java/org/dromara/snailai/SnailAiServerApplication.java b/ruoyi-visual/ruoyi-snailai-server/src/main/java/org/dromara/snailai/SnailAiServerApplication.java new file mode 100644 index 000000000..905efd0d7 --- /dev/null +++ b/ruoyi-visual/ruoyi-snailai-server/src/main/java/org/dromara/snailai/SnailAiServerApplication.java @@ -0,0 +1,23 @@ +package org.dromara.snailai; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Snail AI Server 启动程序 + * + * @author Lion Li + * @date 2026-05-26 + */ +@SpringBootApplication +public class SnailAiServerApplication { + + /** + * Snail AI 服务启动入口。 + * + * @param args 启动参数 + */ + public static void main(String[] args) { + com.aizuda.snail.ai.starter.SnailAiSpringbootApplication.main(args); + } + +} diff --git a/ruoyi-visual/ruoyi-snailai-server/src/main/resources/application.yml b/ruoyi-visual/ruoyi-snailai-server/src/main/resources/application.yml new file mode 100644 index 000000000..820a539f6 --- /dev/null +++ b/ruoyi-visual/ruoyi-snailai-server/src/main/resources/application.yml @@ -0,0 +1,90 @@ +server: + port: 8900 + servlet: + context-path: /snail-ai + +spring: + application: + name: ruoyi-snailai-server + servlet: + multipart: + enabled: true + # 单个文件的最大限制 (根据 RAG 需求建议设为 50MB - 100MB) + max-file-size: 50MB + # 单次请求的总文件大小限制 (如果支持多文件并行上传,调大此项) + max-request-size: 100MB + web: + resources: + static-locations: classpath:admin/ + profiles: + active: @profiles.active@ +snail-ai: + server: + grpc-port: 18888 + skill: + upload-dir: ./upload/skills + crypto: + secret-key: ${SNAIL_AI_CRYPTO_KEY:0123456789abcdef0123456789abcdef} + iv: ${SNAIL_AI_CRYPTO_IV:fedcba9876543210fedcba9876543210} + resource: + storage-type: LOCAL + upload-dir: ./upload/resource + minio: + endpoint: http://localhost:9000 + access-key: minioadmin + secret-key: minioadmin + bucket: snail-ai + # 短期记忆配置 + memory: + short-term: + # 存储类型选项: memory(内存) | db(数据库) + # - memory: 适用于单机部署,高性能,重启后数据丢失 + # - db: 适用于分布式部署,数据持久化,性能较低 + store-type: db + +mybatis-plus: + mapper-locations: classpath:/mapper/*.xml + typeAliasesPackage: com.example.snail.job.po + global-config: + db-config: + capital-mode: false + logic-delete-value: 1 + logic-not-delete-value: 0 + configuration: + map-underscore-to-camel-case: true + cache-enabled: true + +logging: + config: classpath:logback-plus.xml + +management: + endpoints: + web: + exposure: + include: '*' + endpoint: + health: + show-details: ALWAYS + logfile: + external-file: ./logs/${spring.application.name}/console.log + +--- # nacos 配置 +spring: + cloud: + nacos: + # nacos 服务地址 + server-addr: @nacos.server@ + username: @nacos.username@ + password: @nacos.password@ + discovery: + # 注册组 + group: DEFAULT_GROUP + namespace: public + config: + # 配置组 + group: DEFAULT_GROUP + namespace: public + config: + import: + - optional:nacos:datasource.yml + - optional:nacos:${spring.application.name}.yml diff --git a/ruoyi-visual/ruoyi-snailai-server/src/main/resources/banner.txt b/ruoyi-visual/ruoyi-snailai-server/src/main/resources/banner.txt new file mode 100644 index 000000000..d797f2594 --- /dev/null +++ b/ruoyi-visual/ruoyi-snailai-server/src/main/resources/banner.txt @@ -0,0 +1,9 @@ +Application Version: ${revision} +Spring Boot Version: ${spring-boot.version} + _ _ _ + (_) | (_) + ___ _ __ __ _ _| |______ __ _ _ +/ __| '_ \ / _` | | |______/ _` | | +\__ \ | | | (_| | | | | (_| | | +|___/_| |_|\__,_|_|_| \__,_|_| + diff --git a/ruoyi-visual/ruoyi-snailai-server/src/main/resources/logback-plus.xml b/ruoyi-visual/ruoyi-snailai-server/src/main/resources/logback-plus.xml new file mode 100644 index 000000000..a6ec3c7d6 --- /dev/null +++ b/ruoyi-visual/ruoyi-snailai-server/src/main/resources/logback-plus.xml @@ -0,0 +1,87 @@ + + + + + + + + + + ${console.log.pattern} + utf-8 + + + + + + ${log.path}/console.log + + + ${log.path}/console.%d{yyyy-MM-dd}.log + + 1 + + + ${log.pattern} + utf-8 + + + + INFO + + + + + ${log.path}/info.log + + ${log.path}/info.%d{yyyy-MM-dd}.log + 60 + + + ${log.pattern} + + + INFO + ACCEPT + DENY + + + + + ${log.path}/error.log + + ${log.path}/error.%d{yyyy-MM-dd}.log + + 60 + + + ${log.pattern} + + + ERROR + ACCEPT + DENY + + + + + 100 + 1024 + + + + + 100 + 1024 + + + + + + + + + + + diff --git a/script/config/nacos/datasource.yml b/script/config/nacos/datasource.yml index bcf6b9841..bf463b8f1 100644 --- a/script/config/nacos/datasource.yml +++ b/script/config/nacos/datasource.yml @@ -13,6 +13,10 @@ datasource: url: jdbc:mysql://localhost:3306/ry-job?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true username: root password: password + ai: + url: jdbc:mysql://localhost:3306/ry-ai?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true + username: root + password: password workflow: url: jdbc:mysql://localhost:3306/ry-workflow?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true username: root diff --git a/script/config/nacos/ruoyi-ai.yml b/script/config/nacos/ruoyi-ai.yml new file mode 100644 index 000000000..f26e84747 --- /dev/null +++ b/script/config/nacos/ruoyi-ai.yml @@ -0,0 +1,46 @@ +snail-ai: + # 启用客户端模式 + enabled: true + # 聊天发送模式: stream(流式) / sync(同步) + chat-mode: stream + # ==================== Server 连接 ==================== + # Server 端 gRPC 地址(即 ruoyi-snailai-server 的 snail-ai.server.grpc-port) + server-host: 127.0.0.1 + server-port: 18888 + # ==================== 客户端配置 ==================== + # 应用 ID(在 Server「应用管理」页面创建后获取) + app-id: 1 + # 认证令牌(在 Server「应用管理」页面创建时自动生成) + token: SAI_8f1c789e35454ebdabce0b739963da9a + # 本客户端 gRPC 端口(Server 通过此端口分发 Chat 请求) + port: 18889 + # 客户端 IP 指定,留空默认自动获取本地 IP + host: + # 最大并发对话数 + max-concurrent-chats: 10 + # Skill 文件临时目录 + skill-temp-dir: /tmp/snail-ai-agent/skills + # Server 回调配置 + server-callback: + # Server 回调超时时间(毫秒) + timeout: 5000 + # Server 回调重试次数 + retry-times: 3 + # Server 回调重试间隔(毫秒) + retry-interval: 1000 + # ==================== OpenAPI Client 配置 ==================== + openapi: + # 启用 OpenAPI Client + enabled: true + # OpenAPI 服务端地址,不配置则取 snail-ai.server-host + server-host: + # Server HTTP 端口 + web-port: 8900 + # 是否使用 HTTPS + https: false + # API 路径前缀 + prefix: snail-ai + # 超时配置(毫秒) + connect-timeout-ms: 5000 + read-timeout-ms: 60000 + chat-timeout-ms: 300000 diff --git a/script/config/nacos/ruoyi-gateway.yml b/script/config/nacos/ruoyi-gateway.yml index 50bf6a860..c43195a71 100644 --- a/script/config/nacos/ruoyi-gateway.yml +++ b/script/config/nacos/ruoyi-gateway.yml @@ -56,6 +56,11 @@ spring: - Path=/resource/** filters: - StripPrefix=1 + # AI服务 + - id: ruoyi-ai + uri: lb://ruoyi-ai + predicates: + - Path=/snail-ai/** # workflow服务 - id: ruoyi-workflow uri: lb://ruoyi-workflow diff --git a/script/config/nacos/ruoyi-snailai-server.yml b/script/config/nacos/ruoyi-snailai-server.yml new file mode 100644 index 000000000..7d3478234 --- /dev/null +++ b/script/config/nacos/ruoyi-snailai-server.yml @@ -0,0 +1,48 @@ +spring: + datasource: + type: com.zaxxer.hikari.HikariDataSource + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${datasource.ai.url} + username: ${datasource.ai.username} + password: ${datasource.ai.password} + hikari: + connection-timeout: 30000 + validation-timeout: 5000 + minimum-idle: 10 + maximum-pool-size: 20 + idle-timeout: 600000 + max-lifetime: 900000 + keepaliveTime: 30000 + cloud: + nacos: + discovery: + metadata: + # 解决服务有 context-path 无法监控问题 + management.context-path: ${server.servlet.context-path}/actuator + # 监控账号密码 + username: ruoyi + userpassword: 123456 + +snail-ai: + server: + grpc-port: 18888 + skill: + upload-dir: ./upload/skills + crypto: + secret-key: ${SNAIL_AI_CRYPTO_KEY:0123456789abcdef0123456789abcdef} + iv: ${SNAIL_AI_CRYPTO_IV:fedcba9876543210fedcba9876543210} + resource: + storage-type: LOCAL + upload-dir: ./upload/resource + minio: + endpoint: http://localhost:9000 + access-key: minioadmin + secret-key: minioadmin + bucket: snail-ai + # 短期记忆配置 + memory: + short-term: + # 存储类型选项: memory(内存) | db(数据库) + # - memory: 适用于单机部署,高性能,重启后数据丢失 + # - db: 适用于分布式部署,数据持久化,性能较低 + store-type: db diff --git a/script/docker/docker-compose.yml b/script/docker/docker-compose.yml index 4df2f0586..8088d1434 100644 --- a/script/docker/docker-compose.yml +++ b/script/docker/docker-compose.yml @@ -165,6 +165,21 @@ services: privileged: true network_mode: "host" + ruoyi-snailai-server: + image: ruoyi/ruoyi-snailai-server:2.5.3 + container_name: ruoyi-snailai-server + environment: + # 时区上海 + TZ: Asia/Shanghai + ports: + - "8900:8900" + - "18888:18888" + volumes: + - /docker/snailai/logs/:/ruoyi/snailai/logs + - /docker/snailai/upload/:/ruoyi/snailai/upload + privileged: true + network_mode: "host" + ruoyi-gateway: image: ruoyi/ruoyi-gateway:2.5.3 container_name: ruoyi-gateway @@ -279,6 +294,25 @@ services: privileged: true network_mode: "host" + ruoyi-ai: + image: ruoyi/ruoyi-ai:2.5.3 + container_name: ruoyi-ai + environment: + # 时区上海 + TZ: Asia/Shanghai + ports: + - "9206:9206" + - "18889:18889" + volumes: + # 配置文件 + - /docker/ruoyi-ai/logs/:/ruoyi/ai/logs + # skywalking 探针 + - /docker/skywalking/agent/:/ruoyi/skywalking/agent + # Snail AI Skill 临时目录 + - /docker/snailai/agent/skills/:/tmp/snail-ai-agent/skills + privileged: true + network_mode: "host" + ################################################################################################# #################################### 以下为扩展根据需求搭建 ######################################### diff --git a/script/sql/ry-ai.sql b/script/sql/ry-ai.sql new file mode 100644 index 000000000..141c373a2 --- /dev/null +++ b/script/sql/ry-ai.sql @@ -0,0 +1,518 @@ +-- ============================================================ +-- Snail AI MySQL 全量建表脚本(仅 CREATE,无 ALTER) +-- 使用:mysql -u user -p database < snail_ai_schema.sql +-- ============================================================ + +-- ============================================================ +-- Enterprise RAG Schema for MySQL +-- ============================================================ + +-- Knowledge Base +CREATE TABLE sai_rag +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + icon VARCHAR(512), + embedding_model_id BIGINT(128) NOT NULL, + dimension_of_vector_model INT NOT NULL COMMENT '向量维度', + rerank_model_id BIGINT(128), + search_engine_instance_id BIGINT(128), + vector_store_instance_id BIGINT(128), + search_engine_enable TINYINT(1) DEFAULT 0, + delimiter VARCHAR(32) DEFAULT '\n\n', + rag_enhancement TEXT, + config TEXT DEFAULT NULL COMMENT 'RAG检索和问答的页面配置参数', + dedup_strategy TINYINT(1) NOT NULL DEFAULT 2 COMMENT '去重策略: 0=NONE 1=BY_NAME 2=BY_CONTENT 3=BY_NAME_OR_CONTENT', + dedup_action TINYINT(1) NOT NULL DEFAULT 0 COMMENT '冲突动作: 0=REJECT 1=SKIP 2=OVERWRITE', + upload_confirm TINYINT(1) NOT NULL DEFAULT 1 COMMENT '上传前二次确认: 0-关 1-开', + create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +-- RAG Documents +CREATE TABLE sai_rag_document +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + rag_id BIGINT NOT NULL, + name VARCHAR(255), + file_type VARCHAR(32), + source_type VARCHAR(32), + source_path VARCHAR(1024), + storage_path VARCHAR(1024), + storage_type VARCHAR(32) DEFAULT 'LOCAL', + file_size BIGINT DEFAULT 0, + content TEXT, + status TINYINT(1) DEFAULT 0 COMMENT '状态: 0-待处理 1-解析中 2-处理中 3-处理完成 4-处理失败', + error_msg TEXT, + chunk_count INT DEFAULT 0, + content_hash VARCHAR(64) DEFAULT NULL COMMENT '文件内容SHA-256哈希,用于去重', + resource_id BIGINT DEFAULT NULL COMMENT '关联资源库 sai_resource.id', + create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE INDEX idx_rag_doc_rag ON sai_rag_document (rag_id); +CREATE INDEX idx_rag_content_hash ON sai_rag_document (rag_id, content_hash); +CREATE INDEX idx_rag_name ON sai_rag_document (rag_id, name); +CREATE INDEX idx_rag_doc_resource ON sai_rag_document (resource_id); + +-- RAG Chunks +CREATE TABLE sai_rag_chunk +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + rag_id BIGINT NOT NULL, + document_id BIGINT NOT NULL, + paragraph_index INT, + chunk_index INT, + content TEXT, + token_count INT, + vector_id VARCHAR(128), + content_hash VARCHAR(64) DEFAULT NULL COMMENT 'chunk内容SHA-256,用于向量去重', + create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE INDEX idx_rag_chunk_rag ON sai_rag_chunk (rag_id); +CREATE INDEX idx_rag_chunk_document ON sai_rag_chunk (document_id); +CREATE INDEX idx_chunk_rag_hash ON sai_rag_chunk (rag_id, content_hash); + +CREATE TABLE sai_user +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + role INT, + totals INT, + username VARCHAR(255), + email VARCHAR(64), + password VARCHAR(255) NOT NULL, + create_dt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_dt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_username (username) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4; + +-- 默认管理员:admin / admin123 +INSERT INTO sai_user (id, role, username, email, password, create_dt, update_dt) +VALUES (1, 2, 'admin', '', 'pbkdf2$120000$c25haWwtYWktYWRtaW4tMQ==$kakglT/wYKOgv/77Ah1stie58d/JbY2nGgq5DwgUBw4=', + '2026-02-11 13:56:48.210429', '2026-02-11 13:56:48.210429'); + +-- ============================================ +-- 1. AI 模型提供商表 +-- ============================================ +CREATE TABLE IF NOT EXISTS sai_model_provider +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + provider_name VARCHAR(255) NOT NULL COMMENT '提供商名称', + provider_key VARCHAR(50) NOT NULL COMMENT '提供商标识符', + description TEXT COMMENT '提供商描述', + icon_url VARCHAR(500) COMMENT 'LOGO图标URL', + is_enabled TINYINT(1) DEFAULT 1 COMMENT '是否启用', + created_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_provider_name (provider_name), + UNIQUE KEY uk_provider_key (provider_key) + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci COMMENT = 'AI模型提供商表'; + +CREATE INDEX idx_provider_key ON sai_model_provider (provider_key); +CREATE INDEX idx_is_enabled ON sai_model_provider (is_enabled); + +-- ============================================ +-- 2. AI模型配置表 +-- ============================================ +CREATE TABLE IF NOT EXISTS sai_model_config +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + provider_id BIGINT NOT NULL COMMENT '提供商ID', + model_name VARCHAR(255) NOT NULL COMMENT '模型名称', + model_key VARCHAR(100) NOT NULL COMMENT '模型标识符', + model_type VARCHAR(50) NOT NULL COMMENT '模型类型(CHAT/EMBEDDING/RERANKER/IMAGE/SPEECH)', + description VARCHAR(1000) COMMENT '模型描述', + api_key VARCHAR(1000) COMMENT 'API密钥(加密存储)', + api_endpoint VARCHAR(500) COMMENT 'API端点URL', + config_json TEXT COMMENT '模型参数配置(JSON格式)', + owner_id BIGINT COMMENT '所有者ID(NULL=全局,具体值=用户ID)', + scope VARCHAR(20) NOT NULL DEFAULT 'GLOBAL' COMMENT '作用域(GLOBAL/PERSONAL)', + is_default TINYINT(1) DEFAULT 0 COMMENT '是否为默认模型', + is_enabled TINYINT(1) DEFAULT 1 COMMENT '是否启用', + created_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + KEY fk_provider_id (provider_id) + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci COMMENT = 'AI模型配置表'; + +CREATE INDEX idx_provider_model_type ON sai_model_config (provider_id, model_type); +CREATE INDEX idx_model_type_enabled ON sai_model_config (model_type, is_enabled); +CREATE INDEX idx_owner_id ON sai_model_config (owner_id); +CREATE INDEX idx_is_default ON sai_model_config (is_default); +CREATE INDEX idx_scope ON sai_model_config (scope); +CREATE INDEX idx_model_key ON sai_model_config (model_key); + +-- ============================================ +-- 3. 模型使用统计表 +-- ============================================ +CREATE TABLE IF NOT EXISTS sai_model_usage_stat +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + model_id BIGINT NOT NULL COMMENT '模型ID', + user_id BIGINT NOT NULL COMMENT '用户ID', + total_calls BIGINT DEFAULT 0 COMMENT '总调用次数', + success_calls BIGINT DEFAULT 0 COMMENT '成功调用次数', + failed_calls BIGINT DEFAULT 0 COMMENT '失败调用次数', + total_tokens_used BIGINT DEFAULT 0 COMMENT '总Token使用量', + total_cost DECIMAL(18, 8) DEFAULT 0 COMMENT '总费用', + avg_response_time BIGINT DEFAULT 0 COMMENT '平均响应时间(毫秒)', + last_used_dt TIMESTAMP NULL DEFAULT NULL COMMENT '最后使用时间', + created_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY unique_model_user (model_id, user_id), + KEY fk_stat_model_id (model_id) + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci COMMENT = '模型使用统计表'; + +CREATE INDEX idx_model_id ON sai_model_usage_stat (model_id); +CREATE INDEX idx_user_id ON sai_model_usage_stat (user_id); +CREATE INDEX idx_last_used_dt ON sai_model_usage_stat (last_used_dt); + +-- ============================================ +-- 初始化数据 (可选) +-- ============================================ +-- 插入常见的AI提供商(重复 provider_key 则忽略) +INSERT IGNORE INTO sai_model_provider (provider_name, provider_key, description, is_enabled) +VALUES ('OpenAI', 'openai', 'OpenAI官方模型 (GPT-4, GPT-3.5等)', 1), + ('Claude', 'claude', 'Anthropic Claude模型', 1), + ('Ollama', 'ollama', '本地开源模型 (Llama, Mistral等)', 1), + ('Google Gemini', 'gemini', 'Google Gemini模型', 1), + ('阿里云百炼', 'qwen', '阿里云百炼 OpenAI 兼容模型 (Qwen等)', 1), + ('DeepSeek', 'deepseek', 'DeepSeek OpenAI 兼容模型', 1), + ('智谱AI', 'zhipu', '智谱AI OpenAI 兼容模型 (GLM等)', 1); + +-- ============================================ +-- 智能体相关表 +-- ============================================ + +-- 智能体主表 +CREATE TABLE IF NOT EXISTS sai_agent +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL COMMENT '智能体名称', + description TEXT COMMENT '智能体描述', + avatar VARCHAR(512) COMMENT '头像URL', + instruction TEXT COMMENT '系统指令(System Prompt)', + greeting TEXT COMMENT '欢迎语', + preset_questions TEXT COMMENT '预设问题列表(JSON数组字符串)', + chat_model_id BIGINT COMMENT '关联的对话模型ID', + memory_enabled TINYINT(1) DEFAULT 0 COMMENT '是否启用记忆库', + mcp_enabled TINYINT(1) DEFAULT 0 COMMENT '是否启用MCP', + skill_enabled TINYINT(1) DEFAULT 0 COMMENT '是否启用Skill', + web_search_enabled TINYINT(1) DEFAULT 0 COMMENT '是否启用联网搜索', + rag_enabled TINYINT(1) DEFAULT 0 COMMENT '是否启用RAG', + rag_id BIGINT NULL COMMENT '绑定的RAG ID', + short_term_memory_size INT DEFAULT 20 COMMENT '短期记忆滑动窗口保留条数', + creator_id BIGINT COMMENT '创建者用户ID', + is_featured TINYINT(1) DEFAULT 0 COMMENT '是否精选', + view_count INT DEFAULT 0 COMMENT '浏览次数', + status TINYINT DEFAULT 1 COMMENT '状态: 1-活跃 2-非活跃 3-已废弃 4-已禁用', + config TEXT COMMENT '扩展配置(预留)', + app_id VARCHAR(128) NULL COMMENT '关联应用ID(NULL=本地执行)', + create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci COMMENT = '智能体表'; + +CREATE INDEX idx_agent_creator ON sai_agent (creator_id); +CREATE INDEX idx_agent_featured ON sai_agent (is_featured); + +-- 智能体对话表 +CREATE TABLE IF NOT EXISTS sai_agent_conversation +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + agent_id BIGINT NOT NULL COMMENT '智能体ID', + user_id BIGINT NOT NULL COMMENT '用户ID', + conversation_id VARCHAR(64) NOT NULL COMMENT '对话ID(UUID)', + title VARCHAR(255) COMMENT '对话标题', + create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_conv_id (conversation_id) + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci COMMENT = '智能体对话表'; + +CREATE INDEX idx_agent_conv_agent ON sai_agent_conversation (agent_id); +CREATE INDEX idx_agent_conv_user ON sai_agent_conversation (user_id); + +-- 智能体对话消息记录表 +CREATE TABLE IF NOT EXISTS sai_agent_conversation_record +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + agent_id BIGINT NOT NULL COMMENT '智能体ID', + conversation_id VARCHAR(64) NOT NULL COMMENT '对话ID', + user_id BIGINT NOT NULL COMMENT '用户ID', + role VARCHAR(16) DEFAULT 'user' COMMENT 'user/assistant', + content TEXT COMMENT '消息内容', + thinking TEXT COMMENT '思考过程(仅assistant)', + status INT DEFAULT 1 COMMENT '1=成功,2=失败,3=进行中', + token_count INT DEFAULT 0 COMMENT 'Token数', + create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci COMMENT = '智能体对话消息记录'; + +CREATE INDEX idx_agent_rec_conv ON sai_agent_conversation_record (conversation_id); + +-- 智能体使用统计表 +CREATE TABLE IF NOT EXISTS sai_agent_usage_stat +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + agent_id BIGINT NOT NULL COMMENT '智能体ID', + user_id BIGINT NOT NULL COMMENT '用户ID', + user_name VARCHAR(255) COMMENT '用户名', + department VARCHAR(255) DEFAULT '' COMMENT '部门', + message_count INT DEFAULT 0 COMMENT '消息条数', + conversation_count INT DEFAULT 0 COMMENT '对话轮次', + stat_date DATE NOT NULL COMMENT '统计日期', + create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_agent_user_date (agent_id, user_id, stat_date) + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci COMMENT = '智能体使用统计'; + +CREATE INDEX idx_usage_agent ON sai_agent_usage_stat (agent_id); +CREATE INDEX idx_usage_date ON sai_agent_usage_stat (stat_date); + +-- ============================================ +-- MCP 服务管理 +-- ============================================ + +-- MCP 服务配置表 +CREATE TABLE IF NOT EXISTS sai_mcp_server +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL COMMENT 'MCP服务名称', + description TEXT COMMENT 'MCP服务描述', + transport_type TINYINT(1) DEFAULT 1 COMMENT '传输类型: 1-SSE 2-Streamable HTTP 3-Stdio', + base_uri VARCHAR(1024) COMMENT '服务基础地址(SSE/Streamable HTTP时使用)', + endpoint VARCHAR(1024) COMMENT '端点路径(SSE/Streamable HTTP时可选)', + command VARCHAR(1024) COMMENT 'Stdio命令(Stdio时必填)', + args TEXT COMMENT 'Stdio命令参数(JSON数组)', + env_vars TEXT COMMENT 'Stdio环境变量(JSON对象)', + version VARCHAR(32) DEFAULT '1.0.0' COMMENT '版本', + auth_type TINYINT(1) DEFAULT 0 COMMENT '认证方式: 0-无需认证 1-API Key 2-OAuth 3-Basic Auth', + auth_config TEXT COMMENT '认证配置(JSON)', + status TINYINT(1) DEFAULT 0 COMMENT '状态: 0-未连接 1-已连接 2-异常', + capabilities TEXT COMMENT '能力列表(JSON数组)', + last_connect_dt TIMESTAMP NULL COMMENT '最后连接时间', + creator_id BIGINT COMMENT '创建者用户ID', + create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci COMMENT = 'MCP服务配置表'; + +CREATE INDEX idx_mcp_server_creator ON sai_mcp_server (creator_id); +CREATE INDEX idx_mcp_server_status ON sai_mcp_server (status); + +-- 智能体与MCP服务关联表(多对多) +CREATE TABLE IF NOT EXISTS sai_agent_mcp_server +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + agent_id BIGINT NOT NULL COMMENT '智能体ID', + mcp_server_id BIGINT NOT NULL COMMENT 'MCP服务ID', + create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_agent_mcp (agent_id, mcp_server_id) + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci COMMENT = '智能体MCP服务关联表'; + +CREATE INDEX idx_agent_mcp_agent ON sai_agent_mcp_server (agent_id); +CREATE INDEX idx_agent_mcp_server ON sai_agent_mcp_server (mcp_server_id); + +-- 用户订阅的智能体(多对多) +CREATE TABLE IF NOT EXISTS sai_user_agent +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL COMMENT '用户ID', + agent_id BIGINT NOT NULL COMMENT '智能体ID', + create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_user_agent (user_id, agent_id) + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci COMMENT = '用户订阅的智能体'; + +CREATE INDEX idx_user_agent_user ON sai_user_agent (user_id); + +-- ============================================ +-- Skill 技能包管理 +-- ============================================ + +-- Skill 技能包表 +CREATE TABLE IF NOT EXISTS sai_skill +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL COMMENT 'Skill名称(从SKILL.md解析)', + description TEXT COMMENT 'Skill描述(从SKILL.md解析)', + file_name VARCHAR(255) COMMENT '上传的zip文件名', + file_path VARCHAR(1024) COMMENT '解压后存储路径', + file_size BIGINT DEFAULT 0 COMMENT '文件大小(字节)', + skill_content LONGTEXT COMMENT 'SKILL.md正文内容(去除frontmatter)', + storage_path VARCHAR(500) DEFAULT NULL COMMENT '对象存储相对路径前缀(如 skills/123/)', + version BIGINT DEFAULT 0 COMMENT '版本号,文件变更时自增,用于缓存一致性校验', + has_files TINYINT(1) DEFAULT 0 COMMENT '是否包含支撑文件(0=仅SKILL.md,1=有scripts/references等)', + creator_id BIGINT COMMENT '创建者用户ID', + create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci COMMENT = 'Skill技能包表'; + +CREATE INDEX idx_skill_creator ON sai_skill (creator_id); + +-- Skill 支撑文件内容表 +CREATE TABLE IF NOT EXISTS sai_skill_file +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + skill_id BIGINT NOT NULL COMMENT 'Skill ID', + file_path VARCHAR(255) NOT NULL COMMENT '文件相对路径', + content LONGTEXT NOT NULL COMMENT '文件内容', + file_size INT NOT NULL COMMENT '文件大小(字节)', + encoding VARCHAR(50) DEFAULT 'utf-8' COMMENT '编码方式', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_skill_path (skill_id, file_path) COMMENT '同一Skill不能有重复的文件路径', + KEY idx_skill_id (skill_id) COMMENT '技能ID索引' + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci COMMENT = 'Skill支撑文件内容表'; + +-- 智能体与Skill关联表(多对多) +CREATE TABLE IF NOT EXISTS sai_agent_skill +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + agent_id BIGINT NOT NULL COMMENT '智能体ID', + skill_id BIGINT NOT NULL COMMENT 'Skill ID', + create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_agent_skill (agent_id, skill_id) + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci COMMENT = '智能体Skill关联表'; + +CREATE INDEX idx_agent_skill_agent ON sai_agent_skill (agent_id); +CREATE INDEX idx_agent_skill_skill ON sai_agent_skill (skill_id); + +-- ============================================================ +CREATE TABLE IF NOT EXISTS sai_store_instance +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(128) NOT NULL COMMENT '实例名称', + category TINYINT(1) NOT NULL COMMENT '分类: 1-向量库 2-搜索引擎', + type TINYINT(1) NOT NULL COMMENT '类型: 1-PG_VECTOR 2-MILVUS 3-ELASTICSEARCH 4-PG_FULLTEXT', + config TEXT DEFAULT NULL COMMENT '连接参数 JSON', + status TINYINT(1) DEFAULT 1 COMMENT '状态: 0-停用 1-启用', + is_default TINYINT(1) DEFAULT 0 COMMENT '是否为该 category 下默认实例', + create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci COMMENT = '存储实例'; + +CREATE INDEX idx_store_instance_category ON sai_store_instance (category); +CREATE INDEX idx_store_instance_type ON sai_store_instance (type); + +-- ============================================================ +-- 记忆系统(配置 / 主表 / 历史 / 摘要 / 统计 / 提取进度) +-- 依赖:sai_store_instance(sai_memory_conversation 外键) +-- ============================================================ + +-- 客户端应用 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS sai_app +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + app_id VARCHAR(128) NOT NULL COMMENT '应用唯一标识', + app_name VARCHAR(255) NOT NULL COMMENT '应用名称', + description VARCHAR(512) COMMENT '应用描述', + token VARCHAR(128) NOT NULL COMMENT '通信认证令牌', + route_strategy VARCHAR(32) DEFAULT 'LEAST_LOAD' COMMENT '路由策略', + status TINYINT(1) DEFAULT 1 COMMENT '1=启用, 0=停用', + create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_app_id (app_id) + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci COMMENT = '客户端应用'; + +-- ---------------------------- +-- AI客户端实例节点 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS sai_client_node +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + app_id VARCHAR(128) NOT NULL COMMENT '所属应用ID', + host_id VARCHAR(128) NOT NULL COMMENT '客户端实例唯一标识', + host_ip VARCHAR(64) NOT NULL COMMENT '客户端IP', + grpc_port INT NOT NULL COMMENT '客户端gRPC端口', + max_concurrent INT DEFAULT 10 COMMENT '最大并发对话数', + active_chats INT DEFAULT 0 COMMENT '当前活跃对话数', + supported_providers TEXT COMMENT '支持的模型提供商(JSON数组)', + labels TEXT COMMENT '路由标签', + expire_dt DATETIME NOT NULL COMMENT '过期时间(心跳更新)', + create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_client_node (app_id, host_id), + INDEX idx_app_expire (app_id, expire_dt) + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci COMMENT = 'AI客户端实例节点'; + +-- OpenAPI 外部用户映射表 +CREATE TABLE sai_openapi_user +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + app_id VARCHAR(128) NOT NULL COMMENT '关联 sai_app.app_id', + open_id VARCHAR(64) NOT NULL COMMENT '平台分配的唯一标识(UUID)', + platform_user_id BIGINT NOT NULL COMMENT '关联 sai_user.id,注册时自动创建', + external_id VARCHAR(256) DEFAULT NULL COMMENT '外部系统的用户标识(可选,幂等用)', + nickname VARCHAR(128) DEFAULT NULL COMMENT '外部用户昵称', + create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_app_open (app_id, open_id), + UNIQUE KEY uk_app_external (app_id, external_id), + INDEX idx_open_id (open_id), + INDEX idx_platform_user (platform_user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='OpenAPI 外部用户映射表'; + +-- ---------------------------- +-- 通用资源存储 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS sai_resource +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + storage_key VARCHAR(512) NOT NULL COMMENT '存储键(相对路径或对象Key)', + original_name VARCHAR(255) NOT NULL COMMENT '原始文件名', + file_size BIGINT DEFAULT 0 COMMENT '文件大小(bytes)', + mime_type VARCHAR(128) COMMENT 'MIME类型', + storage_type VARCHAR(32) NOT NULL DEFAULT 'LOCAL' COMMENT '存储类型: LOCAL/MINIO', + access_url VARCHAR(1024) COMMENT '访问URL', + biz_type VARCHAR(64) NOT NULL DEFAULT 'GENERAL' COMMENT '业务类型: AVATAR/ATTACHMENT/DOCUMENT/GENERAL', + biz_id BIGINT COMMENT '关联业务ID', + creator_id BIGINT COMMENT '上传者ID', + create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_storage_key (storage_key), + INDEX idx_biz (biz_type, biz_id), + INDEX idx_creator (creator_id) + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci COMMENT = '通用资源存储';