diff --git a/pom.xml b/pom.xml index 3dce47fb1..3a89f760d 100644 --- a/pom.xml +++ b/pom.xml @@ -43,7 +43,7 @@ 4.6.1 2.2.7 2.0.2 - 1.0.0 + 1.1.0 5.8.47 diff --git a/ruoyi-common/ruoyi-common-ai/src/main/java/org/dromara/common/ai/handler/SnailAiChatExceptionHandler.java b/ruoyi-common/ruoyi-common-ai/src/main/java/org/dromara/common/ai/handler/SnailAiChatExceptionHandler.java deleted file mode 100644 index 1d93eda83..000000000 --- a/ruoyi-common/ruoyi-common-ai/src/main/java/org/dromara/common/ai/handler/SnailAiChatExceptionHandler.java +++ /dev/null @@ -1,124 +0,0 @@ -package org.dromara.common.ai.handler; - -import com.aizuda.snail.ai.agent.chat.starter.SnailAiChatGatewayController; -import com.aizuda.snail.ai.common.execption.BaseSnailAiException; -import com.aizuda.snail.ai.common.execption.SnailAiAuthenticationException; -import com.aizuda.snail.ai.common.model.ModelCallException; -import com.aizuda.snail.ai.common.model.Result; -import jakarta.validation.ConstraintViolation; -import jakarta.validation.ConstraintViolationException; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.MessageSourceResolvable; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; -import org.springframework.http.converter.HttpMessageNotReadableException; -import org.springframework.validation.BindException; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.method.annotation.HandlerMethodValidationException; - -import java.util.Collection; -import java.util.stream.Collectors; - -/** - * Snail AI Chat 网关异常处理器。 - *

- * /api/snail/chat/** 接口由 Snail AI SDK 前端消费,响应结构需要保持 SDK Result(status/message/data), - * 不能落到 RuoYi 通用 R(code/msg/data) 格式。 - */ -@Slf4j -@Order(Ordered.HIGHEST_PRECEDENCE) -@RestControllerAdvice(assignableTypes = SnailAiChatGatewayController.class) -public class SnailAiChatExceptionHandler { - - private static final int AUTHENTICATION_ERROR_STATUS = 5001; - - @ExceptionHandler(SnailAiAuthenticationException.class) - public Result handleAuthenticationException(SnailAiAuthenticationException e) { - log.warn("Snail AI Chat authentication failed: {}", e.getMessage()); - return Result.fail(AUTHENTICATION_ERROR_STATUS, defaultMessage(e, "认证失败,请重新登录")); - } - - @ExceptionHandler(BaseSnailAiException.class) - public Result handleSnailAiException(BaseSnailAiException e) { - log.warn("Snail AI Chat request failed: {}", e.getMessage()); - return Result.fail(defaultMessage(e, "AI 服务请求失败,请稍后再试")); - } - - @ExceptionHandler(ModelCallException.class) - public Result handleModelCallException(ModelCallException e) { - log.warn("Snail AI Chat model call failed: {}", e.getMessage(), e); - return Result.fail(defaultMessage(e, "模型调用失败,请稍后再试")); - } - - @ExceptionHandler({ - BindException.class, - MethodArgumentNotValidException.class, - HandlerMethodValidationException.class - }) - public Result handleValidationException(Exception e) { - log.warn("Snail AI Chat validation failed: {}", e.getMessage()); - return Result.fail(validationMessage(e)); - } - - @ExceptionHandler(ConstraintViolationException.class) - public Result handleConstraintViolationException(ConstraintViolationException e) { - log.warn("Snail AI Chat constraint validation failed: {}", e.getMessage()); - String message = e.getConstraintViolations().stream() - .map(ConstraintViolation::getMessage) - .filter(this::hasText) - .collect(Collectors.joining(", ")); - return Result.fail(hasText(message) ? message : "请求参数校验失败"); - } - - @ExceptionHandler(HttpMessageNotReadableException.class) - public Result handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { - log.warn("Snail AI Chat request body parse failed: {}", e.getMessage()); - return Result.fail("请求参数格式错误"); - } - - @ExceptionHandler(IllegalArgumentException.class) - public Result handleIllegalArgumentException(IllegalArgumentException e) { - log.warn("Snail AI Chat illegal argument: {}", e.getMessage()); - return Result.fail(defaultMessage(e, "请求参数不合法")); - } - - @ExceptionHandler(IllegalStateException.class) - public Result handleIllegalStateException(IllegalStateException e) { - log.warn("Snail AI Chat illegal state: {}", e.getMessage()); - return Result.fail(defaultMessage(e, "AI 会话状态异常,请刷新后重试")); - } - - @ExceptionHandler(Exception.class) - public Result handleException(Exception e) { - log.error("Snail AI Chat unexpected exception", e); - return Result.fail("AI 服务异常,请稍后再试"); - } - - private String validationMessage(Exception e) { - Collection errors; - if (e instanceof BindException bindException) { - errors = bindException.getAllErrors(); - } else if (e instanceof MethodArgumentNotValidException methodArgumentNotValidException) { - errors = methodArgumentNotValidException.getBindingResult().getAllErrors(); - } else if (e instanceof HandlerMethodValidationException handlerMethodValidationException) { - errors = handlerMethodValidationException.getAllErrors(); - } else { - return "请求参数校验失败"; - } - String message = errors.stream() - .map(MessageSourceResolvable::getDefaultMessage) - .filter(this::hasText) - .collect(Collectors.joining(", ")); - return hasText(message) ? message : "请求参数校验失败"; - } - - private String defaultMessage(Throwable e, String fallback) { - return hasText(e.getMessage()) ? e.getMessage() : fallback; - } - - private boolean hasText(String value) { - return value != null && !value.isBlank(); - } -} diff --git a/ruoyi-extend/ruoyi-snailai-server/src/main/java/org/dromara/snailai/SnailAiServerApplication.java b/ruoyi-extend/ruoyi-snailai-server/src/main/java/org/dromara/snailai/SnailAiServerApplication.java index 905efd0d7..bd1844d01 100644 --- a/ruoyi-extend/ruoyi-snailai-server/src/main/java/org/dromara/snailai/SnailAiServerApplication.java +++ b/ruoyi-extend/ruoyi-snailai-server/src/main/java/org/dromara/snailai/SnailAiServerApplication.java @@ -17,7 +17,7 @@ public class SnailAiServerApplication { * @param args 启动参数 */ public static void main(String[] args) { - com.aizuda.snail.ai.starter.SnailAiSpringbootApplication.main(args); + com.aizuda.snail.ai.starter.SnailAiApplication.main(args); } } diff --git a/ruoyi-extend/ruoyi-snailai-server/src/main/resources/application.yml b/ruoyi-extend/ruoyi-snailai-server/src/main/resources/application.yml index ca502ab3b..5f2756db8 100644 --- a/ruoyi-extend/ruoyi-snailai-server/src/main/resources/application.yml +++ b/ruoyi-extend/ruoyi-snailai-server/src/main/resources/application.yml @@ -35,6 +35,24 @@ snail-ai: access-key: minioadmin secret-key: minioadmin bucket: snail-ai + # RAG 文档解析与图片 OCR 配置 + rag: + docling: + enabled: ${SNAIL_AI_RAG_DOCLING_ENABLED:true} + url: ${SNAIL_AI_RAG_DOCLING_URL:http://127.0.0.1:5100} + timeout-seconds: ${SNAIL_AI_RAG_DOCLING_TIMEOUT_SECONDS:300} + concurrency: ${SNAIL_AI_RAG_DOCLING_CONCURRENCY:3} + health-timeout-millis: ${SNAIL_AI_RAG_DOCLING_HEALTH_TIMEOUT_MILLIS:3000} + poll-interval-millis: ${SNAIL_AI_RAG_DOCLING_POLL_INTERVAL_MILLIS:3000} + status-timeout-millis: ${SNAIL_AI_RAG_DOCLING_STATUS_TIMEOUT_MILLIS:10000} + result-timeout-millis: ${SNAIL_AI_RAG_DOCLING_RESULT_TIMEOUT_MILLIS:30000} + max-image-count: ${SNAIL_AI_RAG_DOCLING_MAX_IMAGE_COUNT:100} + max-image-bytes: ${SNAIL_AI_RAG_DOCLING_MAX_IMAGE_BYTES:10485760} + paddle-ocr-enabled: ${SNAIL_AI_RAG_DOCLING_PADDLE_OCR_ENABLED:true} + paddle-ocr-url: ${SNAIL_AI_RAG_DOCLING_PADDLE_OCR_URL:http://127.0.0.1:8866/ocr} + paddle-ocr-timeout-millis: ${SNAIL_AI_RAG_DOCLING_PADDLE_OCR_TIMEOUT_MILLIS:180000} + paddle-ocr-batch-size: ${SNAIL_AI_RAG_DOCLING_PADDLE_OCR_BATCH_SIZE:5} + vision-ocr-fallback-enabled: ${SNAIL_AI_RAG_DOCLING_VISION_OCR_FALLBACK_ENABLED:false} # 短期记忆配置 memory: short-term: diff --git a/script/docker/docker-compose.yml b/script/docker/docker-compose.yml index e44def3c2..9a293c389 100644 --- a/script/docker/docker-compose.yml +++ b/script/docker/docker-compose.yml @@ -162,6 +162,11 @@ services: environment: # 时区上海 TZ: Asia/Shanghai + # Docling 与 PaddleOCR 服务需单独部署;host 网络模式下默认通过宿主机端口访问 + SNAIL_AI_RAG_DOCLING_ENABLED: ${SNAIL_AI_RAG_DOCLING_ENABLED:-true} + SNAIL_AI_RAG_DOCLING_URL: ${SNAIL_AI_RAG_DOCLING_URL:-http://127.0.0.1:5100} + SNAIL_AI_RAG_DOCLING_PADDLE_OCR_ENABLED: ${SNAIL_AI_RAG_DOCLING_PADDLE_OCR_ENABLED:-true} + SNAIL_AI_RAG_DOCLING_PADDLE_OCR_URL: ${SNAIL_AI_RAG_DOCLING_PADDLE_OCR_URL:-http://127.0.0.1:8866/ocr} ports: - "8900:8900" - "18888:18888" diff --git a/script/sql/postgres/postgres_ry_ai.sql b/script/sql/postgres/postgres_ry_ai.sql index cae20faf3..8d45a4d2f 100644 --- a/script/sql/postgres/postgres_ry_ai.sql +++ b/script/sql/postgres/postgres_ry_ai.sql @@ -318,6 +318,7 @@ CREATE TABLE IF NOT EXISTS sai_agent_conversation_record role VARCHAR(16) DEFAULT 'user', content TEXT, thinking TEXT, + metadata TEXT DEFAULT NULL, status INT DEFAULT 1, input_tokens INT DEFAULT 0, output_tokens INT DEFAULT 0, @@ -332,6 +333,7 @@ COMMENT ON COLUMN sai_agent_conversation_record.user_id IS '用户ID'; COMMENT ON COLUMN sai_agent_conversation_record.role IS 'user/assistant'; COMMENT ON COLUMN sai_agent_conversation_record.content IS '消息内容'; COMMENT ON COLUMN sai_agent_conversation_record.thinking IS '思考过程(仅assistant)'; +COMMENT ON COLUMN sai_agent_conversation_record.metadata IS '消息扩展元数据JSON'; COMMENT ON COLUMN sai_agent_conversation_record.status IS '1=成功,2=失败,3=进行中'; COMMENT ON COLUMN sai_agent_conversation_record.input_tokens IS '输入Token数(prompt)'; COMMENT ON COLUMN sai_agent_conversation_record.output_tokens IS '输出Token数(completion)'; @@ -438,6 +440,13 @@ CREATE TABLE sai_rag_document status SMALLINT DEFAULT 0, error_msg TEXT, chunk_count INT DEFAULT 0, + page_count INT DEFAULT 0, + element_count INT DEFAULT 0, + table_count INT DEFAULT 0, + image_count INT DEFAULT 0, + parse_time INT DEFAULT 0, + md_content TEXT DEFAULT NULL, + doc_metadata TEXT DEFAULT NULL, content_hash VARCHAR(64) DEFAULT NULL, resource_id BIGINT DEFAULT NULL, create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -458,6 +467,39 @@ 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); +CREATE TABLE sai_rag_document_image +( + id BIGSERIAL PRIMARY KEY, + rag_id BIGINT NOT NULL, + document_id BIGINT NOT NULL, + chunk_id BIGINT DEFAULT NULL, + resource_id BIGINT DEFAULT NULL, + image_index INT, + image_url VARCHAR(1024), + caption TEXT, + figure_no VARCHAR(64), + figure_title VARCHAR(512), + section_title VARCHAR(512), + source_page INT, + document_name VARCHAR(255), + ocr_text TEXT, + create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE sai_rag_document_image IS 'RAG document parsed image table'; +COMMENT ON COLUMN sai_rag_document_image.resource_id IS 'Linked resource id in sai_resource'; + +CREATE TRIGGER trigger_sai_rag_document_image_update + BEFORE UPDATE ON sai_rag_document_image + FOR EACH ROW + EXECUTE FUNCTION update_timestamp(); + +CREATE INDEX idx_rag_doc_image_rag ON sai_rag_document_image (rag_id); +CREATE INDEX idx_rag_doc_image_document ON sai_rag_document_image (document_id); +CREATE INDEX idx_rag_doc_image_chunk ON sai_rag_document_image (chunk_id); +CREATE INDEX idx_rag_doc_image_resource ON sai_rag_document_image (resource_id); + -- 4.3 RAG 分块表 CREATE TABLE sai_rag_chunk ( @@ -470,11 +512,13 @@ CREATE TABLE sai_rag_chunk token_count INT, vector_id VARCHAR(128), content_hash VARCHAR(64) DEFAULT NULL, + source_type VARCHAR(20) NOT NULL DEFAULT 'TEXT', create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, update_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); COMMENT ON COLUMN sai_rag_chunk.content_hash IS 'chunk内容SHA-256,用于向量去重'; +COMMENT ON COLUMN sai_rag_chunk.source_type IS 'chunk来源类型:TEXT文本、IMAGE图片'; CREATE TRIGGER trigger_sai_rag_chunk_update BEFORE UPDATE ON sai_rag_chunk @@ -484,6 +528,7 @@ CREATE TRIGGER trigger_sai_rag_chunk_update 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 INDEX idx_chunk_rag_source_type ON sai_rag_chunk (rag_id, source_type); -- ============================================================ -- 五、MCP 服务管理 @@ -501,11 +546,8 @@ CREATE TABLE IF NOT EXISTS sai_mcp_server command VARCHAR(1024), args TEXT, env_vars TEXT, - version VARCHAR(32) DEFAULT '1.0.0', - auth_type SMALLINT DEFAULT 0, - auth_config TEXT, - status SMALLINT DEFAULT 0, - capabilities TEXT, + timeout BIGINT DEFAULT 60000, + headers TEXT, last_connect_dt TIMESTAMP NULL, creator_id BIGINT, create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -521,11 +563,8 @@ COMMENT ON COLUMN sai_mcp_server.endpoint IS '端点路径(SSE/Streamable HTTP COMMENT ON COLUMN sai_mcp_server.command IS 'Stdio命令(Stdio时必填)'; COMMENT ON COLUMN sai_mcp_server.args IS 'Stdio命令参数(JSON数组)'; COMMENT ON COLUMN sai_mcp_server.env_vars IS 'Stdio环境变量(JSON对象)'; -COMMENT ON COLUMN sai_mcp_server.version IS '版本'; -COMMENT ON COLUMN sai_mcp_server.auth_type IS '认证方式: 0-无需认证 1-API Key 2-OAuth 3-Basic Auth'; -COMMENT ON COLUMN sai_mcp_server.auth_config IS '认证配置(JSON)'; -COMMENT ON COLUMN sai_mcp_server.status IS '状态: 0-未连接 1-已连接 2-异常'; -COMMENT ON COLUMN sai_mcp_server.capabilities IS '能力列表(JSON数组)'; +COMMENT ON COLUMN sai_mcp_server.timeout IS '超时时间(毫秒)'; +COMMENT ON COLUMN sai_mcp_server.headers IS '请求头(JSON对象)'; COMMENT ON COLUMN sai_mcp_server.last_connect_dt IS '最后连接时间'; COMMENT ON COLUMN sai_mcp_server.creator_id IS '创建者用户ID'; @@ -535,7 +574,6 @@ CREATE TRIGGER trigger_sai_mcp_server_update EXECUTE FUNCTION update_timestamp(); CREATE INDEX idx_mcp_server_creator ON sai_mcp_server (creator_id); -CREATE INDEX idx_mcp_server_status ON sai_mcp_server (status); -- 5.2 智能体与MCP服务关联表(多对多) CREATE TABLE IF NOT EXISTS sai_agent_mcp_server diff --git a/script/sql/ry_ai.sql b/script/sql/ry_ai.sql index 76f1c9ec2..2249d115e 100644 --- a/script/sql/ry_ai.sql +++ b/script/sql/ry_ai.sql @@ -190,6 +190,7 @@ CREATE TABLE IF NOT EXISTS sai_agent_conversation_record role VARCHAR(16) DEFAULT 'user' COMMENT 'user/assistant', content TEXT COMMENT '消息内容', thinking TEXT COMMENT '思考过程(仅assistant)', + metadata LONGTEXT DEFAULT NULL COMMENT '消息扩展元数据JSON', status INT DEFAULT 1 COMMENT '1=成功,2=失败,3=进行中', input_tokens INT DEFAULT 0 COMMENT '输入Token数(prompt)', output_tokens INT DEFAULT 0 COMMENT '输出Token数(completion)', @@ -280,6 +281,13 @@ CREATE TABLE sai_rag_document status TINYINT(1) DEFAULT 0 COMMENT '状态: 0-待处理 1-解析中 2-处理中 3-处理完成 4-处理失败', error_msg TEXT, chunk_count INT DEFAULT 0, + page_count INT DEFAULT 0 COMMENT 'Docling parse page count', + element_count INT DEFAULT 0 COMMENT 'Docling parse element count', + table_count INT DEFAULT 0 COMMENT 'Docling parse table count', + image_count INT DEFAULT 0 COMMENT 'Docling parse image count', + parse_time INT DEFAULT 0 COMMENT 'Parse duration in milliseconds', + md_content LONGTEXT DEFAULT NULL COMMENT 'Docling markdown content', + doc_metadata LONGTEXT DEFAULT NULL COMMENT 'Docling document metadata JSON', content_hash VARCHAR(64) DEFAULT NULL COMMENT '文件内容SHA-256哈希,用于去重', resource_id BIGINT DEFAULT NULL COMMENT '关联资源库 sai_resource.id', create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -293,6 +301,33 @@ 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); +CREATE TABLE sai_rag_document_image +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + rag_id BIGINT NOT NULL, + document_id BIGINT NOT NULL, + chunk_id BIGINT DEFAULT NULL, + resource_id BIGINT DEFAULT NULL, + image_index INT, + image_url VARCHAR(1024), + caption TEXT, + figure_no VARCHAR(64), + figure_title VARCHAR(512), + section_title VARCHAR(512), + source_page INT, + document_name VARCHAR(255), + ocr_text TEXT, + 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_image_rag ON sai_rag_document_image (rag_id); +CREATE INDEX idx_rag_doc_image_document ON sai_rag_document_image (document_id); +CREATE INDEX idx_rag_doc_image_chunk ON sai_rag_document_image (chunk_id); +CREATE INDEX idx_rag_doc_image_resource ON sai_rag_document_image (resource_id); + -- 4.3 RAG 分块表 CREATE TABLE sai_rag_chunk ( @@ -305,6 +340,7 @@ CREATE TABLE sai_rag_chunk token_count INT, vector_id VARCHAR(128), content_hash VARCHAR(64) DEFAULT NULL COMMENT 'chunk内容SHA-256,用于向量去重', + source_type VARCHAR(20) NOT NULL DEFAULT 'TEXT' COMMENT 'chunk来源类型:TEXT文本、IMAGE图片', create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, update_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE = InnoDB @@ -314,6 +350,7 @@ CREATE TABLE sai_rag_chunk 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 INDEX idx_chunk_rag_source_type ON sai_rag_chunk (rag_id, source_type); -- ============================================================ @@ -332,11 +369,8 @@ CREATE TABLE IF NOT EXISTS sai_mcp_server 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数组)', + timeout BIGINT DEFAULT 60000 COMMENT '超时时间(毫秒)', + headers TEXT COMMENT '请求头(JSON对象)', last_connect_dt TIMESTAMP NULL COMMENT '最后连接时间', creator_id BIGINT COMMENT '创建者用户ID', create_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -346,7 +380,6 @@ CREATE TABLE IF NOT EXISTS sai_mcp_server 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); -- 5.2 智能体与MCP服务关联表(多对多) CREATE TABLE IF NOT EXISTS sai_agent_mcp_server