From 5d14eb20af1e55e0549575c18dba3643a3f7b4a6 Mon Sep 17 00:00:00 2001 From: octopus Date: Sat, 21 Mar 2026 16:14:19 +0800 Subject: [PATCH 01/17] feat: add MiniMax as first-class LLM provider Add MiniMax AI as the 7th LLM provider, supporting chat (M2.7, M2.5, M2.5-highspeed) and embedding (embo-01) models via OpenAI-compatible API. Changes: - Add MINIMAX enum to ChatModeType - Add MinimaxServiceImpl chat provider (OpenAI-compat streaming) - Add MinimaxEmbeddingProvider for vector embeddings - Add SQL migration for provider and model registration - Add 14 unit tests + 3 integration tests - Update README/README_EN with MiniMax in provider list --- README.md | 2 +- README_EN.md | 2 +- docs/script/sql/minimax_provider.sql | 23 ++++++ ruoyi-modules/ruoyi-chat/pom.xml | 17 +++++ .../java/org/ruoyi/enums/ChatModeType.java | 3 +- .../impl/provider/MinimaxServiceImpl.java | 40 ++++++++++ .../embed/impl/MinimaxEmbeddingProvider.java | 17 +++++ .../org/ruoyi/enums/ChatModeTypeTest.java | 42 ++++++++++ .../integration/MinimaxIntegrationTest.java | 70 +++++++++++++++++ .../impl/provider/MinimaxServiceImplTest.java | 76 +++++++++++++++++++ .../impl/MinimaxEmbeddingProviderTest.java | 55 ++++++++++++++ 11 files changed, 344 insertions(+), 3 deletions(-) create mode 100644 docs/script/sql/minimax_provider.sql create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/MinimaxEmbeddingProvider.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/enums/ChatModeTypeTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/integration/MinimaxIntegrationTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImplTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/embed/impl/MinimaxEmbeddingProviderTest.java diff --git a/README.md b/README.md index e1d8fa6a..4900b012 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ | 模块 | 现有能力 |:----------:|--- -| **模型管理** | 多模型接入(OpenAI/DeepSeek/通义/智谱)、多模态理解、Coze/DIFY/FastGPT平台集成 +| **模型管理** | 多模型接入(OpenAI/DeepSeek/通义/智谱/MiniMax)、多模态理解、Coze/DIFY/FastGPT平台集成 | **知识管理** | 本地RAG + 向量库(Milvus/Weaviate) + 文档解析 | **工具管理** | Mcp协议集成、Skills能力 + 可扩展工具生态 | **流程编排** | 可视化工作流设计器、节点拖拽编排、SSE流式执行,目前已经支持模型调用,邮件发送,人工审核等节点 diff --git a/README_EN.md b/README_EN.md index edd13832..d2be455d 100644 --- a/README_EN.md +++ b/README_EN.md @@ -34,7 +34,7 @@ | Module | Current Capabilities | |:---:|---| -| **Model Management** | Multi-model integration (OpenAI/DeepSeek/Tongyi/Zhipu), multi-modal understanding, Coze/DIFY/FastGPT platform integration | +| **Model Management** | Multi-model integration (OpenAI/DeepSeek/Tongyi/Zhipu/MiniMax), multi-modal understanding, Coze/DIFY/FastGPT platform integration | | **Knowledge Base** | Local RAG + Vector DB (Milvus/Weaviate) + Document parsing | | **Tool Management** | MCP protocol integration, Skills capability + Extensible tool ecosystem | | **Workflow Orchestration** | Visual workflow designer, drag-and-drop node orchestration, SSE streaming execution, currently supports model calls, email sending, manual review nodes | diff --git a/docs/script/sql/minimax_provider.sql b/docs/script/sql/minimax_provider.sql new file mode 100644 index 00000000..e7331aa2 --- /dev/null +++ b/docs/script/sql/minimax_provider.sql @@ -0,0 +1,23 @@ +-- ---------------------------- +-- Add MiniMax provider +-- ---------------------------- +INSERT INTO `chat_provider` (`id`, `provider_name`, `provider_code`, `provider_icon`, `provider_desc`, `api_host`, `status`, `sort_order`, `create_dept`, `create_time`, `create_by`, `update_by`, `update_time`, `remark`, `version`, `del_flag`, `update_ip`, `tenant_id`) +VALUES (2010000000000000001, 'MiniMax', 'minimax', NULL, 'MiniMax大模型服务,支持M2.7、M2.5等模型', 'https://api.minimax.io/v1', '0', 6, NULL, NOW(), '1', '1', NOW(), 'MiniMax厂商', NULL, '0', NULL, 0); + +-- ---------------------------- +-- Add MiniMax chat models +-- ---------------------------- +INSERT INTO `chat_model` (`id`, `category`, `model_name`, `provider_code`, `model_describe`, `model_dimension`, `model_show`, `api_host`, `api_key`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`, `tenant_id`) +VALUES (2010000000000000002, 'chat', 'MiniMax-M2.7', 'minimax', 'MiniMax-M2.7', NULL, 'Y', 'https://api.minimax.io/v1', '', NULL, 1, NOW(), 1, NOW(), 'MiniMax最新旗舰模型M2.7,支持1M上下文窗口', 0); + +INSERT INTO `chat_model` (`id`, `category`, `model_name`, `provider_code`, `model_describe`, `model_dimension`, `model_show`, `api_host`, `api_key`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`, `tenant_id`) +VALUES (2010000000000000003, 'chat', 'MiniMax-M2.5', 'minimax', 'MiniMax-M2.5', NULL, 'Y', 'https://api.minimax.io/v1', '', NULL, 1, NOW(), 1, NOW(), 'MiniMax M2.5模型,204K上下文窗口', 0); + +INSERT INTO `chat_model` (`id`, `category`, `model_name`, `provider_code`, `model_describe`, `model_dimension`, `model_show`, `api_host`, `api_key`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`, `tenant_id`) +VALUES (2010000000000000004, 'chat', 'MiniMax-M2.5-highspeed', 'minimax', 'MiniMax-M2.5-highspeed', NULL, 'Y', 'https://api.minimax.io/v1', '', NULL, 1, NOW(), 1, NOW(), 'MiniMax M2.5高速版,204K上下文窗口,更低延迟', 0); + +-- ---------------------------- +-- Add MiniMax embedding model +-- ---------------------------- +INSERT INTO `chat_model` (`id`, `category`, `model_name`, `provider_code`, `model_describe`, `model_dimension`, `model_show`, `api_host`, `api_key`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`, `tenant_id`) +VALUES (2010000000000000005, 'vector', 'embo-01', 'minimax', 'embo-01', 1536, 'N', 'https://api.minimax.io/v1', '', NULL, 1, NOW(), 1, NOW(), 'MiniMax embo-01嵌入模型,1536维度', 0); diff --git a/ruoyi-modules/ruoyi-chat/pom.xml b/ruoyi-modules/ruoyi-chat/pom.xml index ba2f10c0..c44af49b 100644 --- a/ruoyi-modules/ruoyi-chat/pom.xml +++ b/ruoyi-modules/ruoyi-chat/pom.xml @@ -146,6 +146,23 @@ mysql-connector-j + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + + diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java index c07d9444..9b70b91c 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java @@ -15,7 +15,8 @@ public enum ChatModeType { DEEP_SEEK("deepseek", "深度求索"), QIAN_WEN("qianwen", "通义千问"), OPEN_AI("openai", "openai"), - PPIO("ppio", "ppio"); + PPIO("ppio", "ppio"), + MINIMAX("minimax", "MiniMax"); private final String code; private final String description; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImpl.java new file mode 100644 index 00000000..81df24c5 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImpl.java @@ -0,0 +1,40 @@ +package org.ruoyi.service.chat.impl.provider; + +import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.openai.OpenAiStreamingChatModel; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.chat.domain.dto.request.ChatRequest; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.enums.ChatModeType; +import org.ruoyi.service.chat.AbstractChatService; +import org.springframework.stereotype.Service; + +/** + * MiniMax服务调用 + *

+ * MiniMax提供OpenAI兼容的API接口,支持MiniMax-M2.7、MiniMax-M2.5等模型。 + * API地址:https://api.minimax.io/v1 + * + * @author octopus + * @date 2026/3/21 + */ +@Service +@Slf4j +public class MinimaxServiceImpl implements AbstractChatService { + + @Override + public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) { + return OpenAiStreamingChatModel.builder() + .baseUrl(chatModelVo.getApiHost()) + .apiKey(chatModelVo.getApiKey()) + .modelName(chatModelVo.getModelName()) + .returnThinking(chatRequest.getEnableThinking()) + .build(); + } + + @Override + public String getProviderName() { + return ChatModeType.MINIMAX.getCode(); + } + +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/MinimaxEmbeddingProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/MinimaxEmbeddingProvider.java new file mode 100644 index 00000000..4d2dbec5 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/MinimaxEmbeddingProvider.java @@ -0,0 +1,17 @@ +package org.ruoyi.service.embed.impl; + +import org.springframework.stereotype.Component; + +/** + * MiniMax嵌入模型(兼容OpenAI接口) + *

+ * 支持embo-01模型,1536维度向量。 + * API地址:https://api.minimax.io/v1 + * + * @author octopus + * @date 2026/3/21 + */ +@Component("minimax") +public class MinimaxEmbeddingProvider extends OpenAiEmbeddingProvider { + +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/enums/ChatModeTypeTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/enums/ChatModeTypeTest.java new file mode 100644 index 00000000..9733af8b --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/enums/ChatModeTypeTest.java @@ -0,0 +1,42 @@ +package org.ruoyi.enums; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for ChatModeType enum + */ +class ChatModeTypeTest { + + @Test + void minimaxEnumExists() { + ChatModeType minimax = ChatModeType.MINIMAX; + assertNotNull(minimax); + } + + @Test + void minimaxCode_isMinimax() { + assertEquals("minimax", ChatModeType.MINIMAX.getCode()); + } + + @Test + void minimaxDescription_isMiniMax() { + assertEquals("MiniMax", ChatModeType.MINIMAX.getDescription()); + } + + @Test + void allProviders_haveUniqueCode() { + ChatModeType[] values = ChatModeType.values(); + long uniqueCodes = java.util.Arrays.stream(values) + .map(ChatModeType::getCode) + .distinct() + .count(); + assertEquals(values.length, uniqueCodes, "All providers must have unique codes"); + } + + @Test + void valueOf_minimax() { + assertEquals(ChatModeType.MINIMAX, ChatModeType.valueOf("MINIMAX")); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/integration/MinimaxIntegrationTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/integration/MinimaxIntegrationTest.java new file mode 100644 index 00000000..7e052e6d --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/integration/MinimaxIntegrationTest.java @@ -0,0 +1,70 @@ +package org.ruoyi.integration; + +import dev.langchain4j.model.chat.StreamingChatModel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.ruoyi.common.chat.domain.dto.request.ChatRequest; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.service.chat.impl.provider.MinimaxServiceImpl; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests for MiniMax provider. + * These tests require a valid MINIMAX_API_KEY environment variable. + */ +@EnabledIfEnvironmentVariable(named = "MINIMAX_API_KEY", matches = ".+") +class MinimaxIntegrationTest { + + private MinimaxServiceImpl minimaxService; + private String apiKey; + + @BeforeEach + void setUp() { + minimaxService = new MinimaxServiceImpl(); + apiKey = System.getenv("MINIMAX_API_KEY"); + } + + @Test + void buildStreamingChatModel_withRealApiKey_M27() { + ChatModelVo modelVo = new ChatModelVo(); + modelVo.setApiHost("https://api.minimax.io/v1"); + modelVo.setApiKey(apiKey); + modelVo.setModelName("MiniMax-M2.7"); + + ChatRequest request = new ChatRequest(); + request.setEnableThinking(false); + + StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request); + assertNotNull(model, "Should create streaming model with real API key"); + } + + @Test + void buildStreamingChatModel_withRealApiKey_M25() { + ChatModelVo modelVo = new ChatModelVo(); + modelVo.setApiHost("https://api.minimax.io/v1"); + modelVo.setApiKey(apiKey); + modelVo.setModelName("MiniMax-M2.5"); + + ChatRequest request = new ChatRequest(); + request.setEnableThinking(false); + + StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request); + assertNotNull(model, "Should create streaming model with M2.5"); + } + + @Test + void buildStreamingChatModel_withRealApiKey_M25Highspeed() { + ChatModelVo modelVo = new ChatModelVo(); + modelVo.setApiHost("https://api.minimax.io/v1"); + modelVo.setApiKey(apiKey); + modelVo.setModelName("MiniMax-M2.5-highspeed"); + + ChatRequest request = new ChatRequest(); + request.setEnableThinking(false); + + StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request); + assertNotNull(model, "Should create streaming model with M2.5-highspeed"); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImplTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImplTest.java new file mode 100644 index 00000000..697f52e2 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImplTest.java @@ -0,0 +1,76 @@ +package org.ruoyi.service.chat.impl.provider; + +import dev.langchain4j.model.chat.StreamingChatModel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.ruoyi.common.chat.domain.dto.request.ChatRequest; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.enums.ChatModeType; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for MinimaxServiceImpl + */ +class MinimaxServiceImplTest { + + private MinimaxServiceImpl minimaxService; + + @BeforeEach + void setUp() { + minimaxService = new MinimaxServiceImpl(); + } + + @Test + void getProviderName_returnsMinimaxCode() { + assertEquals("minimax", minimaxService.getProviderName()); + assertEquals(ChatModeType.MINIMAX.getCode(), minimaxService.getProviderName()); + } + + @Test + void buildStreamingChatModel_returnsNonNull() { + ChatModelVo modelVo = new ChatModelVo(); + modelVo.setApiHost("https://api.minimax.io/v1"); + modelVo.setApiKey("test-api-key"); + modelVo.setModelName("MiniMax-M2.7"); + + ChatRequest request = new ChatRequest(); + request.setEnableThinking(false); + + StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request); + assertNotNull(model); + } + + @Test + void buildStreamingChatModel_withThinkingEnabled() { + ChatModelVo modelVo = new ChatModelVo(); + modelVo.setApiHost("https://api.minimax.io/v1"); + modelVo.setApiKey("test-api-key"); + modelVo.setModelName("MiniMax-M2.5"); + + ChatRequest request = new ChatRequest(); + request.setEnableThinking(true); + + StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request); + assertNotNull(model); + } + + @Test + void buildStreamingChatModel_withHighspeedModel() { + ChatModelVo modelVo = new ChatModelVo(); + modelVo.setApiHost("https://api.minimax.io/v1"); + modelVo.setApiKey("test-api-key"); + modelVo.setModelName("MiniMax-M2.5-highspeed"); + + ChatRequest request = new ChatRequest(); + request.setEnableThinking(false); + + StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request); + assertNotNull(model); + } + + @Test + void implementsAbstractChatService() { + assertInstanceOf(org.ruoyi.service.chat.AbstractChatService.class, minimaxService); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/embed/impl/MinimaxEmbeddingProviderTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/embed/impl/MinimaxEmbeddingProviderTest.java new file mode 100644 index 00000000..e089aae1 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/embed/impl/MinimaxEmbeddingProviderTest.java @@ -0,0 +1,55 @@ +package org.ruoyi.service.embed.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.enums.ModalityType; +import org.ruoyi.service.embed.BaseEmbedModelService; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for MinimaxEmbeddingProvider + */ +class MinimaxEmbeddingProviderTest { + + private MinimaxEmbeddingProvider provider; + + @BeforeEach + void setUp() { + provider = new MinimaxEmbeddingProvider(); + } + + @Test + void implementsBaseEmbedModelService() { + assertInstanceOf(BaseEmbedModelService.class, provider); + } + + @Test + void extendsOpenAiEmbeddingProvider() { + assertInstanceOf(OpenAiEmbeddingProvider.class, provider); + } + + @Test + void getSupportedModalities_returnsText() { + Set modalities = provider.getSupportedModalities(); + assertNotNull(modalities); + assertTrue(modalities.contains(ModalityType.TEXT)); + assertEquals(1, modalities.size()); + } + + @Test + void configure_setsModelConfig() { + ChatModelVo config = new ChatModelVo(); + config.setApiHost("https://api.minimax.io/v1"); + config.setApiKey("test-api-key"); + config.setModelName("embo-01"); + config.setModelDimension(1536); + + provider.configure(config); + // configure sets internal state; verify no exception thrown + assertNotNull(provider); + } +} From b9097b49897156f5f76271d84bf2a94aabcd2ecb Mon Sep 17 00:00:00 2001 From: MrWws <1223645048@qq.com> Date: Tue, 31 Mar 2026 22:59:26 +0800 Subject: [PATCH 02/17] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=BF=90?= =?UTF-8?q?=E8=A1=8Cdocker-compose-all.yaml=E6=8A=A5=E9=94=99=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/docker/ruoyi-ai/docker-compose-all.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docker/ruoyi-ai/docker-compose-all.yaml b/docs/docker/ruoyi-ai/docker-compose-all.yaml index 886d5ec6..b9f330c1 100644 --- a/docs/docker/ruoyi-ai/docker-compose-all.yaml +++ b/docs/docker/ruoyi-ai/docker-compose-all.yaml @@ -65,7 +65,7 @@ services: - "28080:8080" environment: QUERY_DEFAULTS_LIMIT: 25 - AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: true + AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: "true" PERSISTENCE_DATA_PATH: /var/lib/weaviate DEFAULT_VECTORIZER_MODULE: none ENABLE_MODULES: text2vec-cohere,text2vec-huggingface,text2vec-palm,text2vec-openai,generative-openai,generative-cohere,generative-palm,ref2vec-centroid,reranker-cohere,qna-openai From ef99c540bb9ada7e2ae6010650e64f57ca63760a Mon Sep 17 00:00:00 2001 From: evo <446796145@qq.com> Date: Wed, 1 Apr 2026 22:32:01 +0800 Subject: [PATCH 03/17] =?UTF-8?q?feat=EF=BC=9A=E5=A2=9E=E5=8A=A0=E5=8F=AF?= =?UTF-8?q?=E8=A7=82=E6=B5=8B=E6=80=A7=E7=9A=84=E7=9B=B8=E5=85=B3=E7=9B=91?= =?UTF-8?q?=E5=90=AC=E5=99=A8=20&=20=E4=BF=AE=E5=A4=8D=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E9=97=AE=E7=AD=94=E6=8A=A5=E9=94=99outputkey=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/ruoyi/RuoYiAIApplication.java | 61 +++++++- .../org/ruoyi/controller/AuthController.java | 2 +- .../org/ruoyi/agent/ChartGenerationAgent.java | 2 +- .../java/org/ruoyi/agent/EchartsAgent.java | 4 +- .../main/java/org/ruoyi/agent/SqlAgent.java | 2 +- .../java/org/ruoyi/agent/WebSearchAgent.java | 2 +- .../ChatModelListenerProvider.java | 41 ++++++ .../EmbeddingModelListenerProvider.java | 34 +++++ .../LangChain4jObservabilityConfig.java | 129 +++++++++++++++++ .../ruoyi/observability/MyAgentListener.java | 130 +++++++++++++++++ .../MyAiServiceCompletedListener.java | 41 ++++++ .../MyAiServiceErrorListener.java | 33 +++++ .../MyAiServiceRequestIssuedListener.java | 33 +++++ .../MyAiServiceResponseReceivedListener.java | 37 +++++ .../MyAiServiceStartedListener.java | 38 +++++ .../observability/MyChatModelListener.java | 43 ++++++ .../MyEmbeddingModelListener.java | 47 ++++++ .../MyInputGuardrailExecutedListener.java | 45 ++++++ .../observability/MyMcpClientListener.java | 136 ++++++++++++++++++ .../MyOutputGuardrailExecutedListener.java | 42 ++++++ .../MyToolExecutedEventListener.java | 38 +++++ .../service/chat/impl/ChatServiceFacade.java | 5 + .../impl/provider/DeepseekServiceImpl.java | 14 +- .../chat/impl/provider/OllamaServiceImpl.java | 7 + .../chat/impl/provider/OpenAIServiceImpl.java | 8 +- .../chat/impl/provider/PPIOServiceImpl.java | 9 +- .../impl/provider/QianWenChatServiceImpl.java | 8 +- .../impl/provider/ZhiPuChatServiceImpl.java | 7 + .../impl/AliBaiLianBaseEmbedProvider.java | 20 ++- .../embed/impl/OllamaEmbeddingProvider.java | 20 ++- .../embed/impl/OpenAiEmbeddingProvider.java | 19 ++- 31 files changed, 1034 insertions(+), 23 deletions(-) create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/ChatModelListenerProvider.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/EmbeddingModelListenerProvider.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/LangChain4jObservabilityConfig.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAgentListener.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAiServiceCompletedListener.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAiServiceErrorListener.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAiServiceRequestIssuedListener.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAiServiceResponseReceivedListener.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAiServiceStartedListener.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyChatModelListener.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyEmbeddingModelListener.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyInputGuardrailExecutedListener.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyMcpClientListener.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyOutputGuardrailExecutedListener.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyToolExecutedEventListener.java diff --git a/ruoyi-admin/src/main/java/org/ruoyi/RuoYiAIApplication.java b/ruoyi-admin/src/main/java/org/ruoyi/RuoYiAIApplication.java index 8363c957..1ce9207c 100644 --- a/ruoyi-admin/src/main/java/org/ruoyi/RuoYiAIApplication.java +++ b/ruoyi-admin/src/main/java/org/ruoyi/RuoYiAIApplication.java @@ -4,6 +4,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup; +import java.net.InetSocketAddress; +import java.net.ServerSocket; + /** * 启动程序 * @@ -13,10 +16,66 @@ import org.springframework.boot.context.metrics.buffering.BufferingApplicationSt public class RuoYiAIApplication { public static void main(String[] args) { + killPortProcess(6039); SpringApplication application = new SpringApplication(RuoYiAIApplication.class); application.setApplicationStartup(new BufferingApplicationStartup(2048)); application.run(args); - System.out.println("(♥◠‿◠)ノ゙ RuoYi-AI启动成功 ლ(´ڡ`ლ)゙"); + System.out.println("(♥◠‿◠)ノ゙ RuoYi-AI启动成功 ლ(´ڡ`ლ)冢"); + } + + /** + * 检查并终止占用指定端口的进程 + * + * @param port 端口号 + */ + private static void killPortProcess(int port) { + try { + if (!isPortInUse(port)) { + return; + } + System.out.println("端口 " + port + " 已被占用,正在查找并终止进程..."); + + ProcessBuilder pb = new ProcessBuilder("netstat", "-ano"); + Process process = pb.start(); + java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(process.getInputStream())); + + String line; + while ((line = reader.readLine()) != null) { + if (line.contains(":" + port + " ") && line.contains("LISTENING")) { + String[] parts = line.trim().split("\\s+"); + String pid = parts[parts.length - 1]; + System.out.println("找到占用端口 " + port + " 的进程 PID: " + pid + ",正在终止..."); + + ProcessBuilder killPb = new ProcessBuilder("taskkill", "/F", "/PID", pid); + Process killProcess = killPb.start(); + int exitCode = killProcess.waitFor(); + if (exitCode == 0) { + System.out.println("进程 " + pid + " 已成功终止"); + } else { + System.out.println("终止进程 " + pid + " 失败,exitCode: " + exitCode); + } + break; + } + } + + // 等待一小段时间确保端口释放 + Thread.sleep(500); + } catch (Exception e) { + System.out.println("检查/终止端口进程时发生异常: " + e.getMessage()); + } + } + + /** + * 检查端口是否被占用 + */ + private static boolean isPortInUse(int port) { + try (ServerSocket socket = new ServerSocket()) { + socket.bind(new InetSocketAddress(port)); + return false; + } catch (Exception e) { + return true; + } } } diff --git a/ruoyi-admin/src/main/java/org/ruoyi/controller/AuthController.java b/ruoyi-admin/src/main/java/org/ruoyi/controller/AuthController.java index 7a801355..655821da 100644 --- a/ruoyi-admin/src/main/java/org/ruoyi/controller/AuthController.java +++ b/ruoyi-admin/src/main/java/org/ruoyi/controller/AuthController.java @@ -76,7 +76,7 @@ public class AuthController { @PostMapping("/login") public R login(@RequestBody String body) { LoginBody loginBody = JsonUtils.parseObject(body, LoginBody.class); - ValidatorUtils.validate(loginBody); +// ValidatorUtils.validate(loginBody); // 授权类型和客户端id String clientId = loginBody.getClientId(); String grantType = loginBody.getGrantType(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/ChartGenerationAgent.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/ChartGenerationAgent.java index ab61ee62..2de1fcea 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/ChartGenerationAgent.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/ChartGenerationAgent.java @@ -6,7 +6,7 @@ import dev.langchain4j.service.UserMessage; import dev.langchain4j.service.V; -public interface ChartGenerationAgent extends Agent { +public interface ChartGenerationAgent { @SystemMessage(""" You are a chart generation specialist. Your only task is to generate Apache ECharts diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/EchartsAgent.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/EchartsAgent.java index 436220fe..82faf524 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/EchartsAgent.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/EchartsAgent.java @@ -14,7 +14,7 @@ public interface EchartsAgent { @SystemMessage(""" You are a data visualization assistant that generates Echarts chart configurations. - + CRITICAL OUTPUT REQUIREMENTS: - Return Echarts JSON wrapped in markdown code block - Use this exact format: ```json\n{...}\n``` @@ -81,7 +81,7 @@ public interface EchartsAgent { """) @UserMessage(""" Generate an Echarts chart for: {{query}} - + IMPORTANT: Return the Echarts configuration JSON wrapped in markdown code block (```json...```). """) @Agent("Data visualization assistant that returns Echarts JSON configurations for frontend rendering") diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/SqlAgent.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/SqlAgent.java index 77b8e1ab..e21b61c1 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/SqlAgent.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/SqlAgent.java @@ -11,7 +11,7 @@ import dev.langchain4j.service.V; * and returning relevant data and analysis results. * */ -public interface SqlAgent extends Agent { +public interface SqlAgent { @SystemMessage(""" This agent is designed for MySQL 5.7 diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/WebSearchAgent.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/WebSearchAgent.java index 970e3a2b..33f8737e 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/WebSearchAgent.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/WebSearchAgent.java @@ -10,7 +10,7 @@ import dev.langchain4j.service.V; * A web search assistant that answers natural language questions by searching the internet * and returning relevant information from web pages. */ -public interface WebSearchAgent extends Agent { +public interface WebSearchAgent { @SystemMessage(""" You are a web search assistant. Answer questions by searching and retrieving web content. diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/ChatModelListenerProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/ChatModelListenerProvider.java new file mode 100644 index 00000000..cb791740 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/ChatModelListenerProvider.java @@ -0,0 +1,41 @@ +package org.ruoyi.observability; + +import cn.hutool.core.collection.CollUtil; +import dev.langchain4j.model.chat.listener.ChatModelListener; +import dev.langchain4j.model.embedding.listener.EmbeddingModelListener; +import lombok.Getter; +import org.springframework.context.annotation.Lazy; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; + +/** + * LangChain4j 监听器共享提供者。 + *

+ * 供所有 {@link dev.langchain4j.model.chat.StreamingChatModel} 构建器使用, + * 将可观测性监听器注入到模型实例中。 + * + * @author evo + */ +@Component +@Getter +@Lazy +public class ChatModelListenerProvider { + + private final List chatModelListeners; + private final List embeddingModelListeners; + + public ChatModelListenerProvider(@Nullable List chatModelListeners, + @Nullable List embeddingModelListeners) { + if (CollUtil.isEmpty(chatModelListeners)) { + chatModelListeners = Collections.emptyList(); + } + if (CollUtil.isEmpty(embeddingModelListeners)) { + embeddingModelListeners = Collections.emptyList(); + } + this.chatModelListeners = chatModelListeners; + this.embeddingModelListeners = embeddingModelListeners; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/EmbeddingModelListenerProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/EmbeddingModelListenerProvider.java new file mode 100644 index 00000000..c404bd10 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/EmbeddingModelListenerProvider.java @@ -0,0 +1,34 @@ +package org.ruoyi.observability; + +import cn.hutool.core.collection.CollUtil; +import dev.langchain4j.model.embedding.listener.EmbeddingModelListener; +import lombok.Getter; +import org.springframework.context.annotation.Lazy; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; + +/** + * EmbeddingModel 监听器共享提供者。 + *

+ * 供所有 {@link dev.langchain4j.model.embedding.EmbeddingModel} 构建器使用, + * 将可观测性监听器注入到模型实例中。 + * + * @author evo + */ +@Component +@Getter +@Lazy +public class EmbeddingModelListenerProvider { + + private final List embeddingModelListeners; + + public EmbeddingModelListenerProvider(@Nullable List embeddingModelListeners) { + if (CollUtil.isEmpty(embeddingModelListeners)) { + embeddingModelListeners = Collections.emptyList(); + } + this.embeddingModelListeners = embeddingModelListeners; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/LangChain4jObservabilityConfig.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/LangChain4jObservabilityConfig.java new file mode 100644 index 00000000..3ada96b0 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/LangChain4jObservabilityConfig.java @@ -0,0 +1,129 @@ +package org.ruoyi.observability; + +import dev.langchain4j.Experimental; +import dev.langchain4j.mcp.client.McpClientListener; +import dev.langchain4j.model.chat.listener.ChatModelListener; +import dev.langchain4j.model.embedding.listener.EmbeddingModelListener; +import dev.langchain4j.observability.api.AiServiceListenerRegistrar; +import dev.langchain4j.observability.api.listener.*; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +/** + * LangChain4j 可观测性配置类。 + *

+ * 负责注册所有 langchain4j 的监听器: + *

+ * + * @author evo + */ +@Configuration +@RequiredArgsConstructor +@Slf4j +public class LangChain4jObservabilityConfig { + + private final AiServiceListenerRegistrar registrar = AiServiceListenerRegistrar.newInstance(); + + /** + * 注册 AI 服务级别的事件监听器 + */ + @PostConstruct + public void registerAiServiceListeners() { + log.info("正在注册 LangChain4j AI Service 事件监听器..."); + registrar.register( + new MyAiServiceStartedListener(), + new MyAiServiceRequestIssuedListener(), + new MyAiServiceResponseReceivedListener(), + new MyAiServiceCompletedListener(), + new MyAiServiceErrorListener(), + new MyInputGuardrailExecutedListener(), + new MyOutputGuardrailExecutedListener(), + new MyToolExecutedEventListener() + ); + log.info("LangChain4j AI Service 事件监听器注册完成"); + } + + // ==================== AI Service 监听器 Beans ==================== + + @Bean + public AiServiceStartedListener aiServiceStartedListener() { + return new MyAiServiceStartedListener(); + } + + @Bean + public AiServiceRequestIssuedListener aiServiceRequestIssuedListener() { + return new MyAiServiceRequestIssuedListener(); + } + + @Bean + public AiServiceResponseReceivedListener aiServiceResponseReceivedListener() { + return new MyAiServiceResponseReceivedListener(); + } + + @Bean + public AiServiceCompletedListener aiServiceCompletedListener() { + return new MyAiServiceCompletedListener(); + } + + @Bean + public AiServiceErrorListener aiServiceErrorListener() { + return new MyAiServiceErrorListener(); + } + + @Bean + public InputGuardrailExecutedListener inputGuardrailExecutedListener() { + return new MyInputGuardrailExecutedListener(); + } + + @Bean + public OutputGuardrailExecutedListener outputGuardrailExecutedListener() { + return new MyOutputGuardrailExecutedListener(); + } + + @Bean + public ToolExecutedEventListener toolExecutedEventListener() { + return new MyToolExecutedEventListener(); + } + + // ==================== ChatModel 监听器 ==================== + + @Bean + public ChatModelListener chatModelListener() { + return new MyChatModelListener(); + } + + @Bean + public List chatModelListeners() { + return List.of(new MyChatModelListener()); + } + + // ==================== EmbeddingModel 监听器 ==================== + + @Bean + @Experimental + public EmbeddingModelListener embeddingModelListener() { + return new MyEmbeddingModelListener(); + } + + @Bean + @Experimental + public List embeddingModelListeners() { + return List.of(new MyEmbeddingModelListener()); + } + + // ==================== MCP Client 监听器 ==================== + + @Bean + public McpClientListener mcpClientListener() { + return new MyMcpClientListener(); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAgentListener.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAgentListener.java new file mode 100644 index 00000000..7a548788 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAgentListener.java @@ -0,0 +1,130 @@ +package org.ruoyi.observability; + +import dev.langchain4j.agentic.observability.AgentInvocationError; +import dev.langchain4j.agentic.observability.AgentRequest; +import dev.langchain4j.agentic.observability.AgentResponse; +import dev.langchain4j.agentic.planner.AgentInstance; +import dev.langchain4j.agentic.scope.AgenticScope; +import dev.langchain4j.service.tool.BeforeToolExecution; +import dev.langchain4j.service.tool.ToolExecution; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; + +/** + * 自定义的 AgentListener 的监听器。 + * 监听 Agent 相关的所有可观测性事件,包括: + *
    + *
  • Agent 调用前/后的生命周期事件
  • + *
  • Agent 执行错误事件
  • + *
  • AgenticScope 的创建/销毁事件
  • + *
  • 工具执行前/后的生命周期事件
  • + *
+ * + * @author evo + */ +@Slf4j +public class MyAgentListener implements dev.langchain4j.agentic.observability.AgentListener { + + // ==================== Agent 调用生命周期 ==================== + + @Override + public void beforeAgentInvocation(AgentRequest agentRequest) { + AgentInstance agent = agentRequest.agent(); + AgenticScope scope = agentRequest.agenticScope(); + Map inputs = agentRequest.inputs(); + + log.info("【Agent调用前】Agent名称: {}", agent.name()); + log.info("【Agent调用前】Agent ID: {}", agent.agentId()); + log.info("【Agent调用前】Agent类型: {}", agent.type().getName()); + log.info("【Agent调用前】Agent描述: {}", agent.description()); + log.info("【Agent调用前】Planner类型: {}", agent.plannerType().getName()); + log.info("【Agent调用前】输出类型: {}", agent.outputType()); + log.info("【Agent调用前】输出Key: {}", agent.outputKey()); + log.info("【Agent调用前】是否为异步: {}", agent.async()); + log.info("【Agent调用前】是否为叶子节点: {}", agent.leaf()); + log.info("【Agent调用前】Agent参数列表:"); + for (var arg : agent.arguments()) { + log.info(" - 参数名: {}, 类型: {}, 默认值: {}", + arg.name(), arg.rawType().getName(), arg.defaultValue()); + } + log.info("【Agent调用前】Agent输入参数: {}", inputs); + log.info("【Agent调用前】AgenticScope memoryId: {}", scope.memoryId()); + log.info("【Agent调用前】AgenticScope当前状态: {}", scope.state()); + log.info("【Agent调用前】Agent调用历史记录数: {}", scope.agentInvocations().size()); + + // 打印嵌套的子Agent信息 + if (!agent.subagents().isEmpty()) { + log.info("【Agent调用前】子Agent列表:"); + for (AgentInstance sub : agent.subagents()) { + log.info(" - 子Agent: {} ({})", sub.name(), sub.type().getName()); + } + } + + // 打印父Agent信息 + if (agent.parent() != null) { + log.info("【Agent调用前】父Agent: {}", agent.parent().name()); + } + } + + @Override + public void afterAgentInvocation(AgentResponse agentResponse) { + AgentInstance agent = agentResponse.agent(); + Map inputs = agentResponse.inputs(); + Object output = agentResponse.output(); + + log.info("【Agent调用后】Agent名称: {}", agent.name()); + log.info("【Agent调用后】Agent ID: {}", agent.agentId()); + log.info("【Agent调用后】Agent输入参数: {}", inputs); + log.info("【Agent调用后】Agent输出结果: {}", output); + log.info("【Agent调用后】是否为叶子节点: {}", agent.leaf()); + } + + @Override + public void onAgentInvocationError(AgentInvocationError error) { + AgentInstance agent = error.agent(); + Map inputs = error.inputs(); + Throwable throwable = error.error(); + + log.error("【Agent执行错误】Agent名称: {}", agent.name()); + log.error("【Agent执行错误】Agent ID: {}", agent.agentId()); + log.error("【Agent执行错误】Agent类型: {}", agent.type().getName()); + log.error("【Agent执行错误】Agent输入参数: {}", inputs); + log.error("【Agent执行错误】错误类型: {}", throwable.getClass().getName()); + log.error("【Agent执行错误】错误信息: {}", throwable.getMessage(), throwable); + } + + // ==================== AgenticScope 生命周期 ==================== + + @Override + public void afterAgenticScopeCreated(AgenticScope agenticScope) { + log.info("【AgenticScope已创建】memoryId: {}", agenticScope.memoryId()); + log.info("【AgenticScope已创建】初始状态: {}", agenticScope.state()); + } + + @Override + public void beforeAgenticScopeDestroyed(AgenticScope agenticScope) { + log.info("【AgenticScope即将销毁】memoryId: {}", agenticScope.memoryId()); + log.info("【AgenticScope即将销毁】最终状态: {}", agenticScope.state()); + log.info("【AgenticScope即将销毁】总调用次数: {}", agenticScope.agentInvocations().size()); + } + + // ==================== 工具执行生命周期 ==================== + + @Override + public void beforeToolExecution(BeforeToolExecution beforeToolExecution) { + var toolRequest = beforeToolExecution.request(); + log.info("【工具执行前】工具请求ID: {}", toolRequest.id()); + log.info("【工具执行前】工具名称: {}", toolRequest.name()); + log.info("【工具执行前】工具参数: {}", toolRequest.arguments()); + } + + @Override + public void afterToolExecution(ToolExecution toolExecution) { + var toolRequest = toolExecution.request(); + log.info("【工具执行后】工具请求ID: {}", toolRequest.id()); + log.info("【工具执行后】工具名称: {}", toolRequest.name()); + log.info("【工具执行后】工具执行结果: {}", toolExecution.result()); + log.info("【工具执行后】工具执行是否失败: {}", toolExecution.hasFailed()); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAiServiceCompletedListener.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAiServiceCompletedListener.java new file mode 100644 index 00000000..b90f30fe --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAiServiceCompletedListener.java @@ -0,0 +1,41 @@ +package org.ruoyi.observability; + +import dev.langchain4j.invocation.InvocationContext; +import dev.langchain4j.observability.api.event.AiServiceCompletedEvent; +import dev.langchain4j.observability.api.listener.AiServiceCompletedListener; +import lombok.extern.slf4j.Slf4j; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * 自定义的 AiServiceCompletedEvent 的监听器。 + * 它表示在 AI 服务调用完成时发生的事件。 + * + * @author evo + */ +@Slf4j +public class MyAiServiceCompletedListener implements AiServiceCompletedListener { + + @Override + public void onEvent(AiServiceCompletedEvent event) { + InvocationContext invocationContext = event.invocationContext(); + Optional result = event.result(); + UUID invocationId = invocationContext.invocationId(); + String aiServiceInterfaceName = invocationContext.interfaceName(); + String aiServiceMethodName = invocationContext.methodName(); + List aiServiceMethodArgs = invocationContext.methodArguments(); + Object chatMemoryId = invocationContext.chatMemoryId(); + Instant eventTimestamp = invocationContext.timestamp(); + + log.info("【AI服务完成】调用唯一标识符: {}", invocationId); + log.info("【AI服务完成】AI服务接口名: {}", aiServiceInterfaceName); + log.info("【AI服务完成】调用的方法名: {}", aiServiceMethodName); + log.info("【AI服务完成】AI服务方法参数: {}", aiServiceMethodArgs); + log.info("【AI服务完成】聊天记忆ID: {}", chatMemoryId); + log.info("【AI服务完成】调用发生的时间: {}", eventTimestamp); + log.info("【AI服务完成】调用结果: {}", result); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAiServiceErrorListener.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAiServiceErrorListener.java new file mode 100644 index 00000000..535946b7 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAiServiceErrorListener.java @@ -0,0 +1,33 @@ +package org.ruoyi.observability; + +import dev.langchain4j.invocation.InvocationContext; +import dev.langchain4j.observability.api.event.AiServiceErrorEvent; +import dev.langchain4j.observability.api.listener.AiServiceErrorListener; +import lombok.extern.slf4j.Slf4j; + +import java.util.UUID; + +/** + * 自定义的 AiServiceErrorEvent 的监听器。 + * 它表示在 AI 服务调用失败时发生的事件。 + * + * @author evo + */ +@Slf4j +public class MyAiServiceErrorListener implements AiServiceErrorListener { + + @Override + public void onEvent(AiServiceErrorEvent event) { + InvocationContext invocationContext = event.invocationContext(); + UUID invocationId = invocationContext.invocationId(); + String aiServiceInterfaceName = invocationContext.interfaceName(); + String aiServiceMethodName = invocationContext.methodName(); + Throwable error = event.error(); + + log.error("【AI服务错误】调用唯一标识符: {}", invocationId); + log.error("【AI服务错误】AI服务接口名: {}", aiServiceInterfaceName); + log.error("【AI服务错误】调用的方法名: {}", aiServiceMethodName); + log.error("【AI服务错误】错误类型: {}", error.getClass().getName()); + log.error("【AI服务错误】错误信息: {}", error.getMessage(), error); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAiServiceRequestIssuedListener.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAiServiceRequestIssuedListener.java new file mode 100644 index 00000000..ac4e506d --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAiServiceRequestIssuedListener.java @@ -0,0 +1,33 @@ +package org.ruoyi.observability; + +import dev.langchain4j.invocation.InvocationContext; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.observability.api.event.AiServiceRequestIssuedEvent; +import dev.langchain4j.observability.api.listener.AiServiceRequestIssuedListener; +import lombok.extern.slf4j.Slf4j; + +import java.util.UUID; + +/** + * 自定义的 AiServiceRequestIssuedEvent 的监听器。 + * 它表示在向 LLM 发送请求之前发生的事件。 + * + * @author evo + */ +@Slf4j +public class MyAiServiceRequestIssuedListener implements AiServiceRequestIssuedListener { + + @Override + public void onEvent(AiServiceRequestIssuedEvent event) { + InvocationContext invocationContext = event.invocationContext(); + UUID invocationId = invocationContext.invocationId(); + String aiServiceInterfaceName = invocationContext.interfaceName(); + String aiServiceMethodName = invocationContext.methodName(); + ChatRequest request = event.request(); + + log.info("【请求已发出】调用唯一标识符: {}", invocationId); + log.info("【请求已发出】AI服务接口名: {}", aiServiceInterfaceName); + log.info("【请求已发出】调用的方法名: {}", aiServiceMethodName); + log.info("【请求已发出】发送给LLM的请求: {}", request); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAiServiceResponseReceivedListener.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAiServiceResponseReceivedListener.java new file mode 100644 index 00000000..19a926b8 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAiServiceResponseReceivedListener.java @@ -0,0 +1,37 @@ +package org.ruoyi.observability; + +import dev.langchain4j.invocation.InvocationContext; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.observability.api.event.AiServiceResponseReceivedEvent; +import dev.langchain4j.observability.api.listener.AiServiceResponseReceivedListener; +import lombok.extern.slf4j.Slf4j; + +import java.util.UUID; + +/** + * 自定义的 AiServiceResponseReceivedEvent 的监听器。 + * 它表示在从 LLM 接收到响应时发生的事件。 + * 在涉及工具或 guardrail 的单个 AI 服务调用期间,可能会被调用多次。 + * + * @author evo + */ +@Slf4j +public class MyAiServiceResponseReceivedListener implements AiServiceResponseReceivedListener { + + @Override + public void onEvent(AiServiceResponseReceivedEvent event) { + InvocationContext invocationContext = event.invocationContext(); + UUID invocationId = invocationContext.invocationId(); + String aiServiceInterfaceName = invocationContext.interfaceName(); + String aiServiceMethodName = invocationContext.methodName(); + ChatRequest request = event.request(); + ChatResponse response = event.response(); + + log.info("【响应已接收】调用唯一标识符: {}", invocationId); + log.info("【响应已接收】AI服务接口名: {}", aiServiceInterfaceName); + log.info("【响应已接收】调用的方法名: {}", aiServiceMethodName); + log.info("【响应已接收】发送给LLM的请求: {}", request); + log.info("【响应已接收】从LLM收到的响应: {}", response); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAiServiceStartedListener.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAiServiceStartedListener.java new file mode 100644 index 00000000..ba7cdb06 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAiServiceStartedListener.java @@ -0,0 +1,38 @@ +package org.ruoyi.observability; + +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.invocation.InvocationContext; +import dev.langchain4j.observability.api.event.AiServiceStartedEvent; +import dev.langchain4j.observability.api.listener.AiServiceStartedListener; +import lombok.extern.slf4j.Slf4j; + +import java.util.Optional; +import java.util.UUID; + +/** + * 自定义的 AiServiceStartedEvent 的监听器。 + * 它表示在 AI 服务调用开始时发生的事件。 + * + * @author evo + */ +@Slf4j +public class MyAiServiceStartedListener implements AiServiceStartedListener { + + @Override + public void onEvent(AiServiceStartedEvent event) { + InvocationContext invocationContext = event.invocationContext(); + UUID invocationId = invocationContext.invocationId(); + String aiServiceInterfaceName = invocationContext.interfaceName(); + String aiServiceMethodName = invocationContext.methodName(); + Optional systemMessage = event.systemMessage(); + UserMessage userMessage = event.userMessage(); + + log.info("【AI服务启动】调用唯一标识符: {}", invocationId); + log.info("【AI服务启动】AI服务接口名: {}", aiServiceInterfaceName); + log.info("【AI服务启动】调用的方法名: {}", aiServiceMethodName); + log.info("【AI服务启动】系统消息: {}", systemMessage.orElse(null)); + log.info("【AI服务启动】用户消息: {}", userMessage); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyChatModelListener.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyChatModelListener.java new file mode 100644 index 00000000..9cc0e2ac --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyChatModelListener.java @@ -0,0 +1,43 @@ +package org.ruoyi.observability; + +import dev.langchain4j.model.chat.listener.ChatModelErrorContext; +import dev.langchain4j.model.chat.listener.ChatModelListener; +import dev.langchain4j.model.chat.listener.ChatModelRequestContext; +import dev.langchain4j.model.chat.listener.ChatModelResponseContext; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import lombok.extern.slf4j.Slf4j; + +/** + * 自定义的 ChatModelListener 的监听器。 + * 它监听 ChatModel 的请求、响应和错误事件。 + * + * @author evo + */ +@Slf4j +public class MyChatModelListener implements ChatModelListener { + + @Override + public void onRequest(ChatModelRequestContext requestContext) { + ChatRequest request = requestContext.chatRequest(); + log.info("【ChatModel请求】发送给模型的请求: {}", request); + log.info("【ChatModel请求】模型提供商: {}", requestContext.modelProvider()); + } + + @Override + public void onResponse(ChatModelResponseContext responseContext) { + ChatRequest request = responseContext.chatRequest(); + ChatResponse response = responseContext.chatResponse(); + log.info("【ChatModel响应】原始请求: {}", request); + log.info("【ChatModel响应】收到的响应: {}", response); + log.info("【ChatModel响应】模型提供商: {}", responseContext.modelProvider()); + } + + @Override + public void onError(ChatModelErrorContext errorContext) { + log.error("【ChatModel错误】错误类型: {}", errorContext.error().getClass().getName()); + log.error("【ChatModel错误】错误信息: {}", errorContext.error().getMessage()); + log.error("【ChatModel错误】原始请求: {}", errorContext.chatRequest()); + log.error("【ChatModel错误】模型提供商: {}", errorContext.modelProvider()); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyEmbeddingModelListener.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyEmbeddingModelListener.java new file mode 100644 index 00000000..56641dce --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyEmbeddingModelListener.java @@ -0,0 +1,47 @@ +package org.ruoyi.observability; + +import dev.langchain4j.Experimental; +import dev.langchain4j.data.embedding.Embedding; +import dev.langchain4j.model.embedding.listener.EmbeddingModelErrorContext; +import dev.langchain4j.model.embedding.listener.EmbeddingModelListener; +import dev.langchain4j.model.embedding.listener.EmbeddingModelRequestContext; +import dev.langchain4j.model.embedding.listener.EmbeddingModelResponseContext; +import dev.langchain4j.model.output.Response; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +/** + * 自定义的 EmbeddingModelListener 的监听器。 + * 它监听 EmbeddingModel 的请求、响应和错误事件。 + * + * @author evo + */ +@Slf4j +@Experimental +public class MyEmbeddingModelListener implements EmbeddingModelListener { + + @Override + public void onRequest(EmbeddingModelRequestContext requestContext) { + log.info("【EmbeddingModel请求】输入文本段落数量: {}", requestContext.textSegments().size()); + log.info("【EmbeddingModel请求】嵌入模型: {}", requestContext.embeddingModel()); + } + + @Override + public void onResponse(EmbeddingModelResponseContext responseContext) { + Response> response = responseContext.response(); + List embeddings = response.content(); + log.info("【EmbeddingModel响应】嵌入向量数量: {}", embeddings.size()); + log.info("【EmbeddingModel响应】嵌入维度: {}", embeddings.isEmpty() ? 0 : embeddings.get(0).dimension()); + log.info("【EmbeddingModel响应】嵌入模型: {}", responseContext.embeddingModel()); + log.info("【EmbeddingModel响应】输入文本段落: {}", responseContext.textSegments()); + } + + @Override + public void onError(EmbeddingModelErrorContext errorContext) { + log.error("【EmbeddingModel错误】错误类型: {}", errorContext.error().getClass().getName()); + log.error("【EmbeddingModel错误】错误信息: {}", errorContext.error().getMessage()); + log.error("【EmbeddingModel错误】输入文本段落数量: {}", errorContext.textSegments().size()); + log.error("【EmbeddingModel错误】嵌入模型: {}", errorContext.embeddingModel()); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyInputGuardrailExecutedListener.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyInputGuardrailExecutedListener.java new file mode 100644 index 00000000..fe9c3b48 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyInputGuardrailExecutedListener.java @@ -0,0 +1,45 @@ +package org.ruoyi.observability; + +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.guardrail.InputGuardrail; +import dev.langchain4j.guardrail.InputGuardrailRequest; +import dev.langchain4j.guardrail.InputGuardrailResult; +import dev.langchain4j.invocation.InvocationContext; +import dev.langchain4j.observability.api.event.InputGuardrailExecutedEvent; +import dev.langchain4j.observability.api.listener.InputGuardrailExecutedListener; +import lombok.extern.slf4j.Slf4j; + +import java.time.Duration; +import java.util.UUID; + +/** + * 自定义的 InputGuardrailExecutedEvent 的监听器。 + * 它表示在输入 guardrail 验证执行时发生的事件。 + * + * @author evo + */ +@Slf4j +public class MyInputGuardrailExecutedListener implements InputGuardrailExecutedListener { + + @Override + public void onEvent(InputGuardrailExecutedEvent event) { + InvocationContext invocationContext = event.invocationContext(); + UUID invocationId = invocationContext.invocationId(); + String aiServiceInterfaceName = invocationContext.interfaceName(); + String aiServiceMethodName = invocationContext.methodName(); + InputGuardrailRequest request = event.request(); + InputGuardrailResult result = event.result(); + Class guardrailClass = event.guardrailClass(); + Duration duration = event.duration(); + UserMessage rewrittenUserMessage = event.rewrittenUserMessage(); + + log.info("【输入Guardrail已执行】调用唯一标识符: {}", invocationId); + log.info("【输入Guardrail已执行】AI服务接口名: {}", aiServiceInterfaceName); + log.info("【输入Guardrail已执行】调用的方法名: {}", aiServiceMethodName); + log.info("【输入Guardrail已执行】Guardrail类名: {}", guardrailClass.getName()); + log.info("【输入Guardrail已执行】输入Guardrail请求: {}", request); + log.info("【输入Guardrail已执行】输入Guardrail结果: {}", result); + log.info("【输入Guardrail已执行】重写后的用户消息: {}", rewrittenUserMessage); + log.info("【输入Guardrail已执行】执行耗时: {}ms", duration.toMillis()); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyMcpClientListener.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyMcpClientListener.java new file mode 100644 index 00000000..fe19d728 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyMcpClientListener.java @@ -0,0 +1,136 @@ +package org.ruoyi.observability; + +import dev.langchain4j.invocation.InvocationContext; +import dev.langchain4j.mcp.client.McpCallContext; +import dev.langchain4j.mcp.client.McpClientListener; +import dev.langchain4j.mcp.client.McpGetPromptResult; +import dev.langchain4j.mcp.client.McpReadResourceResult; +import dev.langchain4j.mcp.protocol.McpClientMessage; +import dev.langchain4j.service.tool.ToolExecutionResult; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; + +/** + * 自定义的 McpClientListener 的监听器。 + * 监听 MCP 客户端相关的所有可观测性事件,包括: + *
    + *
  • MCP 工具执行的开始/成功/错误事件
  • + *
  • MCP 资源读取的开始/成功/错误事件
  • + *
  • MCP 提示词获取的开始/成功/错误事件
  • + *
+ * + * @author evo + */ +@Slf4j +public class MyMcpClientListener implements McpClientListener { + + // ==================== 工具执行 ==================== + + @Override + public void beforeExecuteTool(McpCallContext context) { + InvocationContext invocationContext = context.invocationContext(); + McpClientMessage message = context.message(); + + log.info("【MCP工具执行前】调用唯一标识符: {}", invocationContext.invocationId()); + log.info("【MCP工具执行前】MCP消息ID: {}", message.getId()); + log.info("【MCP工具执行前】MCP方法: {}", message.method); + } + + @Override + public void afterExecuteTool(McpCallContext context, ToolExecutionResult result, Map rawResult) { + InvocationContext invocationContext = context.invocationContext(); + McpClientMessage message = context.message(); + + log.info("【MCP工具执行后】调用唯一标识符: {}", invocationContext.invocationId()); + log.info("【MCP工具执行后】MCP消息ID: {}", message.getId()); + log.info("【MCP工具执行后】MCP方法: {}", message.method); + log.info("【MCP工具执行后】工具执行结果: {}", result); + log.info("【MCP工具执行后】原始结果: {}", rawResult); + } + + @Override + public void onExecuteToolError(McpCallContext context, Throwable error) { + InvocationContext invocationContext = context.invocationContext(); + McpClientMessage message = context.message(); + + log.error("【MCP工具执行错误】调用唯一标识符: {}", invocationContext.invocationId()); + log.error("【MCP工具执行错误】MCP消息ID: {}", message.getId()); + log.error("【MCP工具执行错误】MCP方法: {}", message.method); + log.error("【MCP工具执行错误】错误类型: {}", error.getClass().getName()); + log.error("【MCP工具执行错误】错误信息: {}", error.getMessage(), error); + } + + // ==================== 资源读取 ==================== + + @Override + public void beforeResourceGet(McpCallContext context) { + InvocationContext invocationContext = context.invocationContext(); + McpClientMessage message = context.message(); + + log.info("【MCP资源读取前】调用唯一标识符: {}", invocationContext.invocationId()); + log.info("【MCP资源读取前】MCP消息ID: {}", message.getId()); + log.info("【MCP资源读取前】MCP方法: {}", message.method); + } + + @Override + public void afterResourceGet(McpCallContext context, McpReadResourceResult result, Map rawResult) { + InvocationContext invocationContext = context.invocationContext(); + McpClientMessage message = context.message(); + + log.info("【MCP资源读取后】调用唯一标识符: {}", invocationContext.invocationId()); + log.info("【MCP资源读取后】MCP消息ID: {}", message.getId()); + log.info("【MCP资源读取后】MCP方法: {}", message.method); + log.info("【MCP资源读取后】资源内容数量: {}", result.contents() != null ? result.contents().size() : 0); + log.info("【MCP资源读取后】原始结果: {}", rawResult); + } + + @Override + public void onResourceGetError(McpCallContext context, Throwable error) { + InvocationContext invocationContext = context.invocationContext(); + McpClientMessage message = context.message(); + + log.error("【MCP资源读取错误】调用唯一标识符: {}", invocationContext.invocationId()); + log.error("【MCP资源读取错误】MCP消息ID: {}", message.getId()); + log.error("【MCP资源读取错误】MCP方法: {}", message.method); + log.error("【MCP资源读取错误】错误类型: {}", error.getClass().getName()); + log.error("【MCP资源读取错误】错误信息: {}", error.getMessage(), error); + } + + // ==================== 提示词获取 ==================== + + @Override + public void beforePromptGet(McpCallContext context) { + InvocationContext invocationContext = context.invocationContext(); + McpClientMessage message = context.message(); + + log.info("【MCP提示词获取前】调用唯一标识符: {}", invocationContext.invocationId()); + log.info("【MCP提示词获取前】MCP消息ID: {}", message.getId()); + log.info("【MCP提示词获取前】MCP方法: {}", message.method); + } + + @Override + public void afterPromptGet(McpCallContext context, McpGetPromptResult result, Map rawResult) { + InvocationContext invocationContext = context.invocationContext(); + McpClientMessage message = context.message(); + + log.info("【MCP提示词获取后】调用唯一标识符: {}", invocationContext.invocationId()); + log.info("【MCP提示词获取后】MCP消息ID: {}", message.getId()); + log.info("【MCP提示词获取后】MCP方法: {}", message.method); + log.info("【MCP提示词获取后】提示词描述: {}", result.description()); + log.info("【MCP提示词获取后】提示词消息数量: {}", result.messages() != null ? result.messages().size() : 0); + log.info("【MCP提示词获取后】原始结果: {}", rawResult); + } + + @Override + public void onPromptGetError(McpCallContext context, Throwable error) { + InvocationContext invocationContext = context.invocationContext(); + McpClientMessage message = context.message(); + + log.error("【MCP提示词获取错误】调用唯一标识符: {}", invocationContext.invocationId()); + log.error("【MCP提示词获取错误】MCP消息ID: {}", message.getId()); + log.error("【MCP提示词获取错误】MCP方法: {}", message.method); + log.error("【MCP提示词获取错误】错误类型: {}", error.getClass().getName()); + log.error("【MCP提示词获取错误】错误信息: {}", error.getMessage(), error); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyOutputGuardrailExecutedListener.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyOutputGuardrailExecutedListener.java new file mode 100644 index 00000000..fda4176c --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyOutputGuardrailExecutedListener.java @@ -0,0 +1,42 @@ +package org.ruoyi.observability; + +import dev.langchain4j.guardrail.OutputGuardrail; +import dev.langchain4j.guardrail.OutputGuardrailRequest; +import dev.langchain4j.guardrail.OutputGuardrailResult; +import dev.langchain4j.invocation.InvocationContext; +import dev.langchain4j.observability.api.event.OutputGuardrailExecutedEvent; +import dev.langchain4j.observability.api.listener.OutputGuardrailExecutedListener; +import lombok.extern.slf4j.Slf4j; + +import java.time.Duration; +import java.util.UUID; + +/** + * 自定义的 OutputGuardrailExecutedEvent 的监听器。 + * 它表示在输出 guardrail 验证执行时发生的事件。 + * + * @author evo + */ +@Slf4j +public class MyOutputGuardrailExecutedListener implements OutputGuardrailExecutedListener { + + @Override + public void onEvent(OutputGuardrailExecutedEvent event) { + InvocationContext invocationContext = event.invocationContext(); + UUID invocationId = invocationContext.invocationId(); + String aiServiceInterfaceName = invocationContext.interfaceName(); + String aiServiceMethodName = invocationContext.methodName(); + OutputGuardrailRequest request = event.request(); + OutputGuardrailResult result = event.result(); + Class guardrailClass = event.guardrailClass(); + Duration duration = event.duration(); + + log.info("【输出Guardrail已执行】调用唯一标识符: {}", invocationId); + log.info("【输出Guardrail已执行】AI服务接口名: {}", aiServiceInterfaceName); + log.info("【输出Guardrail已执行】调用的方法名: {}", aiServiceMethodName); + log.info("【输出Guardrail已执行】Guardrail类名: {}", guardrailClass.getName()); + log.info("【输出Guardrail已执行】输出Guardrail请求: {}", request); + log.info("【输出Guardrail已执行】输出Guardrail结果: {}", result); + log.info("【输出Guardrail已执行】执行耗时: {}ms", duration.toMillis()); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyToolExecutedEventListener.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyToolExecutedEventListener.java new file mode 100644 index 00000000..9c60a757 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyToolExecutedEventListener.java @@ -0,0 +1,38 @@ +package org.ruoyi.observability; + +import dev.langchain4j.agent.tool.ToolExecutionRequest; +import dev.langchain4j.invocation.InvocationContext; +import dev.langchain4j.observability.api.event.ToolExecutedEvent; +import dev.langchain4j.observability.api.listener.ToolExecutedEventListener; +import lombok.extern.slf4j.Slf4j; + +import java.util.UUID; + +/** + * 自定义的 ToolExecutedEvent 的监听器。 + * 它表示在工具执行完成后发生的事件。 + * 在单个 AI 服务调用期间,可能会被调用多次。 + * + * @author evo + */ +@Slf4j +public class MyToolExecutedEventListener implements ToolExecutedEventListener { + + @Override + public void onEvent(ToolExecutedEvent event) { + InvocationContext invocationContext = event.invocationContext(); + UUID invocationId = invocationContext.invocationId(); + String aiServiceInterfaceName = invocationContext.interfaceName(); + String aiServiceMethodName = invocationContext.methodName(); + ToolExecutionRequest request = event.request(); + String resultText = event.resultText(); + + log.info("【工具已执行】调用唯一标识符: {}", invocationId); + log.info("【工具已执行】AI服务接口名: {}", aiServiceInterfaceName); + log.info("【工具已执行】调用的方法名: {}", aiServiceMethodName); + log.info("【工具已执行】工具执行请求 ID: {}", request.id()); + log.info("【工具已执行】工具名称: {}", request.name()); + log.info("【工具已执行】工具参数: {}", request.arguments()); + log.info("【工具已执行】工具执行结果: {}", resultText); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java index ba1c5ac8..c0292871 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java @@ -22,6 +22,8 @@ import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.ruoyi.agent.ChartGenerationAgent; import org.ruoyi.agent.SqlAgent; +import org.ruoyi.observability.MyAgentListener; +import org.ruoyi.observability.MyMcpClientListener; import org.ruoyi.agent.WebSearchAgent; import org.ruoyi.agent.tool.ExecuteSqlQueryTool; import org.ruoyi.agent.tool.QueryAllTablesTool; @@ -213,6 +215,7 @@ public class ChatServiceFacade implements IChatService { McpClient mcpClient = new DefaultMcpClient.Builder() .transport(transport) + .listener(new MyMcpClientListener()) .build(); ToolProvider toolProvider = McpToolProvider.builder() @@ -227,6 +230,7 @@ public class ChatServiceFacade implements IChatService { McpClient mcpClient1 = new DefaultMcpClient.Builder() .transport(transport1) + .listener(new MyMcpClientListener()) .build(); ToolProvider toolProvider1 = McpToolProvider.builder() @@ -261,6 +265,7 @@ public class ChatServiceFacade implements IChatService { .chatModel(plannerModel) .subAgents(sqlAgent, chartGenerationAgent) .responseStrategy(SupervisorResponseStrategy.LAST) + .listener(new MyAgentListener()) .build(); String invoke = supervisor.invoke(chatRequest.getContent()); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DeepseekServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DeepseekServiceImpl.java index d5b1b1b9..15bf7556 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DeepseekServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DeepseekServiceImpl.java @@ -1,24 +1,31 @@ package org.ruoyi.service.chat.impl.provider; + import dev.langchain4j.model.chat.StreamingChatModel; import dev.langchain4j.model.openai.OpenAiStreamingChatModel; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.ruoyi.common.chat.domain.dto.request.ChatRequest; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.enums.ChatModeType; +import org.ruoyi.observability.ChatModelListenerProvider; import org.ruoyi.service.chat.AbstractChatService; import org.springframework.stereotype.Service; /** - * @Author: xiaoen - * @Description: deepseek 服务调用 - * @Date: Created in 19:12 2026/3/17 + * Deepseek服务调用 + * + * @author xiaoen + * @date 2026/3/17 */ @Service @Slf4j +@RequiredArgsConstructor public class DeepseekServiceImpl implements AbstractChatService { + private final ChatModelListenerProvider listenerProvider; + @Override public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) { return OpenAiStreamingChatModel.builder() @@ -26,6 +33,7 @@ public class DeepseekServiceImpl implements AbstractChatService { .apiKey(chatModelVo.getApiKey()) .modelName(chatModelVo.getModelName()) .returnThinking(chatRequest.getEnableThinking()) + .listeners(listenerProvider.getChatModelListeners()) .build(); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OllamaServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OllamaServiceImpl.java index c4de3427..4da7073e 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OllamaServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OllamaServiceImpl.java @@ -1,13 +1,16 @@ package org.ruoyi.service.chat.impl.provider; + import dev.langchain4j.model.chat.StreamingChatModel; import dev.langchain4j.model.ollama.OllamaStreamingChatModel; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.ruoyi.enums.ChatModeType; import org.ruoyi.service.chat.AbstractChatService; import org.springframework.stereotype.Service; import org.ruoyi.common.chat.domain.dto.request.ChatRequest; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.observability.ChatModelListenerProvider; /** * OllamaAI服务调用 @@ -17,13 +20,17 @@ import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; */ @Service @Slf4j +@RequiredArgsConstructor public class OllamaServiceImpl implements AbstractChatService { + private final ChatModelListenerProvider listenerProvider; + @Override public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) { return OllamaStreamingChatModel.builder() .baseUrl(chatModelVo.getApiHost()) .modelName(chatModelVo.getModelName()) + .listeners(listenerProvider.getChatModelListeners()) .build(); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java index e601fbcb..76503b89 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java @@ -3,10 +3,12 @@ package org.ruoyi.service.chat.impl.provider; import dev.langchain4j.model.chat.StreamingChatModel; import dev.langchain4j.model.openai.OpenAiStreamingChatModel; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.ruoyi.common.chat.domain.dto.request.ChatRequest; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.enums.ChatModeType; +import org.ruoyi.observability.ChatModelListenerProvider; import org.ruoyi.service.chat.AbstractChatService; import org.springframework.stereotype.Service; @@ -19,15 +21,19 @@ import org.springframework.stereotype.Service; */ @Service @Slf4j +@RequiredArgsConstructor public class OpenAIServiceImpl implements AbstractChatService { + private final ChatModelListenerProvider listenerProvider; + @Override - public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) { + public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo,ChatRequest chatRequest) { return OpenAiStreamingChatModel.builder() .baseUrl(chatModelVo.getApiHost()) .apiKey(chatModelVo.getApiKey()) .modelName(chatModelVo.getModelName()) .returnThinking(chatRequest.getEnableThinking()) + .listeners(listenerProvider.getChatModelListeners()) .build(); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/PPIOServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/PPIOServiceImpl.java index 5b36fee9..fd742abb 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/PPIOServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/PPIOServiceImpl.java @@ -1,24 +1,30 @@ package org.ruoyi.service.chat.impl.provider; + import dev.langchain4j.model.chat.StreamingChatModel; import dev.langchain4j.model.openai.OpenAiStreamingChatModel; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.ruoyi.common.chat.domain.dto.request.ChatRequest; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.enums.ChatModeType; +import org.ruoyi.observability.ChatModelListenerProvider; import org.ruoyi.service.chat.AbstractChatService; import org.springframework.stereotype.Service; /** - * OPENAI服务调用 + * PPIO服务调用 * * @author ageerle@163.com * @date 2025/12/13 */ @Service @Slf4j +@RequiredArgsConstructor public class PPIOServiceImpl implements AbstractChatService { + private final ChatModelListenerProvider listenerProvider; + @Override public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) { return OpenAiStreamingChatModel.builder() @@ -26,6 +32,7 @@ public class PPIOServiceImpl implements AbstractChatService { .apiKey(chatModelVo.getApiKey()) .modelName(chatModelVo.getModelName()) .returnThinking(chatRequest.getEnableThinking()) + .listeners(listenerProvider.getChatModelListeners()) .build(); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceImpl.java index bb7243c4..2f34899e 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceImpl.java @@ -1,11 +1,14 @@ package org.ruoyi.service.chat.impl.provider; + import dev.langchain4j.community.model.dashscope.QwenStreamingChatModel; import dev.langchain4j.model.chat.StreamingChatModel; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.ruoyi.common.chat.domain.dto.request.ChatRequest; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.enums.ChatModeType; +import org.ruoyi.observability.ChatModelListenerProvider; import org.ruoyi.service.chat.AbstractChatService; import org.springframework.stereotype.Service; @@ -18,14 +21,17 @@ import org.springframework.stereotype.Service; */ @Service @Slf4j +@RequiredArgsConstructor public class QianWenChatServiceImpl implements AbstractChatService { - // 添加文档解析的前缀字段 + private final ChatModelListenerProvider listenerProvider; + @Override public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo,ChatRequest chatRequest) { return QwenStreamingChatModel.builder() .apiKey(chatModelVo.getApiKey()) .modelName(chatModelVo.getModelName()) + .listeners(listenerProvider.getChatModelListeners()) .build(); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java index cf19a5a5..0cfebbfd 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java @@ -1,11 +1,14 @@ package org.ruoyi.service.chat.impl.provider; + import dev.langchain4j.community.model.zhipu.ZhipuAiStreamingChatModel; import dev.langchain4j.model.chat.StreamingChatModel; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.ruoyi.common.chat.domain.dto.request.ChatRequest; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.enums.ChatModeType; +import org.ruoyi.observability.ChatModelListenerProvider; import org.ruoyi.service.chat.AbstractChatService; import org.springframework.stereotype.Service; @@ -18,13 +21,17 @@ import org.springframework.stereotype.Service; */ @Service @Slf4j +@RequiredArgsConstructor public class ZhiPuChatServiceImpl implements AbstractChatService { + private final ChatModelListenerProvider listenerProvider; + @Override public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) { return ZhipuAiStreamingChatModel.builder() .apiKey(chatModelVo.getApiKey()) .model(chatModelVo.getModelName()) + .listeners(listenerProvider.getChatModelListeners()) .build(); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianBaseEmbedProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianBaseEmbedProvider.java index 083f98d6..936e0993 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianBaseEmbedProvider.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianBaseEmbedProvider.java @@ -4,8 +4,12 @@ package org.ruoyi.service.embed.impl; import dev.langchain4j.community.model.dashscope.QwenEmbeddingModel; import dev.langchain4j.data.embedding.Embedding; import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.model.embedding.listener.EmbeddingModelListener; import dev.langchain4j.model.output.Response; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.observability.EmbeddingModelListenerProvider; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.ruoyi.enums.ModalityType; @@ -20,9 +24,11 @@ import java.util.Set; @Component("alibailian") public class AliBaiLianBaseEmbedProvider extends OpenAiEmbeddingProvider { - private ChatModelVo chatModelVo; + @Autowired + private EmbeddingModelListenerProvider embeddingModelListenerProvider; + @Override public void configure(ChatModelVo config) { this.chatModelVo = config; @@ -35,12 +41,18 @@ public class AliBaiLianBaseEmbedProvider extends OpenAiEmbeddingProvider { @Override public Response> embedAll(List textSegments) { - return QwenEmbeddingModel.builder() + List listeners = embeddingModelListenerProvider.getEmbeddingModelListeners(); + EmbeddingModel model = QwenEmbeddingModel.builder() .apiKey(chatModelVo.getApiKey()) .modelName(chatModelVo.getModelName()) .dimension(chatModelVo.getModelDimension()) - .build() - .embedAll(textSegments); + .build(); + + if (!listeners.isEmpty()) { + model = model.addListeners(listeners); + } + + return model.embedAll(textSegments); } } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OllamaEmbeddingProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OllamaEmbeddingProvider.java index 8d48a30e..79202425 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OllamaEmbeddingProvider.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OllamaEmbeddingProvider.java @@ -2,11 +2,16 @@ package org.ruoyi.service.embed.impl; import dev.langchain4j.data.embedding.Embedding; import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.model.embedding.listener.EmbeddingModelListener; import dev.langchain4j.model.ollama.OllamaEmbeddingModel; import dev.langchain4j.model.output.Response; +import jakarta.annotation.Resource; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.enums.ModalityType; +import org.ruoyi.observability.EmbeddingModelListenerProvider; import org.ruoyi.service.embed.BaseEmbedModelService; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.util.List; @@ -21,6 +26,9 @@ import java.util.Set; public class OllamaEmbeddingProvider implements BaseEmbedModelService { private ChatModelVo chatModelVo; + @Resource + private EmbeddingModelListenerProvider embeddingModelListenerProvider; + @Override public void configure(ChatModelVo config) { this.chatModelVo = config; @@ -34,10 +42,16 @@ public class OllamaEmbeddingProvider implements BaseEmbedModelService { // ollama不能设置embedding维度,使用milvus时请注意!!创建向量表时需要先设定维度大小 @Override public Response> embedAll(List textSegments) { - return OllamaEmbeddingModel.builder() + List listeners = embeddingModelListenerProvider.getEmbeddingModelListeners(); + EmbeddingModel model = OllamaEmbeddingModel.builder() .baseUrl(chatModelVo.getApiHost()) .modelName(chatModelVo.getModelName()) - .build() - .embedAll(textSegments); + .build(); + + if (!listeners.isEmpty()) { + model = model.addListeners(listeners); + } + + return model.embedAll(textSegments); } } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OpenAiEmbeddingProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OpenAiEmbeddingProvider.java index 2997d5b1..039342ee 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OpenAiEmbeddingProvider.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OpenAiEmbeddingProvider.java @@ -2,11 +2,15 @@ package org.ruoyi.service.embed.impl; import dev.langchain4j.data.embedding.Embedding; import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.model.embedding.listener.EmbeddingModelListener; import dev.langchain4j.model.openai.OpenAiEmbeddingModel; import dev.langchain4j.model.output.Response; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.enums.ModalityType; +import org.ruoyi.observability.EmbeddingModelListenerProvider; import org.ruoyi.service.embed.BaseEmbedModelService; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.util.List; @@ -21,6 +25,9 @@ import java.util.Set; public class OpenAiEmbeddingProvider implements BaseEmbedModelService { protected ChatModelVo chatModelVo; + @Autowired + private EmbeddingModelListenerProvider embeddingModelListenerProvider; + @Override public void configure(ChatModelVo config) { this.chatModelVo = config; @@ -33,12 +40,18 @@ public class OpenAiEmbeddingProvider implements BaseEmbedModelService { @Override public Response> embedAll(List textSegments) { - return OpenAiEmbeddingModel.builder() + List listeners = embeddingModelListenerProvider.getEmbeddingModelListeners(); + EmbeddingModel model = OpenAiEmbeddingModel.builder() .baseUrl(chatModelVo.getApiHost()) .apiKey(chatModelVo.getApiKey()) .modelName(chatModelVo.getModelName()) .dimensions(chatModelVo.getModelDimension()) - .build() - .embedAll(textSegments); + .build(); + + if (!listeners.isEmpty()) { + model = model.addListeners(listeners); + } + + return model.embedAll(textSegments); } } From 3cfb185dded952414d37588a6831232f38137f3d Mon Sep 17 00:00:00 2001 From: evo <446796145@qq.com> Date: Wed, 1 Apr 2026 23:11:54 +0800 Subject: [PATCH 04/17] =?UTF-8?q?feat=EF=BC=9A=E5=A2=9E=E5=8A=A0=E5=8F=AF?= =?UTF-8?q?=E8=A7=82=E6=B5=8B=E6=80=A7=E7=9B=91=E5=90=AC=E5=99=A8=20?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=80=9D=E8=80=83=E8=BE=93=E5=87=BA=E7=9B=91?= =?UTF-8?q?=E5=90=AC=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ruoyi/observability/MyAgentListener.java | 15 ++++++ .../service/chat/impl/ChatServiceFacade.java | 53 ++++++++++++++++--- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAgentListener.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAgentListener.java index 7a548788..96223687 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAgentListener.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAgentListener.java @@ -10,6 +10,7 @@ import dev.langchain4j.service.tool.ToolExecution; import lombok.extern.slf4j.Slf4j; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; /** * 自定义的 AgentListener 的监听器。 @@ -26,6 +27,13 @@ import java.util.Map; @Slf4j public class MyAgentListener implements dev.langchain4j.agentic.observability.AgentListener { + /** 最终捕获到的思考结果(主 Agent 完成后写入,供外部获取) */ + private final AtomicReference sharedOutputRef = new AtomicReference<>(); + + public String getCapturedResult() { + return sharedOutputRef.get(); + } + // ==================== Agent 调用生命周期 ==================== @Override @@ -72,12 +80,19 @@ public class MyAgentListener implements dev.langchain4j.agentic.observability.Ag AgentInstance agent = agentResponse.agent(); Map inputs = agentResponse.inputs(); Object output = agentResponse.output(); + String outputStr = output != null ? output.toString() : ""; log.info("【Agent调用后】Agent名称: {}", agent.name()); log.info("【Agent调用后】Agent ID: {}", agent.agentId()); log.info("【Agent调用后】Agent输入参数: {}", inputs); log.info("【Agent调用后】Agent输出结果: {}", output); log.info("【Agent调用后】是否为叶子节点: {}", agent.leaf()); + + // 捕获主 Agent 的最终输出,供外部获取 + if ("invoke".equals(agent.agentId()) && !outputStr.isEmpty()) { + sharedOutputRef.set(outputStr); + log.info("【Agent调用后】已捕获主Agent输出: {}", outputStr); + } } @Override diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java index c0292871..62b90728 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java @@ -122,7 +122,7 @@ public class ChatServiceFacade implements IChatService { List contextMessages = buildContextMessages(chatRequest); // 3. 处理特殊聊天模式(工作流、人机交互恢复、思考模式) - SseEmitter specialResult = handleSpecialChatModes(chatRequest, contextMessages, chatModelVo, emitter); + SseEmitter specialResult = handleSpecialChatModes(chatRequest, contextMessages, chatModelVo, emitter, userId, tokenValue); if (specialResult != null) { return specialResult; } @@ -151,10 +151,13 @@ public class ChatServiceFacade implements IChatService { * @param contextMessages 上下文消息列表(可能被修改) * @param chatModelVo 聊天模型配置 * @param emitter SSE发射器 + * @param userId 用户ID + * @param tokenValue 会话令牌 * @return 如果需要提前返回则返回SseEmitter,否则返回null */ private SseEmitter handleSpecialChatModes(ChatRequest chatRequest, List contextMessages, - ChatModelVo chatModelVo, SseEmitter emitter) { + ChatModelVo chatModelVo, SseEmitter emitter, + Long userId, String tokenValue) { // 处理工作流对话 if (chatRequest.getEnableWorkFlow()) { log.info("处理工作流对话,会话: {}", chatRequest.getSessionId()); @@ -193,7 +196,16 @@ public class ChatServiceFacade implements IChatService { // 处理思考模式 if (chatRequest.getEnableThinking()) { - handleThinkingMode(chatRequest, contextMessages, chatModelVo); + String thinkingResult = handleThinkingMode(chatRequest, contextMessages, chatModelVo, userId, tokenValue); + // 思考模式产生了有效结果,通过 SSE 发送给前端后结束 + if (thinkingResult != null && !thinkingResult.isBlank()) { + SseMessageUtils.sendDone(userId); + SseMessageUtils.completeConnection(userId, tokenValue); + log.info("思考模式完成,结果已发送: {}", thinkingResult); + return emitter; + } + // 思考结果为空,继续走普通聊天流程 + log.warn("思考模式未产生有效结果,继续普通聊天"); } return null; @@ -205,8 +217,12 @@ public class ChatServiceFacade implements IChatService { * @param chatRequest 聊天请求 * @param contextMessages 上下文消息列表 * @param chatModelVo 聊天模型配置 + * @param userId 用户ID + * @param tokenValue 会话令牌 + * @return 思考结果字符串,如果无结果则返回空字符串 */ - private void handleThinkingMode(ChatRequest chatRequest, List contextMessages, ChatModelVo chatModelVo) { + private String handleThinkingMode(ChatRequest chatRequest, List contextMessages, + ChatModelVo chatModelVo, Long userId, String tokenValue) { // 步骤1: 配置MCP传输层 - 连接到bing-cn-mcp服务器 McpTransport transport = new StdioMcpTransport.Builder() .command(List.of("C:\\Program Files\\nodejs\\npx.cmd", "-y", "bing-cn-mcp")) @@ -263,13 +279,38 @@ public class ChatServiceFacade implements IChatService { // 构建监督者Agent SupervisorAgent supervisor = AgenticServices.supervisorBuilder() .chatModel(plannerModel) - .subAgents(sqlAgent, chartGenerationAgent) + .subAgents(sqlAgent, searchAgent, chartGenerationAgent) .responseStrategy(SupervisorResponseStrategy.LAST) .listener(new MyAgentListener()) .build(); + // 调用 supervisor String invoke = supervisor.invoke(chatRequest.getContent()); - contextMessages.add(AiMessage.from(invoke)); + log.info("【思考模式】supervisor.invoke() 返回: {}", invoke); + + // 如果有有效结果,通过 SSE 发送给前端并保存到数据库 + if (invoke != null && !invoke.isBlank()) { + try { + // 通过 SSE 实时发送思考结果 + SseMessageUtils.sendContent(userId, invoke); + log.info("【思考模式】结果已发送至SSE: {}", invoke); + + // 保存用户消息 + chatMessageService.saveChatMessage(userId, chatRequest.getSessionId(), + chatRequest.getContent(), RoleType.USER.getName(), chatRequest.getModel()); + + // 保存助手思考结果消息 + chatMessageService.saveChatMessage(userId, chatRequest.getSessionId(), + invoke, RoleType.ASSISTANT.getName(), chatRequest.getModel()); + + // 将思考结果添加到上下文,供后续流程使用(如果需要) + contextMessages.add(AiMessage.from(invoke)); + } catch (Exception e) { + log.error("【思考模式】发送结果或保存消息失败: {}", e.getMessage(), e); + } + } + + return invoke != null ? invoke : ""; } /** From 4e38f853f3d21eaa9e3665de72497c1330aa7cec Mon Sep 17 00:00:00 2001 From: evo <446796145@qq.com> Date: Thu, 2 Apr 2026 10:07:26 +0800 Subject: [PATCH 05/17] =?UTF-8?q?feat=EF=BC=9A=E4=BF=AE=E5=A4=8D=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=A0=A1=E9=AA=8C=20&=20=E8=B0=83=E6=95=B4=E4=B8=BB?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E7=B1=BB=E7=9A=84kill=20port=20=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ruoyi-admin/src/main/java/org/ruoyi/RuoYiAIApplication.java | 2 +- .../src/main/java/org/ruoyi/controller/AuthController.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ruoyi-admin/src/main/java/org/ruoyi/RuoYiAIApplication.java b/ruoyi-admin/src/main/java/org/ruoyi/RuoYiAIApplication.java index 1ce9207c..ffca4ef5 100644 --- a/ruoyi-admin/src/main/java/org/ruoyi/RuoYiAIApplication.java +++ b/ruoyi-admin/src/main/java/org/ruoyi/RuoYiAIApplication.java @@ -16,7 +16,7 @@ import java.net.ServerSocket; public class RuoYiAIApplication { public static void main(String[] args) { - killPortProcess(6039); + // killPortProcess(6039); SpringApplication application = new SpringApplication(RuoYiAIApplication.class); application.setApplicationStartup(new BufferingApplicationStartup(2048)); application.run(args); diff --git a/ruoyi-admin/src/main/java/org/ruoyi/controller/AuthController.java b/ruoyi-admin/src/main/java/org/ruoyi/controller/AuthController.java index 655821da..7a801355 100644 --- a/ruoyi-admin/src/main/java/org/ruoyi/controller/AuthController.java +++ b/ruoyi-admin/src/main/java/org/ruoyi/controller/AuthController.java @@ -76,7 +76,7 @@ public class AuthController { @PostMapping("/login") public R login(@RequestBody String body) { LoginBody loginBody = JsonUtils.parseObject(body, LoginBody.class); -// ValidatorUtils.validate(loginBody); + ValidatorUtils.validate(loginBody); // 授权类型和客户端id String clientId = loginBody.getClientId(); String grantType = loginBody.getGrantType(); From d2005cfa4847f94704c46a090dd55eb3d4223fd1 Mon Sep 17 00:00:00 2001 From: evo <446796145@qq.com> Date: Sun, 5 Apr 2026 21:34:41 +0800 Subject: [PATCH 06/17] =?UTF-8?q?feat=EF=BC=9A=E8=B0=83=E6=95=B4=E5=8F=AF?= =?UTF-8?q?=E8=A7=82=E6=B5=8B=E6=80=A7=E7=9B=91=E5=90=AC=E5=99=A8=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ruoyi/agent/tool/ExecuteSqlQueryTool.java | 2 +- .../ruoyi/factory/EmbeddingModelFactory.java | 6 +- .../ruoyi/observability/MyAgentListener.java | 2 +- .../service/chat/impl/ChatServiceFacade.java | 69 ++++++------------- .../impl/provider/DeepseekServiceImpl.java | 7 +- .../chat/impl/provider/OllamaServiceImpl.java | 11 +-- .../chat/impl/provider/OpenAIServiceImpl.java | 7 +- .../chat/impl/provider/PPIOServiceImpl.java | 8 +-- .../impl/provider/QianWenChatServiceImpl.java | 5 +- .../impl/provider/ZhiPuChatServiceImpl.java | 8 +-- .../impl/AliBaiLianBaseEmbedProvider.java | 13 +--- .../embed/impl/OllamaEmbeddingProvider.java | 12 ---- .../embed/impl/OpenAiEmbeddingProvider.java | 11 --- 13 files changed, 56 insertions(+), 105 deletions(-) diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/tool/ExecuteSqlQueryTool.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/tool/ExecuteSqlQueryTool.java index 7fb56ad6..1b9079f1 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/tool/ExecuteSqlQueryTool.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/tool/ExecuteSqlQueryTool.java @@ -43,7 +43,7 @@ public class ExecuteSqlQueryTool implements BuiltinToolProvider { @Tool("Execute a SELECT SQL query and return the results. Example: SELECT * FROM sys_user") public String executeSql(String sql) { // 2. 手动推入数据源上下文 - DynamicDataSourceContextHolder.push("agent"); +// DynamicDataSourceContextHolder.push("agent"); if (sql == null || sql.trim().isEmpty()) { return "Error: SQL query cannot be empty"; } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/EmbeddingModelFactory.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/EmbeddingModelFactory.java index a4d93725..b123e6bb 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/EmbeddingModelFactory.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/EmbeddingModelFactory.java @@ -2,8 +2,9 @@ package org.ruoyi.factory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.ruoyi.common.chat.service.chat.IChatModelService; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.service.chat.IChatModelService; +import org.ruoyi.observability.EmbeddingModelListenerProvider; import org.ruoyi.service.embed.BaseEmbedModelService; import org.ruoyi.service.embed.MultiModalEmbedModelService; import org.springframework.beans.factory.NoSuchBeanDefinitionException; @@ -27,6 +28,7 @@ public class EmbeddingModelFactory { private final ApplicationContext applicationContext; private final IChatModelService chatModelService; + private final EmbeddingModelListenerProvider embeddingModelListenerProvider; // 模型缓存,使用ConcurrentHashMap保证线程安全 private final Map modelCache = new ConcurrentHashMap<>(); @@ -109,6 +111,8 @@ public class EmbeddingModelFactory { BaseEmbedModelService model = applicationContext.getBean(factory, BaseEmbedModelService.class); // 配置模型参数 model.configure(config); + // 增加嵌入模型监听器 + model.addListeners(embeddingModelListenerProvider.getEmbeddingModelListeners()); log.info("成功创建嵌入模型: factory={}, modelId={}", config.getProviderCode(), config.getId()); return model; } catch (NoSuchBeanDefinitionException e) { diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAgentListener.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAgentListener.java index 96223687..299ea6e9 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAgentListener.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAgentListener.java @@ -46,7 +46,7 @@ public class MyAgentListener implements dev.langchain4j.agentic.observability.Ag log.info("【Agent调用前】Agent ID: {}", agent.agentId()); log.info("【Agent调用前】Agent类型: {}", agent.type().getName()); log.info("【Agent调用前】Agent描述: {}", agent.description()); - log.info("【Agent调用前】Planner类型: {}", agent.plannerType().getName()); + log.info("【Agent调用前】Planner类型: {}", agent.plannerType()); log.info("【Agent调用前】输出类型: {}", agent.outputType()); log.info("【Agent调用前】输出Key: {}", agent.outputKey()); log.info("【Agent调用前】是否为异步: {}", agent.async()); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java index 62b90728..ed906b0d 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java @@ -1,11 +1,13 @@ package org.ruoyi.service.chat.impl; import cn.dev33.satoken.stp.StpUtil; +import cn.hutool.core.util.StrUtil; import dev.langchain4j.agentic.AgenticServices; import dev.langchain4j.agentic.supervisor.SupervisorAgent; import dev.langchain4j.agentic.supervisor.SupervisorResponseStrategy; -import dev.langchain4j.community.model.dashscope.QwenChatModel; -import dev.langchain4j.data.message.*; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.mcp.McpToolProvider; import dev.langchain4j.mcp.client.DefaultMcpClient; import dev.langchain4j.mcp.client.McpClient; @@ -22,8 +24,6 @@ import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.ruoyi.agent.ChartGenerationAgent; import org.ruoyi.agent.SqlAgent; -import org.ruoyi.observability.MyAgentListener; -import org.ruoyi.observability.MyMcpClientListener; import org.ruoyi.agent.WebSearchAgent; import org.ruoyi.agent.tool.ExecuteSqlQueryTool; import org.ruoyi.agent.tool.QueryAllTablesTool; @@ -45,6 +45,9 @@ import org.ruoyi.domain.bo.vector.QueryVectorBo; import org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo; import org.ruoyi.factory.ChatServiceFactory; import org.ruoyi.mcp.service.core.ToolProviderFactory; +import org.ruoyi.observability.MyAgentListener; +import org.ruoyi.observability.MyChatModelListener; +import org.ruoyi.observability.MyMcpClientListener; import org.ruoyi.service.chat.AbstractChatService; import org.ruoyi.service.chat.IChatMessageService; import org.ruoyi.service.chat.impl.memory.PersistentChatMemoryStore; @@ -196,16 +199,7 @@ public class ChatServiceFacade implements IChatService { // 处理思考模式 if (chatRequest.getEnableThinking()) { - String thinkingResult = handleThinkingMode(chatRequest, contextMessages, chatModelVo, userId, tokenValue); - // 思考模式产生了有效结果,通过 SSE 发送给前端后结束 - if (thinkingResult != null && !thinkingResult.isBlank()) { - SseMessageUtils.sendDone(userId); - SseMessageUtils.completeConnection(userId, tokenValue); - log.info("思考模式完成,结果已发送: {}", thinkingResult); - return emitter; - } - // 思考结果为空,继续走普通聊天流程 - log.warn("思考模式未产生有效结果,继续普通聊天"); + handleThinkingMode(chatRequest, contextMessages, chatModelVo, userId); } return null; @@ -214,15 +208,13 @@ public class ChatServiceFacade implements IChatService { /** * 处理思考模式 * - * @param chatRequest 聊天请求 - * @param contextMessages 上下文消息列表 - * @param chatModelVo 聊天模型配置 - * @param userId 用户ID - * @param tokenValue 会话令牌 - * @return 思考结果字符串,如果无结果则返回空字符串 + * @param chatRequest 聊天请求 + * @param contextMessages 上下文消息列表 + * @param chatModelVo 聊天模型配置 + * @param userId 用户ID */ - private String handleThinkingMode(ChatRequest chatRequest, List contextMessages, - ChatModelVo chatModelVo, Long userId, String tokenValue) { + private void handleThinkingMode(ChatRequest chatRequest, List contextMessages, + ChatModelVo chatModelVo, Long userId) { // 步骤1: 配置MCP传输层 - 连接到bing-cn-mcp服务器 McpTransport transport = new StdioMcpTransport.Builder() .command(List.of("C:\\Program Files\\nodejs\\npx.cmd", "-y", "bing-cn-mcp")) @@ -257,60 +249,40 @@ public class ChatServiceFacade implements IChatService { OpenAiChatModel plannerModel = OpenAiChatModel.builder() .baseUrl(chatModelVo.getApiHost()) .apiKey(chatModelVo.getApiKey()) + .listeners(List.of(new MyChatModelListener())) .modelName(chatModelVo.getModelName()) .build(); // 构建各Agent SqlAgent sqlAgent = AgenticServices.agentBuilder(SqlAgent.class) .chatModel(plannerModel) + .listener(new MyAgentListener()) .tools(new QueryAllTablesTool(), new QueryTableSchemaTool(), new ExecuteSqlQueryTool()) .build(); WebSearchAgent searchAgent = AgenticServices.agentBuilder(WebSearchAgent.class) .chatModel(plannerModel) + .listener(new MyAgentListener()) .toolProvider(toolProvider) .build(); ChartGenerationAgent chartGenerationAgent = AgenticServices.agentBuilder(ChartGenerationAgent.class) .chatModel(plannerModel) + .listener(new MyAgentListener()) .toolProvider(toolProvider1) .build(); // 构建监督者Agent SupervisorAgent supervisor = AgenticServices.supervisorBuilder() .chatModel(plannerModel) + .listener(new MyAgentListener()) .subAgents(sqlAgent, searchAgent, chartGenerationAgent) .responseStrategy(SupervisorResponseStrategy.LAST) - .listener(new MyAgentListener()) .build(); // 调用 supervisor String invoke = supervisor.invoke(chatRequest.getContent()); - log.info("【思考模式】supervisor.invoke() 返回: {}", invoke); - - // 如果有有效结果,通过 SSE 发送给前端并保存到数据库 - if (invoke != null && !invoke.isBlank()) { - try { - // 通过 SSE 实时发送思考结果 - SseMessageUtils.sendContent(userId, invoke); - log.info("【思考模式】结果已发送至SSE: {}", invoke); - - // 保存用户消息 - chatMessageService.saveChatMessage(userId, chatRequest.getSessionId(), - chatRequest.getContent(), RoleType.USER.getName(), chatRequest.getModel()); - - // 保存助手思考结果消息 - chatMessageService.saveChatMessage(userId, chatRequest.getSessionId(), - invoke, RoleType.ASSISTANT.getName(), chatRequest.getModel()); - - // 将思考结果添加到上下文,供后续流程使用(如果需要) - contextMessages.add(AiMessage.from(invoke)); - } catch (Exception e) { - log.error("【思考模式】发送结果或保存消息失败: {}", e.getMessage(), e); - } - } - - return invoke != null ? invoke : ""; + log.info("supervisor.invoke() 返回: {}", invoke); } /** @@ -348,6 +320,7 @@ public class ChatServiceFacade implements IChatService { // 7. 发起对话 StreamingChatModel streamingChatModel = chatService.buildStreamingChatModel(chatModelVo, chatRequest); + streamingChatModel.listeners().add(new MyChatModelListener()); streamingChatModel.chat(chatRequest.getContent(), combinedHandler); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DeepseekServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DeepseekServiceImpl.java index 15bf7556..7ecf4368 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DeepseekServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DeepseekServiceImpl.java @@ -9,9 +9,12 @@ import org.ruoyi.common.chat.domain.dto.request.ChatRequest; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.enums.ChatModeType; import org.ruoyi.observability.ChatModelListenerProvider; +import org.ruoyi.observability.MyChatModelListener; import org.ruoyi.service.chat.AbstractChatService; import org.springframework.stereotype.Service; +import java.util.List; + /** * Deepseek服务调用 @@ -24,16 +27,14 @@ import org.springframework.stereotype.Service; @RequiredArgsConstructor public class DeepseekServiceImpl implements AbstractChatService { - private final ChatModelListenerProvider listenerProvider; - @Override public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) { return OpenAiStreamingChatModel.builder() .baseUrl(chatModelVo.getApiHost()) .apiKey(chatModelVo.getApiKey()) .modelName(chatModelVo.getModelName()) + .listeners(List.of(new MyChatModelListener())) .returnThinking(chatRequest.getEnableThinking()) - .listeners(listenerProvider.getChatModelListeners()) .build(); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OllamaServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OllamaServiceImpl.java index 4da7073e..a9346538 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OllamaServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OllamaServiceImpl.java @@ -5,12 +5,15 @@ import dev.langchain4j.model.chat.StreamingChatModel; import dev.langchain4j.model.ollama.OllamaStreamingChatModel; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.ruoyi.enums.ChatModeType; -import org.ruoyi.service.chat.AbstractChatService; -import org.springframework.stereotype.Service; import org.ruoyi.common.chat.domain.dto.request.ChatRequest; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.enums.ChatModeType; import org.ruoyi.observability.ChatModelListenerProvider; +import org.ruoyi.observability.MyChatModelListener; +import org.ruoyi.service.chat.AbstractChatService; +import org.springframework.stereotype.Service; + +import java.util.List; /** * OllamaAI服务调用 @@ -30,7 +33,7 @@ public class OllamaServiceImpl implements AbstractChatService { return OllamaStreamingChatModel.builder() .baseUrl(chatModelVo.getApiHost()) .modelName(chatModelVo.getModelName()) - .listeners(listenerProvider.getChatModelListeners()) + .listeners(List.of(new MyChatModelListener())) .build(); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java index 76503b89..eb3ed3ae 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java @@ -9,9 +9,12 @@ import org.ruoyi.common.chat.domain.dto.request.ChatRequest; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.enums.ChatModeType; import org.ruoyi.observability.ChatModelListenerProvider; +import org.ruoyi.observability.MyChatModelListener; import org.ruoyi.service.chat.AbstractChatService; import org.springframework.stereotype.Service; +import java.util.List; + /** * OPENAI服务调用 @@ -24,16 +27,14 @@ import org.springframework.stereotype.Service; @RequiredArgsConstructor public class OpenAIServiceImpl implements AbstractChatService { - private final ChatModelListenerProvider listenerProvider; - @Override public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo,ChatRequest chatRequest) { return OpenAiStreamingChatModel.builder() .baseUrl(chatModelVo.getApiHost()) .apiKey(chatModelVo.getApiKey()) .modelName(chatModelVo.getModelName()) + .listeners(List.of(new MyChatModelListener())) .returnThinking(chatRequest.getEnableThinking()) - .listeners(listenerProvider.getChatModelListeners()) .build(); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/PPIOServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/PPIOServiceImpl.java index fd742abb..b07c21d1 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/PPIOServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/PPIOServiceImpl.java @@ -8,10 +8,12 @@ import lombok.extern.slf4j.Slf4j; import org.ruoyi.common.chat.domain.dto.request.ChatRequest; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.enums.ChatModeType; -import org.ruoyi.observability.ChatModelListenerProvider; +import org.ruoyi.observability.MyChatModelListener; import org.ruoyi.service.chat.AbstractChatService; import org.springframework.stereotype.Service; +import java.util.List; + /** * PPIO服务调用 * @@ -23,16 +25,14 @@ import org.springframework.stereotype.Service; @RequiredArgsConstructor public class PPIOServiceImpl implements AbstractChatService { - private final ChatModelListenerProvider listenerProvider; - @Override public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) { return OpenAiStreamingChatModel.builder() .baseUrl(chatModelVo.getApiHost()) .apiKey(chatModelVo.getApiKey()) .modelName(chatModelVo.getModelName()) + .listeners(List.of(new MyChatModelListener())) .returnThinking(chatRequest.getEnableThinking()) - .listeners(listenerProvider.getChatModelListeners()) .build(); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceImpl.java index 2f34899e..73289939 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceImpl.java @@ -9,9 +9,12 @@ import org.ruoyi.common.chat.domain.dto.request.ChatRequest; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.enums.ChatModeType; import org.ruoyi.observability.ChatModelListenerProvider; +import org.ruoyi.observability.MyChatModelListener; import org.ruoyi.service.chat.AbstractChatService; import org.springframework.stereotype.Service; +import java.util.List; + /** * qianWenAI服务调用 @@ -31,7 +34,7 @@ public class QianWenChatServiceImpl implements AbstractChatService { return QwenStreamingChatModel.builder() .apiKey(chatModelVo.getApiKey()) .modelName(chatModelVo.getModelName()) - .listeners(listenerProvider.getChatModelListeners()) + .listeners(List.of(new MyChatModelListener())) .build(); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java index 0cfebbfd..9e1b504d 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java @@ -8,10 +8,12 @@ import lombok.extern.slf4j.Slf4j; import org.ruoyi.common.chat.domain.dto.request.ChatRequest; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.enums.ChatModeType; -import org.ruoyi.observability.ChatModelListenerProvider; +import org.ruoyi.observability.MyChatModelListener; import org.ruoyi.service.chat.AbstractChatService; import org.springframework.stereotype.Service; +import java.util.List; + /** * 智谱AI服务调用 @@ -24,14 +26,12 @@ import org.springframework.stereotype.Service; @RequiredArgsConstructor public class ZhiPuChatServiceImpl implements AbstractChatService { - private final ChatModelListenerProvider listenerProvider; - @Override public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) { return ZhipuAiStreamingChatModel.builder() .apiKey(chatModelVo.getApiKey()) .model(chatModelVo.getModelName()) - .listeners(listenerProvider.getChatModelListeners()) + .listeners(List.of(new MyChatModelListener())) .build(); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianBaseEmbedProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianBaseEmbedProvider.java index 936e0993..01709e45 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianBaseEmbedProvider.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianBaseEmbedProvider.java @@ -5,13 +5,10 @@ import dev.langchain4j.community.model.dashscope.QwenEmbeddingModel; import dev.langchain4j.data.embedding.Embedding; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.model.embedding.EmbeddingModel; -import dev.langchain4j.model.embedding.listener.EmbeddingModelListener; import dev.langchain4j.model.output.Response; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; -import org.ruoyi.observability.EmbeddingModelListenerProvider; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; import org.ruoyi.enums.ModalityType; +import org.springframework.stereotype.Component; import java.util.List; import java.util.Set; @@ -26,9 +23,6 @@ public class AliBaiLianBaseEmbedProvider extends OpenAiEmbeddingProvider { private ChatModelVo chatModelVo; - @Autowired - private EmbeddingModelListenerProvider embeddingModelListenerProvider; - @Override public void configure(ChatModelVo config) { this.chatModelVo = config; @@ -41,17 +35,12 @@ public class AliBaiLianBaseEmbedProvider extends OpenAiEmbeddingProvider { @Override public Response> embedAll(List textSegments) { - List listeners = embeddingModelListenerProvider.getEmbeddingModelListeners(); EmbeddingModel model = QwenEmbeddingModel.builder() .apiKey(chatModelVo.getApiKey()) .modelName(chatModelVo.getModelName()) .dimension(chatModelVo.getModelDimension()) .build(); - if (!listeners.isEmpty()) { - model = model.addListeners(listeners); - } - return model.embedAll(textSegments); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OllamaEmbeddingProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OllamaEmbeddingProvider.java index 79202425..905b9e73 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OllamaEmbeddingProvider.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OllamaEmbeddingProvider.java @@ -3,15 +3,11 @@ package org.ruoyi.service.embed.impl; import dev.langchain4j.data.embedding.Embedding; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.model.embedding.EmbeddingModel; -import dev.langchain4j.model.embedding.listener.EmbeddingModelListener; import dev.langchain4j.model.ollama.OllamaEmbeddingModel; import dev.langchain4j.model.output.Response; -import jakarta.annotation.Resource; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.enums.ModalityType; -import org.ruoyi.observability.EmbeddingModelListenerProvider; import org.ruoyi.service.embed.BaseEmbedModelService; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.util.List; @@ -26,9 +22,6 @@ import java.util.Set; public class OllamaEmbeddingProvider implements BaseEmbedModelService { private ChatModelVo chatModelVo; - @Resource - private EmbeddingModelListenerProvider embeddingModelListenerProvider; - @Override public void configure(ChatModelVo config) { this.chatModelVo = config; @@ -42,16 +35,11 @@ public class OllamaEmbeddingProvider implements BaseEmbedModelService { // ollama不能设置embedding维度,使用milvus时请注意!!创建向量表时需要先设定维度大小 @Override public Response> embedAll(List textSegments) { - List listeners = embeddingModelListenerProvider.getEmbeddingModelListeners(); EmbeddingModel model = OllamaEmbeddingModel.builder() .baseUrl(chatModelVo.getApiHost()) .modelName(chatModelVo.getModelName()) .build(); - if (!listeners.isEmpty()) { - model = model.addListeners(listeners); - } - return model.embedAll(textSegments); } } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OpenAiEmbeddingProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OpenAiEmbeddingProvider.java index 039342ee..6c583f6f 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OpenAiEmbeddingProvider.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OpenAiEmbeddingProvider.java @@ -3,14 +3,11 @@ package org.ruoyi.service.embed.impl; import dev.langchain4j.data.embedding.Embedding; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.model.embedding.EmbeddingModel; -import dev.langchain4j.model.embedding.listener.EmbeddingModelListener; import dev.langchain4j.model.openai.OpenAiEmbeddingModel; import dev.langchain4j.model.output.Response; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.enums.ModalityType; -import org.ruoyi.observability.EmbeddingModelListenerProvider; import org.ruoyi.service.embed.BaseEmbedModelService; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.util.List; @@ -25,9 +22,6 @@ import java.util.Set; public class OpenAiEmbeddingProvider implements BaseEmbedModelService { protected ChatModelVo chatModelVo; - @Autowired - private EmbeddingModelListenerProvider embeddingModelListenerProvider; - @Override public void configure(ChatModelVo config) { this.chatModelVo = config; @@ -40,7 +34,6 @@ public class OpenAiEmbeddingProvider implements BaseEmbedModelService { @Override public Response> embedAll(List textSegments) { - List listeners = embeddingModelListenerProvider.getEmbeddingModelListeners(); EmbeddingModel model = OpenAiEmbeddingModel.builder() .baseUrl(chatModelVo.getApiHost()) .apiKey(chatModelVo.getApiKey()) @@ -48,10 +41,6 @@ public class OpenAiEmbeddingProvider implements BaseEmbedModelService { .dimensions(chatModelVo.getModelDimension()) .build(); - if (!listeners.isEmpty()) { - model = model.addListeners(listeners); - } - return model.embedAll(textSegments); } } From 2f39fa0f53790b99f0d20f92eb06a6b6505d6a44 Mon Sep 17 00:00:00 2001 From: evo <446796145@qq.com> Date: Sun, 5 Apr 2026 21:36:53 +0800 Subject: [PATCH 07/17] =?UTF-8?q?feat=EF=BC=9A=E8=B0=83=E6=95=B4=E5=8F=AF?= =?UTF-8?q?=E8=A7=82=E6=B5=8B=E6=80=A7=E7=9B=91=E5=90=AC=E5=99=A8=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java | 1 - 1 file changed, 1 deletion(-) diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java index ed906b0d..10955902 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java @@ -320,7 +320,6 @@ public class ChatServiceFacade implements IChatService { // 7. 发起对话 StreamingChatModel streamingChatModel = chatService.buildStreamingChatModel(chatModelVo, chatRequest); - streamingChatModel.listeners().add(new MyChatModelListener()); streamingChatModel.chat(chatRequest.getContent(), combinedHandler); } From d602b805bdca899e5524e6b5f0c63bd0bb25c351 Mon Sep 17 00:00:00 2001 From: wangle Date: Tue, 7 Apr 2026 21:04:31 +0800 Subject: [PATCH 08/17] =?UTF-8?q?docs=EF=BC=9A=E6=9B=B4=E6=96=B0=E6=8A=80?= =?UTF-8?q?=E6=9C=AF=E6=A0=88=E7=89=88=E6=9C=AC=E5=8F=B7=E5=B9=B6=E6=B8=85?= =?UTF-8?q?=E7=90=86=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新后端架构版本为 Spring Boot 3.5.8 + Langchain4j - 删除 rag-failures.md 和文件上传接口文档 - 重命名 mcp-api-spec.md 为 MCP工具模块接口文档.md Co-Authored-By: Claude Opus 4.6 --- README.md | 8 +- README_EN.md | 8 +- docs/troubleshooting/rag-failures.md | 352 ------------------ docs/文件上传接口文档.md | 42 --- ...mcp-api-spec.md => MCP工具模块接口文档.md} | 0 5 files changed, 2 insertions(+), 408 deletions(-) delete mode 100644 docs/troubleshooting/rag-failures.md delete mode 100644 docs/文件上传接口文档.md rename ruoyi-modules/ruoyi-chat/docs/{mcp-api-spec.md => MCP工具模块接口文档.md} (100%) diff --git a/README.md b/README.md index e63365d4..e9c7c082 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ ## 🛠️ 技术架构 ### 核心框架 -- **后端架构**:Spring Boot 4.0 + Spring ai 2.0 + Langchain4j +- **后端架构**:Spring Boot 3.5.8 + Langchain4j - **数据存储**:MySQL 8.0 + Redis + 向量数据库(Milvus/Weaviate/Qdrant) - **前端技术**:Vue 3 + Vben Admin + element-plus-x - **安全认证**:Sa-Token + JWT 双重保障 @@ -189,12 +189,6 @@ docker-compose -f docker-compose-all.yaml restart [服务名] **👉 [完整使用文档](https://doc.pandarobot.chat)** -遇到知识库或 RAG 回答异常问题? - -**👉 [RAG 回答异常排查手册](docs/troubleshooting/rag-failures.md)** - ---- - ## 🤝 参与贡献 我们热烈欢迎社区贡献!无论您是资深开发者还是初学者,都可以为项目贡献力量 💪 diff --git a/README_EN.md b/README_EN.md index 5c5c024b..00564414 100644 --- a/README_EN.md +++ b/README_EN.md @@ -65,7 +65,7 @@ ## 🛠️ Technical Architecture ### Core Framework -- **Backend**: Spring Boot 4.0 + Spring AI 2.0 + Langchain4j +- **Backend**: Spring Boot 3.5.8 + Langchain4j - **Data Storage**: MySQL 8.0 + Redis + Vector Databases (Milvus/Weaviate/Qdrant) - **Frontend**: Vue 3 + Vben Admin + element-plus-x - **Security**: Sa-Token + JWT dual-layer security @@ -192,12 +192,6 @@ Want to learn more about installation, deployment, configuration, and secondary **👉 [Complete Documentation](https://doc.pandarobot.chat)** -Experiencing issues with knowledge base or RAG responses? - -**👉 [RAG Troubleshooting Guide](docs/troubleshooting/rag-failures.md)** - ---- - ## 🤝 Contributing We warmly welcome community contributions! Whether you are a seasoned developer or just getting started, you can contribute to the project 💪 diff --git a/docs/troubleshooting/rag-failures.md b/docs/troubleshooting/rag-failures.md deleted file mode 100644 index fdcadefe..00000000 --- a/docs/troubleshooting/rag-failures.md +++ /dev/null @@ -1,352 +0,0 @@ - - -# RAG 常见故障排查(16 问题清单) - -当知识库已经接入,系统也能正常回答,但结果仍然出现命中错误、引用旧内容、推理漂移、跨轮次失忆,或部署后表面可用但实际异常时,最常见的问题不是“模型不行”,而是**不同层的故障被混在一起处理**。 - -这份页面不重新发明一套新方法。 -它直接使用一份固定的 **16 问题清单** 作为排查主轴,让你先把问题标到正确的 **No.X**,再决定下一步查哪里、改哪里,而不是一次性乱改检索、模型、切块、会话和部署配置。 - -这份清单的核心目的只有一个: - -**先把问题放进正确的故障域,再做修复。** - -快速导航: -[这页怎么用](#how-to-use) | [标签说明](#legend) | [常见症状入口](#symptoms) | [16 问题清单](#map16) | [按层排查](#by-layer) | - ---- - - - -## 一、这页怎么用 - -这不是一篇“从头到尾照着做”的传统教程。 -它更像一张固定的 RAG 故障地图,作用是先帮助你**判断故障属于哪一种类型**。 - -建议按下面顺序使用: - -### 1. 先看现象,不要先改配置 - -先回答两个问题: - -1. 你看到的故障,最像哪一种症状 -2. 这个故障更像发生在输入检索层、推理层、状态层,还是部署层 - -在还没判断层级之前,不要先一起改这些东西: - -- 检索条数 -- 切块大小 -- 会话配置 -- 模型参数 -- 部署顺序 -- 依赖服务 - -如果先全部一起动,问题通常只会更难定位。 - -### 2. 先给问题打上 No.X 标签 - -这份页面最重要的动作,不是“立刻修好”,而是先做一件小事: - -**给当前问题贴上最接近的 No.X。** - -例如: - -- 检索结果看起来相似,但其实答非所问,先看 `No.1` 或 `No.5` -- 切块是对的,但结论还是错,先看 `No.2` -- 系统回答很自信,但没有根据,先看 `No.4` -- 刚部署完就炸,先看 `No.14` 到 `No.16` - -### 3. 一次只排一个故障域 - -同一个表面现象,背后可能是不同层的问题。 -例如“答案不对”既可能是: - -- `No.1` 检索漂移 -- `No.2` 理解塌陷 -- `No.4` 自信乱答 -- `No.8` 根本看不到错误路径 - -所以这张表的用法不是“多选全改”,而是: - -**先挑最接近的一项,优先验证这一项是否成立。** - -[返回顶部](#top) | [下一节:标签说明](#legend) - ---- - - - -## 二、标签说明 - -这份 16 问题清单本身已经带有层级 / 标签结构。 -这些标签不是装饰,而是用来帮助你快速判断故障发生在哪一层。 - -### 1. 层级标签 - -- `[IN]`:输入与检索 - 输入、切块、召回、语义匹配、可见性问题 - -- `[RE]`:推理与规划 - 理解、推理、归纳、逻辑链、抽象处理问题 - -- `[ST]`:状态与上下文 - 会话、记忆、上下文连续性、多代理状态问题 - -- `[OP]`:基础设施与部署 - 启动顺序、依赖就绪、部署锁死、预部署状态问题 - -### 2. `{OBS}` 标签 - -带 `{OBS}` 的项,通常都和“**你是否看得见问题是怎么坏掉的**”有关。 -它们往往不是单纯回答错误,而是: - -- 错误路径不可见 -- 漂移过程不可见 -- 状态熔化过程不可见 -- 多代理覆盖过程不可见 - -所以一旦你发现“我知道结果错,但我根本看不到它是怎么错的”,通常就已经很接近 `{OBS}` 类问题了。 - -### 3. 为什么要保留这些标签 - -因为同样叫“答错了”,实际含义完全不同。 - -例如: - -- `[IN]` 的答错,常常是**拿错材料** -- `[RE]` 的答错,常常是**拿对材料但理解错** -- `[ST]` 的答错,常常是**前文断掉、状态漂移** -- `[OP]` 的答错,常常是**系统根本没在完整状态下运行** - -如果不先分层,就会掉进典型的 RAG 地狱: -表面在改答案,实际上在盲修。 - -[返回顶部](#top) | [下一节:常见症状入口](#symptoms) - ---- - - - -## 三、常见症状入口 - -如果你现在还不知道该从哪一项开始,就先从症状入口反查。 - -### 1. 检索返回了错误内容,或看起来相关但其实不回答问题 - -这类问题最常见的是: -“有命中,但命中的不是该用的内容。” - -优先看: - -- [No.1](#no1) `幻觉与切块漂移` -- [No.5](#no5) `语义 ≠ 向量嵌入` -- [No.8](#no8) `调试是一个黑箱` - -### 2. 切块本身是对的,但最终答案还是错的 - -这类问题不是简单没检索到,而是后面那层坏了。 - -优先看: - -- [No.2](#no2) `解释塌陷` -- [No.4](#no4) `虚张声势 / 过度自信` -- [No.6](#no6) `逻辑塌陷与恢复` - -### 3. 多步任务一开始正常,后面越来越偏 - -这类问题通常不是单点错误,而是中途漂移或熔化。 - -优先看: - -- [No.3](#no3) `长推理链` -- [No.6](#no6) `逻辑塌陷与恢复` -- [No.9](#no9) `熵塌陷` - -### 4. 多轮对话后开始失忆,跨轮次接不上 - -这类问题一般已经进入状态层。 - -优先看: - -- [No.7](#no7) `跨会话记忆断裂` -- [No.9](#no9) `熵塌陷` -- [No.13](#no13) `多代理混乱` - -### 5. 遇到抽象、逻辑、规则、符号关系就崩 - -这类问题通常不是检索空,而是推理结构扛不住。 - -优先看: - -- [No.11](#no11) `符号塌陷` -- [No.12](#no12) `哲学递归` - -### 6. 你根本不知道错在哪一层,只知道结果不对 - -这类问题先不要乱调参数。 -先解决“不可见”的问题。 - -优先看: - -- [No.8](#no8) `调试是一个黑箱` - -### 7. 刚部署完最容易炸,首轮调用异常,重启后偶尔恢复 - -这类问题通常不在答案逻辑,而在部署状态。 - -优先看: - -- [No.14](#no14) `引导启动顺序` -- [No.15](#no15) `部署死锁` -- [No.16](#no16) `预部署塌陷` - -[返回顶部](#top) | [下一节:16 问题清单](#map16) - ---- - - - -## 四、16 问题清单(固定主表) - -下面这 16 项按固定顺序使用。 -不要先重组,不要先混类,先判断最接近哪一个 **No.X**。 - -| # | 问题域(含层级/标签) | 会坏在哪里 | -|---|---|---| -| 1 | `[IN] 幻觉与切块漂移 {OBS}` | 检索返回错误/无关内容 | -| 2 | `[RE] 解释塌陷` | 切块是对的,逻辑是错的 | -| 3 | `[RE] 长推理链 {OBS}` | 在多步任务中逐步漂移 | -| 4 | `[RE] 虚张声势 / 过度自信` | 自信但没有根据的回答 | -| 5 | `[IN] 语义 ≠ 向量嵌入 {OBS}` | 余弦匹配 ≠ 真实语义 | -| 6 | `[RE] 逻辑塌陷与恢复 {OBS}` | 走入死胡同,需要受控重置 | -| 7 | `[ST] 跨会话记忆断裂` | 线索丢失,没有连续性 | -| 8 | `[IN] 调试是一个黑箱 {OBS}` | 看不到故障路径 | -| 9 | `[ST] 熵塌陷` | 注意力熔化,输出失去连贯性 | -| 10 | `[RE] 创造力冻结` | 平直、字面化输出 | -| 11 | `[RE] 符号塌陷` | 抽象/逻辑性提示词失效 | -| 12 | `[RE] 哲学递归` | 自我引用循环、悖论陷阱 | -| 13 | `[ST] 多代理混乱 {OBS}` | 代理互相覆盖或使逻辑错位 | -| 14 | `[OP] 引导启动顺序` | 依赖未就绪时服务先启动 | -| 15 | `[OP] 部署死锁` | 基础设施中的循环等待 | -| 16 | `[OP] 预部署塌陷 {OBS}` | 首次调用时版本错配 / 缺少密钥 | - -这张表是主表。 -如果你时间很少,只做一件事也行: - -**先从这 16 项里选出最接近的一项。** - -[返回顶部](#top) | [下一节:按层排查](#by-layer) - ---- - - - -## 五、按层排查:不要改错层 - -这一节不重写 16 项,只是告诉你: -当你已经选到某个 No.X 时,第一眼应该优先查哪一层。 - -### A. `[IN]` 层:先确认你拿到的是不是对的材料 - -对应编号: - -- [No.1](#no1) -- [No.5](#no5) -- [No.8](#no8) - -这层最常见的误判是: - -“我以为系统理解错了,其实它一开始就拿错了东西。” - -如果你命中了弱相关片段、表面相似文本、错误切块,后面推理再强也没用。 -所以 `[IN]` 层优先看的是: - -1. 原始召回内容到底是什么 -2. 命中的片段是否只是“相似”,而不是“正确” -3. 你是否能看到检索过程,还是整个过程像黑箱 - -这层如果没先排好,后面的推理诊断通常会失真。 - -### B. `[RE]` 层:材料可能是对的,但系统用错了 - -对应编号: - -- [No.2](#no2) -- [No.3](#no3) -- [No.4](#no4) -- [No.6](#no6) -- [No.10](#no10) -- [No.11](#no11) -- [No.12](#no12) - -这层最常见的误判是: - -“我以为是检索坏了,其实是后面理解、归纳、逻辑链坏了。” - -例如: - -- 切块是对的,但结论错了 → 常见是 `No.2` -- 多步任务中途开始偏 → 常见是 `No.3` -- 回答很笃定,但完全站不住 → 常见是 `No.4` -- 遇到抽象规则就崩 → 常见是 `No.11` -- 陷入循环解释 → 常见是 `No.12` - -如果 `[IN]` 层已经基本没问题,答案还是不对,就应该优先回到 `[RE]` 层判断是哪一种塌陷。 - -### C. `[ST]` 层:单轮正常,不代表状态层正常 - -对应编号: - -- [No.7](#no7) -- [No.9](#no9) -- [No.13](#no13) - -这层最常见的误判是: - -“单轮看起来还行,所以我以为系统没问题。” - -其实很多 RAG 地狱不是单轮错误,而是: - -- 多轮之后前文断掉 -- 上下文越来越乱 -- 多角色、多代理之间互相覆盖 - -如果你发现: - -- 第一轮没事,后面越来越歪 -- 切换角色后前面的约束消失 -- 多个步骤之间状态彼此污染 - -那就不要再只盯着检索条数了,应该直接回到 `[ST]` 层看 `No.7 / No.9 / No.13`。 - -### D. `[OP]` 层:别把部署问题误诊成回答问题 - -对应编号: - -- [No.14](#no14) -- [No.15](#no15) -- [No.16](#no16) - -这层最常见的误判是: - -“答案不稳定,所以我先去调模型或检索。” - -但如果系统根本没有在完整状态下启动,所有上层表现都会像鬼打墙。 -尤其是这些情况: - -- 依赖还没就绪,服务先起了 → `No.14` -- 多个组件互相等待,长期半可用 → `No.15` -- 首次调用就因为版本、密钥、环境没对齐而塌陷 → `No.16` - -只要你看到“刚部署最容易出事”“首轮异常最严重”“重启后暂时恢复”,就要优先怀疑 `[OP]` 层,而不是先改提示词或参数。 - -[返回顶部](#top) | - ---- - - - - -## 六、快速返回 - -[返回顶部](#top) | [这页怎么用](#how-to-use) | [标签说明](#legend) | [常见症状入口](#symptoms) | [16 问题清单](#map16) | [按层排查](#by-layer) diff --git a/docs/文件上传接口文档.md b/docs/文件上传接口文档.md deleted file mode 100644 index c84a9258..00000000 --- a/docs/文件上传接口文档.md +++ /dev/null @@ -1,42 +0,0 @@ -## 接口信息 - -**接口路径**: `POST /resource/oss/upload` -**请求类型**: `multipart/form-data` -**权限要求**: `system:oss:upload` -**业务类型**: [INSERT] - -### 接口描述 -上传OSS对象存储接口,用于将文件上传到对象存储服务。 - -### 请求参数 -| 参数名 | 类型 | 必填 | 说明 | -| ---- | ------------- | ---- | ------ | -| file | MultipartFile | 是 | 要上传的文件 | - -### 请求头 -- `Content-Type`: `multipart/form-data` - -### 返回值 -返回 `R` 类型,包含以下字段: -| 字段名 | 类型 | 说明 | -| -------- | ------ | ------- | -| url | String | 文件访问URL | -| fileName | String | 原始文件名 | -| ossId | String | 文件ID | - -### 响应示例 -```json -{ - "code": 200, - "msg": "操作成功", - "data": { - "url": "fileid://xxx", - "fileName": "example.jpg", - "ossId": "123" - } -} -``` - - -### 异常情况 -- 当上传文件为空时,返回错误信息:"上传文件不能为空" diff --git a/ruoyi-modules/ruoyi-chat/docs/mcp-api-spec.md b/ruoyi-modules/ruoyi-chat/docs/MCP工具模块接口文档.md similarity index 100% rename from ruoyi-modules/ruoyi-chat/docs/mcp-api-spec.md rename to ruoyi-modules/ruoyi-chat/docs/MCP工具模块接口文档.md From bf7b5eac721edb9a091017d5167afbc4b2245227 Mon Sep 17 00:00:00 2001 From: wangle Date: Tue, 7 Apr 2026 22:13:32 +0800 Subject: [PATCH 09/17] =?UTF-8?q?fix=EF=BC=9A=E4=BF=AE=E5=A4=8D=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E6=B6=88=E6=81=AF=E6=9E=84=E5=BB=BA=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F=EF=BC=8C=E7=A1=AE=E4=BF=9DAI=E6=AD=A3=E7=A1=AE?= =?UTF-8?q?=E7=90=86=E8=A7=A3=E5=AF=B9=E8=AF=9D=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 消息顺序调整为:历史消息 → 知识库内容 → 当前用户消息 Co-Authored-By: Claude Opus 4.6 --- .../service/chat/impl/ChatServiceFacade.java | 68 ++++++++++--------- 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java index 10955902..8e0f463a 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java @@ -359,44 +359,16 @@ public class ChatServiceFacade implements IChatService { /** * 构建上下文消息列表 + + * 消息顺序:历史消息 → 当前用户消息(确保 AI 正确理解对话上下文) * * @param chatRequest 聊天请求 * @return 上下文消息列表 */ private List buildContextMessages(ChatRequest chatRequest) { List messages = new ArrayList<>(); - // 构建用户消息 - UserMessage userMessage = UserMessage.userMessage(chatRequest.getContent()); - messages.add(userMessage); - // 从向量库查询相关历史消息 - if (chatRequest.getKnowledgeId() != null) { - // 查询知识库信息 - KnowledgeInfoVo knowledgeInfoVo = knowledgeInfoService.queryById(Long.valueOf(chatRequest.getKnowledgeId())); - if (knowledgeInfoVo == null) { - log.warn("知识库信息不存在,kid: {}", chatRequest.getKnowledgeId()); - return messages; - } - - // 查询向量模型配置信息 - ChatModelVo chatModel = chatModelService.selectModelByName(knowledgeInfoVo.getEmbeddingModel()); - if (chatModel == null) { - log.warn("向量模型配置不存在,模型名称: {}", knowledgeInfoVo.getEmbeddingModel()); - return messages; - } - - // 构建向量查询参数 - QueryVectorBo queryVectorBo = buildQueryVectorBo(chatRequest, knowledgeInfoVo, chatModel); - - // 获取向量查询结果 - List nearestList = vectorStoreService.getQueryVector(queryVectorBo); - for (String prompt : nearestList) { - // 知识库内容作为系统上下文添加 - messages.add( new AiMessage(prompt)); - } - } - - // 从数据库查询历史对话消息 + // 从数据库查询历史对话消息(放在前面) if (chatRequest.getSessionId() != null) { MessageWindowChatMemory memory = createChatMemory(chatRequest.getSessionId()); if (memory != null) { @@ -408,6 +380,40 @@ public class ChatServiceFacade implements IChatService { } } + // 从向量库查询相关历史消息(知识库内容作为上下文) + if (chatRequest.getKnowledgeId() != null) { + // 查询知识库信息 + KnowledgeInfoVo knowledgeInfoVo = knowledgeInfoService.queryById(Long.valueOf(chatRequest.getKnowledgeId())); + if (knowledgeInfoVo == null) { + log.warn("知识库信息不存在,kid: {}", chatRequest.getKnowledgeId()); + // 继续添加当前用户消息 + messages.add(UserMessage.userMessage(chatRequest.getContent())); + return messages; + } + + // 查询向量模型配置信息 + ChatModelVo chatModel = chatModelService.selectModelByName(knowledgeInfoVo.getEmbeddingModel()); + if (chatModel == null) { + log.warn("向量模型配置不存在,模型名称: {}", knowledgeInfoVo.getEmbeddingModel()); + messages.add(UserMessage.userMessage(chatRequest.getContent())); + return messages; + } + + // 构建向量查询参数 + QueryVectorBo queryVectorBo = buildQueryVectorBo(chatRequest, knowledgeInfoVo, chatModel); + + // 获取向量查询结果(知识库内容作为系统上下文,放在历史消息之后) + List nearestList = vectorStoreService.getQueryVector(queryVectorBo); + for (String prompt : nearestList) { + // 知识库内容作为系统上下文添加 + messages.add(new AiMessage(prompt)); + } + } + + // 构建当前用户消息(放在最后) + UserMessage userMessage = UserMessage.userMessage(chatRequest.getContent()); + messages.add(userMessage); + return messages; } From c1fc02894b1dc6f4689028128df0cc36b0b804d1 Mon Sep 17 00:00:00 2001 From: wangle Date: Mon, 13 Apr 2026 18:02:27 +0800 Subject: [PATCH 10/17] =?UTF-8?q?feat:=20=E5=8F=91=E5=B8=833.0=E7=89=88?= =?UTF-8?q?=E6=9C=AC=EF=BC=8C=E6=96=B0=E5=A2=9E=E6=96=87=E6=A1=A3=E5=A4=84?= =?UTF-8?q?=E7=90=86=E8=83=BD=E5=8A=9B=E5=92=8C=E6=BC=94=E7=A4=BA=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 升级langchain4j版本至1.13.0 - 新增docx/pdf/xlsx文档处理技能模块 - 添加演示模式配置和切面拦截 - 优化聊天服务和可观测性监听器 Co-Authored-By: Claude Opus 4.6 --- pom.xml | 4 +- .../java/org/ruoyi/RuoYiAIApplication.java | 4 +- .../src/main/resources/application-prod.yml | 3 +- .../src/main/resources/application.yml | 17 + .../main/resources/skills/docx/LICENSE.txt | 30 + .../src/main/resources/skills/docx/SKILL.md | 590 +++ .../resources/skills/docx/scripts/__init__.py | 1 + .../skills/docx/scripts/accept_changes.py | 135 + .../resources/skills/docx/scripts/comment.py | 318 ++ .../docx/scripts/office/helpers/__init__.py | 0 .../docx/scripts/office/helpers/merge_runs.py | 199 + .../office/helpers/simplify_redlines.py | 197 + .../skills/docx/scripts/office/pack.py | 159 + .../schemas/ISO-IEC29500-4_2016/dml-chart.xsd | 1499 ++++++ .../ISO-IEC29500-4_2016/dml-chartDrawing.xsd | 146 + .../ISO-IEC29500-4_2016/dml-diagram.xsd | 1085 ++++ .../ISO-IEC29500-4_2016/dml-lockedCanvas.xsd | 11 + .../schemas/ISO-IEC29500-4_2016/dml-main.xsd | 3081 ++++++++++++ .../ISO-IEC29500-4_2016/dml-picture.xsd | 23 + .../dml-spreadsheetDrawing.xsd | 185 + .../dml-wordprocessingDrawing.xsd | 287 ++ .../schemas/ISO-IEC29500-4_2016/pml.xsd | 1676 +++++++ .../shared-additionalCharacteristics.xsd | 28 + .../shared-bibliography.xsd | 144 + .../shared-commonSimpleTypes.xsd | 174 + .../shared-customXmlDataProperties.xsd | 25 + .../shared-customXmlSchemaProperties.xsd | 18 + .../shared-documentPropertiesCustom.xsd | 59 + .../shared-documentPropertiesExtended.xsd | 56 + .../shared-documentPropertiesVariantTypes.xsd | 195 + .../ISO-IEC29500-4_2016/shared-math.xsd | 582 +++ .../shared-relationshipReference.xsd | 25 + .../schemas/ISO-IEC29500-4_2016/sml.xsd | 4439 +++++++++++++++++ .../schemas/ISO-IEC29500-4_2016/vml-main.xsd | 570 +++ .../ISO-IEC29500-4_2016/vml-officeDrawing.xsd | 509 ++ .../vml-presentationDrawing.xsd | 12 + .../vml-spreadsheetDrawing.xsd | 108 + .../vml-wordprocessingDrawing.xsd | 96 + .../schemas/ISO-IEC29500-4_2016/wml.xsd | 3646 ++++++++++++++ .../schemas/ISO-IEC29500-4_2016/xml.xsd | 116 + .../ecma/fouth-edition/opc-contentTypes.xsd | 42 + .../ecma/fouth-edition/opc-coreProperties.xsd | 50 + .../schemas/ecma/fouth-edition/opc-digSig.xsd | 49 + .../ecma/fouth-edition/opc-relationships.xsd | 33 + .../docx/scripts/office/schemas/mce/mc.xsd | 75 + .../office/schemas/microsoft/wml-2010.xsd | 560 +++ .../office/schemas/microsoft/wml-2012.xsd | 67 + .../office/schemas/microsoft/wml-2018.xsd | 14 + .../office/schemas/microsoft/wml-cex-2018.xsd | 20 + .../office/schemas/microsoft/wml-cid-2016.xsd | 13 + .../microsoft/wml-sdtdatahash-2020.xsd | 4 + .../schemas/microsoft/wml-symex-2015.xsd | 8 + .../skills/docx/scripts/office/soffice.py | 183 + .../skills/docx/scripts/office/unpack.py | 132 + .../skills/docx/scripts/office/validate.py | 111 + .../scripts/office/validators/__init__.py | 15 + .../docx/scripts/office/validators/base.py | 847 ++++ .../docx/scripts/office/validators/docx.py | 446 ++ .../docx/scripts/office/validators/pptx.py | 275 + .../scripts/office/validators/redlining.py | 247 + .../docx/scripts/templates/comments.xml | 3 + .../scripts/templates/commentsExtended.xml | 3 + .../scripts/templates/commentsExtensible.xml | 3 + .../docx/scripts/templates/commentsIds.xml | 3 + .../skills/docx/scripts/templates/people.xml | 3 + .../src/main/resources/skills/pdf/LICENSE.txt | 30 + .../src/main/resources/skills/pdf/SKILL.md | 294 ++ .../src/main/resources/skills/pdf/forms.md | 205 + .../main/resources/skills/pdf/reference.md | 612 +++ .../pdf/scripts/check_bounding_boxes.py | 70 + .../pdf/scripts/check_bounding_boxes_test.py | 226 + .../pdf/scripts/check_fillable_fields.py | 12 + .../pdf/scripts/convert_pdf_to_images.py | 35 + .../pdf/scripts/create_validation_image.py | 41 + .../pdf/scripts/extract_form_field_info.py | 152 + .../pdf/scripts/fill_fillable_fields.py | 114 + .../scripts/fill_pdf_form_with_annotations.py | 108 + .../main/resources/skills/xlsx/LICENSE.txt | 30 + .../src/main/resources/skills/xlsx/SKILL.md | 362 ++ .../src/main/resources/skills/xlsx/recalc.py | 318 ++ .../domain/dto/request/AgentChatRequest.java | 26 + .../chat/domain/dto/request/ChatRequest.java | 30 +- .../security/config/SecurityConfig.java | 6 + .../common/sse/core/SseEmitterManager.java | 3 + .../org/ruoyi/common/sse/dto/SseEventDto.java | 17 + .../ruoyi/common/web/aspectj/DemoAspect.java | 86 + .../web/config/DemoAutoConfiguration.java | 25 + .../web/config/properties/DemoProperties.java | 32 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + ruoyi-modules/ruoyi-chat/pom.xml | 22 + .../java/org/ruoyi/agent/SkillsAgent.java | 35 + .../java/org/ruoyi/agent/WebSearchAgent.java | 37 +- .../ruoyi/controller/chat/ChatController.java | 1 + .../ruoyi/observability/MyAgentListener.java | 34 +- .../observability/MyMcpClientListener.java | 181 +- .../ruoyi/observability/OutputChannel.java | 128 + .../observability/StreamingOutputWrapper.java | 213 + .../SupervisorStreamListener.java | 120 + .../service/chat/impl/ChatServiceFacade.java | 188 +- .../agent/StreamingAgentIntegrationTest.java | 313 ++ 100 files changed, 27576 insertions(+), 189 deletions(-) create mode 100644 ruoyi-admin/src/main/resources/skills/docx/LICENSE.txt create mode 100644 ruoyi-admin/src/main/resources/skills/docx/SKILL.md create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/__init__.py create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/accept_changes.py create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/comment.py create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/helpers/__init__.py create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/helpers/merge_runs.py create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/helpers/simplify_redlines.py create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/pack.py create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/mce/mc.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/soffice.py create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/unpack.py create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/validate.py create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/validators/__init__.py create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/validators/base.py create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/validators/docx.py create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/validators/pptx.py create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/office/validators/redlining.py create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/templates/comments.xml create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/templates/commentsExtended.xml create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/templates/commentsExtensible.xml create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/templates/commentsIds.xml create mode 100644 ruoyi-admin/src/main/resources/skills/docx/scripts/templates/people.xml create mode 100644 ruoyi-admin/src/main/resources/skills/pdf/LICENSE.txt create mode 100644 ruoyi-admin/src/main/resources/skills/pdf/SKILL.md create mode 100644 ruoyi-admin/src/main/resources/skills/pdf/forms.md create mode 100644 ruoyi-admin/src/main/resources/skills/pdf/reference.md create mode 100644 ruoyi-admin/src/main/resources/skills/pdf/scripts/check_bounding_boxes.py create mode 100644 ruoyi-admin/src/main/resources/skills/pdf/scripts/check_bounding_boxes_test.py create mode 100644 ruoyi-admin/src/main/resources/skills/pdf/scripts/check_fillable_fields.py create mode 100644 ruoyi-admin/src/main/resources/skills/pdf/scripts/convert_pdf_to_images.py create mode 100644 ruoyi-admin/src/main/resources/skills/pdf/scripts/create_validation_image.py create mode 100644 ruoyi-admin/src/main/resources/skills/pdf/scripts/extract_form_field_info.py create mode 100644 ruoyi-admin/src/main/resources/skills/pdf/scripts/fill_fillable_fields.py create mode 100644 ruoyi-admin/src/main/resources/skills/pdf/scripts/fill_pdf_form_with_annotations.py create mode 100644 ruoyi-admin/src/main/resources/skills/xlsx/LICENSE.txt create mode 100644 ruoyi-admin/src/main/resources/skills/xlsx/SKILL.md create mode 100644 ruoyi-admin/src/main/resources/skills/xlsx/recalc.py create mode 100644 ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/dto/request/AgentChatRequest.java create mode 100644 ruoyi-common/ruoyi-common-web/src/main/java/org/ruoyi/common/web/aspectj/DemoAspect.java create mode 100644 ruoyi-common/ruoyi-common-web/src/main/java/org/ruoyi/common/web/config/DemoAutoConfiguration.java create mode 100644 ruoyi-common/ruoyi-common-web/src/main/java/org/ruoyi/common/web/config/properties/DemoProperties.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/SkillsAgent.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/OutputChannel.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/StreamingOutputWrapper.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/SupervisorStreamListener.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/agent/StreamingAgentIntegrationTest.java diff --git a/pom.xml b/pom.xml index 434e5f6a..4441852f 100644 --- a/pom.xml +++ b/pom.xml @@ -54,8 +54,8 @@ 2.18.2 - 1.11.0 - 1.11.0-beta19 + 1.13.0 + 1.13.0-beta23 1.5.3 1.19.6 1.0.7 diff --git a/ruoyi-admin/src/main/java/org/ruoyi/RuoYiAIApplication.java b/ruoyi-admin/src/main/java/org/ruoyi/RuoYiAIApplication.java index ffca4ef5..441804bd 100644 --- a/ruoyi-admin/src/main/java/org/ruoyi/RuoYiAIApplication.java +++ b/ruoyi-admin/src/main/java/org/ruoyi/RuoYiAIApplication.java @@ -16,11 +16,11 @@ import java.net.ServerSocket; public class RuoYiAIApplication { public static void main(String[] args) { - // killPortProcess(6039); + killPortProcess(6039); SpringApplication application = new SpringApplication(RuoYiAIApplication.class); application.setApplicationStartup(new BufferingApplicationStartup(2048)); application.run(args); - System.out.println("(♥◠‿◠)ノ゙ RuoYi-AI启动成功 ლ(´ڡ`ლ)冢"); + System.out.println("(♥◠‿◠)ノ゙ RuoYi-AI启动成功 ლ(´ڡ`ლ)"); } /** diff --git a/ruoyi-admin/src/main/resources/application-prod.yml b/ruoyi-admin/src/main/resources/application-prod.yml index 7001224f..2708e5af 100644 --- a/ruoyi-admin/src/main/resources/application-prod.yml +++ b/ruoyi-admin/src/main/resources/application-prod.yml @@ -1,3 +1,4 @@ + --- # 监控中心配置 spring.boot.admin.client: # 增加客户端开关 @@ -58,7 +59,7 @@ spring: driverClassName: com.mysql.cj.jdbc.Driver # jdbc 所有参数配置参考 https://lionli.blog.csdn.net/article/details/122018562 # rewriteBatchedStatements=true 批处理优化 大幅提升批量插入更新删除性能(对数据库有性能损耗 使用批量操作应考虑性能问题) - url: jdbc:mysql://127.0.0.1:3306/ruoyi-ai-agent?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true + url: jdbc:mysql://127.0.0.1:3306/ruoyi-ai?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true username: root password: root # agent: diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 269da7ae..009cedfc 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -260,6 +260,23 @@ websocket: # 设置访问源地址 allowedOrigins: '*' +--- # 演示模式配置 +demo: + # 是否开启演示模式(开启后所有写操作将被拦截) + enabled: false + # 提示消息 + message: "演示模式,不允许进行写操作" + # 排除的路径(这些路径不受演示模式限制) + excludes: + - /login + - /logout + - /register + - /captcha/** + - /auth/** + - /chat/send + - /system/session/** + - /system/message/** + --- # warm-flow工作流配置 warm-flow: # 是否开启工作流,默认true diff --git a/ruoyi-admin/src/main/resources/skills/docx/LICENSE.txt b/ruoyi-admin/src/main/resources/skills/docx/LICENSE.txt new file mode 100644 index 00000000..c55ab422 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/LICENSE.txt @@ -0,0 +1,30 @@ +© 2025 Anthropic, PBC. All rights reserved. + +LICENSE: Use of these materials (including all code, prompts, assets, files, +and other components of this Skill) is governed by your agreement with +Anthropic regarding use of Anthropic's services. If no separate agreement +exists, use is governed by Anthropic's Consumer Terms of Service or +Commercial Terms of Service, as applicable: +https://www.anthropic.com/legal/consumer-terms +https://www.anthropic.com/legal/commercial-terms +Your applicable agreement is referred to as the "Agreement." "Services" are +as defined in the Agreement. + +ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the +contrary, users may not: + +- Extract these materials from the Services or retain copies of these + materials outside the Services +- Reproduce or copy these materials, except for temporary copies created + automatically during authorized use of the Services +- Create derivative works based on these materials +- Distribute, sublicense, or transfer these materials to any third party +- Make, offer to sell, sell, or import any inventions embodied in these + materials +- Reverse engineer, decompile, or disassemble these materials + +The receipt, viewing, or possession of these materials does not convey or +imply any license or right beyond those expressly granted above. + +Anthropic retains all right, title, and interest in these materials, +including all copyrights, patents, and other intellectual property rights. diff --git a/ruoyi-admin/src/main/resources/skills/docx/SKILL.md b/ruoyi-admin/src/main/resources/skills/docx/SKILL.md new file mode 100644 index 00000000..2951e559 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/SKILL.md @@ -0,0 +1,590 @@ +--- +name: docx +description: "Use this skill whenever the user wants to create, read, edit, or manipulate Word documents (.docx files). Triggers include: any mention of 'Word doc', 'word document', '.docx', or requests to produce professional documents with formatting like tables of contents, headings, page numbers, or letterheads. Also use when extracting or reorganizing content from .docx files, inserting or replacing images in documents, performing find-and-replace in Word files, working with tracked changes or comments, or converting content into a polished Word document. If the user asks for a 'report', 'memo', 'letter', 'template', or similar deliverable as a Word or .docx file, use this skill. Do NOT use for PDFs, spreadsheets, Google Docs, or general coding tasks unrelated to document generation." +license: Proprietary. LICENSE.txt has complete terms +--- + +# DOCX creation, editing, and analysis + +## Overview + +A .docx file is a ZIP archive containing XML files. + +## Quick Reference + +| Task | Approach | +|------|----------| +| Read/analyze content | `pandoc` or unpack for raw XML | +| Create new document | Use `docx-js` - see Creating New Documents below | +| Edit existing document | Unpack → edit XML → repack - see Editing Existing Documents below | + +### Converting .doc to .docx + +Legacy `.doc` files must be converted before editing: + +```bash +python scripts/office/soffice.py --headless --convert-to docx document.doc +``` + +### Reading Content + +```bash +# Text extraction with tracked changes +pandoc --track-changes=all document.docx -o output.md + +# Raw XML access +python scripts/office/unpack.py document.docx unpacked/ +``` + +### Converting to Images + +```bash +python scripts/office/soffice.py --headless --convert-to pdf document.docx +pdftoppm -jpeg -r 150 document.pdf page +``` + +### Accepting Tracked Changes + +To produce a clean document with all tracked changes accepted (requires LibreOffice): + +```bash +python scripts/accept_changes.py input.docx output.docx +``` + +--- + +## Creating New Documents + +Generate .docx files with JavaScript, then validate. Install: `npm install -g docx` + +### Setup +```javascript +const { Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell, ImageRun, + Header, Footer, AlignmentType, PageOrientation, LevelFormat, ExternalHyperlink, + InternalHyperlink, Bookmark, FootnoteReferenceRun, PositionalTab, + PositionalTabAlignment, PositionalTabRelativeTo, PositionalTabLeader, + TabStopType, TabStopPosition, Column, SectionType, + TableOfContents, HeadingLevel, BorderStyle, WidthType, ShadingType, + VerticalAlign, PageNumber, PageBreak } = require('docx'); + +const doc = new Document({ sections: [{ children: [/* content */] }] }); +Packer.toBuffer(doc).then(buffer => fs.writeFileSync("doc.docx", buffer)); +``` + +### Validation +After creating the file, validate it. If validation fails, unpack, fix the XML, and repack. +```bash +python scripts/office/validate.py doc.docx +``` + +### Page Size + +```javascript +// CRITICAL: docx-js defaults to A4, not US Letter +// Always set page size explicitly for consistent results +sections: [{ + properties: { + page: { + size: { + width: 12240, // 8.5 inches in DXA + height: 15840 // 11 inches in DXA + }, + margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 } // 1 inch margins + } + }, + children: [/* content */] +}] +``` + +**Common page sizes (DXA units, 1440 DXA = 1 inch):** + +| Paper | Width | Height | Content Width (1" margins) | +|-------|-------|--------|---------------------------| +| US Letter | 12,240 | 15,840 | 9,360 | +| A4 (default) | 11,906 | 16,838 | 9,026 | + +**Landscape orientation:** docx-js swaps width/height internally, so pass portrait dimensions and let it handle the swap: +```javascript +size: { + width: 12240, // Pass SHORT edge as width + height: 15840, // Pass LONG edge as height + orientation: PageOrientation.LANDSCAPE // docx-js swaps them in the XML +}, +// Content width = 15840 - left margin - right margin (uses the long edge) +``` + +### Styles (Override Built-in Headings) + +Use Arial as the default font (universally supported). Keep titles black for readability. + +```javascript +const doc = new Document({ + styles: { + default: { document: { run: { font: "Arial", size: 24 } } }, // 12pt default + paragraphStyles: [ + // IMPORTANT: Use exact IDs to override built-in styles + { id: "Heading1", name: "Heading 1", basedOn: "Normal", next: "Normal", quickFormat: true, + run: { size: 32, bold: true, font: "Arial" }, + paragraph: { spacing: { before: 240, after: 240 }, outlineLevel: 0 } }, // outlineLevel required for TOC + { id: "Heading2", name: "Heading 2", basedOn: "Normal", next: "Normal", quickFormat: true, + run: { size: 28, bold: true, font: "Arial" }, + paragraph: { spacing: { before: 180, after: 180 }, outlineLevel: 1 } }, + ] + }, + sections: [{ + children: [ + new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun("Title")] }), + ] + }] +}); +``` + +### Lists (NEVER use unicode bullets) + +```javascript +// ❌ WRONG - never manually insert bullet characters +new Paragraph({ children: [new TextRun("• Item")] }) // BAD +new Paragraph({ children: [new TextRun("\u2022 Item")] }) // BAD + +// ✅ CORRECT - use numbering config with LevelFormat.BULLET +const doc = new Document({ + numbering: { + config: [ + { reference: "bullets", + levels: [{ level: 0, format: LevelFormat.BULLET, text: "•", alignment: AlignmentType.LEFT, + style: { paragraph: { indent: { left: 720, hanging: 360 } } } }] }, + { reference: "numbers", + levels: [{ level: 0, format: LevelFormat.DECIMAL, text: "%1.", alignment: AlignmentType.LEFT, + style: { paragraph: { indent: { left: 720, hanging: 360 } } } }] }, + ] + }, + sections: [{ + children: [ + new Paragraph({ numbering: { reference: "bullets", level: 0 }, + children: [new TextRun("Bullet item")] }), + new Paragraph({ numbering: { reference: "numbers", level: 0 }, + children: [new TextRun("Numbered item")] }), + ] + }] +}); + +// ⚠️ Each reference creates INDEPENDENT numbering +// Same reference = continues (1,2,3 then 4,5,6) +// Different reference = restarts (1,2,3 then 1,2,3) +``` + +### Tables + +**CRITICAL: Tables need dual widths** - set both `columnWidths` on the table AND `width` on each cell. Without both, tables render incorrectly on some platforms. + +```javascript +// CRITICAL: Always set table width for consistent rendering +// CRITICAL: Use ShadingType.CLEAR (not SOLID) to prevent black backgrounds +const border = { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }; +const borders = { top: border, bottom: border, left: border, right: border }; + +new Table({ + width: { size: 9360, type: WidthType.DXA }, // Always use DXA (percentages break in Google Docs) + columnWidths: [4680, 4680], // Must sum to table width (DXA: 1440 = 1 inch) + rows: [ + new TableRow({ + children: [ + new TableCell({ + borders, + width: { size: 4680, type: WidthType.DXA }, // Also set on each cell + shading: { fill: "D5E8F0", type: ShadingType.CLEAR }, // CLEAR not SOLID + margins: { top: 80, bottom: 80, left: 120, right: 120 }, // Cell padding (internal, not added to width) + children: [new Paragraph({ children: [new TextRun("Cell")] })] + }) + ] + }) + ] +}) +``` + +**Table width calculation:** + +Always use `WidthType.DXA` — `WidthType.PERCENTAGE` breaks in Google Docs. + +```javascript +// Table width = sum of columnWidths = content width +// US Letter with 1" margins: 12240 - 2880 = 9360 DXA +width: { size: 9360, type: WidthType.DXA }, +columnWidths: [7000, 2360] // Must sum to table width +``` + +**Width rules:** +- **Always use `WidthType.DXA`** — never `WidthType.PERCENTAGE` (incompatible with Google Docs) +- Table width must equal the sum of `columnWidths` +- Cell `width` must match corresponding `columnWidth` +- Cell `margins` are internal padding - they reduce content area, not add to cell width +- For full-width tables: use content width (page width minus left and right margins) + +### Images + +```javascript +// CRITICAL: type parameter is REQUIRED +new Paragraph({ + children: [new ImageRun({ + type: "png", // Required: png, jpg, jpeg, gif, bmp, svg + data: fs.readFileSync("image.png"), + transformation: { width: 200, height: 150 }, + altText: { title: "Title", description: "Desc", name: "Name" } // All three required + })] +}) +``` + +### Page Breaks + +```javascript +// CRITICAL: PageBreak must be inside a Paragraph +new Paragraph({ children: [new PageBreak()] }) + +// Or use pageBreakBefore +new Paragraph({ pageBreakBefore: true, children: [new TextRun("New page")] }) +``` + +### Hyperlinks + +```javascript +// External link +new Paragraph({ + children: [new ExternalHyperlink({ + children: [new TextRun({ text: "Click here", style: "Hyperlink" })], + link: "https://example.com", + })] +}) + +// Internal link (bookmark + reference) +// 1. Create bookmark at destination +new Paragraph({ heading: HeadingLevel.HEADING_1, children: [ + new Bookmark({ id: "chapter1", children: [new TextRun("Chapter 1")] }), +]}) +// 2. Link to it +new Paragraph({ children: [new InternalHyperlink({ + children: [new TextRun({ text: "See Chapter 1", style: "Hyperlink" })], + anchor: "chapter1", +})]}) +``` + +### Footnotes + +```javascript +const doc = new Document({ + footnotes: { + 1: { children: [new Paragraph("Source: Annual Report 2024")] }, + 2: { children: [new Paragraph("See appendix for methodology")] }, + }, + sections: [{ + children: [new Paragraph({ + children: [ + new TextRun("Revenue grew 15%"), + new FootnoteReferenceRun(1), + new TextRun(" using adjusted metrics"), + new FootnoteReferenceRun(2), + ], + })] + }] +}); +``` + +### Tab Stops + +```javascript +// Right-align text on same line (e.g., date opposite a title) +new Paragraph({ + children: [ + new TextRun("Company Name"), + new TextRun("\tJanuary 2025"), + ], + tabStops: [{ type: TabStopType.RIGHT, position: TabStopPosition.MAX }], +}) + +// Dot leader (e.g., TOC-style) +new Paragraph({ + children: [ + new TextRun("Introduction"), + new TextRun({ children: [ + new PositionalTab({ + alignment: PositionalTabAlignment.RIGHT, + relativeTo: PositionalTabRelativeTo.MARGIN, + leader: PositionalTabLeader.DOT, + }), + "3", + ]}), + ], +}) +``` + +### Multi-Column Layouts + +```javascript +// Equal-width columns +sections: [{ + properties: { + column: { + count: 2, // number of columns + space: 720, // gap between columns in DXA (720 = 0.5 inch) + equalWidth: true, + separate: true, // vertical line between columns + }, + }, + children: [/* content flows naturally across columns */] +}] + +// Custom-width columns (equalWidth must be false) +sections: [{ + properties: { + column: { + equalWidth: false, + children: [ + new Column({ width: 5400, space: 720 }), + new Column({ width: 3240 }), + ], + }, + }, + children: [/* content */] +}] +``` + +Force a column break with a new section using `type: SectionType.NEXT_COLUMN`. + +### Table of Contents + +```javascript +// CRITICAL: Headings must use HeadingLevel ONLY - no custom styles +new TableOfContents("Table of Contents", { hyperlink: true, headingStyleRange: "1-3" }) +``` + +### Headers/Footers + +```javascript +sections: [{ + properties: { + page: { margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 } } // 1440 = 1 inch + }, + headers: { + default: new Header({ children: [new Paragraph({ children: [new TextRun("Header")] })] }) + }, + footers: { + default: new Footer({ children: [new Paragraph({ + children: [new TextRun("Page "), new TextRun({ children: [PageNumber.CURRENT] })] + })] }) + }, + children: [/* content */] +}] +``` + +### Critical Rules for docx-js + +- **Set page size explicitly** - docx-js defaults to A4; use US Letter (12240 x 15840 DXA) for US documents +- **Landscape: pass portrait dimensions** - docx-js swaps width/height internally; pass short edge as `width`, long edge as `height`, and set `orientation: PageOrientation.LANDSCAPE` +- **Never use `\n`** - use separate Paragraph elements +- **Never use unicode bullets** - use `LevelFormat.BULLET` with numbering config +- **PageBreak must be in Paragraph** - standalone creates invalid XML +- **ImageRun requires `type`** - always specify png/jpg/etc +- **Always set table `width` with DXA** - never use `WidthType.PERCENTAGE` (breaks in Google Docs) +- **Tables need dual widths** - `columnWidths` array AND cell `width`, both must match +- **Table width = sum of columnWidths** - for DXA, ensure they add up exactly +- **Always add cell margins** - use `margins: { top: 80, bottom: 80, left: 120, right: 120 }` for readable padding +- **Use `ShadingType.CLEAR`** - never SOLID for table shading +- **Never use tables as dividers/rules** - cells have minimum height and render as empty boxes (including in headers/footers); use `border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: "2E75B6", space: 1 } }` on a Paragraph instead. For two-column footers, use tab stops (see Tab Stops section), not tables +- **TOC requires HeadingLevel only** - no custom styles on heading paragraphs +- **Override built-in styles** - use exact IDs: "Heading1", "Heading2", etc. +- **Include `outlineLevel`** - required for TOC (0 for H1, 1 for H2, etc.) + +--- + +## Editing Existing Documents + +**Follow all 3 steps in order.** + +### Step 1: Unpack +```bash +python scripts/office/unpack.py document.docx unpacked/ +``` +Extracts XML, pretty-prints, merges adjacent runs, and converts smart quotes to XML entities (`“` etc.) so they survive editing. Use `--merge-runs false` to skip run merging. + +### Step 2: Edit XML + +Edit files in `unpacked/word/`. See XML Reference below for patterns. + +**Use "Claude" as the author** for tracked changes and comments, unless the user explicitly requests use of a different name. + +**Use the Edit tool directly for string replacement. Do not write Python scripts.** Scripts introduce unnecessary complexity. The Edit tool shows exactly what is being replaced. + +**CRITICAL: Use smart quotes for new content.** When adding text with apostrophes or quotes, use XML entities to produce smart quotes: +```xml + +Here’s a quote: “Hello” +``` +| Entity | Character | +|--------|-----------| +| `‘` | ‘ (left single) | +| `’` | ’ (right single / apostrophe) | +| `“` | “ (left double) | +| `”` | ” (right double) | + +**Adding comments:** Use `comment.py` to handle boilerplate across multiple XML files (text must be pre-escaped XML): +```bash +python scripts/comment.py unpacked/ 0 "Comment text with & and ’" +python scripts/comment.py unpacked/ 1 "Reply text" --parent 0 # reply to comment 0 +python scripts/comment.py unpacked/ 0 "Text" --author "Custom Author" # custom author name +``` +Then add markers to document.xml (see Comments in XML Reference). + +### Step 3: Pack +```bash +python scripts/office/pack.py unpacked/ output.docx --original document.docx +``` +Validates with auto-repair, condenses XML, and creates DOCX. Use `--validate false` to skip. + +**Auto-repair will fix:** +- `durableId` >= 0x7FFFFFFF (regenerates valid ID) +- Missing `xml:space="preserve"` on `` with whitespace + +**Auto-repair won't fix:** +- Malformed XML, invalid element nesting, missing relationships, schema violations + +### Common Pitfalls + +- **Replace entire `` elements**: When adding tracked changes, replace the whole `...` block with `......` as siblings. Don't inject tracked change tags inside a run. +- **Preserve `` formatting**: Copy the original run's `` block into your tracked change runs to maintain bold, font size, etc. + +--- + +## XML Reference + +### Schema Compliance + +- **Element order in ``**: ``, ``, ``, ``, ``, `` last +- **Whitespace**: Add `xml:space="preserve"` to `` with leading/trailing spaces +- **RSIDs**: Must be 8-digit hex (e.g., `00AB1234`) + +### Tracked Changes + +**Insertion:** +```xml + + inserted text + +``` + +**Deletion:** +```xml + + deleted text + +``` + +**Inside ``**: Use `` instead of ``, and `` instead of ``. + +**Minimal edits** - only mark what changes: +```xml + +The term is + + 30 + + + 60 + + days. +``` + +**Deleting entire paragraphs/list items** - when removing ALL content from a paragraph, also mark the paragraph mark as deleted so it merges with the next paragraph. Add `` inside ``: +```xml + + + ... + + + + + + Entire paragraph content being deleted... + + +``` +Without the `` in ``, accepting changes leaves an empty paragraph/list item. + +**Rejecting another author's insertion** - nest deletion inside their insertion: +```xml + + + their inserted text + + +``` + +**Restoring another author's deletion** - add insertion after (don't modify their deletion): +```xml + + deleted text + + + deleted text + +``` + +### Comments + +After running `comment.py` (see Step 2), add markers to document.xml. For replies, use `--parent` flag and nest markers inside the parent's. + +**CRITICAL: `` and `` are siblings of ``, never inside ``.** + +```xml + + + + deleted + + more text + + + + + + + text + + + + +``` + +### Images + +1. Add image file to `word/media/` +2. Add relationship to `word/_rels/document.xml.rels`: +```xml + +``` +3. Add content type to `[Content_Types].xml`: +```xml + +``` +4. Reference in document.xml: +```xml + + + + + + + + + + + + +``` + +--- + +## Dependencies + +- **pandoc**: Text extraction +- **docx**: `npm install -g docx` (new documents) +- **LibreOffice**: PDF conversion (auto-configured for sandboxed environments via `scripts/office/soffice.py`) +- **Poppler**: `pdftoppm` for images diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/__init__.py b/ruoyi-admin/src/main/resources/skills/docx/scripts/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/__init__.py @@ -0,0 +1 @@ + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/accept_changes.py b/ruoyi-admin/src/main/resources/skills/docx/scripts/accept_changes.py new file mode 100644 index 00000000..8e363161 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/accept_changes.py @@ -0,0 +1,135 @@ +"""Accept all tracked changes in a DOCX file using LibreOffice. + +Requires LibreOffice (soffice) to be installed. +""" + +import argparse +import logging +import shutil +import subprocess +from pathlib import Path + +from office.soffice import get_soffice_env + +logger = logging.getLogger(__name__) + +LIBREOFFICE_PROFILE = "/tmp/libreoffice_docx_profile" +MACRO_DIR = f"{LIBREOFFICE_PROFILE}/user/basic/Standard" + +ACCEPT_CHANGES_MACRO = """ + + + Sub AcceptAllTrackedChanges() + Dim document As Object + Dim dispatcher As Object + + document = ThisComponent.CurrentController.Frame + dispatcher = createUnoService("com.sun.star.frame.DispatchHelper") + + dispatcher.executeDispatch(document, ".uno:AcceptAllTrackedChanges", "", 0, Array()) + ThisComponent.store() + ThisComponent.close(True) + End Sub +""" + + +def accept_changes( + input_file: str, + output_file: str, +) -> tuple[None, str]: + input_path = Path(input_file) + output_path = Path(output_file) + + if not input_path.exists(): + return None, f"Error: Input file not found: {input_file}" + + if not input_path.suffix.lower() == ".docx": + return None, f"Error: Input file is not a DOCX file: {input_file}" + + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(input_path, output_path) + except Exception as e: + return None, f"Error: Failed to copy input file to output location: {e}" + + if not _setup_libreoffice_macro(): + return None, "Error: Failed to setup LibreOffice macro" + + cmd = [ + "soffice", + "--headless", + f"-env:UserInstallation=file://{LIBREOFFICE_PROFILE}", + "--norestore", + "vnd.sun.star.script:Standard.Module1.AcceptAllTrackedChanges?language=Basic&location=application", + str(output_path.absolute()), + ] + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + check=False, + env=get_soffice_env(), + ) + except subprocess.TimeoutExpired: + return ( + None, + f"Successfully accepted all tracked changes: {input_file} -> {output_file}", + ) + + if result.returncode != 0: + return None, f"Error: LibreOffice failed: {result.stderr}" + + return ( + None, + f"Successfully accepted all tracked changes: {input_file} -> {output_file}", + ) + + +def _setup_libreoffice_macro() -> bool: + macro_dir = Path(MACRO_DIR) + macro_file = macro_dir / "Module1.xba" + + if macro_file.exists() and "AcceptAllTrackedChanges" in macro_file.read_text(): + return True + + if not macro_dir.exists(): + subprocess.run( + [ + "soffice", + "--headless", + f"-env:UserInstallation=file://{LIBREOFFICE_PROFILE}", + "--terminate_after_init", + ], + capture_output=True, + timeout=10, + check=False, + env=get_soffice_env(), + ) + macro_dir.mkdir(parents=True, exist_ok=True) + + try: + macro_file.write_text(ACCEPT_CHANGES_MACRO) + return True + except Exception as e: + logger.warning(f"Failed to setup LibreOffice macro: {e}") + return False + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Accept all tracked changes in a DOCX file" + ) + parser.add_argument("input_file", help="Input DOCX file with tracked changes") + parser.add_argument( + "output_file", help="Output DOCX file (clean, no tracked changes)" + ) + args = parser.parse_args() + + _, message = accept_changes(args.input_file, args.output_file) + print(message) + + if "Error" in message: + raise SystemExit(1) diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/comment.py b/ruoyi-admin/src/main/resources/skills/docx/scripts/comment.py new file mode 100644 index 00000000..36e1c935 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/comment.py @@ -0,0 +1,318 @@ +"""Add comments to DOCX documents. + +Usage: + python comment.py unpacked/ 0 "Comment text" + python comment.py unpacked/ 1 "Reply text" --parent 0 + +Text should be pre-escaped XML (e.g., & for &, ’ for smart quotes). + +After running, add markers to document.xml: + + ... commented content ... + + +""" + +import argparse +import random +import shutil +import sys +from datetime import datetime, timezone +from pathlib import Path + +import defusedxml.minidom + +TEMPLATE_DIR = Path(__file__).parent / "templates" +NS = { + "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + "w14": "http://schemas.microsoft.com/office/word/2010/wordml", + "w15": "http://schemas.microsoft.com/office/word/2012/wordml", + "w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid", + "w16cex": "http://schemas.microsoft.com/office/word/2018/wordml/cex", +} + +COMMENT_XML = """\ + + + + + + + + + + + + + {text} + + +""" + +COMMENT_MARKER_TEMPLATE = """ +Add to document.xml (markers must be direct children of w:p, never inside w:r): + + ... + + """ + +REPLY_MARKER_TEMPLATE = """ +Nest markers inside parent {pid}'s markers (markers must be direct children of w:p, never inside w:r): + + ... + + + """ + + +def _generate_hex_id() -> str: + return f"{random.randint(0, 0x7FFFFFFE):08X}" + + +SMART_QUOTE_ENTITIES = { + "\u201c": "“", + "\u201d": "”", + "\u2018": "‘", + "\u2019": "’", +} + + +def _encode_smart_quotes(text: str) -> str: + for char, entity in SMART_QUOTE_ENTITIES.items(): + text = text.replace(char, entity) + return text + + +def _append_xml(xml_path: Path, root_tag: str, content: str) -> None: + dom = defusedxml.minidom.parseString(xml_path.read_text(encoding="utf-8")) + root = dom.getElementsByTagName(root_tag)[0] + ns_attrs = " ".join(f'xmlns:{k}="{v}"' for k, v in NS.items()) + wrapper_dom = defusedxml.minidom.parseString(f"{content}") + for child in wrapper_dom.documentElement.childNodes: + if child.nodeType == child.ELEMENT_NODE: + root.appendChild(dom.importNode(child, True)) + output = _encode_smart_quotes(dom.toxml(encoding="UTF-8").decode("utf-8")) + xml_path.write_text(output, encoding="utf-8") + + +def _find_para_id(comments_path: Path, comment_id: int) -> str | None: + dom = defusedxml.minidom.parseString(comments_path.read_text(encoding="utf-8")) + for c in dom.getElementsByTagName("w:comment"): + if c.getAttribute("w:id") == str(comment_id): + for p in c.getElementsByTagName("w:p"): + if pid := p.getAttribute("w14:paraId"): + return pid + return None + + +def _get_next_rid(rels_path: Path) -> int: + dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8")) + max_rid = 0 + for rel in dom.getElementsByTagName("Relationship"): + rid = rel.getAttribute("Id") + if rid and rid.startswith("rId"): + try: + max_rid = max(max_rid, int(rid[3:])) + except ValueError: + pass + return max_rid + 1 + + +def _has_relationship(rels_path: Path, target: str) -> bool: + dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8")) + for rel in dom.getElementsByTagName("Relationship"): + if rel.getAttribute("Target") == target: + return True + return False + + +def _has_content_type(ct_path: Path, part_name: str) -> bool: + dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8")) + for override in dom.getElementsByTagName("Override"): + if override.getAttribute("PartName") == part_name: + return True + return False + + +def _ensure_comment_relationships(unpacked_dir: Path) -> None: + rels_path = unpacked_dir / "word" / "_rels" / "document.xml.rels" + if not rels_path.exists(): + return + + if _has_relationship(rels_path, "comments.xml"): + return + + dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8")) + root = dom.documentElement + next_rid = _get_next_rid(rels_path) + + rels = [ + ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments", + "comments.xml", + ), + ( + "http://schemas.microsoft.com/office/2011/relationships/commentsExtended", + "commentsExtended.xml", + ), + ( + "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds", + "commentsIds.xml", + ), + ( + "http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible", + "commentsExtensible.xml", + ), + ] + + for rel_type, target in rels: + rel = dom.createElement("Relationship") + rel.setAttribute("Id", f"rId{next_rid}") + rel.setAttribute("Type", rel_type) + rel.setAttribute("Target", target) + root.appendChild(rel) + next_rid += 1 + + rels_path.write_bytes(dom.toxml(encoding="UTF-8")) + + +def _ensure_comment_content_types(unpacked_dir: Path) -> None: + ct_path = unpacked_dir / "[Content_Types].xml" + if not ct_path.exists(): + return + + if _has_content_type(ct_path, "/word/comments.xml"): + return + + dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8")) + root = dom.documentElement + + overrides = [ + ( + "/word/comments.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml", + ), + ( + "/word/commentsExtended.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml", + ), + ( + "/word/commentsIds.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml", + ), + ( + "/word/commentsExtensible.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml", + ), + ] + + for part_name, content_type in overrides: + override = dom.createElement("Override") + override.setAttribute("PartName", part_name) + override.setAttribute("ContentType", content_type) + root.appendChild(override) + + ct_path.write_bytes(dom.toxml(encoding="UTF-8")) + + +def add_comment( + unpacked_dir: str, + comment_id: int, + text: str, + author: str = "Claude", + initials: str = "C", + parent_id: int | None = None, +) -> tuple[str, str]: + word = Path(unpacked_dir) / "word" + if not word.exists(): + return "", f"Error: {word} not found" + + para_id, durable_id = _generate_hex_id(), _generate_hex_id() + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + comments = word / "comments.xml" + first_comment = not comments.exists() + if first_comment: + shutil.copy(TEMPLATE_DIR / "comments.xml", comments) + _ensure_comment_relationships(Path(unpacked_dir)) + _ensure_comment_content_types(Path(unpacked_dir)) + _append_xml( + comments, + "w:comments", + COMMENT_XML.format( + id=comment_id, + author=author, + date=ts, + initials=initials, + para_id=para_id, + text=text, + ), + ) + + ext = word / "commentsExtended.xml" + if not ext.exists(): + shutil.copy(TEMPLATE_DIR / "commentsExtended.xml", ext) + if parent_id is not None: + parent_para = _find_para_id(comments, parent_id) + if not parent_para: + return "", f"Error: Parent comment {parent_id} not found" + _append_xml( + ext, + "w15:commentsEx", + f'', + ) + else: + _append_xml( + ext, + "w15:commentsEx", + f'', + ) + + ids = word / "commentsIds.xml" + if not ids.exists(): + shutil.copy(TEMPLATE_DIR / "commentsIds.xml", ids) + _append_xml( + ids, + "w16cid:commentsIds", + f'', + ) + + extensible = word / "commentsExtensible.xml" + if not extensible.exists(): + shutil.copy(TEMPLATE_DIR / "commentsExtensible.xml", extensible) + _append_xml( + extensible, + "w16cex:commentsExtensible", + f'', + ) + + action = "reply" if parent_id is not None else "comment" + return para_id, f"Added {action} {comment_id} (para_id={para_id})" + + +if __name__ == "__main__": + p = argparse.ArgumentParser(description="Add comments to DOCX documents") + p.add_argument("unpacked_dir", help="Unpacked DOCX directory") + p.add_argument("comment_id", type=int, help="Comment ID (must be unique)") + p.add_argument("text", help="Comment text") + p.add_argument("--author", default="Claude", help="Author name") + p.add_argument("--initials", default="C", help="Author initials") + p.add_argument("--parent", type=int, help="Parent comment ID (for replies)") + args = p.parse_args() + + para_id, msg = add_comment( + args.unpacked_dir, + args.comment_id, + args.text, + args.author, + args.initials, + args.parent, + ) + print(msg) + if "Error" in msg: + sys.exit(1) + cid = args.comment_id + if args.parent is not None: + print(REPLY_MARKER_TEMPLATE.format(pid=args.parent, cid=cid)) + else: + print(COMMENT_MARKER_TEMPLATE.format(cid=cid)) diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/helpers/__init__.py b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/helpers/merge_runs.py b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/helpers/merge_runs.py new file mode 100644 index 00000000..ad7c25ee --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/helpers/merge_runs.py @@ -0,0 +1,199 @@ +"""Merge adjacent runs with identical formatting in DOCX. + +Merges adjacent elements that have identical properties. +Works on runs in paragraphs and inside tracked changes (, ). + +Also: +- Removes rsid attributes from runs (revision metadata that doesn't affect rendering) +- Removes proofErr elements (spell/grammar markers that block merging) +""" + +from pathlib import Path + +import defusedxml.minidom + + +def merge_runs(input_dir: str) -> tuple[int, str]: + doc_xml = Path(input_dir) / "word" / "document.xml" + + if not doc_xml.exists(): + return 0, f"Error: {doc_xml} not found" + + try: + dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8")) + root = dom.documentElement + + _remove_elements(root, "proofErr") + _strip_run_rsid_attrs(root) + + containers = {run.parentNode for run in _find_elements(root, "r")} + + merge_count = 0 + for container in containers: + merge_count += _merge_runs_in(container) + + doc_xml.write_bytes(dom.toxml(encoding="UTF-8")) + return merge_count, f"Merged {merge_count} runs" + + except Exception as e: + return 0, f"Error: {e}" + + + + +def _find_elements(root, tag: str) -> list: + results = [] + + def traverse(node): + if node.nodeType == node.ELEMENT_NODE: + name = node.localName or node.tagName + if name == tag or name.endswith(f":{tag}"): + results.append(node) + for child in node.childNodes: + traverse(child) + + traverse(root) + return results + + +def _get_child(parent, tag: str): + for child in parent.childNodes: + if child.nodeType == child.ELEMENT_NODE: + name = child.localName or child.tagName + if name == tag or name.endswith(f":{tag}"): + return child + return None + + +def _get_children(parent, tag: str) -> list: + results = [] + for child in parent.childNodes: + if child.nodeType == child.ELEMENT_NODE: + name = child.localName or child.tagName + if name == tag or name.endswith(f":{tag}"): + results.append(child) + return results + + +def _is_adjacent(elem1, elem2) -> bool: + node = elem1.nextSibling + while node: + if node == elem2: + return True + if node.nodeType == node.ELEMENT_NODE: + return False + if node.nodeType == node.TEXT_NODE and node.data.strip(): + return False + node = node.nextSibling + return False + + + + +def _remove_elements(root, tag: str): + for elem in _find_elements(root, tag): + if elem.parentNode: + elem.parentNode.removeChild(elem) + + +def _strip_run_rsid_attrs(root): + for run in _find_elements(root, "r"): + for attr in list(run.attributes.values()): + if "rsid" in attr.name.lower(): + run.removeAttribute(attr.name) + + + + +def _merge_runs_in(container) -> int: + merge_count = 0 + run = _first_child_run(container) + + while run: + while True: + next_elem = _next_element_sibling(run) + if next_elem and _is_run(next_elem) and _can_merge(run, next_elem): + _merge_run_content(run, next_elem) + container.removeChild(next_elem) + merge_count += 1 + else: + break + + _consolidate_text(run) + run = _next_sibling_run(run) + + return merge_count + + +def _first_child_run(container): + for child in container.childNodes: + if child.nodeType == child.ELEMENT_NODE and _is_run(child): + return child + return None + + +def _next_element_sibling(node): + sibling = node.nextSibling + while sibling: + if sibling.nodeType == sibling.ELEMENT_NODE: + return sibling + sibling = sibling.nextSibling + return None + + +def _next_sibling_run(node): + sibling = node.nextSibling + while sibling: + if sibling.nodeType == sibling.ELEMENT_NODE: + if _is_run(sibling): + return sibling + sibling = sibling.nextSibling + return None + + +def _is_run(node) -> bool: + name = node.localName or node.tagName + return name == "r" or name.endswith(":r") + + +def _can_merge(run1, run2) -> bool: + rpr1 = _get_child(run1, "rPr") + rpr2 = _get_child(run2, "rPr") + + if (rpr1 is None) != (rpr2 is None): + return False + if rpr1 is None: + return True + return rpr1.toxml() == rpr2.toxml() + + +def _merge_run_content(target, source): + for child in list(source.childNodes): + if child.nodeType == child.ELEMENT_NODE: + name = child.localName or child.tagName + if name != "rPr" and not name.endswith(":rPr"): + target.appendChild(child) + + +def _consolidate_text(run): + t_elements = _get_children(run, "t") + + for i in range(len(t_elements) - 1, 0, -1): + curr, prev = t_elements[i], t_elements[i - 1] + + if _is_adjacent(prev, curr): + prev_text = prev.firstChild.data if prev.firstChild else "" + curr_text = curr.firstChild.data if curr.firstChild else "" + merged = prev_text + curr_text + + if prev.firstChild: + prev.firstChild.data = merged + else: + prev.appendChild(run.ownerDocument.createTextNode(merged)) + + if merged.startswith(" ") or merged.endswith(" "): + prev.setAttribute("xml:space", "preserve") + elif prev.hasAttribute("xml:space"): + prev.removeAttribute("xml:space") + + run.removeChild(curr) diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/helpers/simplify_redlines.py b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/helpers/simplify_redlines.py new file mode 100644 index 00000000..db963bb9 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/helpers/simplify_redlines.py @@ -0,0 +1,197 @@ +"""Simplify tracked changes by merging adjacent w:ins or w:del elements. + +Merges adjacent elements from the same author into a single element. +Same for elements. This makes heavily-redlined documents easier to +work with by reducing the number of tracked change wrappers. + +Rules: +- Only merges w:ins with w:ins, w:del with w:del (same element type) +- Only merges if same author (ignores timestamp differences) +- Only merges if truly adjacent (only whitespace between them) +""" + +import xml.etree.ElementTree as ET +import zipfile +from pathlib import Path + +import defusedxml.minidom + +WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + + +def simplify_redlines(input_dir: str) -> tuple[int, str]: + doc_xml = Path(input_dir) / "word" / "document.xml" + + if not doc_xml.exists(): + return 0, f"Error: {doc_xml} not found" + + try: + dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8")) + root = dom.documentElement + + merge_count = 0 + + containers = _find_elements(root, "p") + _find_elements(root, "tc") + + for container in containers: + merge_count += _merge_tracked_changes_in(container, "ins") + merge_count += _merge_tracked_changes_in(container, "del") + + doc_xml.write_bytes(dom.toxml(encoding="UTF-8")) + return merge_count, f"Simplified {merge_count} tracked changes" + + except Exception as e: + return 0, f"Error: {e}" + + +def _merge_tracked_changes_in(container, tag: str) -> int: + merge_count = 0 + + tracked = [ + child + for child in container.childNodes + if child.nodeType == child.ELEMENT_NODE and _is_element(child, tag) + ] + + if len(tracked) < 2: + return 0 + + i = 0 + while i < len(tracked) - 1: + curr = tracked[i] + next_elem = tracked[i + 1] + + if _can_merge_tracked(curr, next_elem): + _merge_tracked_content(curr, next_elem) + container.removeChild(next_elem) + tracked.pop(i + 1) + merge_count += 1 + else: + i += 1 + + return merge_count + + +def _is_element(node, tag: str) -> bool: + name = node.localName or node.tagName + return name == tag or name.endswith(f":{tag}") + + +def _get_author(elem) -> str: + author = elem.getAttribute("w:author") + if not author: + for attr in elem.attributes.values(): + if attr.localName == "author" or attr.name.endswith(":author"): + return attr.value + return author + + +def _can_merge_tracked(elem1, elem2) -> bool: + if _get_author(elem1) != _get_author(elem2): + return False + + node = elem1.nextSibling + while node and node != elem2: + if node.nodeType == node.ELEMENT_NODE: + return False + if node.nodeType == node.TEXT_NODE and node.data.strip(): + return False + node = node.nextSibling + + return True + + +def _merge_tracked_content(target, source): + while source.firstChild: + child = source.firstChild + source.removeChild(child) + target.appendChild(child) + + +def _find_elements(root, tag: str) -> list: + results = [] + + def traverse(node): + if node.nodeType == node.ELEMENT_NODE: + name = node.localName or node.tagName + if name == tag or name.endswith(f":{tag}"): + results.append(node) + for child in node.childNodes: + traverse(child) + + traverse(root) + return results + + +def get_tracked_change_authors(doc_xml_path: Path) -> dict[str, int]: + if not doc_xml_path.exists(): + return {} + + try: + tree = ET.parse(doc_xml_path) + root = tree.getroot() + except ET.ParseError: + return {} + + namespaces = {"w": WORD_NS} + author_attr = f"{{{WORD_NS}}}author" + + authors: dict[str, int] = {} + for tag in ["ins", "del"]: + for elem in root.findall(f".//w:{tag}", namespaces): + author = elem.get(author_attr) + if author: + authors[author] = authors.get(author, 0) + 1 + + return authors + + +def _get_authors_from_docx(docx_path: Path) -> dict[str, int]: + try: + with zipfile.ZipFile(docx_path, "r") as zf: + if "word/document.xml" not in zf.namelist(): + return {} + with zf.open("word/document.xml") as f: + tree = ET.parse(f) + root = tree.getroot() + + namespaces = {"w": WORD_NS} + author_attr = f"{{{WORD_NS}}}author" + + authors: dict[str, int] = {} + for tag in ["ins", "del"]: + for elem in root.findall(f".//w:{tag}", namespaces): + author = elem.get(author_attr) + if author: + authors[author] = authors.get(author, 0) + 1 + return authors + except (zipfile.BadZipFile, ET.ParseError): + return {} + + +def infer_author(modified_dir: Path, original_docx: Path, default: str = "Claude") -> str: + modified_xml = modified_dir / "word" / "document.xml" + modified_authors = get_tracked_change_authors(modified_xml) + + if not modified_authors: + return default + + original_authors = _get_authors_from_docx(original_docx) + + new_changes: dict[str, int] = {} + for author, count in modified_authors.items(): + original_count = original_authors.get(author, 0) + diff = count - original_count + if diff > 0: + new_changes[author] = diff + + if not new_changes: + return default + + if len(new_changes) == 1: + return next(iter(new_changes)) + + raise ValueError( + f"Multiple authors added new changes: {new_changes}. " + "Cannot infer which author to validate." + ) diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/pack.py b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/pack.py new file mode 100644 index 00000000..db29ed8b --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/pack.py @@ -0,0 +1,159 @@ +"""Pack a directory into a DOCX, PPTX, or XLSX file. + +Validates with auto-repair, condenses XML formatting, and creates the Office file. + +Usage: + python pack.py [--original ] [--validate true|false] + +Examples: + python pack.py unpacked/ output.docx --original input.docx + python pack.py unpacked/ output.pptx --validate false +""" + +import argparse +import sys +import shutil +import tempfile +import zipfile +from pathlib import Path + +import defusedxml.minidom + +from validators import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator + +def pack( + input_directory: str, + output_file: str, + original_file: str | None = None, + validate: bool = True, + infer_author_func=None, +) -> tuple[None, str]: + input_dir = Path(input_directory) + output_path = Path(output_file) + suffix = output_path.suffix.lower() + + if not input_dir.is_dir(): + return None, f"Error: {input_dir} is not a directory" + + if suffix not in {".docx", ".pptx", ".xlsx"}: + return None, f"Error: {output_file} must be a .docx, .pptx, or .xlsx file" + + if validate and original_file: + original_path = Path(original_file) + if original_path.exists(): + success, output = _run_validation( + input_dir, original_path, suffix, infer_author_func + ) + if output: + print(output) + if not success: + return None, f"Error: Validation failed for {input_dir}" + + with tempfile.TemporaryDirectory() as temp_dir: + temp_content_dir = Path(temp_dir) / "content" + shutil.copytree(input_dir, temp_content_dir) + + for pattern in ["*.xml", "*.rels"]: + for xml_file in temp_content_dir.rglob(pattern): + _condense_xml(xml_file) + + output_path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: + for f in temp_content_dir.rglob("*"): + if f.is_file(): + zf.write(f, f.relative_to(temp_content_dir)) + + return None, f"Successfully packed {input_dir} to {output_file}" + + +def _run_validation( + unpacked_dir: Path, + original_file: Path, + suffix: str, + infer_author_func=None, +) -> tuple[bool, str | None]: + output_lines = [] + validators = [] + + if suffix == ".docx": + author = "Claude" + if infer_author_func: + try: + author = infer_author_func(unpacked_dir, original_file) + except ValueError as e: + print(f"Warning: {e} Using default author 'Claude'.", file=sys.stderr) + + validators = [ + DOCXSchemaValidator(unpacked_dir, original_file), + RedliningValidator(unpacked_dir, original_file, author=author), + ] + elif suffix == ".pptx": + validators = [PPTXSchemaValidator(unpacked_dir, original_file)] + + if not validators: + return True, None + + total_repairs = sum(v.repair() for v in validators) + if total_repairs: + output_lines.append(f"Auto-repaired {total_repairs} issue(s)") + + success = all(v.validate() for v in validators) + + if success: + output_lines.append("All validations PASSED!") + + return success, "\n".join(output_lines) if output_lines else None + + +def _condense_xml(xml_file: Path) -> None: + try: + with open(xml_file, encoding="utf-8") as f: + dom = defusedxml.minidom.parse(f) + + for element in dom.getElementsByTagName("*"): + if element.tagName.endswith(":t"): + continue + + for child in list(element.childNodes): + if ( + child.nodeType == child.TEXT_NODE + and child.nodeValue + and child.nodeValue.strip() == "" + ) or child.nodeType == child.COMMENT_NODE: + element.removeChild(child) + + xml_file.write_bytes(dom.toxml(encoding="UTF-8")) + except Exception as e: + print(f"ERROR: Failed to parse {xml_file.name}: {e}", file=sys.stderr) + raise + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Pack a directory into a DOCX, PPTX, or XLSX file" + ) + parser.add_argument("input_directory", help="Unpacked Office document directory") + parser.add_argument("output_file", help="Output Office file (.docx/.pptx/.xlsx)") + parser.add_argument( + "--original", + help="Original file for validation comparison", + ) + parser.add_argument( + "--validate", + type=lambda x: x.lower() == "true", + default=True, + metavar="true|false", + help="Run validation with auto-repair (default: true)", + ) + args = parser.parse_args() + + _, message = pack( + args.input_directory, + args.output_file, + original_file=args.original, + validate=args.validate, + ) + print(message) + + if "Error" in message: + sys.exit(1) diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd new file mode 100644 index 00000000..6454ef9a --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd @@ -0,0 +1,1499 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd new file mode 100644 index 00000000..afa4f463 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd new file mode 100644 index 00000000..64e66b8a --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd @@ -0,0 +1,1085 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd new file mode 100644 index 00000000..687eea82 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd @@ -0,0 +1,11 @@ + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd new file mode 100644 index 00000000..6ac81b06 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd @@ -0,0 +1,3081 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd new file mode 100644 index 00000000..1dbf0514 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd new file mode 100644 index 00000000..f1af17db --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd new file mode 100644 index 00000000..0a185ab6 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd new file mode 100644 index 00000000..14ef4888 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd @@ -0,0 +1,1676 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd new file mode 100644 index 00000000..c20f3bf1 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd new file mode 100644 index 00000000..ac602522 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd new file mode 100644 index 00000000..424b8ba8 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd new file mode 100644 index 00000000..2bddce29 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd new file mode 100644 index 00000000..8a8c18ba --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd new file mode 100644 index 00000000..5c42706a --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd new file mode 100644 index 00000000..853c341c --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd new file mode 100644 index 00000000..da835ee8 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd new file mode 100644 index 00000000..87ad2658 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd @@ -0,0 +1,582 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd new file mode 100644 index 00000000..9e86f1b2 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd new file mode 100644 index 00000000..d0be42e7 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd @@ -0,0 +1,4439 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd new file mode 100644 index 00000000..8821dd18 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd @@ -0,0 +1,570 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd new file mode 100644 index 00000000..ca2575c7 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd @@ -0,0 +1,509 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd new file mode 100644 index 00000000..dd079e60 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd new file mode 100644 index 00000000..3dd6cf62 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd new file mode 100644 index 00000000..f1041e34 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd new file mode 100644 index 00000000..9c5b7a63 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd @@ -0,0 +1,3646 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd new file mode 100644 index 00000000..0f13678d --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd @@ -0,0 +1,116 @@ + + + + + + See http://www.w3.org/XML/1998/namespace.html and + http://www.w3.org/TR/REC-xml for information about this namespace. + + This schema document describes the XML namespace, in a form + suitable for import by other schema documents. + + Note that local names in this namespace are intended to be defined + only by the World Wide Web Consortium or its subgroups. The + following names are currently defined in this namespace and should + not be used with conflicting semantics by any Working Group, + specification, or document instance: + + base (as an attribute name): denotes an attribute whose value + provides a URI to be used as the base for interpreting any + relative URIs in the scope of the element on which it + appears; its value is inherited. This name is reserved + by virtue of its definition in the XML Base specification. + + lang (as an attribute name): denotes an attribute whose value + is a language code for the natural language of the content of + any element; its value is inherited. This name is reserved + by virtue of its definition in the XML specification. + + space (as an attribute name): denotes an attribute whose + value is a keyword indicating what whitespace processing + discipline is intended for the content of the element; its + value is inherited. This name is reserved by virtue of its + definition in the XML specification. + + Father (in any context at all): denotes Jon Bosak, the chair of + the original XML Working Group. This name is reserved by + the following decision of the W3C XML Plenary and + XML Coordination groups: + + In appreciation for his vision, leadership and dedication + the W3C XML Plenary on this 10th day of February, 2000 + reserves for Jon Bosak in perpetuity the XML name + xml:Father + + + + + This schema defines attributes and an attribute group + suitable for use by + schemas wishing to allow xml:base, xml:lang or xml:space attributes + on elements they define. + + To enable this, such a schema must import this schema + for the XML namespace, e.g. as follows: + <schema . . .> + . . . + <import namespace="http://www.w3.org/XML/1998/namespace" + schemaLocation="http://www.w3.org/2001/03/xml.xsd"/> + + Subsequently, qualified reference to any of the attributes + or the group defined below will have the desired effect, e.g. + + <type . . .> + . . . + <attributeGroup ref="xml:specialAttrs"/> + + will define a type which will schema-validate an instance + element with any of those attributes + + + + In keeping with the XML Schema WG's standard versioning + policy, this schema document will persist at + http://www.w3.org/2001/03/xml.xsd. + At the date of issue it can also be found at + http://www.w3.org/2001/xml.xsd. + The schema document at that URI may however change in the future, + in order to remain compatible with the latest version of XML Schema + itself. In other words, if the XML Schema namespace changes, the version + of this document at + http://www.w3.org/2001/xml.xsd will change + accordingly; the version at + http://www.w3.org/2001/03/xml.xsd will not change. + + + + + + In due course, we should install the relevant ISO 2- and 3-letter + codes as the enumerated possible values . . . + + + + + + + + + + + + + + + See http://www.w3.org/TR/xmlbase/ for + information about this attribute. + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd new file mode 100644 index 00000000..a6de9d27 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd new file mode 100644 index 00000000..10e978b6 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd new file mode 100644 index 00000000..4248bf7a --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd new file mode 100644 index 00000000..56497467 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/mce/mc.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/mce/mc.xsd new file mode 100644 index 00000000..ef725457 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/mce/mc.xsd @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd new file mode 100644 index 00000000..f65f7777 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd @@ -0,0 +1,560 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd new file mode 100644 index 00000000..6b00755a --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd new file mode 100644 index 00000000..f321d333 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd new file mode 100644 index 00000000..364c6a9b --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd new file mode 100644 index 00000000..fed9d15b --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd new file mode 100644 index 00000000..680cf154 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd @@ -0,0 +1,4 @@ + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd new file mode 100644 index 00000000..89ada908 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/soffice.py b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/soffice.py new file mode 100644 index 00000000..c7f7e328 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/soffice.py @@ -0,0 +1,183 @@ +""" +Helper for running LibreOffice (soffice) in environments where AF_UNIX +sockets may be blocked (e.g., sandboxed VMs). Detects the restriction +at runtime and applies an LD_PRELOAD shim if needed. + +Usage: + from office.soffice import run_soffice, get_soffice_env + + # Option 1 – run soffice directly + result = run_soffice(["--headless", "--convert-to", "pdf", "input.docx"]) + + # Option 2 – get env dict for your own subprocess calls + env = get_soffice_env() + subprocess.run(["soffice", ...], env=env) +""" + +import os +import socket +import subprocess +import tempfile +from pathlib import Path + + +def get_soffice_env() -> dict: + env = os.environ.copy() + env["SAL_USE_VCLPLUGIN"] = "svp" + + if _needs_shim(): + shim = _ensure_shim() + env["LD_PRELOAD"] = str(shim) + + return env + + +def run_soffice(args: list[str], **kwargs) -> subprocess.CompletedProcess: + env = get_soffice_env() + return subprocess.run(["soffice"] + args, env=env, **kwargs) + + + +_SHIM_SO = Path(tempfile.gettempdir()) / "lo_socket_shim.so" + + +def _needs_shim() -> bool: + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.close() + return False + except OSError: + return True + + +def _ensure_shim() -> Path: + if _SHIM_SO.exists(): + return _SHIM_SO + + src = Path(tempfile.gettempdir()) / "lo_socket_shim.c" + src.write_text(_SHIM_SOURCE) + subprocess.run( + ["gcc", "-shared", "-fPIC", "-o", str(_SHIM_SO), str(src), "-ldl"], + check=True, + capture_output=True, + ) + src.unlink() + return _SHIM_SO + + + +_SHIM_SOURCE = r""" +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include + +static int (*real_socket)(int, int, int); +static int (*real_socketpair)(int, int, int, int[2]); +static int (*real_listen)(int, int); +static int (*real_accept)(int, struct sockaddr *, socklen_t *); +static int (*real_close)(int); +static int (*real_read)(int, void *, size_t); + +/* Per-FD bookkeeping (FDs >= 1024 are passed through unshimmed). */ +static int is_shimmed[1024]; +static int peer_of[1024]; +static int wake_r[1024]; /* accept() blocks reading this */ +static int wake_w[1024]; /* close() writes to this */ +static int listener_fd = -1; /* FD that received listen() */ + +__attribute__((constructor)) +static void init(void) { + real_socket = dlsym(RTLD_NEXT, "socket"); + real_socketpair = dlsym(RTLD_NEXT, "socketpair"); + real_listen = dlsym(RTLD_NEXT, "listen"); + real_accept = dlsym(RTLD_NEXT, "accept"); + real_close = dlsym(RTLD_NEXT, "close"); + real_read = dlsym(RTLD_NEXT, "read"); + for (int i = 0; i < 1024; i++) { + peer_of[i] = -1; + wake_r[i] = -1; + wake_w[i] = -1; + } +} + +/* ---- socket ---------------------------------------------------------- */ +int socket(int domain, int type, int protocol) { + if (domain == AF_UNIX) { + int fd = real_socket(domain, type, protocol); + if (fd >= 0) return fd; + /* socket(AF_UNIX) blocked – fall back to socketpair(). */ + int sv[2]; + if (real_socketpair(domain, type, protocol, sv) == 0) { + if (sv[0] >= 0 && sv[0] < 1024) { + is_shimmed[sv[0]] = 1; + peer_of[sv[0]] = sv[1]; + int wp[2]; + if (pipe(wp) == 0) { + wake_r[sv[0]] = wp[0]; + wake_w[sv[0]] = wp[1]; + } + } + return sv[0]; + } + errno = EPERM; + return -1; + } + return real_socket(domain, type, protocol); +} + +/* ---- listen ---------------------------------------------------------- */ +int listen(int sockfd, int backlog) { + if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) { + listener_fd = sockfd; + return 0; + } + return real_listen(sockfd, backlog); +} + +/* ---- accept ---------------------------------------------------------- */ +int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen) { + if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) { + /* Block until close() writes to the wake pipe. */ + if (wake_r[sockfd] >= 0) { + char buf; + real_read(wake_r[sockfd], &buf, 1); + } + errno = ECONNABORTED; + return -1; + } + return real_accept(sockfd, addr, addrlen); +} + +/* ---- close ----------------------------------------------------------- */ +int close(int fd) { + if (fd >= 0 && fd < 1024 && is_shimmed[fd]) { + int was_listener = (fd == listener_fd); + is_shimmed[fd] = 0; + + if (wake_w[fd] >= 0) { /* unblock accept() */ + char c = 0; + write(wake_w[fd], &c, 1); + real_close(wake_w[fd]); + wake_w[fd] = -1; + } + if (wake_r[fd] >= 0) { real_close(wake_r[fd]); wake_r[fd] = -1; } + if (peer_of[fd] >= 0) { real_close(peer_of[fd]); peer_of[fd] = -1; } + + if (was_listener) + _exit(0); /* conversion done – exit */ + } + return real_close(fd); +} +""" + + + +if __name__ == "__main__": + import sys + result = run_soffice(sys.argv[1:]) + sys.exit(result.returncode) diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/unpack.py b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/unpack.py new file mode 100644 index 00000000..00152533 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/unpack.py @@ -0,0 +1,132 @@ +"""Unpack Office files (DOCX, PPTX, XLSX) for editing. + +Extracts the ZIP archive, pretty-prints XML files, and optionally: +- Merges adjacent runs with identical formatting (DOCX only) +- Simplifies adjacent tracked changes from same author (DOCX only) + +Usage: + python unpack.py [options] + +Examples: + python unpack.py document.docx unpacked/ + python unpack.py presentation.pptx unpacked/ + python unpack.py document.docx unpacked/ --merge-runs false +""" + +import argparse +import sys +import zipfile +from pathlib import Path + +import defusedxml.minidom + +from helpers.merge_runs import merge_runs as do_merge_runs +from helpers.simplify_redlines import simplify_redlines as do_simplify_redlines + +SMART_QUOTE_REPLACEMENTS = { + "\u201c": "“", + "\u201d": "”", + "\u2018": "‘", + "\u2019": "’", +} + + +def unpack( + input_file: str, + output_directory: str, + merge_runs: bool = True, + simplify_redlines: bool = True, +) -> tuple[None, str]: + input_path = Path(input_file) + output_path = Path(output_directory) + suffix = input_path.suffix.lower() + + if not input_path.exists(): + return None, f"Error: {input_file} does not exist" + + if suffix not in {".docx", ".pptx", ".xlsx"}: + return None, f"Error: {input_file} must be a .docx, .pptx, or .xlsx file" + + try: + output_path.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile(input_path, "r") as zf: + zf.extractall(output_path) + + xml_files = list(output_path.rglob("*.xml")) + list(output_path.rglob("*.rels")) + for xml_file in xml_files: + _pretty_print_xml(xml_file) + + message = f"Unpacked {input_file} ({len(xml_files)} XML files)" + + if suffix == ".docx": + if simplify_redlines: + simplify_count, _ = do_simplify_redlines(str(output_path)) + message += f", simplified {simplify_count} tracked changes" + + if merge_runs: + merge_count, _ = do_merge_runs(str(output_path)) + message += f", merged {merge_count} runs" + + for xml_file in xml_files: + _escape_smart_quotes(xml_file) + + return None, message + + except zipfile.BadZipFile: + return None, f"Error: {input_file} is not a valid Office file" + except Exception as e: + return None, f"Error unpacking: {e}" + + +def _pretty_print_xml(xml_file: Path) -> None: + try: + content = xml_file.read_text(encoding="utf-8") + dom = defusedxml.minidom.parseString(content) + xml_file.write_bytes(dom.toprettyxml(indent=" ", encoding="utf-8")) + except Exception: + pass + + +def _escape_smart_quotes(xml_file: Path) -> None: + try: + content = xml_file.read_text(encoding="utf-8") + for char, entity in SMART_QUOTE_REPLACEMENTS.items(): + content = content.replace(char, entity) + xml_file.write_text(content, encoding="utf-8") + except Exception: + pass + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Unpack an Office file (DOCX, PPTX, XLSX) for editing" + ) + parser.add_argument("input_file", help="Office file to unpack") + parser.add_argument("output_directory", help="Output directory") + parser.add_argument( + "--merge-runs", + type=lambda x: x.lower() == "true", + default=True, + metavar="true|false", + help="Merge adjacent runs with identical formatting (DOCX only, default: true)", + ) + parser.add_argument( + "--simplify-redlines", + type=lambda x: x.lower() == "true", + default=True, + metavar="true|false", + help="Merge adjacent tracked changes from same author (DOCX only, default: true)", + ) + args = parser.parse_args() + + _, message = unpack( + args.input_file, + args.output_directory, + merge_runs=args.merge_runs, + simplify_redlines=args.simplify_redlines, + ) + print(message) + + if "Error" in message: + sys.exit(1) diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/validate.py b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/validate.py new file mode 100644 index 00000000..03b01f6e --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/validate.py @@ -0,0 +1,111 @@ +""" +Command line tool to validate Office document XML files against XSD schemas and tracked changes. + +Usage: + python validate.py [--original ] [--auto-repair] [--author NAME] + +The first argument can be either: +- An unpacked directory containing the Office document XML files +- A packed Office file (.docx/.pptx/.xlsx) which will be unpacked to a temp directory + +Auto-repair fixes: +- paraId/durableId values that exceed OOXML limits +- Missing xml:space="preserve" on w:t elements with whitespace +""" + +import argparse +import sys +import tempfile +import zipfile +from pathlib import Path + +from validators import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator + + +def main(): + parser = argparse.ArgumentParser(description="Validate Office document XML files") + parser.add_argument( + "path", + help="Path to unpacked directory or packed Office file (.docx/.pptx/.xlsx)", + ) + parser.add_argument( + "--original", + required=False, + default=None, + help="Path to original file (.docx/.pptx/.xlsx). If omitted, all XSD errors are reported and redlining validation is skipped.", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose output", + ) + parser.add_argument( + "--auto-repair", + action="store_true", + help="Automatically repair common issues (hex IDs, whitespace preservation)", + ) + parser.add_argument( + "--author", + default="Claude", + help="Author name for redlining validation (default: Claude)", + ) + args = parser.parse_args() + + path = Path(args.path) + assert path.exists(), f"Error: {path} does not exist" + + original_file = None + if args.original: + original_file = Path(args.original) + assert original_file.is_file(), f"Error: {original_file} is not a file" + assert original_file.suffix.lower() in [".docx", ".pptx", ".xlsx"], ( + f"Error: {original_file} must be a .docx, .pptx, or .xlsx file" + ) + + file_extension = (original_file or path).suffix.lower() + assert file_extension in [".docx", ".pptx", ".xlsx"], ( + f"Error: Cannot determine file type from {path}. Use --original or provide a .docx/.pptx/.xlsx file." + ) + + if path.is_file() and path.suffix.lower() in [".docx", ".pptx", ".xlsx"]: + temp_dir = tempfile.mkdtemp() + with zipfile.ZipFile(path, "r") as zf: + zf.extractall(temp_dir) + unpacked_dir = Path(temp_dir) + else: + assert path.is_dir(), f"Error: {path} is not a directory or Office file" + unpacked_dir = path + + match file_extension: + case ".docx": + validators = [ + DOCXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), + ] + if original_file: + validators.append( + RedliningValidator(unpacked_dir, original_file, verbose=args.verbose, author=args.author) + ) + case ".pptx": + validators = [ + PPTXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), + ] + case _: + print(f"Error: Validation not supported for file type {file_extension}") + sys.exit(1) + + if args.auto_repair: + total_repairs = sum(v.repair() for v in validators) + if total_repairs: + print(f"Auto-repaired {total_repairs} issue(s)") + + success = all(v.validate() for v in validators) + + if success: + print("All validations PASSED!") + + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/validators/__init__.py b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/validators/__init__.py new file mode 100644 index 00000000..db092ece --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/validators/__init__.py @@ -0,0 +1,15 @@ +""" +Validation modules for Word document processing. +""" + +from .base import BaseSchemaValidator +from .docx import DOCXSchemaValidator +from .pptx import PPTXSchemaValidator +from .redlining import RedliningValidator + +__all__ = [ + "BaseSchemaValidator", + "DOCXSchemaValidator", + "PPTXSchemaValidator", + "RedliningValidator", +] diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/validators/base.py b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/validators/base.py new file mode 100644 index 00000000..db4a06a2 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/validators/base.py @@ -0,0 +1,847 @@ +""" +Base validator with common validation logic for document files. +""" + +import re +from pathlib import Path + +import defusedxml.minidom +import lxml.etree + + +class BaseSchemaValidator: + + IGNORED_VALIDATION_ERRORS = [ + "hyphenationZone", + "purl.org/dc/terms", + ] + + UNIQUE_ID_REQUIREMENTS = { + "comment": ("id", "file"), + "commentrangestart": ("id", "file"), + "commentrangeend": ("id", "file"), + "bookmarkstart": ("id", "file"), + "bookmarkend": ("id", "file"), + "sldid": ("id", "file"), + "sldmasterid": ("id", "global"), + "sldlayoutid": ("id", "global"), + "cm": ("authorid", "file"), + "sheet": ("sheetid", "file"), + "definedname": ("id", "file"), + "cxnsp": ("id", "file"), + "sp": ("id", "file"), + "pic": ("id", "file"), + "grpsp": ("id", "file"), + } + + EXCLUDED_ID_CONTAINERS = { + "sectionlst", + } + + ELEMENT_RELATIONSHIP_TYPES = {} + + SCHEMA_MAPPINGS = { + "word": "ISO-IEC29500-4_2016/wml.xsd", + "ppt": "ISO-IEC29500-4_2016/pml.xsd", + "xl": "ISO-IEC29500-4_2016/sml.xsd", + "[Content_Types].xml": "ecma/fouth-edition/opc-contentTypes.xsd", + "app.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd", + "core.xml": "ecma/fouth-edition/opc-coreProperties.xsd", + "custom.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd", + ".rels": "ecma/fouth-edition/opc-relationships.xsd", + "people.xml": "microsoft/wml-2012.xsd", + "commentsIds.xml": "microsoft/wml-cid-2016.xsd", + "commentsExtensible.xml": "microsoft/wml-cex-2018.xsd", + "commentsExtended.xml": "microsoft/wml-2012.xsd", + "chart": "ISO-IEC29500-4_2016/dml-chart.xsd", + "theme": "ISO-IEC29500-4_2016/dml-main.xsd", + "drawing": "ISO-IEC29500-4_2016/dml-main.xsd", + } + + MC_NAMESPACE = "http://schemas.openxmlformats.org/markup-compatibility/2006" + XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace" + + PACKAGE_RELATIONSHIPS_NAMESPACE = ( + "http://schemas.openxmlformats.org/package/2006/relationships" + ) + OFFICE_RELATIONSHIPS_NAMESPACE = ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + ) + CONTENT_TYPES_NAMESPACE = ( + "http://schemas.openxmlformats.org/package/2006/content-types" + ) + + MAIN_CONTENT_FOLDERS = {"word", "ppt", "xl"} + + OOXML_NAMESPACES = { + "http://schemas.openxmlformats.org/officeDocument/2006/math", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "http://schemas.openxmlformats.org/schemaLibrary/2006/main", + "http://schemas.openxmlformats.org/drawingml/2006/main", + "http://schemas.openxmlformats.org/drawingml/2006/chart", + "http://schemas.openxmlformats.org/drawingml/2006/chartDrawing", + "http://schemas.openxmlformats.org/drawingml/2006/diagram", + "http://schemas.openxmlformats.org/drawingml/2006/picture", + "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing", + "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", + "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + "http://schemas.openxmlformats.org/presentationml/2006/main", + "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + "http://schemas.openxmlformats.org/officeDocument/2006/sharedTypes", + "http://www.w3.org/XML/1998/namespace", + } + + def __init__(self, unpacked_dir, original_file=None, verbose=False): + self.unpacked_dir = Path(unpacked_dir).resolve() + self.original_file = Path(original_file) if original_file else None + self.verbose = verbose + + self.schemas_dir = Path(__file__).parent.parent / "schemas" + + patterns = ["*.xml", "*.rels"] + self.xml_files = [ + f for pattern in patterns for f in self.unpacked_dir.rglob(pattern) + ] + + if not self.xml_files: + print(f"Warning: No XML files found in {self.unpacked_dir}") + + def validate(self): + raise NotImplementedError("Subclasses must implement the validate method") + + def repair(self) -> int: + return self.repair_whitespace_preservation() + + def repair_whitespace_preservation(self) -> int: + repairs = 0 + + for xml_file in self.xml_files: + try: + content = xml_file.read_text(encoding="utf-8") + dom = defusedxml.minidom.parseString(content) + modified = False + + for elem in dom.getElementsByTagName("*"): + if elem.tagName.endswith(":t") and elem.firstChild: + text = elem.firstChild.nodeValue + if text and (text.startswith((' ', '\t')) or text.endswith((' ', '\t'))): + if elem.getAttribute("xml:space") != "preserve": + elem.setAttribute("xml:space", "preserve") + text_preview = repr(text[:30]) + "..." if len(text) > 30 else repr(text) + print(f" Repaired: {xml_file.name}: Added xml:space='preserve' to {elem.tagName}: {text_preview}") + repairs += 1 + modified = True + + if modified: + xml_file.write_bytes(dom.toxml(encoding="UTF-8")) + + except Exception: + pass + + return repairs + + def validate_xml(self): + errors = [] + + for xml_file in self.xml_files: + try: + lxml.etree.parse(str(xml_file)) + except lxml.etree.XMLSyntaxError as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {e.lineno}: {e.msg}" + ) + except Exception as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Unexpected error: {str(e)}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} XML violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All XML files are well-formed") + return True + + def validate_namespaces(self): + errors = [] + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + declared = set(root.nsmap.keys()) - {None} + + for attr_val in [ + v for k, v in root.attrib.items() if k.endswith("Ignorable") + ]: + undeclared = set(attr_val.split()) - declared + errors.extend( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Namespace '{ns}' in Ignorable but not declared" + for ns in undeclared + ) + except lxml.etree.XMLSyntaxError: + continue + + if errors: + print(f"FAILED - {len(errors)} namespace issues:") + for error in errors: + print(error) + return False + if self.verbose: + print("PASSED - All namespace prefixes properly declared") + return True + + def validate_unique_ids(self): + errors = [] + global_ids = {} + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + file_ids = {} + + mc_elements = root.xpath( + ".//mc:AlternateContent", namespaces={"mc": self.MC_NAMESPACE} + ) + for elem in mc_elements: + elem.getparent().remove(elem) + + for elem in root.iter(): + tag = ( + elem.tag.split("}")[-1].lower() + if "}" in elem.tag + else elem.tag.lower() + ) + + if tag in self.UNIQUE_ID_REQUIREMENTS: + in_excluded_container = any( + ancestor.tag.split("}")[-1].lower() in self.EXCLUDED_ID_CONTAINERS + for ancestor in elem.iterancestors() + ) + if in_excluded_container: + continue + + attr_name, scope = self.UNIQUE_ID_REQUIREMENTS[tag] + + id_value = None + for attr, value in elem.attrib.items(): + attr_local = ( + attr.split("}")[-1].lower() + if "}" in attr + else attr.lower() + ) + if attr_local == attr_name: + id_value = value + break + + if id_value is not None: + if scope == "global": + if id_value in global_ids: + prev_file, prev_line, prev_tag = global_ids[ + id_value + ] + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: Global ID '{id_value}' in <{tag}> " + f"already used in {prev_file} at line {prev_line} in <{prev_tag}>" + ) + else: + global_ids[id_value] = ( + xml_file.relative_to(self.unpacked_dir), + elem.sourceline, + tag, + ) + elif scope == "file": + key = (tag, attr_name) + if key not in file_ids: + file_ids[key] = {} + + if id_value in file_ids[key]: + prev_line = file_ids[key][id_value] + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: Duplicate {attr_name}='{id_value}' in <{tag}> " + f"(first occurrence at line {prev_line})" + ) + else: + file_ids[key][id_value] = elem.sourceline + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} ID uniqueness violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All required IDs are unique") + return True + + def validate_file_references(self): + errors = [] + + rels_files = list(self.unpacked_dir.rglob("*.rels")) + + if not rels_files: + if self.verbose: + print("PASSED - No .rels files found") + return True + + all_files = [] + for file_path in self.unpacked_dir.rglob("*"): + if ( + file_path.is_file() + and file_path.name != "[Content_Types].xml" + and not file_path.name.endswith(".rels") + ): + all_files.append(file_path.resolve()) + + all_referenced_files = set() + + if self.verbose: + print( + f"Found {len(rels_files)} .rels files and {len(all_files)} target files" + ) + + for rels_file in rels_files: + try: + rels_root = lxml.etree.parse(str(rels_file)).getroot() + + rels_dir = rels_file.parent + + referenced_files = set() + broken_refs = [] + + for rel in rels_root.findall( + ".//ns:Relationship", + namespaces={"ns": self.PACKAGE_RELATIONSHIPS_NAMESPACE}, + ): + target = rel.get("Target") + if target and not target.startswith( + ("http", "mailto:") + ): + if target.startswith("/"): + target_path = self.unpacked_dir / target.lstrip("/") + elif rels_file.name == ".rels": + target_path = self.unpacked_dir / target + else: + base_dir = rels_dir.parent + target_path = base_dir / target + + try: + target_path = target_path.resolve() + if target_path.exists() and target_path.is_file(): + referenced_files.add(target_path) + all_referenced_files.add(target_path) + else: + broken_refs.append((target, rel.sourceline)) + except (OSError, ValueError): + broken_refs.append((target, rel.sourceline)) + + if broken_refs: + rel_path = rels_file.relative_to(self.unpacked_dir) + for broken_ref, line_num in broken_refs: + errors.append( + f" {rel_path}: Line {line_num}: Broken reference to {broken_ref}" + ) + + except Exception as e: + rel_path = rels_file.relative_to(self.unpacked_dir) + errors.append(f" Error parsing {rel_path}: {e}") + + unreferenced_files = set(all_files) - all_referenced_files + + if unreferenced_files: + for unref_file in sorted(unreferenced_files): + unref_rel_path = unref_file.relative_to(self.unpacked_dir) + errors.append(f" Unreferenced file: {unref_rel_path}") + + if errors: + print(f"FAILED - Found {len(errors)} relationship validation errors:") + for error in errors: + print(error) + print( + "CRITICAL: These errors will cause the document to appear corrupt. " + + "Broken references MUST be fixed, " + + "and unreferenced files MUST be referenced or removed." + ) + return False + else: + if self.verbose: + print( + "PASSED - All references are valid and all files are properly referenced" + ) + return True + + def validate_all_relationship_ids(self): + import lxml.etree + + errors = [] + + for xml_file in self.xml_files: + if xml_file.suffix == ".rels": + continue + + rels_dir = xml_file.parent / "_rels" + rels_file = rels_dir / f"{xml_file.name}.rels" + + if not rels_file.exists(): + continue + + try: + rels_root = lxml.etree.parse(str(rels_file)).getroot() + rid_to_type = {} + + for rel in rels_root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rid = rel.get("Id") + rel_type = rel.get("Type", "") + if rid: + if rid in rid_to_type: + rels_rel_path = rels_file.relative_to(self.unpacked_dir) + errors.append( + f" {rels_rel_path}: Line {rel.sourceline}: " + f"Duplicate relationship ID '{rid}' (IDs must be unique)" + ) + type_name = ( + rel_type.split("/")[-1] if "/" in rel_type else rel_type + ) + rid_to_type[rid] = type_name + + xml_root = lxml.etree.parse(str(xml_file)).getroot() + + r_ns = self.OFFICE_RELATIONSHIPS_NAMESPACE + rid_attrs_to_check = ["id", "embed", "link"] + for elem in xml_root.iter(): + for attr_name in rid_attrs_to_check: + rid_attr = elem.get(f"{{{r_ns}}}{attr_name}") + if not rid_attr: + continue + xml_rel_path = xml_file.relative_to(self.unpacked_dir) + elem_name = ( + elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag + ) + + if rid_attr not in rid_to_type: + errors.append( + f" {xml_rel_path}: Line {elem.sourceline}: " + f"<{elem_name}> r:{attr_name} references non-existent relationship '{rid_attr}' " + f"(valid IDs: {', '.join(sorted(rid_to_type.keys())[:5])}{'...' if len(rid_to_type) > 5 else ''})" + ) + elif attr_name == "id" and self.ELEMENT_RELATIONSHIP_TYPES: + expected_type = self._get_expected_relationship_type( + elem_name + ) + if expected_type: + actual_type = rid_to_type[rid_attr] + if expected_type not in actual_type.lower(): + errors.append( + f" {xml_rel_path}: Line {elem.sourceline}: " + f"<{elem_name}> references '{rid_attr}' which points to '{actual_type}' " + f"but should point to a '{expected_type}' relationship" + ) + + except Exception as e: + xml_rel_path = xml_file.relative_to(self.unpacked_dir) + errors.append(f" Error processing {xml_rel_path}: {e}") + + if errors: + print(f"FAILED - Found {len(errors)} relationship ID reference errors:") + for error in errors: + print(error) + print("\nThese ID mismatches will cause the document to appear corrupt!") + return False + else: + if self.verbose: + print("PASSED - All relationship ID references are valid") + return True + + def _get_expected_relationship_type(self, element_name): + elem_lower = element_name.lower() + + if elem_lower in self.ELEMENT_RELATIONSHIP_TYPES: + return self.ELEMENT_RELATIONSHIP_TYPES[elem_lower] + + if elem_lower.endswith("id") and len(elem_lower) > 2: + prefix = elem_lower[:-2] + if prefix.endswith("master"): + return prefix.lower() + elif prefix.endswith("layout"): + return prefix.lower() + else: + if prefix == "sld": + return "slide" + return prefix.lower() + + if elem_lower.endswith("reference") and len(elem_lower) > 9: + prefix = elem_lower[:-9] + return prefix.lower() + + return None + + def validate_content_types(self): + errors = [] + + content_types_file = self.unpacked_dir / "[Content_Types].xml" + if not content_types_file.exists(): + print("FAILED - [Content_Types].xml file not found") + return False + + try: + root = lxml.etree.parse(str(content_types_file)).getroot() + declared_parts = set() + declared_extensions = set() + + for override in root.findall( + f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Override" + ): + part_name = override.get("PartName") + if part_name is not None: + declared_parts.add(part_name.lstrip("/")) + + for default in root.findall( + f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Default" + ): + extension = default.get("Extension") + if extension is not None: + declared_extensions.add(extension.lower()) + + declarable_roots = { + "sld", + "sldLayout", + "sldMaster", + "presentation", + "document", + "workbook", + "worksheet", + "theme", + } + + media_extensions = { + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "gif": "image/gif", + "bmp": "image/bmp", + "tiff": "image/tiff", + "wmf": "image/x-wmf", + "emf": "image/x-emf", + } + + all_files = list(self.unpacked_dir.rglob("*")) + all_files = [f for f in all_files if f.is_file()] + + for xml_file in self.xml_files: + path_str = str(xml_file.relative_to(self.unpacked_dir)).replace( + "\\", "/" + ) + + if any( + skip in path_str + for skip in [".rels", "[Content_Types]", "docProps/", "_rels/"] + ): + continue + + try: + root_tag = lxml.etree.parse(str(xml_file)).getroot().tag + root_name = root_tag.split("}")[-1] if "}" in root_tag else root_tag + + if root_name in declarable_roots and path_str not in declared_parts: + errors.append( + f" {path_str}: File with <{root_name}> root not declared in [Content_Types].xml" + ) + + except Exception: + continue + + for file_path in all_files: + if file_path.suffix.lower() in {".xml", ".rels"}: + continue + if file_path.name == "[Content_Types].xml": + continue + if "_rels" in file_path.parts or "docProps" in file_path.parts: + continue + + extension = file_path.suffix.lstrip(".").lower() + if extension and extension not in declared_extensions: + if extension in media_extensions: + relative_path = file_path.relative_to(self.unpacked_dir) + errors.append( + f' {relative_path}: File with extension \'{extension}\' not declared in [Content_Types].xml - should add: ' + ) + + except Exception as e: + errors.append(f" Error parsing [Content_Types].xml: {e}") + + if errors: + print(f"FAILED - Found {len(errors)} content type declaration errors:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print( + "PASSED - All content files are properly declared in [Content_Types].xml" + ) + return True + + def validate_file_against_xsd(self, xml_file, verbose=False): + xml_file = Path(xml_file).resolve() + unpacked_dir = self.unpacked_dir.resolve() + + is_valid, current_errors = self._validate_single_file_xsd( + xml_file, unpacked_dir + ) + + if is_valid is None: + return None, set() + elif is_valid: + return True, set() + + original_errors = self._get_original_file_errors(xml_file) + + assert current_errors is not None + new_errors = current_errors - original_errors + + new_errors = { + e for e in new_errors + if not any(pattern in e for pattern in self.IGNORED_VALIDATION_ERRORS) + } + + if new_errors: + if verbose: + relative_path = xml_file.relative_to(unpacked_dir) + print(f"FAILED - {relative_path}: {len(new_errors)} new error(s)") + for error in list(new_errors)[:3]: + truncated = error[:250] + "..." if len(error) > 250 else error + print(f" - {truncated}") + return False, new_errors + else: + if verbose: + print( + f"PASSED - No new errors (original had {len(current_errors)} errors)" + ) + return True, set() + + def validate_against_xsd(self): + new_errors = [] + original_error_count = 0 + valid_count = 0 + skipped_count = 0 + + for xml_file in self.xml_files: + relative_path = str(xml_file.relative_to(self.unpacked_dir)) + is_valid, new_file_errors = self.validate_file_against_xsd( + xml_file, verbose=False + ) + + if is_valid is None: + skipped_count += 1 + continue + elif is_valid and not new_file_errors: + valid_count += 1 + continue + elif is_valid: + original_error_count += 1 + valid_count += 1 + continue + + new_errors.append(f" {relative_path}: {len(new_file_errors)} new error(s)") + for error in list(new_file_errors)[:3]: + new_errors.append( + f" - {error[:250]}..." if len(error) > 250 else f" - {error}" + ) + + if self.verbose: + print(f"Validated {len(self.xml_files)} files:") + print(f" - Valid: {valid_count}") + print(f" - Skipped (no schema): {skipped_count}") + if original_error_count: + print(f" - With original errors (ignored): {original_error_count}") + print( + f" - With NEW errors: {len(new_errors) > 0 and len([e for e in new_errors if not e.startswith(' ')]) or 0}" + ) + + if new_errors: + print("\nFAILED - Found NEW validation errors:") + for error in new_errors: + print(error) + return False + else: + if self.verbose: + print("\nPASSED - No new XSD validation errors introduced") + return True + + def _get_schema_path(self, xml_file): + if xml_file.name in self.SCHEMA_MAPPINGS: + return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.name] + + if xml_file.suffix == ".rels": + return self.schemas_dir / self.SCHEMA_MAPPINGS[".rels"] + + if "charts/" in str(xml_file) and xml_file.name.startswith("chart"): + return self.schemas_dir / self.SCHEMA_MAPPINGS["chart"] + + if "theme/" in str(xml_file) and xml_file.name.startswith("theme"): + return self.schemas_dir / self.SCHEMA_MAPPINGS["theme"] + + if xml_file.parent.name in self.MAIN_CONTENT_FOLDERS: + return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.parent.name] + + return None + + def _clean_ignorable_namespaces(self, xml_doc): + xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") + xml_copy = lxml.etree.fromstring(xml_string) + + for elem in xml_copy.iter(): + attrs_to_remove = [] + + for attr in elem.attrib: + if "{" in attr: + ns = attr.split("}")[0][1:] + if ns not in self.OOXML_NAMESPACES: + attrs_to_remove.append(attr) + + for attr in attrs_to_remove: + del elem.attrib[attr] + + self._remove_ignorable_elements(xml_copy) + + return lxml.etree.ElementTree(xml_copy) + + def _remove_ignorable_elements(self, root): + elements_to_remove = [] + + for elem in list(root): + if not hasattr(elem, "tag") or callable(elem.tag): + continue + + tag_str = str(elem.tag) + if tag_str.startswith("{"): + ns = tag_str.split("}")[0][1:] + if ns not in self.OOXML_NAMESPACES: + elements_to_remove.append(elem) + continue + + self._remove_ignorable_elements(elem) + + for elem in elements_to_remove: + root.remove(elem) + + def _preprocess_for_mc_ignorable(self, xml_doc): + root = xml_doc.getroot() + + if f"{{{self.MC_NAMESPACE}}}Ignorable" in root.attrib: + del root.attrib[f"{{{self.MC_NAMESPACE}}}Ignorable"] + + return xml_doc + + def _validate_single_file_xsd(self, xml_file, base_path): + schema_path = self._get_schema_path(xml_file) + if not schema_path: + return None, None + + try: + with open(schema_path, "rb") as xsd_file: + parser = lxml.etree.XMLParser() + xsd_doc = lxml.etree.parse( + xsd_file, parser=parser, base_url=str(schema_path) + ) + schema = lxml.etree.XMLSchema(xsd_doc) + + with open(xml_file, "r") as f: + xml_doc = lxml.etree.parse(f) + + xml_doc, _ = self._remove_template_tags_from_text_nodes(xml_doc) + xml_doc = self._preprocess_for_mc_ignorable(xml_doc) + + relative_path = xml_file.relative_to(base_path) + if ( + relative_path.parts + and relative_path.parts[0] in self.MAIN_CONTENT_FOLDERS + ): + xml_doc = self._clean_ignorable_namespaces(xml_doc) + + if schema.validate(xml_doc): + return True, set() + else: + errors = set() + for error in schema.error_log: + errors.add(error.message) + return False, errors + + except Exception as e: + return False, {str(e)} + + def _get_original_file_errors(self, xml_file): + if self.original_file is None: + return set() + + import tempfile + import zipfile + + xml_file = Path(xml_file).resolve() + unpacked_dir = self.unpacked_dir.resolve() + relative_path = xml_file.relative_to(unpacked_dir) + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + with zipfile.ZipFile(self.original_file, "r") as zip_ref: + zip_ref.extractall(temp_path) + + original_xml_file = temp_path / relative_path + + if not original_xml_file.exists(): + return set() + + is_valid, errors = self._validate_single_file_xsd( + original_xml_file, temp_path + ) + return errors if errors else set() + + def _remove_template_tags_from_text_nodes(self, xml_doc): + warnings = [] + template_pattern = re.compile(r"\{\{[^}]*\}\}") + + xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") + xml_copy = lxml.etree.fromstring(xml_string) + + def process_text_content(text, content_type): + if not text: + return text + matches = list(template_pattern.finditer(text)) + if matches: + for match in matches: + warnings.append( + f"Found template tag in {content_type}: {match.group()}" + ) + return template_pattern.sub("", text) + return text + + for elem in xml_copy.iter(): + if not hasattr(elem, "tag") or callable(elem.tag): + continue + tag_str = str(elem.tag) + if tag_str.endswith("}t") or tag_str == "t": + continue + + elem.text = process_text_content(elem.text, "text content") + elem.tail = process_text_content(elem.tail, "tail content") + + return lxml.etree.ElementTree(xml_copy), warnings + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/validators/docx.py b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/validators/docx.py new file mode 100644 index 00000000..fec405e6 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/validators/docx.py @@ -0,0 +1,446 @@ +""" +Validator for Word document XML files against XSD schemas. +""" + +import random +import re +import tempfile +import zipfile + +import defusedxml.minidom +import lxml.etree + +from .base import BaseSchemaValidator + + +class DOCXSchemaValidator(BaseSchemaValidator): + + WORD_2006_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + W14_NAMESPACE = "http://schemas.microsoft.com/office/word/2010/wordml" + W16CID_NAMESPACE = "http://schemas.microsoft.com/office/word/2016/wordml/cid" + + ELEMENT_RELATIONSHIP_TYPES = {} + + def validate(self): + if not self.validate_xml(): + return False + + all_valid = True + if not self.validate_namespaces(): + all_valid = False + + if not self.validate_unique_ids(): + all_valid = False + + if not self.validate_file_references(): + all_valid = False + + if not self.validate_content_types(): + all_valid = False + + if not self.validate_against_xsd(): + all_valid = False + + if not self.validate_whitespace_preservation(): + all_valid = False + + if not self.validate_deletions(): + all_valid = False + + if not self.validate_insertions(): + all_valid = False + + if not self.validate_all_relationship_ids(): + all_valid = False + + if not self.validate_id_constraints(): + all_valid = False + + if not self.validate_comment_markers(): + all_valid = False + + self.compare_paragraph_counts() + + return all_valid + + def validate_whitespace_preservation(self): + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + + for elem in root.iter(f"{{{self.WORD_2006_NAMESPACE}}}t"): + if elem.text: + text = elem.text + if re.search(r"^[ \t\n\r]", text) or re.search( + r"[ \t\n\r]$", text + ): + xml_space_attr = f"{{{self.XML_NAMESPACE}}}space" + if ( + xml_space_attr not in elem.attrib + or elem.attrib[xml_space_attr] != "preserve" + ): + text_preview = ( + repr(text)[:50] + "..." + if len(repr(text)) > 50 + else repr(text) + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: w:t element with whitespace missing xml:space='preserve': {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} whitespace preservation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All whitespace is properly preserved") + return True + + def validate_deletions(self): + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + for t_elem in root.xpath(".//w:del//w:t", namespaces=namespaces): + if t_elem.text: + text_preview = ( + repr(t_elem.text)[:50] + "..." + if len(repr(t_elem.text)) > 50 + else repr(t_elem.text) + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {t_elem.sourceline}: found within : {text_preview}" + ) + + for instr_elem in root.xpath( + ".//w:del//w:instrText", namespaces=namespaces + ): + text_preview = ( + repr(instr_elem.text or "")[:50] + "..." + if len(repr(instr_elem.text or "")) > 50 + else repr(instr_elem.text or "") + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {instr_elem.sourceline}: found within (use ): {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} deletion validation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - No w:t elements found within w:del elements") + return True + + def count_paragraphs_in_unpacked(self): + count = 0 + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") + count = len(paragraphs) + except Exception as e: + print(f"Error counting paragraphs in unpacked document: {e}") + + return count + + def count_paragraphs_in_original(self): + original = self.original_file + if original is None: + return 0 + + count = 0 + + try: + with tempfile.TemporaryDirectory() as temp_dir: + with zipfile.ZipFile(original, "r") as zip_ref: + zip_ref.extractall(temp_dir) + + doc_xml_path = temp_dir + "/word/document.xml" + root = lxml.etree.parse(doc_xml_path).getroot() + + paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") + count = len(paragraphs) + + except Exception as e: + print(f"Error counting paragraphs in original document: {e}") + + return count + + def validate_insertions(self): + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + invalid_elements = root.xpath( + ".//w:ins//w:delText[not(ancestor::w:del)]", namespaces=namespaces + ) + + for elem in invalid_elements: + text_preview = ( + repr(elem.text or "")[:50] + "..." + if len(repr(elem.text or "")) > 50 + else repr(elem.text or "") + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: within : {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} insertion validation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - No w:delText elements within w:ins elements") + return True + + def compare_paragraph_counts(self): + original_count = self.count_paragraphs_in_original() + new_count = self.count_paragraphs_in_unpacked() + + diff = new_count - original_count + diff_str = f"+{diff}" if diff > 0 else str(diff) + print(f"\nParagraphs: {original_count} → {new_count} ({diff_str})") + + def _parse_id_value(self, val: str, base: int = 16) -> int: + return int(val, base) + + def validate_id_constraints(self): + errors = [] + para_id_attr = f"{{{self.W14_NAMESPACE}}}paraId" + durable_id_attr = f"{{{self.W16CID_NAMESPACE}}}durableId" + + for xml_file in self.xml_files: + try: + for elem in lxml.etree.parse(str(xml_file)).iter(): + if val := elem.get(para_id_attr): + if self._parse_id_value(val, base=16) >= 0x80000000: + errors.append( + f" {xml_file.name}:{elem.sourceline}: paraId={val} >= 0x80000000" + ) + + if val := elem.get(durable_id_attr): + if xml_file.name == "numbering.xml": + try: + if self._parse_id_value(val, base=10) >= 0x7FFFFFFF: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} >= 0x7FFFFFFF" + ) + except ValueError: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} must be decimal in numbering.xml" + ) + else: + if self._parse_id_value(val, base=16) >= 0x7FFFFFFF: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} >= 0x7FFFFFFF" + ) + except Exception: + pass + + if errors: + print(f"FAILED - {len(errors)} ID constraint violations:") + for e in errors: + print(e) + elif self.verbose: + print("PASSED - All paraId/durableId values within constraints") + return not errors + + def validate_comment_markers(self): + errors = [] + + document_xml = None + comments_xml = None + for xml_file in self.xml_files: + if xml_file.name == "document.xml" and "word" in str(xml_file): + document_xml = xml_file + elif xml_file.name == "comments.xml": + comments_xml = xml_file + + if not document_xml: + if self.verbose: + print("PASSED - No document.xml found (skipping comment validation)") + return True + + try: + doc_root = lxml.etree.parse(str(document_xml)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + range_starts = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in doc_root.xpath( + ".//w:commentRangeStart", namespaces=namespaces + ) + } + range_ends = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in doc_root.xpath( + ".//w:commentRangeEnd", namespaces=namespaces + ) + } + references = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in doc_root.xpath( + ".//w:commentReference", namespaces=namespaces + ) + } + + orphaned_ends = range_ends - range_starts + for comment_id in sorted( + orphaned_ends, key=lambda x: int(x) if x and x.isdigit() else 0 + ): + errors.append( + f' document.xml: commentRangeEnd id="{comment_id}" has no matching commentRangeStart' + ) + + orphaned_starts = range_starts - range_ends + for comment_id in sorted( + orphaned_starts, key=lambda x: int(x) if x and x.isdigit() else 0 + ): + errors.append( + f' document.xml: commentRangeStart id="{comment_id}" has no matching commentRangeEnd' + ) + + comment_ids = set() + if comments_xml and comments_xml.exists(): + comments_root = lxml.etree.parse(str(comments_xml)).getroot() + comment_ids = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in comments_root.xpath( + ".//w:comment", namespaces=namespaces + ) + } + + marker_ids = range_starts | range_ends | references + invalid_refs = marker_ids - comment_ids + for comment_id in sorted( + invalid_refs, key=lambda x: int(x) if x and x.isdigit() else 0 + ): + if comment_id: + errors.append( + f' document.xml: marker id="{comment_id}" references non-existent comment' + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append(f" Error parsing XML: {e}") + + if errors: + print(f"FAILED - {len(errors)} comment marker violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All comment markers properly paired") + return True + + def repair(self) -> int: + repairs = super().repair() + repairs += self.repair_durableId() + return repairs + + def repair_durableId(self) -> int: + repairs = 0 + + for xml_file in self.xml_files: + try: + content = xml_file.read_text(encoding="utf-8") + dom = defusedxml.minidom.parseString(content) + modified = False + + for elem in dom.getElementsByTagName("*"): + if not elem.hasAttribute("w16cid:durableId"): + continue + + durable_id = elem.getAttribute("w16cid:durableId") + needs_repair = False + + if xml_file.name == "numbering.xml": + try: + needs_repair = ( + self._parse_id_value(durable_id, base=10) >= 0x7FFFFFFF + ) + except ValueError: + needs_repair = True + else: + try: + needs_repair = ( + self._parse_id_value(durable_id, base=16) >= 0x7FFFFFFF + ) + except ValueError: + needs_repair = True + + if needs_repair: + value = random.randint(1, 0x7FFFFFFE) + if xml_file.name == "numbering.xml": + new_id = str(value) + else: + new_id = f"{value:08X}" + + elem.setAttribute("w16cid:durableId", new_id) + print( + f" Repaired: {xml_file.name}: durableId {durable_id} → {new_id}" + ) + repairs += 1 + modified = True + + if modified: + xml_file.write_bytes(dom.toxml(encoding="UTF-8")) + + except Exception: + pass + + return repairs + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/validators/pptx.py b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/validators/pptx.py new file mode 100644 index 00000000..09842aa9 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/validators/pptx.py @@ -0,0 +1,275 @@ +""" +Validator for PowerPoint presentation XML files against XSD schemas. +""" + +import re + +from .base import BaseSchemaValidator + + +class PPTXSchemaValidator(BaseSchemaValidator): + + PRESENTATIONML_NAMESPACE = ( + "http://schemas.openxmlformats.org/presentationml/2006/main" + ) + + ELEMENT_RELATIONSHIP_TYPES = { + "sldid": "slide", + "sldmasterid": "slidemaster", + "notesmasterid": "notesmaster", + "sldlayoutid": "slidelayout", + "themeid": "theme", + "tablestyleid": "tablestyles", + } + + def validate(self): + if not self.validate_xml(): + return False + + all_valid = True + if not self.validate_namespaces(): + all_valid = False + + if not self.validate_unique_ids(): + all_valid = False + + if not self.validate_uuid_ids(): + all_valid = False + + if not self.validate_file_references(): + all_valid = False + + if not self.validate_slide_layout_ids(): + all_valid = False + + if not self.validate_content_types(): + all_valid = False + + if not self.validate_against_xsd(): + all_valid = False + + if not self.validate_notes_slide_references(): + all_valid = False + + if not self.validate_all_relationship_ids(): + all_valid = False + + if not self.validate_no_duplicate_slide_layouts(): + all_valid = False + + return all_valid + + def validate_uuid_ids(self): + import lxml.etree + + errors = [] + uuid_pattern = re.compile( + r"^[\{\(]?[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{12}[\}\)]?$" + ) + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + + for elem in root.iter(): + for attr, value in elem.attrib.items(): + attr_name = attr.split("}")[-1].lower() + if attr_name == "id" or attr_name.endswith("id"): + if self._looks_like_uuid(value): + if not uuid_pattern.match(value): + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: ID '{value}' appears to be a UUID but contains invalid hex characters" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} UUID ID validation errors:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All UUID-like IDs contain valid hex values") + return True + + def _looks_like_uuid(self, value): + clean_value = value.strip("{}()").replace("-", "") + return len(clean_value) == 32 and all(c.isalnum() for c in clean_value) + + def validate_slide_layout_ids(self): + import lxml.etree + + errors = [] + + slide_masters = list(self.unpacked_dir.glob("ppt/slideMasters/*.xml")) + + if not slide_masters: + if self.verbose: + print("PASSED - No slide masters found") + return True + + for slide_master in slide_masters: + try: + root = lxml.etree.parse(str(slide_master)).getroot() + + rels_file = slide_master.parent / "_rels" / f"{slide_master.name}.rels" + + if not rels_file.exists(): + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: " + f"Missing relationships file: {rels_file.relative_to(self.unpacked_dir)}" + ) + continue + + rels_root = lxml.etree.parse(str(rels_file)).getroot() + + valid_layout_rids = set() + for rel in rels_root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rel_type = rel.get("Type", "") + if "slideLayout" in rel_type: + valid_layout_rids.add(rel.get("Id")) + + for sld_layout_id in root.findall( + f".//{{{self.PRESENTATIONML_NAMESPACE}}}sldLayoutId" + ): + r_id = sld_layout_id.get( + f"{{{self.OFFICE_RELATIONSHIPS_NAMESPACE}}}id" + ) + layout_id = sld_layout_id.get("id") + + if r_id and r_id not in valid_layout_rids: + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: " + f"Line {sld_layout_id.sourceline}: sldLayoutId with id='{layout_id}' " + f"references r:id='{r_id}' which is not found in slide layout relationships" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} slide layout ID validation errors:") + for error in errors: + print(error) + print( + "Remove invalid references or add missing slide layouts to the relationships file." + ) + return False + else: + if self.verbose: + print("PASSED - All slide layout IDs reference valid slide layouts") + return True + + def validate_no_duplicate_slide_layouts(self): + import lxml.etree + + errors = [] + slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) + + for rels_file in slide_rels_files: + try: + root = lxml.etree.parse(str(rels_file)).getroot() + + layout_rels = [ + rel + for rel in root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ) + if "slideLayout" in rel.get("Type", "") + ] + + if len(layout_rels) > 1: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: has {len(layout_rels)} slideLayout references" + ) + + except Exception as e: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print("FAILED - Found slides with duplicate slideLayout references:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All slides have exactly one slideLayout reference") + return True + + def validate_notes_slide_references(self): + import lxml.etree + + errors = [] + notes_slide_references = {} + + slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) + + if not slide_rels_files: + if self.verbose: + print("PASSED - No slide relationship files found") + return True + + for rels_file in slide_rels_files: + try: + root = lxml.etree.parse(str(rels_file)).getroot() + + for rel in root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rel_type = rel.get("Type", "") + if "notesSlide" in rel_type: + target = rel.get("Target", "") + if target: + normalized_target = target.replace("../", "") + + slide_name = rels_file.stem.replace( + ".xml", "" + ) + + if normalized_target not in notes_slide_references: + notes_slide_references[normalized_target] = [] + notes_slide_references[normalized_target].append( + (slide_name, rels_file) + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + for target, references in notes_slide_references.items(): + if len(references) > 1: + slide_names = [ref[0] for ref in references] + errors.append( + f" Notes slide '{target}' is referenced by multiple slides: {', '.join(slide_names)}" + ) + for slide_name, rels_file in references: + errors.append(f" - {rels_file.relative_to(self.unpacked_dir)}") + + if errors: + print( + f"FAILED - Found {len([e for e in errors if not e.startswith(' ')])} notes slide reference validation errors:" + ) + for error in errors: + print(error) + print("Each slide may optionally have its own slide file.") + return False + else: + if self.verbose: + print("PASSED - All notes slide references are unique") + return True + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/office/validators/redlining.py b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/validators/redlining.py new file mode 100644 index 00000000..71c81b6b --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/office/validators/redlining.py @@ -0,0 +1,247 @@ +""" +Validator for tracked changes in Word documents. +""" + +import subprocess +import tempfile +import zipfile +from pathlib import Path + + +class RedliningValidator: + + def __init__(self, unpacked_dir, original_docx, verbose=False, author="Claude"): + self.unpacked_dir = Path(unpacked_dir) + self.original_docx = Path(original_docx) + self.verbose = verbose + self.author = author + self.namespaces = { + "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + } + + def repair(self) -> int: + return 0 + + def validate(self): + modified_file = self.unpacked_dir / "word" / "document.xml" + if not modified_file.exists(): + print(f"FAILED - Modified document.xml not found at {modified_file}") + return False + + try: + import xml.etree.ElementTree as ET + + tree = ET.parse(modified_file) + root = tree.getroot() + + del_elements = root.findall(".//w:del", self.namespaces) + ins_elements = root.findall(".//w:ins", self.namespaces) + + author_del_elements = [ + elem + for elem in del_elements + if elem.get(f"{{{self.namespaces['w']}}}author") == self.author + ] + author_ins_elements = [ + elem + for elem in ins_elements + if elem.get(f"{{{self.namespaces['w']}}}author") == self.author + ] + + if not author_del_elements and not author_ins_elements: + if self.verbose: + print(f"PASSED - No tracked changes by {self.author} found.") + return True + + except Exception: + pass + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + try: + with zipfile.ZipFile(self.original_docx, "r") as zip_ref: + zip_ref.extractall(temp_path) + except Exception as e: + print(f"FAILED - Error unpacking original docx: {e}") + return False + + original_file = temp_path / "word" / "document.xml" + if not original_file.exists(): + print( + f"FAILED - Original document.xml not found in {self.original_docx}" + ) + return False + + try: + import xml.etree.ElementTree as ET + + modified_tree = ET.parse(modified_file) + modified_root = modified_tree.getroot() + original_tree = ET.parse(original_file) + original_root = original_tree.getroot() + except ET.ParseError as e: + print(f"FAILED - Error parsing XML files: {e}") + return False + + self._remove_author_tracked_changes(original_root) + self._remove_author_tracked_changes(modified_root) + + modified_text = self._extract_text_content(modified_root) + original_text = self._extract_text_content(original_root) + + if modified_text != original_text: + error_message = self._generate_detailed_diff( + original_text, modified_text + ) + print(error_message) + return False + + if self.verbose: + print(f"PASSED - All changes by {self.author} are properly tracked") + return True + + def _generate_detailed_diff(self, original_text, modified_text): + error_parts = [ + f"FAILED - Document text doesn't match after removing {self.author}'s tracked changes", + "", + "Likely causes:", + " 1. Modified text inside another author's or tags", + " 2. Made edits without proper tracked changes", + " 3. Didn't nest inside when deleting another's insertion", + "", + "For pre-redlined documents, use correct patterns:", + " - To reject another's INSERTION: Nest inside their ", + " - To restore another's DELETION: Add new AFTER their ", + "", + ] + + git_diff = self._get_git_word_diff(original_text, modified_text) + if git_diff: + error_parts.extend(["Differences:", "============", git_diff]) + else: + error_parts.append("Unable to generate word diff (git not available)") + + return "\n".join(error_parts) + + def _get_git_word_diff(self, original_text, modified_text): + try: + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + original_file = temp_path / "original.txt" + modified_file = temp_path / "modified.txt" + + original_file.write_text(original_text, encoding="utf-8") + modified_file.write_text(modified_text, encoding="utf-8") + + result = subprocess.run( + [ + "git", + "diff", + "--word-diff=plain", + "--word-diff-regex=.", + "-U0", + "--no-index", + str(original_file), + str(modified_file), + ], + capture_output=True, + text=True, + ) + + if result.stdout.strip(): + lines = result.stdout.split("\n") + content_lines = [] + in_content = False + for line in lines: + if line.startswith("@@"): + in_content = True + continue + if in_content and line.strip(): + content_lines.append(line) + + if content_lines: + return "\n".join(content_lines) + + result = subprocess.run( + [ + "git", + "diff", + "--word-diff=plain", + "-U0", + "--no-index", + str(original_file), + str(modified_file), + ], + capture_output=True, + text=True, + ) + + if result.stdout.strip(): + lines = result.stdout.split("\n") + content_lines = [] + in_content = False + for line in lines: + if line.startswith("@@"): + in_content = True + continue + if in_content and line.strip(): + content_lines.append(line) + return "\n".join(content_lines) + + except (subprocess.CalledProcessError, FileNotFoundError, Exception): + pass + + return None + + def _remove_author_tracked_changes(self, root): + ins_tag = f"{{{self.namespaces['w']}}}ins" + del_tag = f"{{{self.namespaces['w']}}}del" + author_attr = f"{{{self.namespaces['w']}}}author" + + for parent in root.iter(): + to_remove = [] + for child in parent: + if child.tag == ins_tag and child.get(author_attr) == self.author: + to_remove.append(child) + for elem in to_remove: + parent.remove(elem) + + deltext_tag = f"{{{self.namespaces['w']}}}delText" + t_tag = f"{{{self.namespaces['w']}}}t" + + for parent in root.iter(): + to_process = [] + for child in parent: + if child.tag == del_tag and child.get(author_attr) == self.author: + to_process.append((child, list(parent).index(child))) + + for del_elem, del_index in reversed(to_process): + for elem in del_elem.iter(): + if elem.tag == deltext_tag: + elem.tag = t_tag + + for child in reversed(list(del_elem)): + parent.insert(del_index, child) + parent.remove(del_elem) + + def _extract_text_content(self, root): + p_tag = f"{{{self.namespaces['w']}}}p" + t_tag = f"{{{self.namespaces['w']}}}t" + + paragraphs = [] + for p_elem in root.findall(f".//{p_tag}"): + text_parts = [] + for t_elem in p_elem.findall(f".//{t_tag}"): + if t_elem.text: + text_parts.append(t_elem.text) + paragraph_text = "".join(text_parts) + if paragraph_text: + paragraphs.append(paragraph_text) + + return "\n".join(paragraphs) + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/templates/comments.xml b/ruoyi-admin/src/main/resources/skills/docx/scripts/templates/comments.xml new file mode 100644 index 00000000..cd01a7d7 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/templates/comments.xml @@ -0,0 +1,3 @@ + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/templates/commentsExtended.xml b/ruoyi-admin/src/main/resources/skills/docx/scripts/templates/commentsExtended.xml new file mode 100644 index 00000000..411003cc --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/templates/commentsExtended.xml @@ -0,0 +1,3 @@ + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/templates/commentsExtensible.xml b/ruoyi-admin/src/main/resources/skills/docx/scripts/templates/commentsExtensible.xml new file mode 100644 index 00000000..f5572d71 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/templates/commentsExtensible.xml @@ -0,0 +1,3 @@ + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/templates/commentsIds.xml b/ruoyi-admin/src/main/resources/skills/docx/scripts/templates/commentsIds.xml new file mode 100644 index 00000000..32f1629f --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/templates/commentsIds.xml @@ -0,0 +1,3 @@ + + + diff --git a/ruoyi-admin/src/main/resources/skills/docx/scripts/templates/people.xml b/ruoyi-admin/src/main/resources/skills/docx/scripts/templates/people.xml new file mode 100644 index 00000000..3803d2de --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/docx/scripts/templates/people.xml @@ -0,0 +1,3 @@ + + + diff --git a/ruoyi-admin/src/main/resources/skills/pdf/LICENSE.txt b/ruoyi-admin/src/main/resources/skills/pdf/LICENSE.txt new file mode 100644 index 00000000..c55ab422 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/pdf/LICENSE.txt @@ -0,0 +1,30 @@ +© 2025 Anthropic, PBC. All rights reserved. + +LICENSE: Use of these materials (including all code, prompts, assets, files, +and other components of this Skill) is governed by your agreement with +Anthropic regarding use of Anthropic's services. If no separate agreement +exists, use is governed by Anthropic's Consumer Terms of Service or +Commercial Terms of Service, as applicable: +https://www.anthropic.com/legal/consumer-terms +https://www.anthropic.com/legal/commercial-terms +Your applicable agreement is referred to as the "Agreement." "Services" are +as defined in the Agreement. + +ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the +contrary, users may not: + +- Extract these materials from the Services or retain copies of these + materials outside the Services +- Reproduce or copy these materials, except for temporary copies created + automatically during authorized use of the Services +- Create derivative works based on these materials +- Distribute, sublicense, or transfer these materials to any third party +- Make, offer to sell, sell, or import any inventions embodied in these + materials +- Reverse engineer, decompile, or disassemble these materials + +The receipt, viewing, or possession of these materials does not convey or +imply any license or right beyond those expressly granted above. + +Anthropic retains all right, title, and interest in these materials, +including all copyrights, patents, and other intellectual property rights. diff --git a/ruoyi-admin/src/main/resources/skills/pdf/SKILL.md b/ruoyi-admin/src/main/resources/skills/pdf/SKILL.md new file mode 100644 index 00000000..f6a22ddf --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/pdf/SKILL.md @@ -0,0 +1,294 @@ +--- +name: pdf +description: Comprehensive PDF manipulation toolkit for extracting text and tables, creating new PDFs, merging/splitting documents, and handling forms. When Claude needs to fill in a PDF form or programmatically process, generate, or analyze PDF documents at scale. +license: Proprietary. LICENSE.txt has complete terms +--- + +# PDF Processing Guide + +## Overview + +This guide covers essential PDF processing operations using Python libraries and command-line tools. For advanced features, JavaScript libraries, and detailed examples, see reference.md. If you need to fill out a PDF form, read forms.md and follow its instructions. + +## Quick Start + +```python +from pypdf import PdfReader, PdfWriter + +# Read a PDF +reader = PdfReader("document.pdf") +print(f"Pages: {len(reader.pages)}") + +# Extract text +text = "" +for page in reader.pages: + text += page.extract_text() +``` + +## Python Libraries + +### pypdf - Basic Operations + +#### Merge PDFs +```python +from pypdf import PdfWriter, PdfReader + +writer = PdfWriter() +for pdf_file in ["doc1.pdf", "doc2.pdf", "doc3.pdf"]: + reader = PdfReader(pdf_file) + for page in reader.pages: + writer.add_page(page) + +with open("merged.pdf", "wb") as output: + writer.write(output) +``` + +#### Split PDF +```python +reader = PdfReader("input.pdf") +for i, page in enumerate(reader.pages): + writer = PdfWriter() + writer.add_page(page) + with open(f"page_{i+1}.pdf", "wb") as output: + writer.write(output) +``` + +#### Extract Metadata +```python +reader = PdfReader("document.pdf") +meta = reader.metadata +print(f"Title: {meta.title}") +print(f"Author: {meta.author}") +print(f"Subject: {meta.subject}") +print(f"Creator: {meta.creator}") +``` + +#### Rotate Pages +```python +reader = PdfReader("input.pdf") +writer = PdfWriter() + +page = reader.pages[0] +page.rotate(90) # Rotate 90 degrees clockwise +writer.add_page(page) + +with open("rotated.pdf", "wb") as output: + writer.write(output) +``` + +### pdfplumber - Text and Table Extraction + +#### Extract Text with Layout +```python +import pdfplumber + +with pdfplumber.open("document.pdf") as pdf: + for page in pdf.pages: + text = page.extract_text() + print(text) +``` + +#### Extract Tables +```python +with pdfplumber.open("document.pdf") as pdf: + for i, page in enumerate(pdf.pages): + tables = page.extract_tables() + for j, table in enumerate(tables): + print(f"Table {j+1} on page {i+1}:") + for row in table: + print(row) +``` + +#### Advanced Table Extraction +```python +import pandas as pd + +with pdfplumber.open("document.pdf") as pdf: + all_tables = [] + for page in pdf.pages: + tables = page.extract_tables() + for table in tables: + if table: # Check if table is not empty + df = pd.DataFrame(table[1:], columns=table[0]) + all_tables.append(df) + +# Combine all tables +if all_tables: + combined_df = pd.concat(all_tables, ignore_index=True) + combined_df.to_excel("extracted_tables.xlsx", index=False) +``` + +### reportlab - Create PDFs + +#### Basic PDF Creation +```python +from reportlab.lib.pagesizes import letter +from reportlab.pdfgen import canvas + +c = canvas.Canvas("hello.pdf", pagesize=letter) +width, height = letter + +# Add text +c.drawString(100, height - 100, "Hello World!") +c.drawString(100, height - 120, "This is a PDF created with reportlab") + +# Add a line +c.line(100, height - 140, 400, height - 140) + +# Save +c.save() +``` + +#### Create PDF with Multiple Pages +```python +from reportlab.lib.pagesizes import letter +from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak +from reportlab.lib.styles import getSampleStyleSheet + +doc = SimpleDocTemplate("report.pdf", pagesize=letter) +styles = getSampleStyleSheet() +story = [] + +# Add content +title = Paragraph("Report Title", styles['Title']) +story.append(title) +story.append(Spacer(1, 12)) + +body = Paragraph("This is the body of the report. " * 20, styles['Normal']) +story.append(body) +story.append(PageBreak()) + +# Page 2 +story.append(Paragraph("Page 2", styles['Heading1'])) +story.append(Paragraph("Content for page 2", styles['Normal'])) + +# Build PDF +doc.build(story) +``` + +## Command-Line Tools + +### pdftotext (poppler-utils) +```bash +# Extract text +pdftotext input.pdf output.txt + +# Extract text preserving layout +pdftotext -layout input.pdf output.txt + +# Extract specific pages +pdftotext -f 1 -l 5 input.pdf output.txt # Pages 1-5 +``` + +### qpdf +```bash +# Merge PDFs +qpdf --empty --pages file1.pdf file2.pdf -- merged.pdf + +# Split pages +qpdf input.pdf --pages . 1-5 -- pages1-5.pdf +qpdf input.pdf --pages . 6-10 -- pages6-10.pdf + +# Rotate pages +qpdf input.pdf output.pdf --rotate=+90:1 # Rotate page 1 by 90 degrees + +# Remove password +qpdf --password=mypassword --decrypt encrypted.pdf decrypted.pdf +``` + +### pdftk (if available) +```bash +# Merge +pdftk file1.pdf file2.pdf cat output merged.pdf + +# Split +pdftk input.pdf burst + +# Rotate +pdftk input.pdf rotate 1east output rotated.pdf +``` + +## Common Tasks + +### Extract Text from Scanned PDFs +```python +# Requires: pip install pytesseract pdf2image +import pytesseract +from pdf2image import convert_from_path + +# Convert PDF to images +images = convert_from_path('scanned.pdf') + +# OCR each page +text = "" +for i, image in enumerate(images): + text += f"Page {i+1}:\n" + text += pytesseract.image_to_string(image) + text += "\n\n" + +print(text) +``` + +### Add Watermark +```python +from pypdf import PdfReader, PdfWriter + +# Create watermark (or load existing) +watermark = PdfReader("watermark.pdf").pages[0] + +# Apply to all pages +reader = PdfReader("document.pdf") +writer = PdfWriter() + +for page in reader.pages: + page.merge_page(watermark) + writer.add_page(page) + +with open("watermarked.pdf", "wb") as output: + writer.write(output) +``` + +### Extract Images +```bash +# Using pdfimages (poppler-utils) +pdfimages -j input.pdf output_prefix + +# This extracts all images as output_prefix-000.jpg, output_prefix-001.jpg, etc. +``` + +### Password Protection +```python +from pypdf import PdfReader, PdfWriter + +reader = PdfReader("input.pdf") +writer = PdfWriter() + +for page in reader.pages: + writer.add_page(page) + +# Add password +writer.encrypt("userpassword", "ownerpassword") + +with open("encrypted.pdf", "wb") as output: + writer.write(output) +``` + +## Quick Reference + +| Task | Best Tool | Command/Code | +|------|-----------|--------------| +| Merge PDFs | pypdf | `writer.add_page(page)` | +| Split PDFs | pypdf | One page per file | +| Extract text | pdfplumber | `page.extract_text()` | +| Extract tables | pdfplumber | `page.extract_tables()` | +| Create PDFs | reportlab | Canvas or Platypus | +| Command line merge | qpdf | `qpdf --empty --pages ...` | +| OCR scanned PDFs | pytesseract | Convert to image first | +| Fill PDF forms | pdf-lib or pypdf (see forms.md) | See forms.md | + +## Next Steps + +- For advanced pypdfium2 usage, see reference.md +- For JavaScript libraries (pdf-lib), see reference.md +- If you need to fill out a PDF form, follow the instructions in forms.md +- For troubleshooting guides, see reference.md diff --git a/ruoyi-admin/src/main/resources/skills/pdf/forms.md b/ruoyi-admin/src/main/resources/skills/pdf/forms.md new file mode 100644 index 00000000..4e234506 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/pdf/forms.md @@ -0,0 +1,205 @@ +**CRITICAL: You MUST complete these steps in order. Do not skip ahead to writing code.** + +If you need to fill out a PDF form, first check to see if the PDF has fillable form fields. Run this script from this file's directory: + `python scripts/check_fillable_fields `, and depending on the result go to either the "Fillable fields" or "Non-fillable fields" and follow those instructions. + +# Fillable fields +If the PDF has fillable form fields: +- Run this script from this file's directory: `python scripts/extract_form_field_info.py `. It will create a JSON file with a list of fields in this format: +``` +[ + { + "field_id": (unique ID for the field), + "page": (page number, 1-based), + "rect": ([left, bottom, right, top] bounding box in PDF coordinates, y=0 is the bottom of the page), + "type": ("text", "checkbox", "radio_group", or "choice"), + }, + // Checkboxes have "checked_value" and "unchecked_value" properties: + { + "field_id": (unique ID for the field), + "page": (page number, 1-based), + "type": "checkbox", + "checked_value": (Set the field to this value to check the checkbox), + "unchecked_value": (Set the field to this value to uncheck the checkbox), + }, + // Radio groups have a "radio_options" list with the possible choices. + { + "field_id": (unique ID for the field), + "page": (page number, 1-based), + "type": "radio_group", + "radio_options": [ + { + "value": (set the field to this value to select this radio option), + "rect": (bounding box for the radio button for this option) + }, + // Other radio options + ] + }, + // Multiple choice fields have a "choice_options" list with the possible choices: + { + "field_id": (unique ID for the field), + "page": (page number, 1-based), + "type": "choice", + "choice_options": [ + { + "value": (set the field to this value to select this option), + "text": (display text of the option) + }, + // Other choice options + ], + } +] +``` +- Convert the PDF to PNGs (one image for each page) with this script (run from this file's directory): +`python scripts/convert_pdf_to_images.py ` +Then analyze the images to determine the purpose of each form field (make sure to convert the bounding box PDF coordinates to image coordinates). +- Create a `field_values.json` file in this format with the values to be entered for each field: +``` +[ + { + "field_id": "last_name", // Must match the field_id from `extract_form_field_info.py` + "description": "The user's last name", + "page": 1, // Must match the "page" value in field_info.json + "value": "Simpson" + }, + { + "field_id": "Checkbox12", + "description": "Checkbox to be checked if the user is 18 or over", + "page": 1, + "value": "/On" // If this is a checkbox, use its "checked_value" value to check it. If it's a radio button group, use one of the "value" values in "radio_options". + }, + // more fields +] +``` +- Run the `fill_fillable_fields.py` script from this file's directory to create a filled-in PDF: +`python scripts/fill_fillable_fields.py ` +This script will verify that the field IDs and values you provide are valid; if it prints error messages, correct the appropriate fields and try again. + +# Non-fillable fields +If the PDF doesn't have fillable form fields, you'll need to visually determine where the data should be added and create text annotations. Follow the below steps *exactly*. You MUST perform all of these steps to ensure that the the form is accurately completed. Details for each step are below. +- Convert the PDF to PNG images and determine field bounding boxes. +- Create a JSON file with field information and validation images showing the bounding boxes. +- Validate the the bounding boxes. +- Use the bounding boxes to fill in the form. + +## Step 1: Visual Analysis (REQUIRED) +- Convert the PDF to PNG images. Run this script from this file's directory: +`python scripts/convert_pdf_to_images.py ` +The script will create a PNG image for each page in the PDF. +- Carefully examine each PNG image and identify all form fields and areas where the user should enter data. For each form field where the user should enter text, determine bounding boxes for both the form field label, and the area where the user should enter text. The label and entry bounding boxes MUST NOT INTERSECT; the text entry box should only include the area where data should be entered. Usually this area will be immediately to the side, above, or below its label. Entry bounding boxes must be tall and wide enough to contain their text. + +These are some examples of form structures that you might see: + +*Label inside box* +``` +┌────────────────────────┐ +│ Name: │ +└────────────────────────┘ +``` +The input area should be to the right of the "Name" label and extend to the edge of the box. + +*Label before line* +``` +Email: _______________________ +``` +The input area should be above the line and include its entire width. + +*Label under line* +``` +_________________________ +Name +``` +The input area should be above the line and include the entire width of the line. This is common for signature and date fields. + +*Label above line* +``` +Please enter any special requests: +________________________________________________ +``` +The input area should extend from the bottom of the label to the line, and should include the entire width of the line. + +*Checkboxes* +``` +Are you a US citizen? Yes □ No □ +``` +For checkboxes: +- Look for small square boxes (□) - these are the actual checkboxes to target. They may be to the left or right of their labels. +- Distinguish between label text ("Yes", "No") and the clickable checkbox squares. +- The entry bounding box should cover ONLY the small square, not the text label. + +### Step 2: Create fields.json and validation images (REQUIRED) +- Create a file named `fields.json` with information for the form fields and bounding boxes in this format: +``` +{ + "pages": [ + { + "page_number": 1, + "image_width": (first page image width in pixels), + "image_height": (first page image height in pixels), + }, + { + "page_number": 2, + "image_width": (second page image width in pixels), + "image_height": (second page image height in pixels), + } + // additional pages + ], + "form_fields": [ + // Example for a text field. + { + "page_number": 1, + "description": "The user's last name should be entered here", + // Bounding boxes are [left, top, right, bottom]. The bounding boxes for the label and text entry should not overlap. + "field_label": "Last name", + "label_bounding_box": [30, 125, 95, 142], + "entry_bounding_box": [100, 125, 280, 142], + "entry_text": { + "text": "Johnson", // This text will be added as an annotation at the entry_bounding_box location + "font_size": 14, // optional, defaults to 14 + "font_color": "000000", // optional, RRGGBB format, defaults to 000000 (black) + } + }, + // Example for a checkbox. TARGET THE SQUARE for the entry bounding box, NOT THE TEXT + { + "page_number": 2, + "description": "Checkbox that should be checked if the user is over 18", + "entry_bounding_box": [140, 525, 155, 540], // Small box over checkbox square + "field_label": "Yes", + "label_bounding_box": [100, 525, 132, 540], // Box containing "Yes" text + // Use "X" to check a checkbox. + "entry_text": { + "text": "X", + } + } + // additional form field entries + ] +} +``` + +Create validation images by running this script from this file's directory for each page: +`python scripts/create_validation_image.py + +The validation images will have red rectangles where text should be entered, and blue rectangles covering label text. + +### Step 3: Validate Bounding Boxes (REQUIRED) +#### Automated intersection check +- Verify that none of bounding boxes intersect and that the entry bounding boxes are tall enough by checking the fields.json file with the `check_bounding_boxes.py` script (run from this file's directory): +`python scripts/check_bounding_boxes.py ` + +If there are errors, reanalyze the relevant fields, adjust the bounding boxes, and iterate until there are no remaining errors. Remember: label (blue) bounding boxes should contain text labels, entry (red) boxes should not. + +#### Manual image inspection +**CRITICAL: Do not proceed without visually inspecting validation images** +- Red rectangles must ONLY cover input areas +- Red rectangles MUST NOT contain any text +- Blue rectangles should contain label text +- For checkboxes: + - Red rectangle MUST be centered on the checkbox square + - Blue rectangle should cover the text label for the checkbox + +- If any rectangles look wrong, fix fields.json, regenerate the validation images, and verify again. Repeat this process until the bounding boxes are fully accurate. + + +### Step 4: Add annotations to the PDF +Run this script from this file's directory to create a filled-out PDF using the information in fields.json: +`python scripts/fill_pdf_form_with_annotations.py diff --git a/ruoyi-admin/src/main/resources/skills/pdf/reference.md b/ruoyi-admin/src/main/resources/skills/pdf/reference.md new file mode 100644 index 00000000..41400bf4 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/pdf/reference.md @@ -0,0 +1,612 @@ +# PDF Processing Advanced Reference + +This document contains advanced PDF processing features, detailed examples, and additional libraries not covered in the main skill instructions. + +## pypdfium2 Library (Apache/BSD License) + +### Overview +pypdfium2 is a Python binding for PDFium (Chromium's PDF library). It's excellent for fast PDF rendering, image generation, and serves as a PyMuPDF replacement. + +### Render PDF to Images +```python +import pypdfium2 as pdfium +from PIL import Image + +# Load PDF +pdf = pdfium.PdfDocument("document.pdf") + +# Render page to image +page = pdf[0] # First page +bitmap = page.render( + scale=2.0, # Higher resolution + rotation=0 # No rotation +) + +# Convert to PIL Image +img = bitmap.to_pil() +img.save("page_1.png", "PNG") + +# Process multiple pages +for i, page in enumerate(pdf): + bitmap = page.render(scale=1.5) + img = bitmap.to_pil() + img.save(f"page_{i+1}.jpg", "JPEG", quality=90) +``` + +### Extract Text with pypdfium2 +```python +import pypdfium2 as pdfium + +pdf = pdfium.PdfDocument("document.pdf") +for i, page in enumerate(pdf): + text = page.get_text() + print(f"Page {i+1} text length: {len(text)} chars") +``` + +## JavaScript Libraries + +### pdf-lib (MIT License) + +pdf-lib is a powerful JavaScript library for creating and modifying PDF documents in any JavaScript environment. + +#### Load and Manipulate Existing PDF +```javascript +import { PDFDocument } from 'pdf-lib'; +import fs from 'fs'; + +async function manipulatePDF() { + // Load existing PDF + const existingPdfBytes = fs.readFileSync('input.pdf'); + const pdfDoc = await PDFDocument.load(existingPdfBytes); + + // Get page count + const pageCount = pdfDoc.getPageCount(); + console.log(`Document has ${pageCount} pages`); + + // Add new page + const newPage = pdfDoc.addPage([600, 400]); + newPage.drawText('Added by pdf-lib', { + x: 100, + y: 300, + size: 16 + }); + + // Save modified PDF + const pdfBytes = await pdfDoc.save(); + fs.writeFileSync('modified.pdf', pdfBytes); +} +``` + +#### Create Complex PDFs from Scratch +```javascript +import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; +import fs from 'fs'; + +async function createPDF() { + const pdfDoc = await PDFDocument.create(); + + // Add fonts + const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica); + const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + // Add page + const page = pdfDoc.addPage([595, 842]); // A4 size + const { width, height } = page.getSize(); + + // Add text with styling + page.drawText('Invoice #12345', { + x: 50, + y: height - 50, + size: 18, + font: helveticaBold, + color: rgb(0.2, 0.2, 0.8) + }); + + // Add rectangle (header background) + page.drawRectangle({ + x: 40, + y: height - 100, + width: width - 80, + height: 30, + color: rgb(0.9, 0.9, 0.9) + }); + + // Add table-like content + const items = [ + ['Item', 'Qty', 'Price', 'Total'], + ['Widget', '2', '$50', '$100'], + ['Gadget', '1', '$75', '$75'] + ]; + + let yPos = height - 150; + items.forEach(row => { + let xPos = 50; + row.forEach(cell => { + page.drawText(cell, { + x: xPos, + y: yPos, + size: 12, + font: helveticaFont + }); + xPos += 120; + }); + yPos -= 25; + }); + + const pdfBytes = await pdfDoc.save(); + fs.writeFileSync('created.pdf', pdfBytes); +} +``` + +#### Advanced Merge and Split Operations +```javascript +import { PDFDocument } from 'pdf-lib'; +import fs from 'fs'; + +async function mergePDFs() { + // Create new document + const mergedPdf = await PDFDocument.create(); + + // Load source PDFs + const pdf1Bytes = fs.readFileSync('doc1.pdf'); + const pdf2Bytes = fs.readFileSync('doc2.pdf'); + + const pdf1 = await PDFDocument.load(pdf1Bytes); + const pdf2 = await PDFDocument.load(pdf2Bytes); + + // Copy pages from first PDF + const pdf1Pages = await mergedPdf.copyPages(pdf1, pdf1.getPageIndices()); + pdf1Pages.forEach(page => mergedPdf.addPage(page)); + + // Copy specific pages from second PDF (pages 0, 2, 4) + const pdf2Pages = await mergedPdf.copyPages(pdf2, [0, 2, 4]); + pdf2Pages.forEach(page => mergedPdf.addPage(page)); + + const mergedPdfBytes = await mergedPdf.save(); + fs.writeFileSync('merged.pdf', mergedPdfBytes); +} +``` + +### pdfjs-dist (Apache License) + +PDF.js is Mozilla's JavaScript library for rendering PDFs in the browser. + +#### Basic PDF Loading and Rendering +```javascript +import * as pdfjsLib from 'pdfjs-dist'; + +// Configure worker (important for performance) +pdfjsLib.GlobalWorkerOptions.workerSrc = './pdf.worker.js'; + +async function renderPDF() { + // Load PDF + const loadingTask = pdfjsLib.getDocument('document.pdf'); + const pdf = await loadingTask.promise; + + console.log(`Loaded PDF with ${pdf.numPages} pages`); + + // Get first page + const page = await pdf.getPage(1); + const viewport = page.getViewport({ scale: 1.5 }); + + // Render to canvas + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + canvas.height = viewport.height; + canvas.width = viewport.width; + + const renderContext = { + canvasContext: context, + viewport: viewport + }; + + await page.render(renderContext).promise; + document.body.appendChild(canvas); +} +``` + +#### Extract Text with Coordinates +```javascript +import * as pdfjsLib from 'pdfjs-dist'; + +async function extractText() { + const loadingTask = pdfjsLib.getDocument('document.pdf'); + const pdf = await loadingTask.promise; + + let fullText = ''; + + // Extract text from all pages + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const textContent = await page.getTextContent(); + + const pageText = textContent.items + .map(item => item.str) + .join(' '); + + fullText += `\n--- Page ${i} ---\n${pageText}`; + + // Get text with coordinates for advanced processing + const textWithCoords = textContent.items.map(item => ({ + text: item.str, + x: item.transform[4], + y: item.transform[5], + width: item.width, + height: item.height + })); + } + + console.log(fullText); + return fullText; +} +``` + +#### Extract Annotations and Forms +```javascript +import * as pdfjsLib from 'pdfjs-dist'; + +async function extractAnnotations() { + const loadingTask = pdfjsLib.getDocument('annotated.pdf'); + const pdf = await loadingTask.promise; + + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const annotations = await page.getAnnotations(); + + annotations.forEach(annotation => { + console.log(`Annotation type: ${annotation.subtype}`); + console.log(`Content: ${annotation.contents}`); + console.log(`Coordinates: ${JSON.stringify(annotation.rect)}`); + }); + } +} +``` + +## Advanced Command-Line Operations + +### poppler-utils Advanced Features + +#### Extract Text with Bounding Box Coordinates +```bash +# Extract text with bounding box coordinates (essential for structured data) +pdftotext -bbox-layout document.pdf output.xml + +# The XML output contains precise coordinates for each text element +``` + +#### Advanced Image Conversion +```bash +# Convert to PNG images with specific resolution +pdftoppm -png -r 300 document.pdf output_prefix + +# Convert specific page range with high resolution +pdftoppm -png -r 600 -f 1 -l 3 document.pdf high_res_pages + +# Convert to JPEG with quality setting +pdftoppm -jpeg -jpegopt quality=85 -r 200 document.pdf jpeg_output +``` + +#### Extract Embedded Images +```bash +# Extract all embedded images with metadata +pdfimages -j -p document.pdf page_images + +# List image info without extracting +pdfimages -list document.pdf + +# Extract images in their original format +pdfimages -all document.pdf images/img +``` + +### qpdf Advanced Features + +#### Complex Page Manipulation +```bash +# Split PDF into groups of pages +qpdf --split-pages=3 input.pdf output_group_%02d.pdf + +# Extract specific pages with complex ranges +qpdf input.pdf --pages input.pdf 1,3-5,8,10-end -- extracted.pdf + +# Merge specific pages from multiple PDFs +qpdf --empty --pages doc1.pdf 1-3 doc2.pdf 5-7 doc3.pdf 2,4 -- combined.pdf +``` + +#### PDF Optimization and Repair +```bash +# Optimize PDF for web (linearize for streaming) +qpdf --linearize input.pdf optimized.pdf + +# Remove unused objects and compress +qpdf --optimize-level=all input.pdf compressed.pdf + +# Attempt to repair corrupted PDF structure +qpdf --check input.pdf +qpdf --fix-qdf damaged.pdf repaired.pdf + +# Show detailed PDF structure for debugging +qpdf --show-all-pages input.pdf > structure.txt +``` + +#### Advanced Encryption +```bash +# Add password protection with specific permissions +qpdf --encrypt user_pass owner_pass 256 --print=none --modify=none -- input.pdf encrypted.pdf + +# Check encryption status +qpdf --show-encryption encrypted.pdf + +# Remove password protection (requires password) +qpdf --password=secret123 --decrypt encrypted.pdf decrypted.pdf +``` + +## Advanced Python Techniques + +### pdfplumber Advanced Features + +#### Extract Text with Precise Coordinates +```python +import pdfplumber + +with pdfplumber.open("document.pdf") as pdf: + page = pdf.pages[0] + + # Extract all text with coordinates + chars = page.chars + for char in chars[:10]: # First 10 characters + print(f"Char: '{char['text']}' at x:{char['x0']:.1f} y:{char['y0']:.1f}") + + # Extract text by bounding box (left, top, right, bottom) + bbox_text = page.within_bbox((100, 100, 400, 200)).extract_text() +``` + +#### Advanced Table Extraction with Custom Settings +```python +import pdfplumber +import pandas as pd + +with pdfplumber.open("complex_table.pdf") as pdf: + page = pdf.pages[0] + + # Extract tables with custom settings for complex layouts + table_settings = { + "vertical_strategy": "lines", + "horizontal_strategy": "lines", + "snap_tolerance": 3, + "intersection_tolerance": 15 + } + tables = page.extract_tables(table_settings) + + # Visual debugging for table extraction + img = page.to_image(resolution=150) + img.save("debug_layout.png") +``` + +### reportlab Advanced Features + +#### Create Professional Reports with Tables +```python +from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.lib import colors + +# Sample data +data = [ + ['Product', 'Q1', 'Q2', 'Q3', 'Q4'], + ['Widgets', '120', '135', '142', '158'], + ['Gadgets', '85', '92', '98', '105'] +] + +# Create PDF with table +doc = SimpleDocTemplate("report.pdf") +elements = [] + +# Add title +styles = getSampleStyleSheet() +title = Paragraph("Quarterly Sales Report", styles['Title']) +elements.append(title) + +# Add table with advanced styling +table = Table(data) +table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), colors.grey), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, 0), 14), + ('BOTTOMPADDING', (0, 0), (-1, 0), 12), + ('BACKGROUND', (0, 1), (-1, -1), colors.beige), + ('GRID', (0, 0), (-1, -1), 1, colors.black) +])) +elements.append(table) + +doc.build(elements) +``` + +## Complex Workflows + +### Extract Figures/Images from PDF + +#### Method 1: Using pdfimages (fastest) +```bash +# Extract all images with original quality +pdfimages -all document.pdf images/img +``` + +#### Method 2: Using pypdfium2 + Image Processing +```python +import pypdfium2 as pdfium +from PIL import Image +import numpy as np + +def extract_figures(pdf_path, output_dir): + pdf = pdfium.PdfDocument(pdf_path) + + for page_num, page in enumerate(pdf): + # Render high-resolution page + bitmap = page.render(scale=3.0) + img = bitmap.to_pil() + + # Convert to numpy for processing + img_array = np.array(img) + + # Simple figure detection (non-white regions) + mask = np.any(img_array != [255, 255, 255], axis=2) + + # Find contours and extract bounding boxes + # (This is simplified - real implementation would need more sophisticated detection) + + # Save detected figures + # ... implementation depends on specific needs +``` + +### Batch PDF Processing with Error Handling +```python +import os +import glob +from pypdf import PdfReader, PdfWriter +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def batch_process_pdfs(input_dir, operation='merge'): + pdf_files = glob.glob(os.path.join(input_dir, "*.pdf")) + + if operation == 'merge': + writer = PdfWriter() + for pdf_file in pdf_files: + try: + reader = PdfReader(pdf_file) + for page in reader.pages: + writer.add_page(page) + logger.info(f"Processed: {pdf_file}") + except Exception as e: + logger.error(f"Failed to process {pdf_file}: {e}") + continue + + with open("batch_merged.pdf", "wb") as output: + writer.write(output) + + elif operation == 'extract_text': + for pdf_file in pdf_files: + try: + reader = PdfReader(pdf_file) + text = "" + for page in reader.pages: + text += page.extract_text() + + output_file = pdf_file.replace('.pdf', '.txt') + with open(output_file, 'w', encoding='utf-8') as f: + f.write(text) + logger.info(f"Extracted text from: {pdf_file}") + + except Exception as e: + logger.error(f"Failed to extract text from {pdf_file}: {e}") + continue +``` + +### Advanced PDF Cropping +```python +from pypdf import PdfWriter, PdfReader + +reader = PdfReader("input.pdf") +writer = PdfWriter() + +# Crop page (left, bottom, right, top in points) +page = reader.pages[0] +page.mediabox.left = 50 +page.mediabox.bottom = 50 +page.mediabox.right = 550 +page.mediabox.top = 750 + +writer.add_page(page) +with open("cropped.pdf", "wb") as output: + writer.write(output) +``` + +## Performance Optimization Tips + +### 1. For Large PDFs +- Use streaming approaches instead of loading entire PDF in memory +- Use `qpdf --split-pages` for splitting large files +- Process pages individually with pypdfium2 + +### 2. For Text Extraction +- `pdftotext -bbox-layout` is fastest for plain text extraction +- Use pdfplumber for structured data and tables +- Avoid `pypdf.extract_text()` for very large documents + +### 3. For Image Extraction +- `pdfimages` is much faster than rendering pages +- Use low resolution for previews, high resolution for final output + +### 4. For Form Filling +- pdf-lib maintains form structure better than most alternatives +- Pre-validate form fields before processing + +### 5. Memory Management +```python +# Process PDFs in chunks +def process_large_pdf(pdf_path, chunk_size=10): + reader = PdfReader(pdf_path) + total_pages = len(reader.pages) + + for start_idx in range(0, total_pages, chunk_size): + end_idx = min(start_idx + chunk_size, total_pages) + writer = PdfWriter() + + for i in range(start_idx, end_idx): + writer.add_page(reader.pages[i]) + + # Process chunk + with open(f"chunk_{start_idx//chunk_size}.pdf", "wb") as output: + writer.write(output) +``` + +## Troubleshooting Common Issues + +### Encrypted PDFs +```python +# Handle password-protected PDFs +from pypdf import PdfReader + +try: + reader = PdfReader("encrypted.pdf") + if reader.is_encrypted: + reader.decrypt("password") +except Exception as e: + print(f"Failed to decrypt: {e}") +``` + +### Corrupted PDFs +```bash +# Use qpdf to repair +qpdf --check corrupted.pdf +qpdf --replace-input corrupted.pdf +``` + +### Text Extraction Issues +```python +# Fallback to OCR for scanned PDFs +import pytesseract +from pdf2image import convert_from_path + +def extract_text_with_ocr(pdf_path): + images = convert_from_path(pdf_path) + text = "" + for i, image in enumerate(images): + text += pytesseract.image_to_string(image) + return text +``` + +## License Information + +- **pypdf**: BSD License +- **pdfplumber**: MIT License +- **pypdfium2**: Apache/BSD License +- **reportlab**: BSD License +- **poppler-utils**: GPL-2 License +- **qpdf**: Apache License +- **pdf-lib**: MIT License +- **pdfjs-dist**: Apache License \ No newline at end of file diff --git a/ruoyi-admin/src/main/resources/skills/pdf/scripts/check_bounding_boxes.py b/ruoyi-admin/src/main/resources/skills/pdf/scripts/check_bounding_boxes.py new file mode 100644 index 00000000..7443660c --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/pdf/scripts/check_bounding_boxes.py @@ -0,0 +1,70 @@ +from dataclasses import dataclass +import json +import sys + + +# Script to check that the `fields.json` file that Claude creates when analyzing PDFs +# does not have overlapping bounding boxes. See forms.md. + + +@dataclass +class RectAndField: + rect: list[float] + rect_type: str + field: dict + + +# Returns a list of messages that are printed to stdout for Claude to read. +def get_bounding_box_messages(fields_json_stream) -> list[str]: + messages = [] + fields = json.load(fields_json_stream) + messages.append(f"Read {len(fields['form_fields'])} fields") + + def rects_intersect(r1, r2): + disjoint_horizontal = r1[0] >= r2[2] or r1[2] <= r2[0] + disjoint_vertical = r1[1] >= r2[3] or r1[3] <= r2[1] + return not (disjoint_horizontal or disjoint_vertical) + + rects_and_fields = [] + for f in fields["form_fields"]: + rects_and_fields.append(RectAndField(f["label_bounding_box"], "label", f)) + rects_and_fields.append(RectAndField(f["entry_bounding_box"], "entry", f)) + + has_error = False + for i, ri in enumerate(rects_and_fields): + # This is O(N^2); we can optimize if it becomes a problem. + for j in range(i + 1, len(rects_and_fields)): + rj = rects_and_fields[j] + if ri.field["page_number"] == rj.field["page_number"] and rects_intersect(ri.rect, rj.rect): + has_error = True + if ri.field is rj.field: + messages.append(f"FAILURE: intersection between label and entry bounding boxes for `{ri.field['description']}` ({ri.rect}, {rj.rect})") + else: + messages.append(f"FAILURE: intersection between {ri.rect_type} bounding box for `{ri.field['description']}` ({ri.rect}) and {rj.rect_type} bounding box for `{rj.field['description']}` ({rj.rect})") + if len(messages) >= 20: + messages.append("Aborting further checks; fix bounding boxes and try again") + return messages + if ri.rect_type == "entry": + if "entry_text" in ri.field: + font_size = ri.field["entry_text"].get("font_size", 14) + entry_height = ri.rect[3] - ri.rect[1] + if entry_height < font_size: + has_error = True + messages.append(f"FAILURE: entry bounding box height ({entry_height}) for `{ri.field['description']}` is too short for the text content (font size: {font_size}). Increase the box height or decrease the font size.") + if len(messages) >= 20: + messages.append("Aborting further checks; fix bounding boxes and try again") + return messages + + if not has_error: + messages.append("SUCCESS: All bounding boxes are valid") + return messages + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: check_bounding_boxes.py [fields.json]") + sys.exit(1) + # Input file should be in the `fields.json` format described in forms.md. + with open(sys.argv[1]) as f: + messages = get_bounding_box_messages(f) + for msg in messages: + print(msg) diff --git a/ruoyi-admin/src/main/resources/skills/pdf/scripts/check_bounding_boxes_test.py b/ruoyi-admin/src/main/resources/skills/pdf/scripts/check_bounding_boxes_test.py new file mode 100644 index 00000000..1dbb463c --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/pdf/scripts/check_bounding_boxes_test.py @@ -0,0 +1,226 @@ +import unittest +import json +import io +from check_bounding_boxes import get_bounding_box_messages + + +# Currently this is not run automatically in CI; it's just for documentation and manual checking. +class TestGetBoundingBoxMessages(unittest.TestCase): + + def create_json_stream(self, data): + """Helper to create a JSON stream from data""" + return io.StringIO(json.dumps(data)) + + def test_no_intersections(self): + """Test case with no bounding box intersections""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [60, 10, 150, 30] + }, + { + "description": "Email", + "page_number": 1, + "label_bounding_box": [10, 40, 50, 60], + "entry_bounding_box": [60, 40, 150, 60] + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("SUCCESS" in msg for msg in messages)) + self.assertFalse(any("FAILURE" in msg for msg in messages)) + + def test_label_entry_intersection_same_field(self): + """Test intersection between label and entry of the same field""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 60, 30], + "entry_bounding_box": [50, 10, 150, 30] # Overlaps with label + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("FAILURE" in msg and "intersection" in msg for msg in messages)) + self.assertFalse(any("SUCCESS" in msg for msg in messages)) + + def test_intersection_between_different_fields(self): + """Test intersection between bounding boxes of different fields""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [60, 10, 150, 30] + }, + { + "description": "Email", + "page_number": 1, + "label_bounding_box": [40, 20, 80, 40], # Overlaps with Name's boxes + "entry_bounding_box": [160, 10, 250, 30] + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("FAILURE" in msg and "intersection" in msg for msg in messages)) + self.assertFalse(any("SUCCESS" in msg for msg in messages)) + + def test_different_pages_no_intersection(self): + """Test that boxes on different pages don't count as intersecting""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [60, 10, 150, 30] + }, + { + "description": "Email", + "page_number": 2, + "label_bounding_box": [10, 10, 50, 30], # Same coordinates but different page + "entry_bounding_box": [60, 10, 150, 30] + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("SUCCESS" in msg for msg in messages)) + self.assertFalse(any("FAILURE" in msg for msg in messages)) + + def test_entry_height_too_small(self): + """Test that entry box height is checked against font size""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [60, 10, 150, 20], # Height is 10 + "entry_text": { + "font_size": 14 # Font size larger than height + } + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("FAILURE" in msg and "height" in msg for msg in messages)) + self.assertFalse(any("SUCCESS" in msg for msg in messages)) + + def test_entry_height_adequate(self): + """Test that adequate entry box height passes""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [60, 10, 150, 30], # Height is 20 + "entry_text": { + "font_size": 14 # Font size smaller than height + } + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("SUCCESS" in msg for msg in messages)) + self.assertFalse(any("FAILURE" in msg for msg in messages)) + + def test_default_font_size(self): + """Test that default font size is used when not specified""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [60, 10, 150, 20], # Height is 10 + "entry_text": {} # No font_size specified, should use default 14 + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("FAILURE" in msg and "height" in msg for msg in messages)) + self.assertFalse(any("SUCCESS" in msg for msg in messages)) + + def test_no_entry_text(self): + """Test that missing entry_text doesn't cause height check""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [60, 10, 150, 20] # Small height but no entry_text + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("SUCCESS" in msg for msg in messages)) + self.assertFalse(any("FAILURE" in msg for msg in messages)) + + def test_multiple_errors_limit(self): + """Test that error messages are limited to prevent excessive output""" + fields = [] + # Create many overlapping fields + for i in range(25): + fields.append({ + "description": f"Field{i}", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], # All overlap + "entry_bounding_box": [20, 15, 60, 35] # All overlap + }) + + data = {"form_fields": fields} + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + # Should abort after ~20 messages + self.assertTrue(any("Aborting" in msg for msg in messages)) + # Should have some FAILURE messages but not hundreds + failure_count = sum(1 for msg in messages if "FAILURE" in msg) + self.assertGreater(failure_count, 0) + self.assertLess(len(messages), 30) # Should be limited + + def test_edge_touching_boxes(self): + """Test that boxes touching at edges don't count as intersecting""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [50, 10, 150, 30] # Touches at x=50 + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("SUCCESS" in msg for msg in messages)) + self.assertFalse(any("FAILURE" in msg for msg in messages)) + + +if __name__ == '__main__': + unittest.main() diff --git a/ruoyi-admin/src/main/resources/skills/pdf/scripts/check_fillable_fields.py b/ruoyi-admin/src/main/resources/skills/pdf/scripts/check_fillable_fields.py new file mode 100644 index 00000000..dc43d182 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/pdf/scripts/check_fillable_fields.py @@ -0,0 +1,12 @@ +import sys +from pypdf import PdfReader + + +# Script for Claude to run to determine whether a PDF has fillable form fields. See forms.md. + + +reader = PdfReader(sys.argv[1]) +if (reader.get_fields()): + print("This PDF has fillable form fields") +else: + print("This PDF does not have fillable form fields; you will need to visually determine where to enter data") diff --git a/ruoyi-admin/src/main/resources/skills/pdf/scripts/convert_pdf_to_images.py b/ruoyi-admin/src/main/resources/skills/pdf/scripts/convert_pdf_to_images.py new file mode 100644 index 00000000..f8a4ec52 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/pdf/scripts/convert_pdf_to_images.py @@ -0,0 +1,35 @@ +import os +import sys + +from pdf2image import convert_from_path + + +# Converts each page of a PDF to a PNG image. + + +def convert(pdf_path, output_dir, max_dim=1000): + images = convert_from_path(pdf_path, dpi=200) + + for i, image in enumerate(images): + # Scale image if needed to keep width/height under `max_dim` + width, height = image.size + if width > max_dim or height > max_dim: + scale_factor = min(max_dim / width, max_dim / height) + new_width = int(width * scale_factor) + new_height = int(height * scale_factor) + image = image.resize((new_width, new_height)) + + image_path = os.path.join(output_dir, f"page_{i+1}.png") + image.save(image_path) + print(f"Saved page {i+1} as {image_path} (size: {image.size})") + + print(f"Converted {len(images)} pages to PNG images") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: convert_pdf_to_images.py [input pdf] [output directory]") + sys.exit(1) + pdf_path = sys.argv[1] + output_directory = sys.argv[2] + convert(pdf_path, output_directory) diff --git a/ruoyi-admin/src/main/resources/skills/pdf/scripts/create_validation_image.py b/ruoyi-admin/src/main/resources/skills/pdf/scripts/create_validation_image.py new file mode 100644 index 00000000..4913f8f8 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/pdf/scripts/create_validation_image.py @@ -0,0 +1,41 @@ +import json +import sys + +from PIL import Image, ImageDraw + + +# Creates "validation" images with rectangles for the bounding box information that +# Claude creates when determining where to add text annotations in PDFs. See forms.md. + + +def create_validation_image(page_number, fields_json_path, input_path, output_path): + # Input file should be in the `fields.json` format described in forms.md. + with open(fields_json_path, 'r') as f: + data = json.load(f) + + img = Image.open(input_path) + draw = ImageDraw.Draw(img) + num_boxes = 0 + + for field in data["form_fields"]: + if field["page_number"] == page_number: + entry_box = field['entry_bounding_box'] + label_box = field['label_bounding_box'] + # Draw red rectangle over entry bounding box and blue rectangle over the label. + draw.rectangle(entry_box, outline='red', width=2) + draw.rectangle(label_box, outline='blue', width=2) + num_boxes += 2 + + img.save(output_path) + print(f"Created validation image at {output_path} with {num_boxes} bounding boxes") + + +if __name__ == "__main__": + if len(sys.argv) != 5: + print("Usage: create_validation_image.py [page number] [fields.json file] [input image path] [output image path]") + sys.exit(1) + page_number = int(sys.argv[1]) + fields_json_path = sys.argv[2] + input_image_path = sys.argv[3] + output_image_path = sys.argv[4] + create_validation_image(page_number, fields_json_path, input_image_path, output_image_path) diff --git a/ruoyi-admin/src/main/resources/skills/pdf/scripts/extract_form_field_info.py b/ruoyi-admin/src/main/resources/skills/pdf/scripts/extract_form_field_info.py new file mode 100644 index 00000000..f42a2df8 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/pdf/scripts/extract_form_field_info.py @@ -0,0 +1,152 @@ +import json +import sys + +from pypdf import PdfReader + + +# Extracts data for the fillable form fields in a PDF and outputs JSON that +# Claude uses to fill the fields. See forms.md. + + +# This matches the format used by PdfReader `get_fields` and `update_page_form_field_values` methods. +def get_full_annotation_field_id(annotation): + components = [] + while annotation: + field_name = annotation.get('/T') + if field_name: + components.append(field_name) + annotation = annotation.get('/Parent') + return ".".join(reversed(components)) if components else None + + +def make_field_dict(field, field_id): + field_dict = {"field_id": field_id} + ft = field.get('/FT') + if ft == "/Tx": + field_dict["type"] = "text" + elif ft == "/Btn": + field_dict["type"] = "checkbox" # radio groups handled separately + states = field.get("/_States_", []) + if len(states) == 2: + # "/Off" seems to always be the unchecked value, as suggested by + # https://opensource.adobe.com/dc-acrobat-sdk-docs/standards/pdfstandards/pdf/PDF32000_2008.pdf#page=448 + # It can be either first or second in the "/_States_" list. + if "/Off" in states: + field_dict["checked_value"] = states[0] if states[0] != "/Off" else states[1] + field_dict["unchecked_value"] = "/Off" + else: + print(f"Unexpected state values for checkbox `${field_id}`. Its checked and unchecked values may not be correct; if you're trying to check it, visually verify the results.") + field_dict["checked_value"] = states[0] + field_dict["unchecked_value"] = states[1] + elif ft == "/Ch": + field_dict["type"] = "choice" + states = field.get("/_States_", []) + field_dict["choice_options"] = [{ + "value": state[0], + "text": state[1], + } for state in states] + else: + field_dict["type"] = f"unknown ({ft})" + return field_dict + + +# Returns a list of fillable PDF fields: +# [ +# { +# "field_id": "name", +# "page": 1, +# "type": ("text", "checkbox", "radio_group", or "choice") +# // Per-type additional fields described in forms.md +# }, +# ] +def get_field_info(reader: PdfReader): + fields = reader.get_fields() + + field_info_by_id = {} + possible_radio_names = set() + + for field_id, field in fields.items(): + # Skip if this is a container field with children, except that it might be + # a parent group for radio button options. + if field.get("/Kids"): + if field.get("/FT") == "/Btn": + possible_radio_names.add(field_id) + continue + field_info_by_id[field_id] = make_field_dict(field, field_id) + + # Bounding rects are stored in annotations in page objects. + + # Radio button options have a separate annotation for each choice; + # all choices have the same field name. + # See https://westhealth.github.io/exploring-fillable-forms-with-pdfrw.html + radio_fields_by_id = {} + + for page_index, page in enumerate(reader.pages): + annotations = page.get('/Annots', []) + for ann in annotations: + field_id = get_full_annotation_field_id(ann) + if field_id in field_info_by_id: + field_info_by_id[field_id]["page"] = page_index + 1 + field_info_by_id[field_id]["rect"] = ann.get('/Rect') + elif field_id in possible_radio_names: + try: + # ann['/AP']['/N'] should have two items. One of them is '/Off', + # the other is the active value. + on_values = [v for v in ann["/AP"]["/N"] if v != "/Off"] + except KeyError: + continue + if len(on_values) == 1: + rect = ann.get("/Rect") + if field_id not in radio_fields_by_id: + radio_fields_by_id[field_id] = { + "field_id": field_id, + "type": "radio_group", + "page": page_index + 1, + "radio_options": [], + } + # Note: at least on macOS 15.7, Preview.app doesn't show selected + # radio buttons correctly. (It does if you remove the leading slash + # from the value, but that causes them not to appear correctly in + # Chrome/Firefox/Acrobat/etc). + radio_fields_by_id[field_id]["radio_options"].append({ + "value": on_values[0], + "rect": rect, + }) + + # Some PDFs have form field definitions without corresponding annotations, + # so we can't tell where they are. Ignore these fields for now. + fields_with_location = [] + for field_info in field_info_by_id.values(): + if "page" in field_info: + fields_with_location.append(field_info) + else: + print(f"Unable to determine location for field id: {field_info.get('field_id')}, ignoring") + + # Sort by page number, then Y position (flipped in PDF coordinate system), then X. + def sort_key(f): + if "radio_options" in f: + rect = f["radio_options"][0]["rect"] or [0, 0, 0, 0] + else: + rect = f.get("rect") or [0, 0, 0, 0] + adjusted_position = [-rect[1], rect[0]] + return [f.get("page"), adjusted_position] + + sorted_fields = fields_with_location + list(radio_fields_by_id.values()) + sorted_fields.sort(key=sort_key) + + return sorted_fields + + +def write_field_info(pdf_path: str, json_output_path: str): + reader = PdfReader(pdf_path) + field_info = get_field_info(reader) + with open(json_output_path, "w") as f: + json.dump(field_info, f, indent=2) + print(f"Wrote {len(field_info)} fields to {json_output_path}") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: extract_form_field_info.py [input pdf] [output json]") + sys.exit(1) + write_field_info(sys.argv[1], sys.argv[2]) diff --git a/ruoyi-admin/src/main/resources/skills/pdf/scripts/fill_fillable_fields.py b/ruoyi-admin/src/main/resources/skills/pdf/scripts/fill_fillable_fields.py new file mode 100644 index 00000000..ac35753c --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/pdf/scripts/fill_fillable_fields.py @@ -0,0 +1,114 @@ +import json +import sys + +from pypdf import PdfReader, PdfWriter + +from extract_form_field_info import get_field_info + + +# Fills fillable form fields in a PDF. See forms.md. + + +def fill_pdf_fields(input_pdf_path: str, fields_json_path: str, output_pdf_path: str): + with open(fields_json_path) as f: + fields = json.load(f) + # Group by page number. + fields_by_page = {} + for field in fields: + if "value" in field: + field_id = field["field_id"] + page = field["page"] + if page not in fields_by_page: + fields_by_page[page] = {} + fields_by_page[page][field_id] = field["value"] + + reader = PdfReader(input_pdf_path) + + has_error = False + field_info = get_field_info(reader) + fields_by_ids = {f["field_id"]: f for f in field_info} + for field in fields: + existing_field = fields_by_ids.get(field["field_id"]) + if not existing_field: + has_error = True + print(f"ERROR: `{field['field_id']}` is not a valid field ID") + elif field["page"] != existing_field["page"]: + has_error = True + print(f"ERROR: Incorrect page number for `{field['field_id']}` (got {field['page']}, expected {existing_field['page']})") + else: + if "value" in field: + err = validation_error_for_field_value(existing_field, field["value"]) + if err: + print(err) + has_error = True + if has_error: + sys.exit(1) + + writer = PdfWriter(clone_from=reader) + for page, field_values in fields_by_page.items(): + writer.update_page_form_field_values(writer.pages[page - 1], field_values, auto_regenerate=False) + + # This seems to be necessary for many PDF viewers to format the form values correctly. + # It may cause the viewer to show a "save changes" dialog even if the user doesn't make any changes. + writer.set_need_appearances_writer(True) + + with open(output_pdf_path, "wb") as f: + writer.write(f) + + +def validation_error_for_field_value(field_info, field_value): + field_type = field_info["type"] + field_id = field_info["field_id"] + if field_type == "checkbox": + checked_val = field_info["checked_value"] + unchecked_val = field_info["unchecked_value"] + if field_value != checked_val and field_value != unchecked_val: + return f'ERROR: Invalid value "{field_value}" for checkbox field "{field_id}". The checked value is "{checked_val}" and the unchecked value is "{unchecked_val}"' + elif field_type == "radio_group": + option_values = [opt["value"] for opt in field_info["radio_options"]] + if field_value not in option_values: + return f'ERROR: Invalid value "{field_value}" for radio group field "{field_id}". Valid values are: {option_values}' + elif field_type == "choice": + choice_values = [opt["value"] for opt in field_info["choice_options"]] + if field_value not in choice_values: + return f'ERROR: Invalid value "{field_value}" for choice field "{field_id}". Valid values are: {choice_values}' + return None + + +# pypdf (at least version 5.7.0) has a bug when setting the value for a selection list field. +# In _writer.py around line 966: +# +# if field.get(FA.FT, "/Tx") == "/Ch" and field_flags & FA.FfBits.Combo == 0: +# txt = "\n".join(annotation.get_inherited(FA.Opt, [])) +# +# The problem is that for selection lists, `get_inherited` returns a list of two-element lists like +# [["value1", "Text 1"], ["value2", "Text 2"], ...] +# This causes `join` to throw a TypeError because it expects an iterable of strings. +# The horrible workaround is to patch `get_inherited` to return a list of the value strings. +# We call the original method and adjust the return value only if the argument to `get_inherited` +# is `FA.Opt` and if the return value is a list of two-element lists. +def monkeypatch_pydpf_method(): + from pypdf.generic import DictionaryObject + from pypdf.constants import FieldDictionaryAttributes + + original_get_inherited = DictionaryObject.get_inherited + + def patched_get_inherited(self, key: str, default = None): + result = original_get_inherited(self, key, default) + if key == FieldDictionaryAttributes.Opt: + if isinstance(result, list) and all(isinstance(v, list) and len(v) == 2 for v in result): + result = [r[0] for r in result] + return result + + DictionaryObject.get_inherited = patched_get_inherited + + +if __name__ == "__main__": + if len(sys.argv) != 4: + print("Usage: fill_fillable_fields.py [input pdf] [field_values.json] [output pdf]") + sys.exit(1) + monkeypatch_pydpf_method() + input_pdf = sys.argv[1] + fields_json = sys.argv[2] + output_pdf = sys.argv[3] + fill_pdf_fields(input_pdf, fields_json, output_pdf) diff --git a/ruoyi-admin/src/main/resources/skills/pdf/scripts/fill_pdf_form_with_annotations.py b/ruoyi-admin/src/main/resources/skills/pdf/scripts/fill_pdf_form_with_annotations.py new file mode 100644 index 00000000..f9805313 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/pdf/scripts/fill_pdf_form_with_annotations.py @@ -0,0 +1,108 @@ +import json +import sys + +from pypdf import PdfReader, PdfWriter +from pypdf.annotations import FreeText + + +# Fills a PDF by adding text annotations defined in `fields.json`. See forms.md. + + +def transform_coordinates(bbox, image_width, image_height, pdf_width, pdf_height): + """Transform bounding box from image coordinates to PDF coordinates""" + # Image coordinates: origin at top-left, y increases downward + # PDF coordinates: origin at bottom-left, y increases upward + x_scale = pdf_width / image_width + y_scale = pdf_height / image_height + + left = bbox[0] * x_scale + right = bbox[2] * x_scale + + # Flip Y coordinates for PDF + top = pdf_height - (bbox[1] * y_scale) + bottom = pdf_height - (bbox[3] * y_scale) + + return left, bottom, right, top + + +def fill_pdf_form(input_pdf_path, fields_json_path, output_pdf_path): + """Fill the PDF form with data from fields.json""" + + # `fields.json` format described in forms.md. + with open(fields_json_path, "r") as f: + fields_data = json.load(f) + + # Open the PDF + reader = PdfReader(input_pdf_path) + writer = PdfWriter() + + # Copy all pages to writer + writer.append(reader) + + # Get PDF dimensions for each page + pdf_dimensions = {} + for i, page in enumerate(reader.pages): + mediabox = page.mediabox + pdf_dimensions[i + 1] = [mediabox.width, mediabox.height] + + # Process each form field + annotations = [] + for field in fields_data["form_fields"]: + page_num = field["page_number"] + + # Get page dimensions and transform coordinates. + page_info = next(p for p in fields_data["pages"] if p["page_number"] == page_num) + image_width = page_info["image_width"] + image_height = page_info["image_height"] + pdf_width, pdf_height = pdf_dimensions[page_num] + + transformed_entry_box = transform_coordinates( + field["entry_bounding_box"], + image_width, image_height, + pdf_width, pdf_height + ) + + # Skip empty fields + if "entry_text" not in field or "text" not in field["entry_text"]: + continue + entry_text = field["entry_text"] + text = entry_text["text"] + if not text: + continue + + font_name = entry_text.get("font", "Arial") + font_size = str(entry_text.get("font_size", 14)) + "pt" + font_color = entry_text.get("font_color", "000000") + + # Font size/color seems to not work reliably across viewers: + # https://github.com/py-pdf/pypdf/issues/2084 + annotation = FreeText( + text=text, + rect=transformed_entry_box, + font=font_name, + font_size=font_size, + font_color=font_color, + border_color=None, + background_color=None, + ) + annotations.append(annotation) + # page_number is 0-based for pypdf + writer.add_annotation(page_number=page_num - 1, annotation=annotation) + + # Save the filled PDF + with open(output_pdf_path, "wb") as output: + writer.write(output) + + print(f"Successfully filled PDF form and saved to {output_pdf_path}") + print(f"Added {len(annotations)} text annotations") + + +if __name__ == "__main__": + if len(sys.argv) != 4: + print("Usage: fill_pdf_form_with_annotations.py [input pdf] [fields.json] [output pdf]") + sys.exit(1) + input_pdf = sys.argv[1] + fields_json = sys.argv[2] + output_pdf = sys.argv[3] + + fill_pdf_form(input_pdf, fields_json, output_pdf) \ No newline at end of file diff --git a/ruoyi-admin/src/main/resources/skills/xlsx/LICENSE.txt b/ruoyi-admin/src/main/resources/skills/xlsx/LICENSE.txt new file mode 100644 index 00000000..c55ab422 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/xlsx/LICENSE.txt @@ -0,0 +1,30 @@ +© 2025 Anthropic, PBC. All rights reserved. + +LICENSE: Use of these materials (including all code, prompts, assets, files, +and other components of this Skill) is governed by your agreement with +Anthropic regarding use of Anthropic's services. If no separate agreement +exists, use is governed by Anthropic's Consumer Terms of Service or +Commercial Terms of Service, as applicable: +https://www.anthropic.com/legal/consumer-terms +https://www.anthropic.com/legal/commercial-terms +Your applicable agreement is referred to as the "Agreement." "Services" are +as defined in the Agreement. + +ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the +contrary, users may not: + +- Extract these materials from the Services or retain copies of these + materials outside the Services +- Reproduce or copy these materials, except for temporary copies created + automatically during authorized use of the Services +- Create derivative works based on these materials +- Distribute, sublicense, or transfer these materials to any third party +- Make, offer to sell, sell, or import any inventions embodied in these + materials +- Reverse engineer, decompile, or disassemble these materials + +The receipt, viewing, or possession of these materials does not convey or +imply any license or right beyond those expressly granted above. + +Anthropic retains all right, title, and interest in these materials, +including all copyrights, patents, and other intellectual property rights. diff --git a/ruoyi-admin/src/main/resources/skills/xlsx/SKILL.md b/ruoyi-admin/src/main/resources/skills/xlsx/SKILL.md new file mode 100644 index 00000000..3fa1d994 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/xlsx/SKILL.md @@ -0,0 +1,362 @@ +--- +name: xlsx +description: "Comprehensive spreadsheet creation, editing, and analysis with support for formulas, formatting, data analysis, and visualization. When Claude needs to work with spreadsheets (.xlsx, .xlsm, .csv, .tsv, etc) for: (1) Creating new spreadsheets with formulas and formatting, (2) Reading or analyzing data, (3) Modify existing spreadsheets while preserving formulas, (4) Data analysis and visualization in spreadsheets, or (5) Recalculating formulas" +license: Proprietary. LICENSE.txt has complete terms +--- + +# Requirements for Outputs + +## All Excel files + +### Zero Formula Errors +- Every Excel model MUST be delivered with ZERO formula errors (#REF!, #DIV/0!, #VALUE!, #N/A, #NAME?) + +### Preserve Existing Templates (when updating templates) +- Study and EXACTLY match existing format, style, and conventions when modifying files +- Never impose standardized formatting on files with established patterns +- Existing template conventions ALWAYS override these guidelines + +## Financial models + +### Color Coding Standards +Unless otherwise stated by the user or existing template + +#### Industry-Standard Color Conventions +- **Blue text (RGB: 0,0,255)**: Hardcoded inputs, and numbers users will change for scenarios +- **Black text (RGB: 0,0,0)**: ALL formulas and calculations +- **Green text (RGB: 0,128,0)**: Links pulling from other worksheets within same workbook +- **Red text (RGB: 255,0,0)**: External links to other files +- **Yellow background (RGB: 255,255,0)**: Key assumptions needing attention or cells that need to be updated + +### Number Formatting Standards + +#### Required Format Rules +- **Years**: Format as text strings (e.g., "2024" not "2,024") +- **Currency**: Use $#,##0 format; ALWAYS specify units in headers ("Revenue ($mm)") +- **Zeros**: Use number formatting to make all zeros "-", including percentages (e.g., "$#,##0;($#,##0);-") +- **Percentages**: Default to 0.0% format (one decimal) +- **Multiples**: Format as 0.0x for valuation multiples (EV/EBITDA, P/E) +- **Negative numbers**: Use parentheses (123) not minus -123 + +### Formula Construction Rules + +#### Assumptions Placement +- Place ALL assumptions (growth rates, margins, multiples, etc.) in separate assumption cells +- Use cell references instead of hardcoded values in formulas +- Example: Use =B5*(1+$B$6) instead of =B5*1.05 + +#### Formula Error Prevention +- Verify all cell references are correct +- Check for off-by-one errors in ranges +- Ensure consistent formulas across all projection periods +- Test with edge cases (zero values, negative numbers) +- Verify no unintended circular references + +#### Documentation Requirements for Hardcodes +- Comment or in cells beside (if end of table). Format: "Source: [System/Document], [Date], [Specific Reference], [URL if applicable]" +- Examples: + - "Source: Company 10-K, FY2024, Page 45, Revenue Note, [SEC EDGAR URL]" + - "Source: Company 10-Q, Q2 2025, Exhibit 99.1, [SEC EDGAR URL]" + - "Source: Bloomberg Terminal, 8/15/2025, AAPL US Equity" + - "Source: FactSet, 8/20/2025, Consensus Estimates Screen" + +# XLSX creation, editing, and analysis + +## Overview + +A user may ask you to create, edit, or analyze the contents of an .xlsx file. You have different tools and workflows available for different tasks. + +## Important Requirements + +**LibreOffice Required for Formula Recalculation**: You can assume LibreOffice is installed for recalculating formula values using the `recalc.py` script. The script automatically configures LibreOffice on first run + +## Reading and analyzing data + +### Data analysis with pandas +For data analysis, visualization, and basic operations, use **pandas** which provides powerful data manipulation capabilities: + +```python +import pandas as pd + +# Read Excel +df = pd.read_excel('file.xlsx') # Default: first sheet +all_sheets = pd.read_excel('file.xlsx', sheet_name=None) # All sheets as dict + +# Analyze +df.head() # Preview data +df.info() # Column info +df.describe() # Statistics + +# Write Excel +df.to_excel('output.xlsx', index=False) +``` + +## Excel File Workflows + +## CRITICAL: Use Formulas, Not Hardcoded Values + +**Always use Excel formulas instead of calculating values in Python and hardcoding them.** This ensures the spreadsheet remains dynamic and updateable. + +### ❌ WRONG - Hardcoding Calculated Values +```python +# Bad: Calculating in Python and hardcoding result +total = df['Sales'].sum() +sheet['B10'] = total # Hardcodes 5000 + +# Bad: Computing growth rate in Python +growth = (df.iloc[-1]['Revenue'] - df.iloc[0]['Revenue']) / df.iloc[0]['Revenue'] +sheet['C5'] = growth # Hardcodes 0.15 + +# Bad: Python calculation for average +avg = sum(values) / len(values) +sheet['D20'] = avg # Hardcodes 42.5 +``` + +### ✅ CORRECT - Using Excel Formulas +```python +# Good: Let Excel calculate the sum +sheet['B10'] = '=SUM(B2:B9)' + +# Good: Growth rate as Excel formula +sheet['C5'] = '=(C4-C2)/C2' + +# Good: Average using Excel function +sheet['D20'] = '=AVERAGE(D2:D19)' +``` + +This applies to ALL calculations - totals, percentages, ratios, differences, etc. The spreadsheet should be able to recalculate when source data changes. + +## Common Workflow +1. **Choose tool**: pandas for data, openpyxl for formulas/formatting +2. **Create/Load**: Create new workbook or load existing file +3. **Modify**: Add/edit data, formulas, and formatting +4. **Save**: Write to file +5. **Recalculate formulas (MANDATORY IF USING FORMULAS)**: Use the recalc.py script + ```bash + python recalc.py output.xlsx + ``` +6. **Verify and fix any errors**: + - The script returns JSON with error details + - If `status` is `errors_found`, check `error_summary` for specific error types and locations + - Fix the identified errors and recalculate again + - Common errors to fix: + - `#REF!`: Invalid cell references + - `#DIV/0!`: Division by zero + - `#VALUE!`: Wrong data type in formula + - `#NAME?`: Unrecognized formula name + +### Creating new Excel files + +```python +# Using openpyxl for formulas and formatting +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment + +wb = Workbook() +sheet = wb.active + +# Add data +sheet['A1'] = 'Hello' +sheet['B1'] = 'World' +sheet.append(['Row', 'of', 'data']) + +# Add formula +sheet['B2'] = '=SUM(A1:A10)' + +# Formatting +sheet['A1'].font = Font(bold=True, color='FF0000') +sheet['A1'].fill = PatternFill('solid', start_color='FFFF00') +sheet['A1'].alignment = Alignment(horizontal='center') + +# Column width +sheet.column_dimensions['A'].width = 20 + +wb.save('output.xlsx') +``` + +### Editing existing Excel files + +```python +# Using openpyxl to preserve formulas and formatting +from openpyxl import load_workbook + +# Load existing file +wb = load_workbook('existing.xlsx') +sheet = wb.active # or wb['SheetName'] for specific sheet + +# Working with multiple sheets +for sheet_name in wb.sheetnames: + sheet = wb[sheet_name] + print(f"Sheet: {sheet_name}") + +# Modify cells +sheet['A1'] = 'New Value' +sheet.insert_rows(2) # Insert row at position 2 +sheet.delete_cols(3) # Delete column 3 + +# Add new sheet +new_sheet = wb.create_sheet('NewSheet') +new_sheet['A1'] = 'Data' + +wb.save('modified.xlsx') +``` + +## Recalculating formulas + +Excel files created or modified by openpyxl contain formulas as strings but not calculated values. Use the provided `recalc.py` script to recalculate formulas: + +```bash +# Recalculate existing Excel file +python recalc.py [timeout_seconds] + +# Create new Excel workbook +python recalc.py --create '' +``` + +Examples: +```bash +python recalc.py output.xlsx 30 + +python recalc.py --create output.xlsx '{"Sheet1": {"data": [["Name", "Value"], ["Test", 100]]}}' +``` + +The script: +- Automatically sets up LibreOffice macro on first run +- Recalculates all formulas in all sheets +- Scans ALL cells for Excel errors (#REF!, #DIV/0!, etc.) +- Returns JSON with detailed error locations and counts +- Works on Windows, Linux, and macOS +- Can create new workbooks from JSON configuration + +## Creating workbooks with recalc.py + +The `--create` mode allows you to create new Excel workbooks directly from JSON configuration: + +```bash +python recalc.py --create '' +``` + +**Default Behavior**: If you provide a relative path (e.g., `test.xlsx`), the file will be created in the **project root** directory (`ruoyi-ai-v3/`). To save to a specific directory, use an absolute path. + +### JSON Format + +```json +{ + "Sheet1": { + "data": [ + ["Header1", "Header2", "Header3"], + ["Value1", "Value2", "=A2*B2"], + ["Value3", "Value4", "=SUM(C2:C10)"] + ] + }, + "Sheet2": { + "data": [ + ["Name", "Score"], + ["Alice", 95], + ["Bob", 87] + ] + } +} +``` + +### Examples + +**Simple data table (saves to project root)**: +```bash +python recalc.py --create data.xlsx '{"Sheet1": {"data": [["Name", "Age"], ["John", 30], ["Jane", 28]]}}' +# Creates: ruoyi-ai-v3/data.xlsx +``` + +**With formulas (saves to project root)**: +```bash +python recalc.py --create model.xlsx '{"Sheet1": {"data": [["A", "B", "Sum"], [10, 20, "=A2+B2"], [5, 15, "=A3+B3"]]}}' +# Creates: ruoyi-ai-v3/model.xlsx +``` + +**Save to custom directory (use absolute path)**: +```bash +python recalc.py --create "D:\Downloads\custom.xlsx" '{"Sheet1": {"data": [["A", "B"], [1, 2]]}}' +# Creates: D:\Downloads\custom.xlsx +``` + +### File Path Options + +| Input | Output Location | +|-------|-----------------| +| `test.xlsx` | `ruoyi-ai-v3/test.xlsx` (project root) | +| `output/test.xlsx` | `ruoyi-ai-v3/output/test.xlsx` | +| `D:\Downloads\test.xlsx` | `D:\Downloads\test.xlsx` (absolute path) | + +### Features + +- First row is automatically formatted as bold header +- Supports both values and Excel formulas (strings starting with `=`) +- Multiple sheets can be created in one command +- Output file extension determines format (.xlsx, .xlsm, etc.) + +## Formula Verification Checklist + +Quick checks to ensure formulas work correctly: + +### Essential Verification +- [ ] **Test 2-3 sample references**: Verify they pull correct values before building full model +- [ ] **Column mapping**: Confirm Excel columns match (e.g., column 64 = BL, not BK) +- [ ] **Row offset**: Remember Excel rows are 1-indexed (DataFrame row 5 = Excel row 6) + +### Common Pitfalls +- [ ] **NaN handling**: Check for null values with `pd.notna()` +- [ ] **Far-right columns**: FY data often in columns 50+ +- [ ] **Multiple matches**: Search all occurrences, not just first +- [ ] **Division by zero**: Check denominators before using `/` in formulas (#DIV/0!) +- [ ] **Wrong references**: Verify all cell references point to intended cells (#REF!) +- [ ] **Cross-sheet references**: Use correct format (Sheet1!A1) for linking sheets + +### Formula Testing Strategy +- [ ] **Start small**: Test formulas on 2-3 cells before applying broadly +- [ ] **Verify dependencies**: Check all cells referenced in formulas exist +- [ ] **Test edge cases**: Include zero, negative, and very large values + +### Interpreting recalc.py Output +The script returns JSON with error details: +```json +{ + "status": "success", // or "errors_found" + "total_errors": 0, // Total error count + "total_formulas": 42, // Number of formulas in file + "error_summary": { // Only present if errors found + "#REF!": { + "count": 2, + "locations": ["Sheet1!B5", "Sheet1!C10"] + } + } +} +``` + +## Best Practices + +### Library Selection +- **pandas**: Best for data analysis, bulk operations, and simple data export +- **openpyxl**: Best for complex formatting, formulas, and Excel-specific features + +### Working with openpyxl +- Cell indices are 1-based (row=1, column=1 refers to cell A1) +- Use `data_only=True` to read calculated values: `load_workbook('file.xlsx', data_only=True)` +- **Warning**: If opened with `data_only=True` and saved, formulas are replaced with values and permanently lost +- For large files: Use `read_only=True` for reading or `write_only=True` for writing +- Formulas are preserved but not evaluated - use recalc.py to update values + +### Working with pandas +- Specify data types to avoid inference issues: `pd.read_excel('file.xlsx', dtype={'id': str})` +- For large files, read specific columns: `pd.read_excel('file.xlsx', usecols=['A', 'C', 'E'])` +- Handle dates properly: `pd.read_excel('file.xlsx', parse_dates=['date_column'])` + +## Code Style Guidelines +**IMPORTANT**: When generating Python code for Excel operations: +- Write minimal, concise Python code without unnecessary comments +- Avoid verbose variable names and redundant operations +- Avoid unnecessary print statements + +**For Excel files themselves**: +- Add comments to cells with complex formulas or important assumptions +- Document data sources for hardcoded values +- Include notes for key calculations and model sections \ No newline at end of file diff --git a/ruoyi-admin/src/main/resources/skills/xlsx/recalc.py b/ruoyi-admin/src/main/resources/skills/xlsx/recalc.py new file mode 100644 index 00000000..e25865d3 --- /dev/null +++ b/ruoyi-admin/src/main/resources/skills/xlsx/recalc.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +""" +Excel Formula Recalculation Script +Recalculates all formulas in an Excel file using LibreOffice +""" + +import json +import sys +import subprocess +import os +import platform +from pathlib import Path +from openpyxl import load_workbook + + +def get_soffice_path(): + """Find LibreOffice soffice executable path for the current OS""" + system = platform.system() + + if system == 'Windows': + # Common LibreOffice installation paths on Windows + possible_paths = [ + Path(os.environ.get('PROGRAMFILES', 'C:\\Program Files')) / 'LibreOffice' / 'program' / 'soffice.exe', + Path(os.environ.get('PROGRAMFILES(X86)', 'C:\\Program Files (x86)')) / 'LibreOffice' / 'program' / 'soffice.exe', + Path(os.path.expanduser('~')) / 'AppData' / 'Local' / 'LibreOffice' / 'program' / 'soffice.exe', + ] + + for path in possible_paths: + if path.exists(): + return str(path) + + # Try to find it using where command + try: + result = subprocess.run(['where', 'soffice.exe'], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + return result.stdout.strip().split('\n')[0] + except Exception: + pass + + return None + else: + # For Linux and macOS, soffice is usually in PATH + return 'soffice' + + +def setup_libreoffice_macro(): + """Setup LibreOffice macro for recalculation if not already configured""" + system = platform.system() + + if system == 'Darwin': + macro_dir = os.path.expanduser('~/Library/Application Support/LibreOffice/4/user/basic/Standard') + elif system == 'Windows': + # Windows path for LibreOffice config + appdata = os.path.expanduser('~\\AppData\\Roaming\\LibreOffice\\4\\user\\basic\\Standard') + macro_dir = appdata + else: + # Linux + macro_dir = os.path.expanduser('~/.config/libreoffice/4/user/basic/Standard') + + macro_file = os.path.join(macro_dir, 'Module1.xba') + + if os.path.exists(macro_file): + try: + with open(macro_file, 'r') as f: + if 'RecalculateAndSave' in f.read(): + return True + except Exception: + pass + + if not os.path.exists(macro_dir): + soffice_path = get_soffice_path() + if not soffice_path: + return False + + try: + subprocess.run([soffice_path, '--headless', '--terminate_after_init'], + capture_output=True, timeout=10) + except Exception: + pass + + os.makedirs(macro_dir, exist_ok=True) + + macro_content = ''' + + + Sub RecalculateAndSave() + ThisComponent.calculateAll() + ThisComponent.store() + ThisComponent.close(True) + End Sub +''' + + try: + with open(macro_file, 'w') as f: + f.write(macro_content) + return True + except Exception: + return False + + +def recalc(filename, timeout=30): + """ + Recalculate formulas in Excel file and report any errors + + Args: + filename: Path to Excel file + timeout: Maximum time to wait for recalculation (seconds) + + Returns: + dict with error locations and counts + """ + if not Path(filename).exists(): + return {'error': f'File {filename} does not exist'} + + abs_path = str(Path(filename).absolute()) + + if not setup_libreoffice_macro(): + return {'error': 'Failed to setup LibreOffice macro'} + + soffice_path = get_soffice_path() + if not soffice_path: + return {'error': 'LibreOffice not found. Please install LibreOffice.'} + + cmd = [ + soffice_path, '--headless', '--norestore', + 'vnd.sun.star.script:Standard.Module1.RecalculateAndSave?language=Basic&location=application', + abs_path + ] + + system = platform.system() + + # Handle timeout for different operating systems + if system == 'Windows': + # Windows: use taskkill as fallback, but rely on subprocess timeout + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired: + return {'error': f'LibreOffice recalculation timed out after {timeout} seconds'} + elif system == 'Linux': + # Linux: use timeout command + timeout_cmd = 'timeout' + cmd = [timeout_cmd, str(timeout)] + cmd + result = subprocess.run(cmd, capture_output=True, text=True) + elif system == 'Darwin': + # macOS: try gtimeout first, fallback to timeout handling + timeout_cmd = None + try: + subprocess.run(['gtimeout', '--version'], capture_output=True, timeout=1, check=False) + timeout_cmd = 'gtimeout' + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + if timeout_cmd: + cmd = [timeout_cmd, str(timeout)] + cmd + result = subprocess.run(cmd, capture_output=True, text=True) + else: + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired: + return {'error': f'LibreOffice recalculation timed out after {timeout} seconds'} + else: + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0 and result.returncode != 124: # 124 is timeout exit code + error_msg = result.stderr or 'Unknown error during recalculation' + if 'Module1' in error_msg or 'RecalculateAndSave' not in error_msg: + return {'error': 'LibreOffice macro not configured properly. Error: ' + error_msg} + else: + return {'error': error_msg} + + # Check for Excel errors in the recalculated file - scan ALL cells + try: + wb = load_workbook(filename, data_only=True) + + excel_errors = ['#VALUE!', '#DIV/0!', '#REF!', '#NAME?', '#NULL!', '#NUM!', '#N/A'] + error_details = {err: [] for err in excel_errors} + total_errors = 0 + + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + # Check ALL rows and columns - no limits + for row in ws.iter_rows(): + for cell in row: + if cell.value is not None and isinstance(cell.value, str): + for err in excel_errors: + if err in cell.value: + location = f"{sheet_name}!{cell.coordinate}" + error_details[err].append(location) + total_errors += 1 + break + + wb.close() + + # Build result summary + result = { + 'status': 'success' if total_errors == 0 else 'errors_found', + 'total_errors': total_errors, + 'error_summary': {} + } + + # Add non-empty error categories + for err_type, locations in error_details.items(): + if locations: + result['error_summary'][err_type] = { + 'count': len(locations), + 'locations': locations[:20] # Show up to 20 locations + } + + # Add formula count for context - also check ALL cells + wb_formulas = load_workbook(filename, data_only=False) + formula_count = 0 + for sheet_name in wb_formulas.sheetnames: + ws = wb_formulas[sheet_name] + for row in ws.iter_rows(): + for cell in row: + if cell.value and isinstance(cell.value, str) and cell.value.startswith('='): + formula_count += 1 + wb_formulas.close() + + result['total_formulas'] = formula_count + + return result + + except Exception as e: + return {'error': str(e)} + + +def get_project_root(): + """Get project root directory (ruoyi-ai-v3)""" + current = os.path.dirname(os.path.abspath(__file__)) + # Traverse up 8 levels from xlsx/recalc.py to project root + for _ in range(8): + current = os.path.dirname(current) + return current + + +def create_workbook(output_path, sheets_config): + """ + Create a new Excel workbook with specified sheets and data + + Args: + output_path: Path where the workbook will be saved + sheets_config: Dict with sheet configurations + Example: { + 'Sheet1': { + 'data': [ + ['Header1', 'Header2'], + ['Value1', '=B2*2'] + ], + 'formulas': False # Whether sheet contains formulas + } + } + + Returns: + dict with success/error status + """ + try: + from openpyxl import Workbook + from openpyxl.styles import Font, Alignment + + wb = Workbook() + wb.remove(wb.active) # Remove default sheet + + for sheet_name, config in sheets_config.items(): + ws = wb.create_sheet(sheet_name) + data = config.get('data', []) + + for row_idx, row_data in enumerate(data, 1): + for col_idx, cell_value in enumerate(row_data, 1): + cell = ws.cell(row=row_idx, column=col_idx, value=cell_value) + # Make header row bold + if row_idx == 1: + cell.font = Font(bold=True) + + wb.save(output_path) + return {'status': 'success', 'message': f'Workbook created at {output_path}'} + + except Exception as e: + return {'status': 'error', 'message': str(e)} + + +def main(): + if len(sys.argv) < 2: + print("Usage:") + print(" python recalc.py [timeout_seconds] # Recalculate formulas") + print(" python recalc.py --create # Create new workbook") + print("\nRecalculate formulas:") + print(" Recalculates all formulas in an Excel file using LibreOffice") + print(" Returns JSON with error details") + print("\nCreate workbook:") + print(" data_json format: '{\"Sheet1\": {\"data\": [[\"A\", \"B\"], [1, 2]]}}'") + sys.exit(1) + + if sys.argv[1] == '--create': + if len(sys.argv) < 4: + print("Error: --create requires output_file and data_json") + sys.exit(1) + output_file = sys.argv[2] + # If relative path, place in project root + if not os.path.isabs(output_file): + project_root = get_project_root() + output_file = os.path.join(project_root, output_file) + data_json = sys.argv[3] + try: + sheets_config = json.loads(data_json) + result = create_workbook(output_file, sheets_config) + print(json.dumps(result, indent=2)) + except json.JSONDecodeError as e: + print(json.dumps({'status': 'error', 'message': f'Invalid JSON: {str(e)}'}, indent=2)) + else: + filename = sys.argv[1] + timeout = int(sys.argv[2]) if len(sys.argv) > 2 else 30 + result = recalc(filename, timeout) + print(json.dumps(result, indent=2)) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/dto/request/AgentChatRequest.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/dto/request/AgentChatRequest.java new file mode 100644 index 00000000..1db39338 --- /dev/null +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/dto/request/AgentChatRequest.java @@ -0,0 +1,26 @@ +package org.ruoyi.common.chat.domain.dto.request; + +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +/** + * Agent 对话请求对象(简化版) + * + * @author ageerle@163.com + * @date 2025/04/10 + */ +@Data +public class AgentChatRequest { + + /** + * 对话消息 + */ + @NotEmpty(message = "对话消息不能为空") + private String content; + + /** + * 会话id(可选,不传则不保存历史) + */ + private Long sessionId; + +} diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/dto/request/ChatRequest.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/dto/request/ChatRequest.java index f14fb086..23e42375 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/dto/request/ChatRequest.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/dto/request/ChatRequest.java @@ -3,8 +3,13 @@ package org.ruoyi.common.chat.domain.dto.request; import com.alibaba.fastjson.annotation.JSONField; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import dev.langchain4j.data.message.ChatMessage; import jakarta.validation.constraints.NotEmpty; import lombok.Data; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.List; /** @@ -62,7 +67,6 @@ public class ChatRequest { */ private String appId; - /** * 对话id(每个聊天窗口都不一样) */ @@ -76,8 +80,28 @@ public class ChatRequest { private Boolean enableThinking = false; /** - * 是否支持联网 + * 对话模型详情 */ - private Boolean enableInternet; + private ChatModelVo chatModelVo; + + /** + * 对话事件 + */ + private SseEmitter emitter; + + /** + * 当前登录用户id + */ + private Long userId; + + /** + * 当前登录用户TOKEN + */ + private String tokenValue; + + /** + * 完整的上下文 + */ + private List contextMessages; } diff --git a/ruoyi-common/ruoyi-common-security/src/main/java/org/ruoyi/common/security/config/SecurityConfig.java b/ruoyi-common/ruoyi-common-security/src/main/java/org/ruoyi/common/security/config/SecurityConfig.java index c068cd11..e358ced6 100644 --- a/ruoyi-common/ruoyi-common-security/src/main/java/org/ruoyi/common/security/config/SecurityConfig.java +++ b/ruoyi-common/ruoyi-common-security/src/main/java/org/ruoyi/common/security/config/SecurityConfig.java @@ -1,5 +1,6 @@ package org.ruoyi.common.security.config; +import cn.dev33.satoken.context.SaTokenContextForThreadLocalStaff; import cn.dev33.satoken.exception.NotLoginException; import cn.dev33.satoken.filter.SaServletFilter; import cn.dev33.satoken.httpauth.basic.SaHttpBasicUtil; @@ -49,6 +50,11 @@ public class SecurityConfig implements WebMvcConfigurer { public void addInterceptors(InterceptorRegistry registry) { // 注册路由拦截器,自定义验证规则 registry.addInterceptor(new SaInterceptor(handler -> { + // 异步线程中 SaToken 上下文不存在,跳过检查 + // 这避免了 SSE 流式响应完成后 emitter.complete() 触发的问题 + if (SaTokenContextForThreadLocalStaff.getModelBoxOrNull() == null) { + return; + } AllUrlHandler allUrlHandler = SpringUtils.getBean(AllUrlHandler.class); // 登录验证 -- 排除多个路径 SaRouter diff --git a/ruoyi-common/ruoyi-common-sse/src/main/java/org/ruoyi/common/sse/core/SseEmitterManager.java b/ruoyi-common/ruoyi-common-sse/src/main/java/org/ruoyi/common/sse/core/SseEmitterManager.java index 53086b20..fe4835db 100644 --- a/ruoyi-common/ruoyi-common-sse/src/main/java/org/ruoyi/common/sse/core/SseEmitterManager.java +++ b/ruoyi-common/ruoyi-common-sse/src/main/java/org/ruoyi/common/sse/core/SseEmitterManager.java @@ -202,12 +202,14 @@ public class SseEmitterManager { public void sendEvent(Long userId, SseEventDto eventDto) { Map emitters = USER_TOKEN_EMITTERS.get(userId); if (MapUtil.isNotEmpty(emitters)) { + log.debug("【SSE发送】userId: {}, emitter数量: {}, event: {}", userId, emitters.size(), eventDto.getEvent()); for (Map.Entry entry : emitters.entrySet()) { try { entry.getValue().send(SseEmitter.event() .name(eventDto.getEvent()) .data(JSONUtil.toJsonStr(eventDto))); } catch (Exception e) { + log.error("【SSE发送失败】userId: {}, token: {}, error: {}", userId, entry.getKey(), e.getMessage()); SseEmitter remove = emitters.remove(entry.getKey()); if (remove != null) { remove.complete(); @@ -215,6 +217,7 @@ public class SseEmitterManager { } } } else { + log.warn("【SSE发送失败】userId: {} 没有活跃的SSE连接, 当前连接用户: {}", userId, USER_TOKEN_EMITTERS.keySet()); USER_TOKEN_EMITTERS.remove(userId); } } diff --git a/ruoyi-common/ruoyi-common-sse/src/main/java/org/ruoyi/common/sse/dto/SseEventDto.java b/ruoyi-common/ruoyi-common-sse/src/main/java/org/ruoyi/common/sse/dto/SseEventDto.java index 68081926..f42f7232 100644 --- a/ruoyi-common/ruoyi-common-sse/src/main/java/org/ruoyi/common/sse/dto/SseEventDto.java +++ b/ruoyi-common/ruoyi-common-sse/src/main/java/org/ruoyi/common/sse/dto/SseEventDto.java @@ -89,4 +89,21 @@ public class SseEventDto implements Serializable { .error(error) .build(); } + + /** + * 创建 MCP 工具事件 + */ + public static SseEventDto mcpTool(String toolName, String status, String result) { + return SseEventDto.builder() + .event("mcp_tool") + .content(buildMcpJson(toolName, status, result)) + .build(); + } + + private static String buildMcpJson(String toolName, String status, String result) { + return String.format("{\"toolName\":\"%s\",\"status\":\"%s\",\"result\":\"%s\"}", + toolName != null ? toolName.replace("\"", "\\\"") : "", + status != null ? status : "", + result != null ? result.replace("\"", "\\\"").replace("\n", "\\n") : ""); + } } \ No newline at end of file diff --git a/ruoyi-common/ruoyi-common-web/src/main/java/org/ruoyi/common/web/aspectj/DemoAspect.java b/ruoyi-common/ruoyi-common-web/src/main/java/org/ruoyi/common/web/aspectj/DemoAspect.java new file mode 100644 index 00000000..a1c5afa9 --- /dev/null +++ b/ruoyi-common/ruoyi-common-web/src/main/java/org/ruoyi/common/web/aspectj/DemoAspect.java @@ -0,0 +1,86 @@ +package org.ruoyi.common.web.aspectj; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.ruoyi.common.core.exception.ServiceException; +import org.ruoyi.common.core.utils.ServletUtils; +import org.ruoyi.common.web.config.properties.DemoProperties; + +import java.util.Set; + +/** + * 演示模式切面 - 拦截写操作 + * + * @author ruoyi + */ +@Slf4j +@Aspect +@RequiredArgsConstructor +public class DemoAspect { + + private final DemoProperties demoProperties; + + /** + * 需要拦截的 HTTP 方法 + */ + private static final Set WRITE_METHODS = Set.of( + "POST", "PUT", "DELETE", "PATCH" + ); + + /** + * 拦截所有 Controller 的写操作 + */ + @Around("execution(* org.ruoyi..controller..*.*(..))") + public Object around(ProceedingJoinPoint point) throws Throwable { + // 未开启演示模式,直接放行 + if (!Boolean.TRUE.equals(demoProperties.getEnabled())) { + return point.proceed(); + } + + HttpServletRequest request = ServletUtils.getRequest(); + if (request == null) { + return point.proceed(); + } + + String method = request.getMethod(); + String requestUri = request.getRequestURI(); + + // 非写操作,放行 + if (!WRITE_METHODS.contains(method.toUpperCase())) { + return point.proceed(); + } + + // 检查排除路径 + for (String exclude : demoProperties.getExcludes()) { + if (match(exclude, requestUri)) { + return point.proceed(); + } + } + + log.info("演示模式拦截写操作: {} {}", method, requestUri); + throw new ServiceException(demoProperties.getMessage()); + } + + /** + * 路径匹配(支持通配符 * 和 **) + */ + private boolean match(String pattern, String path) { + if (pattern.endsWith("/**")) { + String prefix = pattern.substring(0, pattern.length() - 3); + return path.startsWith(prefix); + } + if (pattern.endsWith("/*")) { + String prefix = pattern.substring(0, pattern.length() - 2); + if (!path.startsWith(prefix)) { + return false; + } + String suffix = path.substring(prefix.length()); + return suffix.indexOf('/') == -1; + } + return path.equals(pattern); + } +} \ No newline at end of file diff --git a/ruoyi-common/ruoyi-common-web/src/main/java/org/ruoyi/common/web/config/DemoAutoConfiguration.java b/ruoyi-common/ruoyi-common-web/src/main/java/org/ruoyi/common/web/config/DemoAutoConfiguration.java new file mode 100644 index 00000000..a67a7a81 --- /dev/null +++ b/ruoyi-common/ruoyi-common-web/src/main/java/org/ruoyi/common/web/config/DemoAutoConfiguration.java @@ -0,0 +1,25 @@ +package org.ruoyi.common.web.config; + +import org.ruoyi.common.web.aspectj.DemoAspect; +import org.ruoyi.common.web.config.properties.DemoProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 演示模式自动配置 + * + * @author ruoyi + */ +@Configuration +@EnableConfigurationProperties(DemoProperties.class) +public class DemoAutoConfiguration { + + /** + * 注册演示模式切面 + */ + @Bean + public DemoAspect demoAspect(DemoProperties demoProperties) { + return new DemoAspect(demoProperties); + } +} \ No newline at end of file diff --git a/ruoyi-common/ruoyi-common-web/src/main/java/org/ruoyi/common/web/config/properties/DemoProperties.java b/ruoyi-common/ruoyi-common-web/src/main/java/org/ruoyi/common/web/config/properties/DemoProperties.java new file mode 100644 index 00000000..f04fc665 --- /dev/null +++ b/ruoyi-common/ruoyi-common-web/src/main/java/org/ruoyi/common/web/config/properties/DemoProperties.java @@ -0,0 +1,32 @@ +package org.ruoyi.common.web.config.properties; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.ArrayList; +import java.util.List; + +/** + * 演示模式 配置属性 + * + * @author ruoyi + */ +@Data +@ConfigurationProperties(prefix = "demo") +public class DemoProperties { + + /** + * 是否开启演示模式 + */ + private Boolean enabled = false; + + /** + * 提示消息 + */ + private String message = "演示模式,不允许进行写操作"; + + /** + * 排除的路径(这些路径不受演示模式限制) + */ + private List excludes = new ArrayList<>(); +} \ No newline at end of file diff --git a/ruoyi-common/ruoyi-common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ruoyi-common/ruoyi-common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index e1eb5138..9a3a59f9 100644 --- a/ruoyi-common/ruoyi-common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/ruoyi-common/ruoyi-common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,4 +1,5 @@ org.ruoyi.common.web.config.CaptchaConfig +org.ruoyi.common.web.config.DemoAutoConfiguration org.ruoyi.common.web.config.FilterConfig org.ruoyi.common.web.config.I18nConfig org.ruoyi.common.web.config.ResourcesConfig diff --git a/ruoyi-modules/ruoyi-chat/pom.xml b/ruoyi-modules/ruoyi-chat/pom.xml index 6cb40321..43ea2b1e 100644 --- a/ruoyi-modules/ruoyi-chat/pom.xml +++ b/ruoyi-modules/ruoyi-chat/pom.xml @@ -103,6 +103,21 @@ ${langchain4j.community.version} + + + dev.langchain4j + langchain4j-skills + ${langchain4j.community.version} + + + + + dev.langchain4j + langchain4j-experimental-skills-shell + ${langchain4j.community.version} + + + org.testcontainers weaviate @@ -152,6 +167,13 @@ mysql-connector-j + + + org.springframework.boot + spring-boot-starter-test + test + + diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/SkillsAgent.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/SkillsAgent.java new file mode 100644 index 00000000..d0831441 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/SkillsAgent.java @@ -0,0 +1,35 @@ +package org.ruoyi.agent; + +import dev.langchain4j.agentic.Agent; +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; + +/** + * 技能管理 Agent + * 管理 docx、pdf、xlsx 等文档处理技能 + * + *

可用技能: + *

    + *
  • docx - Word 文档创建、编辑和分析
  • + *
  • pdf - PDF 文档处理、提取文本和表格
  • + *
  • xlsx - Excel 电子表格创建、编辑和分析
  • + *
+ * + * @author ageerle@163.com + * @date 2026/04/10 + */ +public interface SkillsAgent { + + @SystemMessage(""" + 你是一个文档处理技能助手,能够使用 activate_skill 工具激活特定技能来处理各种文档任务。 + 使用指南: + 1. 根据用户请求判断需要哪个技能 + 2. 使用 activate_skill("skill-name") 激活对应技能 + 3. 按照技能指令执行任务 + 4. 如果需要参考文件,使用 read_skill_resource 读取 + """) + @UserMessage("{{query}}") + @Agent("文档处理技能助手,支持 Word、PDF、Excel 文档的创建、编辑和分析") + String process(@V("query") String query); +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/WebSearchAgent.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/WebSearchAgent.java index 33f8737e..15a6ec52 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/WebSearchAgent.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/WebSearchAgent.java @@ -6,33 +6,26 @@ import dev.langchain4j.service.UserMessage; import dev.langchain4j.service.V; /** - * Web Search Agent - * A web search assistant that answers natural language questions by searching the internet - * and returning relevant information from web pages. + * 浏览器工具 Agent + * 能够操作浏览器相关工具:网络搜索、网页抓取、浏览器自动化等 + * + * @author ageerle@163.com + * @date 2025/04/10 */ public interface WebSearchAgent { @SystemMessage(""" - You are a web search assistant. Answer questions by searching and retrieving web content. + 你是一个系统工具助手,能够使用工具来帮助用户获取信息和操作浏览器。 - Available tools: - 1. bing_search: Search the internet with keywords - - query (required): search keywords - - count (optional): number of results, default 10, max 50 - - offset (optional): pagination offset, default 0 - Returns: title, link, and summary for each result - - 2. crawl_webpage: Extract text content from a web page - - url (required): web page URL - Returns: cleaned page title and main content - - Instructions: - - Always cite sources in your answers - - Only use the two tools listed above + 【最重要原则】 + 除非用户明确要求使用浏览器查询信息,否则不要主动调用任何搜索或浏览器工具。 + 使用指南: + - 搜索信息时使用 bing_search + - 需要详细网页内容时使用 crawl_webpage + - 需要交互操作(登录、点击、填写表单)时使用 Playwright 工具 + - 在回答中注明信息来源 """) - @UserMessage(""" - Answer the following question by searching the web: {{query}} - """) - @Agent("Web search assistant using Bing search and web scraping to find and retrieve information") + @UserMessage("{{query}}") + @Agent("浏览器工具助手,支持网络搜索、网页抓取和浏览器自动化操作") String search(@V("query") String query); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatController.java index d265206f..5a57c0e0 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatController.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatController.java @@ -4,6 +4,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.chat.domain.dto.request.AgentChatRequest; import org.ruoyi.common.chat.domain.dto.request.ChatRequest; import org.ruoyi.service.chat.impl.ChatServiceFacade; import org.springframework.stereotype.Controller; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAgentListener.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAgentListener.java index 299ea6e9..42c1720b 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAgentListener.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyAgentListener.java @@ -125,21 +125,21 @@ public class MyAgentListener implements dev.langchain4j.agentic.observability.Ag } // ==================== 工具执行生命周期 ==================== - - @Override - public void beforeToolExecution(BeforeToolExecution beforeToolExecution) { - var toolRequest = beforeToolExecution.request(); - log.info("【工具执行前】工具请求ID: {}", toolRequest.id()); - log.info("【工具执行前】工具名称: {}", toolRequest.name()); - log.info("【工具执行前】工具参数: {}", toolRequest.arguments()); - } - - @Override - public void afterToolExecution(ToolExecution toolExecution) { - var toolRequest = toolExecution.request(); - log.info("【工具执行后】工具请求ID: {}", toolRequest.id()); - log.info("【工具执行后】工具名称: {}", toolRequest.name()); - log.info("【工具执行后】工具执行结果: {}", toolExecution.result()); - log.info("【工具执行后】工具执行是否失败: {}", toolExecution.hasFailed()); - } +// +// @Override +// public void beforeToolExecution(BeforeToolExecution beforeToolExecution) { +// var toolRequest = beforeToolExecution.request(); +// log.info("【工具执行前】工具请求ID: {}", toolRequest.id()); +// log.info("【工具执行前】工具名称: {}", toolRequest.name()); +// log.info("【工具执行前】工具参数: {}", toolRequest.arguments()); +// } +// +// @Override +// public void afterToolExecution(ToolExecution toolExecution) { +// var toolRequest = toolExecution.request(); +// log.info("【工具执行后】工具请求ID: {}", toolRequest.id()); +// log.info("【工具执行后】工具名称: {}", toolRequest.name()); +// log.info("【工具执行后】工具执行结果: {}", toolExecution.result()); +// log.info("【工具执行后】工具执行是否失败: {}", toolExecution.hasFailed()); +// } } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyMcpClientListener.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyMcpClientListener.java index fe19d728..c1457100 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyMcpClientListener.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/MyMcpClientListener.java @@ -1,23 +1,37 @@ package org.ruoyi.observability; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import dev.langchain4j.invocation.InvocationContext; import dev.langchain4j.mcp.client.McpCallContext; import dev.langchain4j.mcp.client.McpClientListener; import dev.langchain4j.mcp.client.McpGetPromptResult; import dev.langchain4j.mcp.client.McpReadResourceResult; -import dev.langchain4j.mcp.protocol.McpClientMessage; +import dev.langchain4j.mcp.protocol.*; import dev.langchain4j.service.tool.ToolExecutionResult; import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.sse.dto.SseEventDto; +import org.ruoyi.common.sse.utils.SseMessageUtils; +import java.util.HashMap; import java.util.Map; /** - * 自定义的 McpClientListener 的监听器。 - * 监听 MCP 客户端相关的所有可观测性事件,包括: + * MCP 客户端监听器 + *

+ * 监听 MCP 工具执行事件,并通过 SSE 推送到前端 + *

+ * SSE 推送格式: + *

+ * {
+ *   "event": "mcp",
+ *   "content": "{\"name\":\"工具名称\",\"status\":\"pending|success|error\",\"result\":\"执行结果\"}"
+ * }
+ * 
+ * 前端区分方式: *
    - *
  • MCP 工具执行的开始/成功/错误事件
  • - *
  • MCP 资源读取的开始/成功/错误事件
  • - *
  • MCP 提示词获取的开始/成功/错误事件
  • + *
  • 对话内容:event="content"
  • + *
  • MCP 事件:event="mcp"
  • *
* * @author evo @@ -25,112 +39,135 @@ import java.util.Map; @Slf4j public class MyMcpClientListener implements McpClientListener { - // ==================== 工具执行 ==================== + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private final Long userId; + + public MyMcpClientListener(Long userId) { + this.userId = userId; + } + + public MyMcpClientListener() { + this.userId = null; + } + + // ==================== 工具执行 ==================== @Override public void beforeExecuteTool(McpCallContext context) { - InvocationContext invocationContext = context.invocationContext(); - McpClientMessage message = context.message(); + McpClientRequest message = (McpClientRequest) context.message(); + McpClientParams params = message.getParams(); + if (params instanceof McpCallToolParams callToolParams) { + String name = callToolParams.getName(); + log.info("工具调用之前:{}",name); + pushMcpEvent(name, "pending", null); + } - log.info("【MCP工具执行前】调用唯一标识符: {}", invocationContext.invocationId()); - log.info("【MCP工具执行前】MCP消息ID: {}", message.getId()); - log.info("【MCP工具执行前】MCP方法: {}", message.method); } @Override public void afterExecuteTool(McpCallContext context, ToolExecutionResult result, Map rawResult) { - InvocationContext invocationContext = context.invocationContext(); - McpClientMessage message = context.message(); - - log.info("【MCP工具执行后】调用唯一标识符: {}", invocationContext.invocationId()); - log.info("【MCP工具执行后】MCP消息ID: {}", message.getId()); - log.info("【MCP工具执行后】MCP方法: {}", message.method); - log.info("【MCP工具执行后】工具执行结果: {}", result); - log.info("【MCP工具执行后】原始结果: {}", rawResult); + McpClientRequest message = (McpClientRequest) context.message(); + McpClientParams params = message.getParams(); + if (params instanceof McpCallToolParams callToolParams) { + String name = callToolParams.getName(); + String resultText = result != null ? result.toString() : ""; + log.info("工具调用之后:{},返回结果{}",name,result); + pushMcpEvent(name, "success", truncate(resultText, 500)); + } } @Override public void onExecuteToolError(McpCallContext context, Throwable error) { - InvocationContext invocationContext = context.invocationContext(); - McpClientMessage message = context.message(); - - log.error("【MCP工具执行错误】调用唯一标识符: {}", invocationContext.invocationId()); - log.error("【MCP工具执行错误】MCP消息ID: {}", message.getId()); - log.error("【MCP工具执行错误】MCP方法: {}", message.method); - log.error("【MCP工具执行错误】错误类型: {}", error.getClass().getName()); - log.error("【MCP工具执行错误】错误信息: {}", error.getMessage(), error); + String toolName = getMethodName(context); + log.error("【MCP工具执行错误】工具: {}, 错误: {}", toolName, error.getMessage()); + pushMcpEvent(toolName, "error", error.getMessage()); } // ==================== 资源读取 ==================== @Override public void beforeResourceGet(McpCallContext context) { - InvocationContext invocationContext = context.invocationContext(); - McpClientMessage message = context.message(); - - log.info("【MCP资源读取前】调用唯一标识符: {}", invocationContext.invocationId()); - log.info("【MCP资源读取前】MCP消息ID: {}", message.getId()); - log.info("【MCP资源读取前】MCP方法: {}", message.method); + String name = getMethodName(context); + log.info("【MCP资源读取前】资源: {}", name); + pushMcpEvent(name, "pending", null); } @Override public void afterResourceGet(McpCallContext context, McpReadResourceResult result, Map rawResult) { - InvocationContext invocationContext = context.invocationContext(); - McpClientMessage message = context.message(); - - log.info("【MCP资源读取后】调用唯一标识符: {}", invocationContext.invocationId()); - log.info("【MCP资源读取后】MCP消息ID: {}", message.getId()); - log.info("【MCP资源读取后】MCP方法: {}", message.method); - log.info("【MCP资源读取后】资源内容数量: {}", result.contents() != null ? result.contents().size() : 0); - log.info("【MCP资源读取后】原始结果: {}", rawResult); + String name = getMethodName(context); + int count = result.contents() != null ? result.contents().size() : 0; + log.info("【MCP资源读取后】资源: {}, 数量: {}", name, count); + pushMcpEvent(name, "success", "读取 " + count + " 条资源"); } @Override public void onResourceGetError(McpCallContext context, Throwable error) { - InvocationContext invocationContext = context.invocationContext(); - McpClientMessage message = context.message(); - - log.error("【MCP资源读取错误】调用唯一标识符: {}", invocationContext.invocationId()); - log.error("【MCP资源读取错误】MCP消息ID: {}", message.getId()); - log.error("【MCP资源读取错误】MCP方法: {}", message.method); - log.error("【MCP资源读取错误】错误类型: {}", error.getClass().getName()); - log.error("【MCP资源读取错误】错误信息: {}", error.getMessage(), error); + String name = getMethodName(context); + log.error("【MCP资源读取错误】资源: {}, 错误: {}", name, error.getMessage()); + pushMcpEvent(name, "error", error.getMessage()); } // ==================== 提示词获取 ==================== @Override public void beforePromptGet(McpCallContext context) { - InvocationContext invocationContext = context.invocationContext(); - McpClientMessage message = context.message(); - - log.info("【MCP提示词获取前】调用唯一标识符: {}", invocationContext.invocationId()); - log.info("【MCP提示词获取前】MCP消息ID: {}", message.getId()); - log.info("【MCP提示词获取前】MCP方法: {}", message.method); + String name = getMethodName(context); + log.info("【MCP提示词获取前】提示词: {}", name); + pushMcpEvent(name, "pending", null); } @Override public void afterPromptGet(McpCallContext context, McpGetPromptResult result, Map rawResult) { - InvocationContext invocationContext = context.invocationContext(); - McpClientMessage message = context.message(); - - log.info("【MCP提示词获取后】调用唯一标识符: {}", invocationContext.invocationId()); - log.info("【MCP提示词获取后】MCP消息ID: {}", message.getId()); - log.info("【MCP提示词获取后】MCP方法: {}", message.method); - log.info("【MCP提示词获取后】提示词描述: {}", result.description()); - log.info("【MCP提示词获取后】提示词消息数量: {}", result.messages() != null ? result.messages().size() : 0); - log.info("【MCP提示词获取后】原始结果: {}", rawResult); + String name = getMethodName(context); + int count = result.messages() != null ? result.messages().size() : 0; + log.info("【MCP提示词获取后】提示词: {}, 消息数: {}", name, count); + pushMcpEvent(name, "success", "获取 " + count + " 条消息"); } @Override public void onPromptGetError(McpCallContext context, Throwable error) { - InvocationContext invocationContext = context.invocationContext(); - McpClientMessage message = context.message(); + String name = getMethodName(context); + log.error("【MCP提示词获取错误】提示词: {}, 错误: {}", name, error.getMessage()); + pushMcpEvent(name, "error", error.getMessage()); + } - log.error("【MCP提示词获取错误】调用唯一标识符: {}", invocationContext.invocationId()); - log.error("【MCP提示词获取错误】MCP消息ID: {}", message.getId()); - log.error("【MCP提示词获取错误】MCP方法: {}", message.method); - log.error("【MCP提示词获取错误】错误类型: {}", error.getClass().getName()); - log.error("【MCP提示词获取错误】错误信息: {}", error.getMessage(), error); + // ==================== 辅助方法 ==================== + + private String getMethodName(McpCallContext context) { + try { + McpClientMessage message = context.message(); + return message.method != null ? message.method.toString() : "unknown"; + } catch (Exception e) { + return "unknown"; + } + } + + /** + * 推送 MCP 事件到前端 + */ + private void pushMcpEvent(String name, String status, String result) { + if (userId == null) { + log.warn("userId 为空,无法推送 MCP 事件"); + return; + } + try { + Map content = new HashMap<>(); + content.put("name", name); + content.put("status", status); + content.put("result", result); + + String json = OBJECT_MAPPER.writeValueAsString(content); + SseMessageUtils.sendEvent(userId, SseEventDto.builder() + .event("mcp") + .content(json) + .build()); + } catch (JsonProcessingException e) { + log.error("序列化 MCP 事件失败: {}", e.getMessage()); + } + } + + private String truncate(String str, int maxLen) { + if (str == null) return null; + return str.length() > maxLen ? str.substring(0, maxLen) + "..." : str; } } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/OutputChannel.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/OutputChannel.java new file mode 100644 index 00000000..bddd8257 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/OutputChannel.java @@ -0,0 +1,128 @@ +package org.ruoyi.observability; + +import java.util.Map; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +/** + * 跨线程事件总线 + * + * 写入端(异步线程):StreamingOutputWrapper / SupervisorStreamListener + * 读取端(SSE 线程):ChatServiceFacade.drain + * + * 调用链路: + * SSE请求 -> 创建 OutputChannel + * -> Supervisor.invoke() [同步阻塞调用子Agent] + * ├── SupervisorStreamListener -> channel.send() + * └── searchAgent.search() + * └── StreamingOutputWrapper -> channel.send() [每个token] + * -> channel.complete() + * drain线程 -> channel.drain() -> SSE实时推送 + * + * @author ageerle@163.com + * @date 2025/04/10 + */ +public class OutputChannel { + + private static final String DONE = "__DONE__"; + private static final Map REGISTRY = new ConcurrentHashMap<>(); + + private final BlockingQueue queue = new LinkedBlockingQueue<>(4096); + private final AtomicReference error = new AtomicReference<>(); + private final CountDownLatch completed = new CountDownLatch(1); + + /** + * 创建并注册到全局注册表 + */ + public static OutputChannel create(String requestId) { + OutputChannel ch = new OutputChannel(); + REGISTRY.put(requestId, ch); + return ch; + } + + /** + * 从全局注册表移除 + */ + public static void remove(String requestId) { + REGISTRY.remove(requestId); + } + + /** + * 从全局注册表获取 + */ + public static OutputChannel get(String requestId) { + return REGISTRY.get(requestId); + } + + /** + * 写入:线程安全,非阻塞,队列满时丢弃 + */ +public void send(String text) { + if (text == null || text.isEmpty()) { + return; + } + try { + if (!queue.offer(text, 100, TimeUnit.MILLISECONDS)) { + System.err.println("[OutputChannel] 队列满,丢弃消息: " + truncate(text, 100)); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + /** + * 标记完成 + */ + public void complete() { + queue.offer(DONE); + completed.countDown(); + } + + /** + * 标记错误完成 + */ + public void completeWithError(Throwable t) { + error.set(t); + queue.offer("\n[错误] 致命错误: " + t.getMessage()); + queue.offer(DONE); + completed.countDown(); + } + + /** + * 读取:阻塞迭代,配合 SSE 使用 + */ + public void drain(Consumer emitter) throws InterruptedException { + while (true) { + String msg = queue.poll(200, TimeUnit.MILLISECONDS); + if (msg != null) { + if (DONE.equals(msg)) { + break; + } + emitter.accept(msg); + } else { + if (completed.getCount() == 0 && queue.isEmpty()) { + break; + } + } + } + Throwable t = error.get(); + if (t != null && !(t instanceof InterruptedException)) { + throw new RuntimeException("Agent 执行出错", t); + } + } + + /** + * 检查是否已完成 + */ + public boolean isCompleted() { + return completed.getCount() == 0; + } + + private String truncate(String s, int maxLen) { + if (s == null) { + return "null"; + } + return s.length() > maxLen ? s.substring(0, maxLen) + "..." : s; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/StreamingOutputWrapper.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/StreamingOutputWrapper.java new file mode 100644 index 00000000..22965c23 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/StreamingOutputWrapper.java @@ -0,0 +1,213 @@ +package org.ruoyi.observability; + +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.chat.listener.ChatModelListener; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.request.ChatRequestParameters; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.chat.response.PartialThinking; +import dev.langchain4j.model.chat.response.PartialThinkingContext; +import dev.langchain4j.model.chat.response.PartialToolCall; +import dev.langchain4j.model.chat.response.PartialToolCallContext; +import dev.langchain4j.model.chat.response.PartialResponse; +import dev.langchain4j.model.chat.response.PartialResponseContext; +import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; +import dev.langchain4j.model.chat.Capability; +import dev.langchain4j.model.ModelProvider; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; + +/** + * 包装 StreamingChatModel,同时实现 ChatModel 接口。 + * + * 当 AI Service 方法返回 String 时,LangChain4j 使用 ChatModel.chat() + * 当返回 TokenStream 时,使用 StreamingChatModel.chat() + * + * 此包装器同时实现两个接口,将同步调用转换为流式调用并收集结果, + * 同时拦截每个 token 推送到 OutputChannel。 + * + * @author ageerle@163.com + * @date 2025/04/10 + */ +@Slf4j +public class StreamingOutputWrapper implements StreamingChatModel, ChatModel { + + private final StreamingChatModel streamingDelegate; + private final OutputChannel channel; + + /** + * 包装 StreamingChatModel + */ + public StreamingOutputWrapper(StreamingChatModel delegate, OutputChannel channel) { + this.streamingDelegate = delegate; + this.channel = channel; + } + + // ==================== 解决接口默认方法冲突 ==================== + + @Override + public Set supportedCapabilities() { + return streamingDelegate.supportedCapabilities(); + } + + // ==================== ChatModel 接口实现(同步调用) ==================== + + @Override + public ChatResponse chat(ChatRequest request) { + log.info("【StreamingOutputWrapper】chat() 被调用,开始流式处理"); + // 用于收集完整响应 + AtomicReference responseRef = new AtomicReference<>(); + CompletableFuture future = new CompletableFuture<>(); + + // 调用流式模型,拦截每个 token + streamingDelegate.chat(request, new StreamingChatResponseHandler() { + @Override + public void onPartialResponse(String token) { + // 推送到 channel + channel.send(token); + log.debug("【流式Token】{}", token); + } + + @Override + public void onPartialResponse(PartialResponse pr, PartialResponseContext ctx) { + channel.send(pr.text()); + log.debug("【流式PartialResponse】{}", pr.text()); + } + + @Override + public void onPartialThinking(PartialThinking thinking) { + channel.send("[思考] " + thinking.text()); + log.debug("【流式思考】{}", thinking.text()); + } + + @Override + public void onPartialThinking(PartialThinking thinking, PartialThinkingContext ctx) { + channel.send("[思考] " + thinking.text()); + } + + @Override + public void onPartialToolCall(PartialToolCall toolCall) { + // channel.send("[工具参数生成中] " + toolCall); + } + + @Override + public void onPartialToolCall(PartialToolCall toolCall, PartialToolCallContext ctx) { + // channel.send("[工具参数生成中] " + toolCall); + } + + @Override + public void onCompleteResponse(ChatResponse response) { + responseRef.set(response); + if (response.metadata() != null && response.metadata().tokenUsage() != null) { + var usage = response.metadata().tokenUsage(); +// channel.send("\n[Token统计] input=" + usage.inputTokenCount() +// + " output=" + usage.outputTokenCount()); + } + log.info("【StreamingOutputWrapper】流式处理完成"); + future.complete(null); + } + + @Override + public void onError(Throwable error) { + channel.send("\n[错误] " + error.getMessage()); + channel.completeWithError(error); + future.completeExceptionally(error); + log.error("【StreamingOutputWrapper】流式处理出错", error); + } + }); + + // 等待流式完成 + future.join(); + + // 返回收集的响应 + return responseRef.get(); + } + + // ==================== StreamingChatModel 接口实现(流式调用) ==================== + + @Override + public void chat(ChatRequest request, StreamingChatResponseHandler handler) { + StreamingChatResponseHandler wrapped = wrapHandler(handler); + streamingDelegate.chat(request, wrapped); + } + + private StreamingChatResponseHandler wrapHandler(StreamingChatResponseHandler original) { + return new StreamingChatResponseHandler() { + + @Override + public void onPartialResponse(String token) { + channel.send(token); + original.onPartialResponse(token); + } + + @Override + public void onPartialResponse(PartialResponse pr, PartialResponseContext ctx) { + channel.send(pr.text()); + original.onPartialResponse(pr, ctx); + } + + @Override + public void onPartialThinking(PartialThinking thinking) { + channel.send("[思考] " + thinking.text()); + original.onPartialThinking(thinking); + } + + @Override + public void onPartialThinking(PartialThinking thinking, PartialThinkingContext ctx) { + channel.send("[思考] " + thinking.text()); + original.onPartialThinking(thinking, ctx); + } + + @Override + public void onPartialToolCall(PartialToolCall toolCall) { + //channel.send("[工具参数生成中] " + toolCall); + original.onPartialToolCall(toolCall); + } + + @Override + public void onPartialToolCall(PartialToolCall toolCall, PartialToolCallContext ctx) { + //channel.send("[工具参数生成中] " + toolCall); + original.onPartialToolCall(toolCall, ctx); + } + + @Override + public void onCompleteResponse(ChatResponse response) { + if (response.metadata() != null && response.metadata().tokenUsage() != null) { + var usage = response.metadata().tokenUsage(); +// channel.send("\n[Token统计] input=" + usage.inputTokenCount() +// + " output=" + usage.outputTokenCount()); + } + original.onCompleteResponse(response); + } + + @Override + public void onError(Throwable error) { + channel.send("\n[错误] " + error.getMessage()); + channel.completeWithError(error); + original.onError(error); + } + }; + } + + // ==================== 共用接口方法 ==================== + + @Override + public ChatRequestParameters defaultRequestParameters() { + return streamingDelegate.defaultRequestParameters(); + } + + @Override + public List listeners() { + return streamingDelegate.listeners(); + } + + @Override + public ModelProvider provider() { + return streamingDelegate.provider(); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/SupervisorStreamListener.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/SupervisorStreamListener.java new file mode 100644 index 00000000..4f98e5cb --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/observability/SupervisorStreamListener.java @@ -0,0 +1,120 @@ +package org.ruoyi.observability; + +import dev.langchain4j.agentic.observability.AgentInvocationError; +import dev.langchain4j.agentic.observability.AgentRequest; +import dev.langchain4j.agentic.observability.AgentResponse; +import dev.langchain4j.agentic.planner.AgentInstance; +import dev.langchain4j.agentic.scope.AgenticScope; +import dev.langchain4j.service.tool.BeforeToolExecution; +import dev.langchain4j.service.tool.ToolExecution; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; + +/** + * Supervisor 流式监听器 + * + * 捕获 Agent 生命周期事件、工具执行前后事件,推送到 OutputChannel + * inheritedBySubagents() = true -> 注册在 Supervisor 上,自动继承到所有子 Agent + * + * @author ageerle@163.com + * @date 2025/04/10 + */ +@Slf4j +public class SupervisorStreamListener implements dev.langchain4j.agentic.observability.AgentListener { + + private final OutputChannel channel; + + /** + * 用于在 AgenticScope 中存储 userId 的 key + */ + public static final String USER_ID_KEY = "userId"; + + public SupervisorStreamListener(OutputChannel channel) { + this.channel = channel; + } + + // ==================== Agent 调用生命周期 ==================== + + @Override + public void beforeAgentInvocation(AgentRequest agentRequest) { + AgentInstance agent = agentRequest.agent(); + AgenticScope scope = agentRequest.agenticScope(); + Map inputs = agentRequest.inputs(); + // 只记录日志,不推送输入信息(避免干扰流式输出) + log.info("[Agent开始] {} 输入: {}", agent.name(), inputs); + } + + @Override + public void afterAgentInvocation(AgentResponse agentResponse) { + AgentInstance agent = agentResponse.agent(); + Map inputs = agentResponse.inputs(); + Object output = agentResponse.output(); + String outputStr = output != null ? output.toString() : ""; + + // 只记录日志,不推送输出信息 + // 流式输出由 StreamingOutputWrapper 处理 + // 当无子Agent被调用时,由 ChatServiceFacade 用 plannerModel 生成回复 + log.info("[Agent完成] {} 输出长度: {}", agent.name(), outputStr.length()); + } + + @Override + public void onAgentInvocationError(AgentInvocationError error) { + AgentInstance agent = error.agent(); + Map inputs = error.inputs(); + Throwable throwable = error.error(); + + channel.send("\n[Agent错误] " + agent.name() + + " 异常: " + throwable.getMessage()); + log.error("[Agent错误] {} 异常: {}", agent.name(), throwable.getMessage(), throwable); + } + + // ==================== AgenticScope 生命周期 ==================== + + @Override + public void afterAgenticScopeCreated(AgenticScope agenticScope) { + log.info("[AgenticScope创建] memoryId: {}", agenticScope.memoryId()); + } + + @Override + public void beforeAgenticScopeDestroyed(AgenticScope agenticScope) { + log.info("[AgenticScope销毁] memoryId: {}", agenticScope.memoryId()); + } + + // ==================== 工具执行生命周期 ==================== + +// @Override +// public void beforeToolExecution(BeforeToolExecution beforeToolExecution) { +// var toolRequest = beforeToolExecution.request(); +//// channel.send("\n[工具即将执行] " + toolRequest.name() +//// + " 参数: " + truncate(toolRequest.arguments(), 150)); +// log.info("[工具即将执行] {} 参数: {}", toolRequest.name(), toolRequest.arguments()); +// } + +// @Override +// public void afterToolExecution(ToolExecution toolExecution) { +// var toolRequest = toolExecution.request(); +//// channel.send("\n[工具执行完成] " + toolRequest.name() +//// + " 结果: " + truncate(String.valueOf(toolExecution.result()), 300)); +// log.info("[工具执行完成] {} 结果: {}", toolRequest.name(), toolExecution.result()); +// } + + // ==================== 继承机制 ==================== + + /** + * 返回 true,让此监听器自动继承给所有子 Agent + */ + @Override + public boolean inheritedBySubagents() { + return true; + } + + // ==================== 辅助方法 ==================== + + private String truncate(String s, int maxLen) { + if (s == null) { + return "null"; + } + return s.length() > maxLen ? s.substring(0, maxLen) + "..." : s; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java index 8e0f463a..108c3cba 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java @@ -1,7 +1,6 @@ package org.ruoyi.service.chat.impl; import cn.dev33.satoken.stp.StpUtil; -import cn.hutool.core.util.StrUtil; import dev.langchain4j.agentic.AgenticServices; import dev.langchain4j.agentic.supervisor.SupervisorAgent; import dev.langchain4j.agentic.supervisor.SupervisorResponseStrategy; @@ -14,15 +13,19 @@ import dev.langchain4j.mcp.client.McpClient; import dev.langchain4j.mcp.client.transport.McpTransport; import dev.langchain4j.mcp.client.transport.stdio.StdioMcpTransport; import dev.langchain4j.memory.chat.MessageWindowChatMemory; +import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.StreamingChatModel; import dev.langchain4j.model.chat.response.ChatResponse; import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.service.tool.ToolProvider; +import dev.langchain4j.skills.shell.ShellSkills; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.ruoyi.agent.ChartGenerationAgent; +import org.ruoyi.agent.EchartsAgent; +import org.ruoyi.agent.SkillsAgent; import org.ruoyi.agent.SqlAgent; import org.ruoyi.agent.WebSearchAgent; import org.ruoyi.agent.tool.ExecuteSqlQueryTool; @@ -38,6 +41,7 @@ import org.ruoyi.common.chat.service.chat.IChatModelService; import org.ruoyi.common.chat.service.chat.IChatService; import org.ruoyi.common.chat.service.workFlow.IWorkFlowStarterService; import org.ruoyi.common.core.utils.ObjectUtils; +import org.ruoyi.common.core.utils.StringUtils; import org.ruoyi.common.satoken.utils.LoginHelper; import org.ruoyi.common.sse.core.SseEmitterManager; import org.ruoyi.common.sse.utils.SseMessageUtils; @@ -45,9 +49,7 @@ import org.ruoyi.domain.bo.vector.QueryVectorBo; import org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo; import org.ruoyi.factory.ChatServiceFactory; import org.ruoyi.mcp.service.core.ToolProviderFactory; -import org.ruoyi.observability.MyAgentListener; -import org.ruoyi.observability.MyChatModelListener; -import org.ruoyi.observability.MyMcpClientListener; +import org.ruoyi.observability.*; import org.ruoyi.service.chat.AbstractChatService; import org.ruoyi.service.chat.IChatMessageService; import org.ruoyi.service.chat.impl.memory.PersistentChatMemoryStore; @@ -59,6 +61,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; /** @@ -124,10 +127,19 @@ public class ChatServiceFacade implements IChatService { // 2. 构建上下文消息列表 List contextMessages = buildContextMessages(chatRequest); + chatRequest.setEmitter(emitter); + chatRequest.setUserId(userId); + chatRequest.setTokenValue(tokenValue); + chatRequest.setChatModelVo(chatModelVo); + chatRequest.setContextMessages(contextMessages); + + // 保存用户消息 + chatMessageService.saveChatMessage(userId, chatRequest.getSessionId(), chatRequest.getContent(), RoleType.USER.getName(), chatRequest.getModel()); + // 3. 处理特殊聊天模式(工作流、人机交互恢复、思考模式) - SseEmitter specialResult = handleSpecialChatModes(chatRequest, contextMessages, chatModelVo, emitter, userId, tokenValue); - if (specialResult != null) { - return specialResult; + SseEmitter sseEmitter = handleSpecialChatModes(chatRequest); + if (sseEmitter != null) { + return sseEmitter; } // 4. 路由服务提供商 @@ -135,11 +147,8 @@ public class ChatServiceFacade implements IChatService { log.info("路由到服务提供商: {}, 模型: {}", providerCode, chatRequest.getModel()); AbstractChatService chatService = chatServiceFactory.getOriginalService(providerCode); - StreamingChatResponseHandler handler = createResponseHandler(userId, tokenValue,chatRequest); - // 保存用户消息 - chatMessageService.saveChatMessage(userId, chatRequest.getSessionId(), chatRequest.getContent(), RoleType.USER.getName(), chatRequest.getModel()); // 5. 发起对话 StreamingChatModel streamingChatModel = chatService.buildStreamingChatModel(chatModelVo, chatRequest); @@ -151,16 +160,9 @@ public class ChatServiceFacade implements IChatService { * 处理特殊聊天模式(工作流、人机交互恢复、思考模式) * * @param chatRequest 聊天请求 - * @param contextMessages 上下文消息列表(可能被修改) - * @param chatModelVo 聊天模型配置 - * @param emitter SSE发射器 - * @param userId 用户ID - * @param tokenValue 会话令牌 * @return 如果需要提前返回则返回SseEmitter,否则返回null */ - private SseEmitter handleSpecialChatModes(ChatRequest chatRequest, List contextMessages, - ChatModelVo chatModelVo, SseEmitter emitter, - Long userId, String tokenValue) { + private SseEmitter handleSpecialChatModes(ChatRequest chatRequest) { // 处理工作流对话 if (chatRequest.getEnableWorkFlow()) { log.info("处理工作流对话,会话: {}", chatRequest.getSessionId()); @@ -169,7 +171,6 @@ public class ChatServiceFacade implements IChatService { if (ObjectUtils.isEmpty(runner)) { log.warn("工作流参数为空"); } - return workFlowStarterService.streaming( ThreadContext.getCurrentUser(), runner.getUuid(), @@ -181,25 +182,22 @@ public class ChatServiceFacade implements IChatService { // 处理人机交互恢复 if (chatRequest.getIsResume()) { log.info("处理人机交互恢复"); - ReSumeRunner reSumeRunner = chatRequest.getReSumeRunner(); if (ObjectUtils.isEmpty(reSumeRunner)) { log.warn("人机交互恢复参数为空"); - return emitter; } - workFlowStarterService.resumeFlow( reSumeRunner.getRuntimeUuid(), reSumeRunner.getFeedbackContent(), - emitter + chatRequest.getEmitter() ); - return emitter; - } + return chatRequest.getEmitter(); + } // 处理思考模式 if (chatRequest.getEnableThinking()) { - handleThinkingMode(chatRequest, contextMessages, chatModelVo, userId); + return handleThinkingMode(chatRequest); } return null; @@ -209,80 +207,132 @@ public class ChatServiceFacade implements IChatService { * 处理思考模式 * * @param chatRequest 聊天请求 - * @param contextMessages 上下文消息列表 - * @param chatModelVo 聊天模型配置 - * @param userId 用户ID + */ - private void handleThinkingMode(ChatRequest chatRequest, List contextMessages, - ChatModelVo chatModelVo, Long userId) { - // 步骤1: 配置MCP传输层 - 连接到bing-cn-mcp服务器 - McpTransport transport = new StdioMcpTransport.Builder() + private SseEmitter handleThinkingMode(ChatRequest chatRequest) { + // 配置监督者模型 + OpenAiChatModel plannerModel = OpenAiChatModel.builder() + .baseUrl(chatRequest.getChatModelVo().getApiHost()) + .apiKey(chatRequest.getChatModelVo().getApiKey()) + .modelName(chatRequest.getChatModelVo().getModelName()) + .build(); + + // Bing 搜索 MCP 客户端 + McpTransport bingTransport = new StdioMcpTransport.Builder() .command(List.of("C:\\Program Files\\nodejs\\npx.cmd", "-y", "bing-cn-mcp")) .logEvents(true) .build(); - McpClient mcpClient = new DefaultMcpClient.Builder() - .transport(transport) - .listener(new MyMcpClientListener()) + Long userId = chatRequest.getUserId(); + McpClient bingMcpClient = new DefaultMcpClient.Builder() + .transport(bingTransport) + .listener(new MyMcpClientListener(userId)) .build(); - ToolProvider toolProvider = McpToolProvider.builder() - .mcpClients(List.of(mcpClient)) - .build(); - - // 配置echarts MCP - McpTransport transport1 = new StdioMcpTransport.Builder() - .command(List.of("C:\\Program Files\\nodejs\\npx.cmd", "-y", "mcp-echarts")) + // Playwright MCP 客户端 - 浏览器自动化工具 + McpTransport playwrightTransport = new StdioMcpTransport.Builder() + .command(List.of("C:\\Program Files\\nodejs\\npx.cmd", "-y", "@playwright/mcp@latest")) .logEvents(true) .build(); - McpClient mcpClient1 = new DefaultMcpClient.Builder() - .transport(transport1) - .listener(new MyMcpClientListener()) + McpClient playwrightMcpClient = new DefaultMcpClient.Builder() + .transport(playwrightTransport) + .listener(new MyMcpClientListener(userId)) .build(); - ToolProvider toolProvider1 = McpToolProvider.builder() - .mcpClients(List.of(mcpClient1)) + // Filesystem MCP 客户端 - 文件管理工具 + // 允许 AI 读取、写入、搜索文件(基于当前项目根目录) + String userDir = System.getProperty("user.dir"); + McpTransport filesystemTransport = new StdioMcpTransport.Builder() + .command(List.of("C:\\Program Files\\nodejs\\npx.cmd", "-y", + "@modelcontextprotocol/server-filesystem", userDir)) + .logEvents(true) + .build(); - // 配置模型 - OpenAiChatModel plannerModel = OpenAiChatModel.builder() - .baseUrl(chatModelVo.getApiHost()) - .apiKey(chatModelVo.getApiKey()) - .listeners(List.of(new MyChatModelListener())) - .modelName(chatModelVo.getModelName()) + McpClient filesystemMcpClient = new DefaultMcpClient.Builder() + .transport(filesystemTransport) + .listener(new MyMcpClientListener(userId)) .build(); - // 构建各Agent + // 合并三个 MCP 客户端的工具 + ToolProvider toolProvider = McpToolProvider.builder() + // bingMcpClient, + .mcpClients(List.of(playwrightMcpClient, filesystemMcpClient)) + .build(); + + // ========== LangChain4j Skills 基本用法 ========== + // 通过 SKILL.md 文件定义,LLM 按需通过 activate_skill 工具加载 + // 加载 Skills - 使用相对路径,基于项目根目录 + java.nio.file.Path skillsPath = java.nio.file.Path.of(userDir, "ruoyi-admin/src/main/resources/skills"); + List skillsList = dev.langchain4j.skills.FileSystemSkillLoader + .loadSkills(skillsPath) + ; + + ShellSkills skills = ShellSkills.from(skillsList); + + // 构建子 Agent + WebSearchAgent searchAgent = AgenticServices.agentBuilder(WebSearchAgent.class) + .chatModel(plannerModel) + .toolProvider(toolProvider) + .listener(new MyAgentListener()) + .build(); + + // 构建子 Agent 2: SkillsAgent - 负责文档处理技能(docx、pdf、xlsx) + // 独立管理 Skills 工具 + SkillsAgent skillsAgent = AgenticServices.agentBuilder(SkillsAgent.class) + .chatModel(plannerModel) + .systemMessage("You have access to the following skills:\n" + skills.formatAvailableSkills() + + "\nWhen the user's request relates to one of these skills, activate it first using the `activate_skill` tool before proceeding.") + .toolProvider(skills.toolProvider()) + .build(); + + // 构建子 Agent 3: SqlAgent - 负责数据库查询 SqlAgent sqlAgent = AgenticServices.agentBuilder(SqlAgent.class) .chatModel(plannerModel) - .listener(new MyAgentListener()) .tools(new QueryAllTablesTool(), new QueryTableSchemaTool(), new ExecuteSqlQueryTool()) - .build(); - - WebSearchAgent searchAgent = AgenticServices.agentBuilder(WebSearchAgent.class) - .chatModel(plannerModel) .listener(new MyAgentListener()) - .toolProvider(toolProvider) .build(); + // 构建子 Agent 4: ChartGenerationAgent - 负责图表生成 ChartGenerationAgent chartGenerationAgent = AgenticServices.agentBuilder(ChartGenerationAgent.class) .chatModel(plannerModel) .listener(new MyAgentListener()) - .toolProvider(toolProvider1) .build(); - // 构建监督者Agent + // 构建子 Agent 5: EchartsAgent - 负责数据可视化(结合 SQL 查询生成 Echarts 图表) + EchartsAgent echartsAgent = AgenticServices.agentBuilder(EchartsAgent.class) + .chatModel(plannerModel) + .tools(new QueryAllTablesTool(), new QueryTableSchemaTool(), new ExecuteSqlQueryTool()) + .listener(new MyAgentListener()) + .build(); + + // 构建监督者 Agent - 管理多个子 Agent SupervisorAgent supervisor = AgenticServices.supervisorBuilder() .chatModel(plannerModel) - .listener(new MyAgentListener()) - .subAgents(sqlAgent, searchAgent, chartGenerationAgent) + //.listener(new SupervisorStreamListener(null)) + .subAgents(skillsAgent,searchAgent, sqlAgent, chartGenerationAgent, echartsAgent) + // 加入历史上下文 - 使用 ChatMemoryProvider 提供持久化的聊天内存 + //.chatMemoryProvider(memoryId -> createChatMemory(chatRequest.getSessionId())) .responseStrategy(SupervisorResponseStrategy.LAST) .build(); - // 调用 supervisor - String invoke = supervisor.invoke(chatRequest.getContent()); - log.info("supervisor.invoke() 返回: {}", invoke); + String tokenValue = chatRequest.getTokenValue(); + + // 异步执行 supervisor,避免阻塞 HTTP 请求线程导致 SSE 事件被缓冲 + CompletableFuture.runAsync(() -> { + try { + String result = supervisor.invoke(chatRequest.getContent()); + SseMessageUtils.sendContent(userId, result); + SseMessageUtils.sendDone(userId); + } catch (Exception e) { + log.error("Supervisor 执行失败", e); + SseMessageUtils.sendError(userId, e.getMessage()); + } finally { + SseMessageUtils.completeConnection(userId, tokenValue); + } + }); + return chatRequest.getEmitter(); } /** @@ -549,7 +599,5 @@ public class ChatServiceFacade implements IChatService { } }; } - - } diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/agent/StreamingAgentIntegrationTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/agent/StreamingAgentIntegrationTest.java new file mode 100644 index 00000000..4456affa --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/agent/StreamingAgentIntegrationTest.java @@ -0,0 +1,313 @@ +package org.ruoyi.agent; + +import dev.langchain4j.agentic.AgenticServices; +import dev.langchain4j.agentic.supervisor.SupervisorAgent; +import dev.langchain4j.agentic.supervisor.SupervisorResponseStrategy; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.chat.listener.ChatModelErrorContext; +import dev.langchain4j.model.chat.listener.ChatModelListener; +import dev.langchain4j.model.chat.listener.ChatModelRequestContext; +import dev.langchain4j.model.chat.listener.ChatModelResponseContext; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.openai.OpenAiChatModel; +import dev.langchain4j.model.openai.OpenAiStreamingChatModel; +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.ruoyi.observability.OutputChannel; +import org.ruoyi.observability.StreamingOutputWrapper; +import org.ruoyi.observability.SupervisorStreamListener; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * 子 Agent 流式输出集成测试 + * + * 测试内容: + * 1. 观察单个 Agent 的流式输出 + * 2. 观察 Supervisor 调用子 Agent 的流式输出 + * 3. 验证 AgentListener 事件回调 + * 4. 验证 StreamingOutputWrapper 的 token 拦截 + * + * 注意:运行测试前需要配置正确的 API Key + * 可以通过环境变量或直接修改配置区域 + * + * @author ageerle@163.com + * @date 2025/04/10 + */ +@Disabled("需要配置 API Key 后手动启用") +public class StreamingAgentIntegrationTest { + + // ==================== 配置区域 ==================== + private static final String BASE_URL = "https://api.ppio.com/openai"; + private static final String API_KEY = System.getenv("PPIO_API_KEY") != null + ? System.getenv("PPIO_API_KEY") + : "xx"; // 默认 Key + private static final String MODEL_NAME = "deepseek/deepseek-v3.2"; + + private StreamingChatModel streamingModel; + private OpenAiChatModel syncModel; + + // ==================== Agent 接口定义 ==================== + + public interface MathAgent { + @SystemMessage("你是一个数学计算助手,帮助用户解决数学问题。直接给出计算结果和简要解释。") + @UserMessage("计算:{{query}}") + @dev.langchain4j.agentic.Agent("数学计算助手") + String calculate(@V("query") String query); + } + + public interface TextAgent { + @SystemMessage("你是一个文本分析助手,帮助用户分析文本内容。给出简洁的分析结果。") + @UserMessage("分析以下文本:{{text}}") + @dev.langchain4j.agentic.Agent("文本分析助手") + String analyze(@V("text") String text); + } + + public interface WeatherAgent { + @SystemMessage("你是一个天气助手。根据用户提供的信息给出天气相关的回答。") + @UserMessage("回答问题:{{query}}") + @dev.langchain4j.agentic.Agent("天气助手") + String answer(@V("query") String query); + } + + // ==================== 初始化 ==================== + + @BeforeEach + void setUp() { +// streamingModel = OpenAiStreamingChatModel.builder() +// .baseUrl(BASE_URL) +// .apiKey(API_KEY) +// .modelName(MODEL_NAME) +// .build(); + + streamingModel = OpenAiStreamingChatModel.builder() + .baseUrl(BASE_URL) + .apiKey(API_KEY) + .listeners(List.of(new ChatModelListener() { + @Override + public void onRequest(ChatModelRequestContext ctx) { + // 请求发送前 + } + @Override + public void onResponse(ChatModelResponseContext ctx) { + // 响应完成后 + } + @Override + public void onError(ChatModelErrorContext ctx) { + // 错误时 + } + })) + .build(); + + + syncModel = OpenAiChatModel.builder() + .baseUrl(BASE_URL) + .apiKey(API_KEY) + .modelName(MODEL_NAME) + .build(); + } + + // ==================== 测试方法 ==================== + + @Test + @DisplayName("测试1: 基础流式输出 - 单个 Agent") + void testBasicStreamingAgent() throws Exception { + System.out.println("\n=== 测试1: 基础流式输出 - 单个 Agent ===\n"); + + // 创建事件总线 + String requestId = UUID.randomUUID().toString(); + OutputChannel channel = OutputChannel.create(requestId); + CountDownLatch completed = new CountDownLatch(1); + + // 包装模型以捕获流式输出 + ChatModel wrappedModel = new StreamingOutputWrapper(streamingModel, channel); + + // 构建 Agent + MathAgent mathAgent = AgenticServices.agentBuilder(MathAgent.class) + .chatModel(wrappedModel) + .build(); + + // 异步执行 + CompletableFuture.runAsync(() -> { + try { + System.out.println(">>> 调用 MathAgent.calculate()..."); + String result = mathAgent.calculate("计算 123 * 456 + 789 的值"); + System.out.println("\n>>> 最终结果: " + result); + } catch (Exception e) { + System.err.println(">>> 异常: " + e.getMessage()); + channel.completeWithError(e); + } finally { + channel.complete(); + completed.countDown(); + } + }); + + // drain 推送 + channel.drain(text -> { + System.out.print(text); + System.out.flush(); + }); + + completed.await(30, TimeUnit.SECONDS); + OutputChannel.remove(requestId); + } + + @Test + @DisplayName("测试2: Supervisor 模式 - 单个子 Agent 流式输出") + void testSupervisorWithSingleSubAgent() throws Exception { + System.out.println("\n=== 测试2: Supervisor 模式 - 单个子 Agent ===\n"); + + String requestId = UUID.randomUUID().toString(); + OutputChannel channel = OutputChannel.create(requestId); + CountDownLatch completed = new CountDownLatch(1); + + // 包装模型 + + // 子 Agent + MathAgent mathAgent = AgenticServices.agentBuilder(MathAgent.class) + .streamingChatModel(streamingModel) + .build(); + + + // Supervisor(注册监听器) + SupervisorAgent supervisor = AgenticServices.supervisorBuilder() + .chatModel(syncModel) + //.listener(new SupervisorStreamListener(channel)) + .subAgents(mathAgent) + .responseStrategy(SupervisorResponseStrategy.LAST) + .build(); + + // 异步执行 + CompletableFuture.runAsync(() -> { + try { + System.out.println(">>> Supervisor.invoke() 开始..."); + String result = supervisor.invoke("帮我计算 999 除以 3 等于多少"); + System.out.println("\n>>> Supervisor 结果: " + result); + } catch (Exception e) { + System.err.println(">>> 异常: " + e.getMessage()); + e.printStackTrace(); + // channel.completeWithError(e); + } finally { + // channel.complete(); + completed.countDown(); + } + }); + + // drain 推送 + channel.drain(text -> { + System.out.print(text); + System.out.flush(); + }); + + completed.await(60, TimeUnit.SECONDS); + OutputChannel.remove(requestId); + } + + @Test + @DisplayName("测试3: Supervisor 模式 - 多个子 Agent 流式输出") + void testSupervisorWithMultipleSubAgents() throws Exception { + System.out.println("\n=== 测试3: Supervisor 模式 - 多个子 Agent ===\n"); + + String requestId = UUID.randomUUID().toString(); + OutputChannel channel = OutputChannel.create(requestId); + CountDownLatch completed = new CountDownLatch(1); + + // 包装模型 + ChatModel wrappedModel = new StreamingOutputWrapper(streamingModel, channel); + + + // 子 Agent + MathAgent mathAgent = AgenticServices.agentBuilder(MathAgent.class) + .chatModel(wrappedModel) + .build(); + + TextAgent textAgent = AgenticServices.agentBuilder(TextAgent.class) + .chatModel(wrappedModel) + .build(); + + WeatherAgent weatherAgent = AgenticServices.agentBuilder(WeatherAgent.class) + .chatModel(wrappedModel) + .build(); + + // Supervisor + SupervisorAgent supervisor = AgenticServices.supervisorBuilder() + .chatModel(syncModel) + .listener(new SupervisorStreamListener(channel)) + .subAgents(mathAgent, textAgent, weatherAgent) + .responseStrategy(SupervisorResponseStrategy.LAST) + .build(); + + // 异步执行 - 提一个会触发多个 Agent 的问题 + CompletableFuture.runAsync(() -> { + try { + System.out.println(">>> Supervisor.invoke() 开始..."); + String result = supervisor.invoke( + "请帮我做两件事:1. 计算 50 * 20 的结果;2. 分析 '人工智能正在改变世界' 这句话的含义" + ); + System.out.println("\n>>> Supervisor 结果: " + result); + } catch (Exception e) { + System.err.println(">>> 异常: " + e.getMessage()); + e.printStackTrace(); + channel.completeWithError(e); + } finally { + channel.complete(); + completed.countDown(); + } + }); + + // drain 推送 - 实时观察流式输出 + channel.drain(text -> { + System.out.print("观察流式输出:"+text); + System.out.flush(); + }); + + completed.await(90, TimeUnit.SECONDS); + OutputChannel.remove(requestId); + } + + @Test + @DisplayName("测试4: 直接观察 StreamingChatModel 的流式响应") + void testDirectStreamingChatModel() throws Exception { + System.out.println("\n=== 测试4: 直接观察 StreamingChatModel ===\n"); + + StringBuilder buffer = new StringBuilder(); + CountDownLatch completed = new CountDownLatch(1); + + streamingModel.chat("你好,请自我介绍", new dev.langchain4j.model.chat.response.StreamingChatResponseHandler() { + @Override + public void onPartialResponse(String partialResponse) { + buffer.append(partialResponse); + System.out.print(partialResponse); + System.out.flush(); + } + + @Override + public void onCompleteResponse(ChatResponse completeResponse) { + System.out.println("\n\n[完成] 总Token数: " + + (completeResponse.metadata() != null && completeResponse.metadata().tokenUsage() != null + ? completeResponse.metadata().tokenUsage().totalTokenCount() + : "无")); + completed.countDown(); + } + + @Override + public void onError(Throwable error) { + System.err.println("[错误] " + error.getMessage()); + completed.countDown(); + } + }); + + completed.await(30, TimeUnit.SECONDS); + System.out.println("完整响应内容: " + buffer.toString()); + } +} From d9c3de660aceb7636335e4397b9ab66d1c25000d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=8C=AF?= Date: Thu, 16 Apr 2026 21:18:11 +0800 Subject: [PATCH 11/17] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=99=BA=E8=B0=B1?= =?UTF-8?q?=E5=90=91=E9=87=8F=E6=A8=A1=E5=9E=8B=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../embed/impl/ZhipuAiEmbeddingProvider.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/ZhipuAiEmbeddingProvider.java diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/ZhipuAiEmbeddingProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/ZhipuAiEmbeddingProvider.java new file mode 100644 index 00000000..f7f6a048 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/ZhipuAiEmbeddingProvider.java @@ -0,0 +1,48 @@ +package org.ruoyi.service.embed.impl; + + + +import dev.langchain4j.community.model.zhipu.ZhipuAiEmbeddingModel; +import dev.langchain4j.data.embedding.Embedding; +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.model.output.Response; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.enums.ModalityType; +import org.ruoyi.service.embed.BaseEmbedModelService; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Set; + +/** + * @Author:yang + * @Date: + * @Description: 智谱AI嵌入模型 + */ +@Component("zhipu") +public class ZhipuAiEmbeddingProvider implements BaseEmbedModelService { + protected ChatModelVo chatModelVo; + + @Override + public void configure(ChatModelVo config) { + this.chatModelVo = config; + } + + @Override + public Set getSupportedModalities() { + return Set.of(ModalityType.TEXT); + } + + @Override + public Response> embedAll(List textSegments) { + EmbeddingModel model = ZhipuAiEmbeddingModel.builder() + .baseUrl(chatModelVo.getApiHost()) + .apiKey(chatModelVo.getApiKey()) + .model(chatModelVo.getModelName()) + .dimensions(chatModelVo.getModelDimension()) + .build(); + + return model.embedAll(textSegments); + } +} From 2ee0aae57eb75d5b73bb00989da70acdb1b24cad Mon Sep 17 00:00:00 2001 From: Administrator <1037463791@qq.com> Date: Fri, 17 Apr 2026 08:31:40 +0800 Subject: [PATCH 12/17] =?UTF-8?q?fate=EF=BC=9A=E5=A2=9E=E5=8A=A0=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E6=A8=A1=E5=9E=8B=EF=BC=8C=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E6=A8=A1=E5=9E=8B=E9=80=89=E6=8B=A9=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/chat/ChatModelController.java | 18 +++++ .../java/org/ruoyi/enums/ChatModeType.java | 3 +- .../service/chat/AbstractChatService.java | 21 ++++++ .../impl/provider/CustomApiServiceImpl.java | 72 +++++++++++++++++++ .../chat/impl/provider/OllamaServiceImpl.java | 10 +++ .../impl/provider/QianWenChatServiceImpl.java | 10 +++ .../impl/provider/ZhiPuChatServiceImpl.java | 10 +++ 7 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CustomApiServiceImpl.java diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatModelController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatModelController.java index 15884158..adf4322b 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatModelController.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatModelController.java @@ -9,6 +9,7 @@ import cn.dev33.satoken.annotation.SaCheckPermission; import org.ruoyi.common.chat.service.chat.IChatModelService; import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.enums.ChatModeType; import org.ruoyi.enums.ModelType; import org.springframework.web.bind.annotation.*; import org.springframework.validation.annotation.Validated; @@ -23,6 +24,8 @@ import org.ruoyi.common.log.enums.BusinessType; import org.ruoyi.common.excel.utils.ExcelUtil; import org.ruoyi.common.mybatis.core.page.TableDataInfo; +import java.util.LinkedHashMap; + /** * 模型管理 * @@ -55,6 +58,21 @@ public class ChatModelController extends BaseController { return R.ok(chatModelService.queryList(bo)); } + /** + * 获取模型供应商枚举 + */ + @GetMapping("/providerOptions") + public R>> providerOptions() { + List> options = new java.util.ArrayList<>(); + for (ChatModeType type : ChatModeType.values()) { + LinkedHashMap item = new LinkedHashMap<>(); + item.put("label", type.getDescription()); + item.put("value", type.getCode()); + options.add(item); + } + return R.ok(options); + } + /** * 导出模型管理列表 */ diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java index c07d9444..1b91b8a2 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java @@ -15,7 +15,8 @@ public enum ChatModeType { DEEP_SEEK("deepseek", "深度求索"), QIAN_WEN("qianwen", "通义千问"), OPEN_AI("openai", "openai"), - PPIO("ppio", "ppio"); + PPIO("ppio", "ppio"), + CUSTOM_API("custom_api", "自定义API"); private final String code; private final String description; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/AbstractChatService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/AbstractChatService.java index 8017fea2..91331c92 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/AbstractChatService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/AbstractChatService.java @@ -1,9 +1,13 @@ package org.ruoyi.service.chat; +import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.openai.OpenAiChatModel; import org.ruoyi.common.chat.domain.dto.request.ChatRequest; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import java.time.Duration; + /** * 聊天消息Service接口 * @@ -21,6 +25,23 @@ public interface AbstractChatService { */ StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest); + /** + * 创建同步聊天模型(供 Agent/SupervisorAgent 使用) + * 默认实现使用 OpenAI 兼容协议,适用于 OpenAI、DeepSeek、PPIO 等兼容接口的 provider。 + * ZhiPu、QianWen、Ollama 等需覆盖此方法使用各自 SDK。 + * + * @param chatModelVo 模型配置 + * @return 同步聊天模型实例 + */ + default ChatModel buildChatModel(ChatModelVo chatModelVo) { + return OpenAiChatModel.builder() + .baseUrl(chatModelVo.getApiHost()) + .apiKey(chatModelVo.getApiKey()) + .modelName(chatModelVo.getModelName()) + .timeout(Duration.ofSeconds(120)) + .build(); + } + /** * 获取服务提供商名称 */ diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CustomApiServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CustomApiServiceImpl.java new file mode 100644 index 00000000..a4db18f7 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CustomApiServiceImpl.java @@ -0,0 +1,72 @@ +package org.ruoyi.service.chat.impl.provider; + +import cn.hutool.core.util.StrUtil; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.openai.OpenAiChatModel; +import dev.langchain4j.model.openai.OpenAiStreamingChatModel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.chat.domain.dto.request.ChatRequest; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.enums.ChatModeType; +import org.ruoyi.observability.MyChatModelListener; +import org.ruoyi.service.chat.AbstractChatService; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.List; + +/** + * 自定义 API 服务调用 + * + * 适用于 OpenAI 兼容接口或仅通过通用 HTTP 协议接入的第三方大模型服务。 + * 通过模型配置中的 apiHost / apiKey / modelName 即可复用,不需要再写死具体供应商。 + * + * @author better + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class CustomApiServiceImpl implements AbstractChatService { + + private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(180); + + @Override + public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) { + return OpenAiStreamingChatModel.builder() + .baseUrl(normalizeBaseUrl(chatModelVo.getApiHost())) + .apiKey(defaultIfBlank(chatModelVo.getApiKey(), "EMPTY")) + .modelName(chatModelVo.getModelName()) + .timeout(DEFAULT_TIMEOUT) + .listeners(List.of(new MyChatModelListener())) + .returnThinking(chatRequest.getEnableThinking()) + .build(); + } + + @Override + public ChatModel buildChatModel(ChatModelVo chatModelVo) { + return OpenAiChatModel.builder() + .baseUrl(normalizeBaseUrl(chatModelVo.getApiHost())) + .apiKey(defaultIfBlank(chatModelVo.getApiKey(), "EMPTY")) + .modelName(chatModelVo.getModelName()) + .timeout(DEFAULT_TIMEOUT) + .build(); + } + + @Override + public String getProviderName() { + return ChatModeType.CUSTOM_API.getCode(); + } + + private String normalizeBaseUrl(String baseUrl) { + if (StrUtil.isBlank(baseUrl)) { + throw new IllegalArgumentException("自定义API的请求地址(apiHost)不能为空"); + } + return baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + } + + private String defaultIfBlank(String value, String defaultValue) { + return StrUtil.isBlank(value) ? defaultValue : value; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OllamaServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OllamaServiceImpl.java index a9346538..240ed215 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OllamaServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OllamaServiceImpl.java @@ -1,7 +1,9 @@ package org.ruoyi.service.chat.impl.provider; +import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.ollama.OllamaChatModel; import dev.langchain4j.model.ollama.OllamaStreamingChatModel; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -37,6 +39,14 @@ public class OllamaServiceImpl implements AbstractChatService { .build(); } + @Override + public ChatModel buildChatModel(ChatModelVo chatModelVo) { + return OllamaChatModel.builder() + .baseUrl(chatModelVo.getApiHost()) + .modelName(chatModelVo.getModelName()) + .build(); + } + @Override public String getProviderName() { return ChatModeType.OLLAMA.getCode(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceImpl.java index 73289939..2490dd2e 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceImpl.java @@ -1,7 +1,9 @@ package org.ruoyi.service.chat.impl.provider; +import dev.langchain4j.community.model.dashscope.QwenChatModel; import dev.langchain4j.community.model.dashscope.QwenStreamingChatModel; +import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.StreamingChatModel; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -38,6 +40,14 @@ public class QianWenChatServiceImpl implements AbstractChatService { .build(); } + @Override + public ChatModel buildChatModel(ChatModelVo chatModelVo) { + return QwenChatModel.builder() + .apiKey(chatModelVo.getApiKey()) + .modelName(chatModelVo.getModelName()) + .build(); + } + @Override public String getProviderName() { return ChatModeType.QIAN_WEN.getCode(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java index 9e1b504d..920dbaf1 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java @@ -1,7 +1,9 @@ package org.ruoyi.service.chat.impl.provider; +import dev.langchain4j.community.model.zhipu.ZhipuAiChatModel; import dev.langchain4j.community.model.zhipu.ZhipuAiStreamingChatModel; +import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.StreamingChatModel; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -35,6 +37,14 @@ public class ZhiPuChatServiceImpl implements AbstractChatService { .build(); } + @Override + public ChatModel buildChatModel(ChatModelVo chatModelVo) { + return ZhipuAiChatModel.builder() + .apiKey(chatModelVo.getApiKey()) + .model(chatModelVo.getModelName()) + .build(); + } + @Override public String getProviderName() { return ChatModeType.ZHI_PU.getCode(); From 081da6d18da4df098736bb21fca44a2f1c8760b6 Mon Sep 17 00:00:00 2001 From: wangle Date: Fri, 17 Apr 2026 18:31:53 +0800 Subject: [PATCH 13/17] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0MiniMax?= =?UTF-8?q?=E4=BD=9C=E4=B8=BALLM=E6=8F=90=E4=BE=9B=E5=95=86=EF=BC=8C?= =?UTF-8?q?=E5=90=88=E5=B9=B6PR#280=E5=B9=B6=E8=A1=A5=E5=85=85=E7=9B=91?= =?UTF-8?q?=E5=90=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并PR#280的MiniMax provider实现,解决与main分支的冲突, 并在MinimaxServiceImpl中补充MyChatModelListener监听, 与其他provider保持一致。 Co-Authored-By: Claude Opus 4.7 --- README.md | 4 +- README_EN.md | 2 +- docs/script/sql/minimax_provider.sql | 23 ++++ ruoyi-modules/ruoyi-chat/pom.xml | 15 +++ .../KnowledgeGraphInstanceController.java | 105 ------------------ .../KnowledgeGraphSegmentController.java | 105 ------------------ .../java/org/ruoyi/enums/ChatModeType.java | 3 +- .../impl/provider/MinimaxServiceImpl.java | 44 ++++++++ .../embed/impl/MinimaxEmbeddingProvider.java | 17 +++ .../org/ruoyi/enums/ChatModeTypeTest.java | 42 +++++++ .../integration/MinimaxIntegrationTest.java | 70 ++++++++++++ .../impl/provider/MinimaxServiceImplTest.java | 76 +++++++++++++ .../impl/MinimaxEmbeddingProviderTest.java | 55 +++++++++ 13 files changed, 347 insertions(+), 214 deletions(-) create mode 100644 docs/script/sql/minimax_provider.sql delete mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeGraphInstanceController.java delete mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeGraphSegmentController.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/MinimaxEmbeddingProvider.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/enums/ChatModeTypeTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/integration/MinimaxIntegrationTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImplTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/embed/impl/MinimaxEmbeddingProviderTest.java diff --git a/README.md b/README.md index e9c7c082..3b679895 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,8 @@ | 模块 | 现有能力 |:----------:|--- -| **模型管理** | 多模型接入(OpenAI/DeepSeek/通义/智谱)、多模态理解、Coze/DIFY/FastGPT平台集成 -| **知识管理** | 本地RAG + 向量库(Milvus/Weaviate/Qdrant) + 文档解析 +| **模型管理** | 多模型接入(OpenAI/DeepSeek/通义/智谱/MiniMax)、多模态理解、Coze/DIFY/FastGPT平台集成 +| **知识管理** | 本地RAG + 向量库(Milvus/Weaviate/Qdrant) + 文档解析 | **工具管理** | Mcp协议集成、Skills能力 + 可扩展工具生态 | **流程编排** | 可视化工作流设计器、节点拖拽编排、SSE流式执行,目前已经支持模型调用,邮件发送,人工审核等节点 | **多智能体** | 基于Langchain4j的Agent框架、Supervisor模式编排,支持多种决策模型 diff --git a/README_EN.md b/README_EN.md index 00564414..b369491e 100644 --- a/README_EN.md +++ b/README_EN.md @@ -34,7 +34,7 @@ | Module | Current Capabilities | |:---:|---| -| **Model Management** | Multi-model integration (OpenAI/DeepSeek/Tongyi/Zhipu), multi-modal understanding, Coze/DIFY/FastGPT platform integration | +| **Model Management** | Multi-model integration (OpenAI/DeepSeek/Tongyi/Zhipu/MiniMax), multi-modal understanding, Coze/DIFY/FastGPT platform integration | | **Knowledge Base** | Local RAG + Vector DB (Milvus/Weaviate/Qdrant) + Document parsing | | **Tool Management** | MCP protocol integration, Skills capability + Extensible tool ecosystem | | **Workflow Orchestration** | Visual workflow designer, drag-and-drop node orchestration, SSE streaming execution, currently supports model calls, email sending, manual review nodes | diff --git a/docs/script/sql/minimax_provider.sql b/docs/script/sql/minimax_provider.sql new file mode 100644 index 00000000..e7331aa2 --- /dev/null +++ b/docs/script/sql/minimax_provider.sql @@ -0,0 +1,23 @@ +-- ---------------------------- +-- Add MiniMax provider +-- ---------------------------- +INSERT INTO `chat_provider` (`id`, `provider_name`, `provider_code`, `provider_icon`, `provider_desc`, `api_host`, `status`, `sort_order`, `create_dept`, `create_time`, `create_by`, `update_by`, `update_time`, `remark`, `version`, `del_flag`, `update_ip`, `tenant_id`) +VALUES (2010000000000000001, 'MiniMax', 'minimax', NULL, 'MiniMax大模型服务,支持M2.7、M2.5等模型', 'https://api.minimax.io/v1', '0', 6, NULL, NOW(), '1', '1', NOW(), 'MiniMax厂商', NULL, '0', NULL, 0); + +-- ---------------------------- +-- Add MiniMax chat models +-- ---------------------------- +INSERT INTO `chat_model` (`id`, `category`, `model_name`, `provider_code`, `model_describe`, `model_dimension`, `model_show`, `api_host`, `api_key`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`, `tenant_id`) +VALUES (2010000000000000002, 'chat', 'MiniMax-M2.7', 'minimax', 'MiniMax-M2.7', NULL, 'Y', 'https://api.minimax.io/v1', '', NULL, 1, NOW(), 1, NOW(), 'MiniMax最新旗舰模型M2.7,支持1M上下文窗口', 0); + +INSERT INTO `chat_model` (`id`, `category`, `model_name`, `provider_code`, `model_describe`, `model_dimension`, `model_show`, `api_host`, `api_key`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`, `tenant_id`) +VALUES (2010000000000000003, 'chat', 'MiniMax-M2.5', 'minimax', 'MiniMax-M2.5', NULL, 'Y', 'https://api.minimax.io/v1', '', NULL, 1, NOW(), 1, NOW(), 'MiniMax M2.5模型,204K上下文窗口', 0); + +INSERT INTO `chat_model` (`id`, `category`, `model_name`, `provider_code`, `model_describe`, `model_dimension`, `model_show`, `api_host`, `api_key`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`, `tenant_id`) +VALUES (2010000000000000004, 'chat', 'MiniMax-M2.5-highspeed', 'minimax', 'MiniMax-M2.5-highspeed', NULL, 'Y', 'https://api.minimax.io/v1', '', NULL, 1, NOW(), 1, NOW(), 'MiniMax M2.5高速版,204K上下文窗口,更低延迟', 0); + +-- ---------------------------- +-- Add MiniMax embedding model +-- ---------------------------- +INSERT INTO `chat_model` (`id`, `category`, `model_name`, `provider_code`, `model_describe`, `model_dimension`, `model_show`, `api_host`, `api_key`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`, `tenant_id`) +VALUES (2010000000000000005, 'vector', 'embo-01', 'minimax', 'embo-01', 1536, 'N', 'https://api.minimax.io/v1', '', NULL, 1, NOW(), 1, NOW(), 'MiniMax embo-01嵌入模型,1536维度', 0); diff --git a/ruoyi-modules/ruoyi-chat/pom.xml b/ruoyi-modules/ruoyi-chat/pom.xml index 43ea2b1e..b551fda0 100644 --- a/ruoyi-modules/ruoyi-chat/pom.xml +++ b/ruoyi-modules/ruoyi-chat/pom.xml @@ -173,6 +173,21 @@ spring-boot-starter-test test + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeGraphInstanceController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeGraphInstanceController.java deleted file mode 100644 index 8fbc547a..00000000 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeGraphInstanceController.java +++ /dev/null @@ -1,105 +0,0 @@ -package org.ruoyi.controller.knowledge; - -import java.util.List; - -import lombok.RequiredArgsConstructor; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.validation.constraints.*; -import cn.dev33.satoken.annotation.SaCheckPermission; -import org.ruoyi.domain.bo.knowledge.KnowledgeGraphInstanceBo; -import org.ruoyi.domain.vo.knowledge.KnowledgeGraphInstanceVo; -import org.ruoyi.service.knowledge.IKnowledgeGraphInstanceService; -import org.springframework.web.bind.annotation.*; -import org.springframework.validation.annotation.Validated; -import org.ruoyi.common.idempotent.annotation.RepeatSubmit; -import org.ruoyi.common.log.annotation.Log; -import org.ruoyi.common.web.core.BaseController; -import org.ruoyi.common.mybatis.core.page.PageQuery; -import org.ruoyi.common.core.domain.R; -import org.ruoyi.common.core.validate.AddGroup; -import org.ruoyi.common.core.validate.EditGroup; -import org.ruoyi.common.log.enums.BusinessType; -import org.ruoyi.common.excel.utils.ExcelUtil; -import org.ruoyi.common.mybatis.core.page.TableDataInfo; - -/** - * 知识图谱实例 - * - * @author ageerle - * @date 2025-12-17 - */ -@Validated -@RequiredArgsConstructor -@RestController -@RequestMapping("/system/graphInstance") -public class KnowledgeGraphInstanceController extends BaseController { - - private final IKnowledgeGraphInstanceService knowledgeGraphInstanceService; - - /** - * 查询知识图谱实例列表 - */ - @SaCheckPermission("system:graphInstance:list") - @GetMapping("/list") - public TableDataInfo list(KnowledgeGraphInstanceBo bo, PageQuery pageQuery) { - return knowledgeGraphInstanceService.queryPageList(bo, pageQuery); - } - - /** - * 导出知识图谱实例列表 - */ - @SaCheckPermission("system:graphInstance:export") - @Log(title = "知识图谱实例", businessType = BusinessType.EXPORT) - @PostMapping("/export") - public void export(KnowledgeGraphInstanceBo bo, HttpServletResponse response) { - List list = knowledgeGraphInstanceService.queryList(bo); - ExcelUtil.exportExcel(list, "知识图谱实例", KnowledgeGraphInstanceVo.class, response); - } - - /** - * 获取知识图谱实例详细信息 - * - * @param id 主键 - */ - @SaCheckPermission("system:graphInstance:query") - @GetMapping("/{id}") - public R getInfo(@NotNull(message = "主键不能为空") - @PathVariable Long id) { - return R.ok(knowledgeGraphInstanceService.queryById(id)); - } - - /** - * 新增知识图谱实例 - */ - @SaCheckPermission("system:graphInstance:add") - @Log(title = "知识图谱实例", businessType = BusinessType.INSERT) - @RepeatSubmit() - @PostMapping() - public R add(@Validated(AddGroup.class) @RequestBody KnowledgeGraphInstanceBo bo) { - return toAjax(knowledgeGraphInstanceService.insertByBo(bo)); - } - - /** - * 修改知识图谱实例 - */ - @SaCheckPermission("system:graphInstance:edit") - @Log(title = "知识图谱实例", businessType = BusinessType.UPDATE) - @RepeatSubmit() - @PutMapping() - public R edit(@Validated(EditGroup.class) @RequestBody KnowledgeGraphInstanceBo bo) { - return toAjax(knowledgeGraphInstanceService.updateByBo(bo)); - } - - /** - * 删除知识图谱实例 - * - * @param ids 主键串 - */ - @SaCheckPermission("system:graphInstance:remove") - @Log(title = "知识图谱实例", businessType = BusinessType.DELETE) - @DeleteMapping("/{ids}") - public R remove(@NotEmpty(message = "主键不能为空") - @PathVariable Long[] ids) { - return toAjax(knowledgeGraphInstanceService.deleteWithValidByIds(List.of(ids), true)); - } -} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeGraphSegmentController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeGraphSegmentController.java deleted file mode 100644 index 7a0c7963..00000000 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeGraphSegmentController.java +++ /dev/null @@ -1,105 +0,0 @@ -package org.ruoyi.controller.knowledge; - -import java.util.List; - -import lombok.RequiredArgsConstructor; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.validation.constraints.*; -import cn.dev33.satoken.annotation.SaCheckPermission; -import org.ruoyi.domain.bo.knowledge.KnowledgeGraphSegmentBo; -import org.ruoyi.domain.vo.knowledge.KnowledgeGraphSegmentVo; -import org.ruoyi.service.knowledge.IKnowledgeGraphSegmentService; -import org.springframework.web.bind.annotation.*; -import org.springframework.validation.annotation.Validated; -import org.ruoyi.common.idempotent.annotation.RepeatSubmit; -import org.ruoyi.common.log.annotation.Log; -import org.ruoyi.common.web.core.BaseController; -import org.ruoyi.common.mybatis.core.page.PageQuery; -import org.ruoyi.common.core.domain.R; -import org.ruoyi.common.core.validate.AddGroup; -import org.ruoyi.common.core.validate.EditGroup; -import org.ruoyi.common.log.enums.BusinessType; -import org.ruoyi.common.excel.utils.ExcelUtil; -import org.ruoyi.common.mybatis.core.page.TableDataInfo; - -/** - * 知识图谱片段 - * - * @author ageerle - * @date 2025-12-17 - */ -@Validated -@RequiredArgsConstructor -@RestController -@RequestMapping("/system/graphSegment") -public class KnowledgeGraphSegmentController extends BaseController { - - private final IKnowledgeGraphSegmentService knowledgeGraphSegmentService; - - /** - * 查询知识图谱片段列表 - */ - @SaCheckPermission("system:graphSegment:list") - @GetMapping("/list") - public TableDataInfo list(KnowledgeGraphSegmentBo bo, PageQuery pageQuery) { - return knowledgeGraphSegmentService.queryPageList(bo, pageQuery); - } - - /** - * 导出知识图谱片段列表 - */ - @SaCheckPermission("system:graphSegment:export") - @Log(title = "知识图谱片段", businessType = BusinessType.EXPORT) - @PostMapping("/export") - public void export(KnowledgeGraphSegmentBo bo, HttpServletResponse response) { - List list = knowledgeGraphSegmentService.queryList(bo); - ExcelUtil.exportExcel(list, "知识图谱片段", KnowledgeGraphSegmentVo.class, response); - } - - /** - * 获取知识图谱片段详细信息 - * - * @param id 主键 - */ - @SaCheckPermission("system:graphSegment:query") - @GetMapping("/{id}") - public R getInfo(@NotNull(message = "主键不能为空") - @PathVariable Long id) { - return R.ok(knowledgeGraphSegmentService.queryById(id)); - } - - /** - * 新增知识图谱片段 - */ - @SaCheckPermission("system:graphSegment:add") - @Log(title = "知识图谱片段", businessType = BusinessType.INSERT) - @RepeatSubmit() - @PostMapping() - public R add(@Validated(AddGroup.class) @RequestBody KnowledgeGraphSegmentBo bo) { - return toAjax(knowledgeGraphSegmentService.insertByBo(bo)); - } - - /** - * 修改知识图谱片段 - */ - @SaCheckPermission("system:graphSegment:edit") - @Log(title = "知识图谱片段", businessType = BusinessType.UPDATE) - @RepeatSubmit() - @PutMapping() - public R edit(@Validated(EditGroup.class) @RequestBody KnowledgeGraphSegmentBo bo) { - return toAjax(knowledgeGraphSegmentService.updateByBo(bo)); - } - - /** - * 删除知识图谱片段 - * - * @param ids 主键串 - */ - @SaCheckPermission("system:graphSegment:remove") - @Log(title = "知识图谱片段", businessType = BusinessType.DELETE) - @DeleteMapping("/{ids}") - public R remove(@NotEmpty(message = "主键不能为空") - @PathVariable Long[] ids) { - return toAjax(knowledgeGraphSegmentService.deleteWithValidByIds(List.of(ids), true)); - } -} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java index 1b91b8a2..62729ce2 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java @@ -16,7 +16,8 @@ public enum ChatModeType { QIAN_WEN("qianwen", "通义千问"), OPEN_AI("openai", "openai"), PPIO("ppio", "ppio"), - CUSTOM_API("custom_api", "自定义API"); + CUSTOM_API("custom_api", "自定义API"), + MINIMAX("minimax", "MiniMax"); private final String code; private final String description; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImpl.java new file mode 100644 index 00000000..d83d9900 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImpl.java @@ -0,0 +1,44 @@ +package org.ruoyi.service.chat.impl.provider; + +import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.openai.OpenAiStreamingChatModel; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.chat.domain.dto.request.ChatRequest; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.enums.ChatModeType; +import org.ruoyi.observability.MyChatModelListener; +import org.ruoyi.service.chat.AbstractChatService; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * MiniMax服务调用 + *

+ * MiniMax提供OpenAI兼容的API接口,支持MiniMax-M2.7、MiniMax-M2.5等模型。 + * API地址:https://api.minimax.io/v1 + * + * @author octopus + * @date 2026/3/21 + */ +@Service +@Slf4j +public class MinimaxServiceImpl implements AbstractChatService { + + @Override + public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) { + return OpenAiStreamingChatModel.builder() + .baseUrl(chatModelVo.getApiHost()) + .apiKey(chatModelVo.getApiKey()) + .modelName(chatModelVo.getModelName()) + .listeners(List.of(new MyChatModelListener())) + .returnThinking(chatRequest.getEnableThinking()) + .build(); + } + + @Override + public String getProviderName() { + return ChatModeType.MINIMAX.getCode(); + } + +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/MinimaxEmbeddingProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/MinimaxEmbeddingProvider.java new file mode 100644 index 00000000..4d2dbec5 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/MinimaxEmbeddingProvider.java @@ -0,0 +1,17 @@ +package org.ruoyi.service.embed.impl; + +import org.springframework.stereotype.Component; + +/** + * MiniMax嵌入模型(兼容OpenAI接口) + *

+ * 支持embo-01模型,1536维度向量。 + * API地址:https://api.minimax.io/v1 + * + * @author octopus + * @date 2026/3/21 + */ +@Component("minimax") +public class MinimaxEmbeddingProvider extends OpenAiEmbeddingProvider { + +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/enums/ChatModeTypeTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/enums/ChatModeTypeTest.java new file mode 100644 index 00000000..9733af8b --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/enums/ChatModeTypeTest.java @@ -0,0 +1,42 @@ +package org.ruoyi.enums; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for ChatModeType enum + */ +class ChatModeTypeTest { + + @Test + void minimaxEnumExists() { + ChatModeType minimax = ChatModeType.MINIMAX; + assertNotNull(minimax); + } + + @Test + void minimaxCode_isMinimax() { + assertEquals("minimax", ChatModeType.MINIMAX.getCode()); + } + + @Test + void minimaxDescription_isMiniMax() { + assertEquals("MiniMax", ChatModeType.MINIMAX.getDescription()); + } + + @Test + void allProviders_haveUniqueCode() { + ChatModeType[] values = ChatModeType.values(); + long uniqueCodes = java.util.Arrays.stream(values) + .map(ChatModeType::getCode) + .distinct() + .count(); + assertEquals(values.length, uniqueCodes, "All providers must have unique codes"); + } + + @Test + void valueOf_minimax() { + assertEquals(ChatModeType.MINIMAX, ChatModeType.valueOf("MINIMAX")); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/integration/MinimaxIntegrationTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/integration/MinimaxIntegrationTest.java new file mode 100644 index 00000000..7e052e6d --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/integration/MinimaxIntegrationTest.java @@ -0,0 +1,70 @@ +package org.ruoyi.integration; + +import dev.langchain4j.model.chat.StreamingChatModel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.ruoyi.common.chat.domain.dto.request.ChatRequest; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.service.chat.impl.provider.MinimaxServiceImpl; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests for MiniMax provider. + * These tests require a valid MINIMAX_API_KEY environment variable. + */ +@EnabledIfEnvironmentVariable(named = "MINIMAX_API_KEY", matches = ".+") +class MinimaxIntegrationTest { + + private MinimaxServiceImpl minimaxService; + private String apiKey; + + @BeforeEach + void setUp() { + minimaxService = new MinimaxServiceImpl(); + apiKey = System.getenv("MINIMAX_API_KEY"); + } + + @Test + void buildStreamingChatModel_withRealApiKey_M27() { + ChatModelVo modelVo = new ChatModelVo(); + modelVo.setApiHost("https://api.minimax.io/v1"); + modelVo.setApiKey(apiKey); + modelVo.setModelName("MiniMax-M2.7"); + + ChatRequest request = new ChatRequest(); + request.setEnableThinking(false); + + StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request); + assertNotNull(model, "Should create streaming model with real API key"); + } + + @Test + void buildStreamingChatModel_withRealApiKey_M25() { + ChatModelVo modelVo = new ChatModelVo(); + modelVo.setApiHost("https://api.minimax.io/v1"); + modelVo.setApiKey(apiKey); + modelVo.setModelName("MiniMax-M2.5"); + + ChatRequest request = new ChatRequest(); + request.setEnableThinking(false); + + StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request); + assertNotNull(model, "Should create streaming model with M2.5"); + } + + @Test + void buildStreamingChatModel_withRealApiKey_M25Highspeed() { + ChatModelVo modelVo = new ChatModelVo(); + modelVo.setApiHost("https://api.minimax.io/v1"); + modelVo.setApiKey(apiKey); + modelVo.setModelName("MiniMax-M2.5-highspeed"); + + ChatRequest request = new ChatRequest(); + request.setEnableThinking(false); + + StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request); + assertNotNull(model, "Should create streaming model with M2.5-highspeed"); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImplTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImplTest.java new file mode 100644 index 00000000..697f52e2 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImplTest.java @@ -0,0 +1,76 @@ +package org.ruoyi.service.chat.impl.provider; + +import dev.langchain4j.model.chat.StreamingChatModel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.ruoyi.common.chat.domain.dto.request.ChatRequest; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.enums.ChatModeType; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for MinimaxServiceImpl + */ +class MinimaxServiceImplTest { + + private MinimaxServiceImpl minimaxService; + + @BeforeEach + void setUp() { + minimaxService = new MinimaxServiceImpl(); + } + + @Test + void getProviderName_returnsMinimaxCode() { + assertEquals("minimax", minimaxService.getProviderName()); + assertEquals(ChatModeType.MINIMAX.getCode(), minimaxService.getProviderName()); + } + + @Test + void buildStreamingChatModel_returnsNonNull() { + ChatModelVo modelVo = new ChatModelVo(); + modelVo.setApiHost("https://api.minimax.io/v1"); + modelVo.setApiKey("test-api-key"); + modelVo.setModelName("MiniMax-M2.7"); + + ChatRequest request = new ChatRequest(); + request.setEnableThinking(false); + + StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request); + assertNotNull(model); + } + + @Test + void buildStreamingChatModel_withThinkingEnabled() { + ChatModelVo modelVo = new ChatModelVo(); + modelVo.setApiHost("https://api.minimax.io/v1"); + modelVo.setApiKey("test-api-key"); + modelVo.setModelName("MiniMax-M2.5"); + + ChatRequest request = new ChatRequest(); + request.setEnableThinking(true); + + StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request); + assertNotNull(model); + } + + @Test + void buildStreamingChatModel_withHighspeedModel() { + ChatModelVo modelVo = new ChatModelVo(); + modelVo.setApiHost("https://api.minimax.io/v1"); + modelVo.setApiKey("test-api-key"); + modelVo.setModelName("MiniMax-M2.5-highspeed"); + + ChatRequest request = new ChatRequest(); + request.setEnableThinking(false); + + StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request); + assertNotNull(model); + } + + @Test + void implementsAbstractChatService() { + assertInstanceOf(org.ruoyi.service.chat.AbstractChatService.class, minimaxService); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/embed/impl/MinimaxEmbeddingProviderTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/embed/impl/MinimaxEmbeddingProviderTest.java new file mode 100644 index 00000000..e089aae1 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/embed/impl/MinimaxEmbeddingProviderTest.java @@ -0,0 +1,55 @@ +package org.ruoyi.service.embed.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.enums.ModalityType; +import org.ruoyi.service.embed.BaseEmbedModelService; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for MinimaxEmbeddingProvider + */ +class MinimaxEmbeddingProviderTest { + + private MinimaxEmbeddingProvider provider; + + @BeforeEach + void setUp() { + provider = new MinimaxEmbeddingProvider(); + } + + @Test + void implementsBaseEmbedModelService() { + assertInstanceOf(BaseEmbedModelService.class, provider); + } + + @Test + void extendsOpenAiEmbeddingProvider() { + assertInstanceOf(OpenAiEmbeddingProvider.class, provider); + } + + @Test + void getSupportedModalities_returnsText() { + Set modalities = provider.getSupportedModalities(); + assertNotNull(modalities); + assertTrue(modalities.contains(ModalityType.TEXT)); + assertEquals(1, modalities.size()); + } + + @Test + void configure_setsModelConfig() { + ChatModelVo config = new ChatModelVo(); + config.setApiHost("https://api.minimax.io/v1"); + config.setApiKey("test-api-key"); + config.setModelName("embo-01"); + config.setModelDimension(1536); + + provider.configure(config); + // configure sets internal state; verify no exception thrown + assertNotNull(provider); + } +} From 4f79a66559e59e445ba7465d8c4675acb5211bcc Mon Sep 17 00:00:00 2001 From: wangle Date: Sun, 19 Apr 2026 13:42:05 +0800 Subject: [PATCH 14/17] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=B0=8F?= =?UTF-8?q?=E7=B1=B3MiMo=E3=80=81DeepSeek=E3=80=81=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E5=8E=82=E5=95=86=E7=AD=89provider=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增小米MiMo服务实现类(MiMoServiceImpl) - ChatModeType添加XIAOMI枚举 - 更新SQL初始化脚本,新增多家厂商(provider)和模型数据 - 添加2026-04-19数据库更新脚本 - application.yml演示模式排除路径增加attach/fragment/info接口 - 删除独立的minimax_provider.sql(数据已合并到主SQL) Co-Authored-By: Claude Opus 4.7 --- docs/script/sql/minimax_provider.sql | 23 ----- docs/script/sql/ruoyi-ai-v3_mysql8.sql | 25 +++-- docs/script/sql/update/updat-0419.sql | 92 +++++++++++++++++++ .../src/main/resources/application.yml | 6 +- .../java/org/ruoyi/enums/ChatModeType.java | 3 +- .../chat/impl/provider/MiMoServiceImpl.java | 47 ++++++++++ 6 files changed, 160 insertions(+), 36 deletions(-) delete mode 100644 docs/script/sql/minimax_provider.sql create mode 100644 docs/script/sql/update/updat-0419.sql create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MiMoServiceImpl.java diff --git a/docs/script/sql/minimax_provider.sql b/docs/script/sql/minimax_provider.sql deleted file mode 100644 index e7331aa2..00000000 --- a/docs/script/sql/minimax_provider.sql +++ /dev/null @@ -1,23 +0,0 @@ --- ---------------------------- --- Add MiniMax provider --- ---------------------------- -INSERT INTO `chat_provider` (`id`, `provider_name`, `provider_code`, `provider_icon`, `provider_desc`, `api_host`, `status`, `sort_order`, `create_dept`, `create_time`, `create_by`, `update_by`, `update_time`, `remark`, `version`, `del_flag`, `update_ip`, `tenant_id`) -VALUES (2010000000000000001, 'MiniMax', 'minimax', NULL, 'MiniMax大模型服务,支持M2.7、M2.5等模型', 'https://api.minimax.io/v1', '0', 6, NULL, NOW(), '1', '1', NOW(), 'MiniMax厂商', NULL, '0', NULL, 0); - --- ---------------------------- --- Add MiniMax chat models --- ---------------------------- -INSERT INTO `chat_model` (`id`, `category`, `model_name`, `provider_code`, `model_describe`, `model_dimension`, `model_show`, `api_host`, `api_key`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`, `tenant_id`) -VALUES (2010000000000000002, 'chat', 'MiniMax-M2.7', 'minimax', 'MiniMax-M2.7', NULL, 'Y', 'https://api.minimax.io/v1', '', NULL, 1, NOW(), 1, NOW(), 'MiniMax最新旗舰模型M2.7,支持1M上下文窗口', 0); - -INSERT INTO `chat_model` (`id`, `category`, `model_name`, `provider_code`, `model_describe`, `model_dimension`, `model_show`, `api_host`, `api_key`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`, `tenant_id`) -VALUES (2010000000000000003, 'chat', 'MiniMax-M2.5', 'minimax', 'MiniMax-M2.5', NULL, 'Y', 'https://api.minimax.io/v1', '', NULL, 1, NOW(), 1, NOW(), 'MiniMax M2.5模型,204K上下文窗口', 0); - -INSERT INTO `chat_model` (`id`, `category`, `model_name`, `provider_code`, `model_describe`, `model_dimension`, `model_show`, `api_host`, `api_key`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`, `tenant_id`) -VALUES (2010000000000000004, 'chat', 'MiniMax-M2.5-highspeed', 'minimax', 'MiniMax-M2.5-highspeed', NULL, 'Y', 'https://api.minimax.io/v1', '', NULL, 1, NOW(), 1, NOW(), 'MiniMax M2.5高速版,204K上下文窗口,更低延迟', 0); - --- ---------------------------- --- Add MiniMax embedding model --- ---------------------------- -INSERT INTO `chat_model` (`id`, `category`, `model_name`, `provider_code`, `model_describe`, `model_dimension`, `model_show`, `api_host`, `api_key`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`, `tenant_id`) -VALUES (2010000000000000005, 'vector', 'embo-01', 'minimax', 'embo-01', 1536, 'N', 'https://api.minimax.io/v1', '', NULL, 1, NOW(), 1, NOW(), 'MiniMax embo-01嵌入模型,1536维度', 0); diff --git a/docs/script/sql/ruoyi-ai-v3_mysql8.sql b/docs/script/sql/ruoyi-ai-v3_mysql8.sql index 64770ca8..69a169bd 100644 --- a/docs/script/sql/ruoyi-ai-v3_mysql8.sql +++ b/docs/script/sql/ruoyi-ai-v3_mysql8.sql @@ -72,8 +72,9 @@ CREATE TABLE `chat_model` ( -- ---------------------------- -- Records of chat_model -- ---------------------------- -INSERT INTO `chat_model` VALUES (2000585866022060033, 'chat', 'deepseek/deepseek-v3.2', 'ppio', 'deepseek', NULL, 'Y', 'https://api.ppinfra.com/openai', 'sk_xx', 103, 1, '2025-12-15 23:16:54', 1, '2026-03-15 19:18:48', 'DeepSeek-V3.2 是一款在高效推理、复杂推理能力与智能体场景中表现突出的领先模型。其基于 DeepSeek Sparse Attention(DSA)稀疏注意力机制,在显著降低计算开销的同时优化长上下文性能;通过可扩展强化学习框架,整体能力达到 GPT-5 同级,高算力版本 V3.2-Speciale 更在推理表现上接近 Gemini-3.0-Pro;同时,模型依托大型智能体任务合成管线,具备更强的工具调用与多步骤决策能力,并在 2025 年 IMO 与 IOI 中取得金牌级表现。作为 MaaS 平台,我们已对 DeepSeek-V3.2 完成深度适配,通过动态调度、批处理加速、低延迟推理与企业级 SLA 保障,进一步增强其在企业生产环境中的稳定性、性价比与可控性,适用于搜索、问答、智能体、代码、数据处理等多类高价值场景。', 0); -INSERT INTO `chat_model` VALUES (2007528268536287233, 'vector', 'baai/bge-m3', 'ppio', 'bge-m3', 1024, 'N', 'https://api.ppinfra.com/openai', 'sk_xx', 103, 1, '2026-01-04 03:03:32', 1, '2026-03-15 19:18:51', 'BGE-M3 是一款具备多维度能力的文本嵌入模型,可同时实现密集检索、多向量检索和稀疏检索三大核心功能。该模型设计上兼容超过100种语言,并支持从短句到长达8192词元的长文本等多种输入形式。在跨语言检索任务中,BGE-M3展现出显著优势,其性能在MIRACL、MKQA等国际基准测试中位居前列。此外,针对长文档检索场景,该模型在MLDR、NarritiveQA等数据集上的表现同样达到行业领先水平。', 0); +INSERT INTO `chat_model` VALUES (2000585866022060033, 'chat', 'zai-org/glm-5', 'ppio', 'zai-org/glm-5', NULL, 'Y', 'https://api.ppio.com/openai', 'sk_xx', 103, 1, '2025-12-15 23:16:54', 1, '2026-03-15 19:18:48', 'DeepSeek-V3.2 是一款在高效推理、复杂推理能力与智能体场景中表现突出的领先模型。其基于 DeepSeek Sparse Attention(DSA)稀疏注意力机制,在显著降低计算开销的同时优化长上下文性能;通过可扩展强化学习框架,整体能力达到 GPT-5 同级,高算力版本 V3.2-Speciale 更在推理表现上接近 Gemini-3.0-Pro;同时,模型依托大型智能体任务合成管线,具备更强的工具调用与多步骤决策能力,并在 2025 年 IMO 与 IOI 中取得金牌级表现。作为 MaaS 平台,我们已对 DeepSeek-V3.2 完成深度适配,通过动态调度、批处理加速、低延迟推理与企业级 SLA 保障,进一步增强其在企业生产环境中的稳定性、性价比与可控性,适用于搜索、问答、智能体、代码、数据处理等多类高价值场景。', 0); +INSERT INTO `chat_model` VALUES (2007528268536287233, 'vector', 'baai/bge-m3', 'ppio', 'bge-m3', 1024, 'N', 'https://api.ppio.com/openai', 'sk_xx', 103, 1, '2026-01-04 03:03:32', 1, '2026-03-15 19:18:51', 'BGE-M3 是一款具备多维度能力的文本嵌入模型,可同时实现密集检索、多向量检索和稀疏检索三大核心功能。该模型设计上兼容超过100种语言,并支持从短句到长达8192词元的长文本等多种输入形式。在跨语言检索任务中,BGE-M3展现出显著优势,其性能在MIRACL、MKQA等国际基准测试中位居前列。此外,针对长文档检索场景,该模型在MLDR、NarritiveQA等数据集上的表现同样达到行业领先水平。', 0); +INSERT INTO `chat_model` VALUES (2045735140488847361, 'chat', 'deepseek-chat', 'custom_api', 'deepseek-chat', NULL, NULL, 'https://api.deepseek.com', 'sk_xx', 103, 1, '2026-04-19 13:24:00', 1, '2026-04-19 13:24:00', 'deepseek对话模型', 0); -- ---------------------------- -- Table structure for chat_provider @@ -95,22 +96,26 @@ CREATE TABLE `chat_provider` ( `update_time` datetime NULL DEFAULT NULL COMMENT '更新时间', `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注', `version` int NULL DEFAULT NULL COMMENT '版本', - `del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '删除标志(0代表存在 1代表删除)', + `del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '删除标志', `update_ip` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '更新IP', `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户Id', PRIMARY KEY (`id`) USING BTREE, - UNIQUE INDEX `unique_provider_code`(`provider_code` ASC, `tenant_id` ASC) USING BTREE, + UNIQUE INDEX `unique_provider_code`(`provider_code` ASC, `tenant_id` ASC, `del_flag` ASC) USING BTREE, INDEX `idx_status`(`status` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 2008460994477690882 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '厂商管理表' ROW_FORMAT = DYNAMIC; +) ENGINE = InnoDB AUTO_INCREMENT = 2045727230803255298 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '厂商管理表' ROW_FORMAT = DYNAMIC; -- ---------------------------- -- Records of chat_provider -- ---------------------------- -INSERT INTO `chat_provider` VALUES (1, 'OpenAI', 'openai', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/01091be272334383a1efd9bc22b73ee6.png', 'OpenAI官方API服务商', 'https://api.openai.com', '0', 1, NULL, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:46:59', 'OpenAI厂商', NULL, '0', NULL, 0); -INSERT INTO `chat_provider` VALUES (2, '阿里云百炼', 'qianwen', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/de2aa7e649de44f3ba5c6380ac6acd04.png', '阿里云百炼大模型服务', 'https://dashscope.aliyuncs.com', '0', 2, NULL, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:49:13', '阿里云厂商', NULL, '0', NULL, 0); -INSERT INTO `chat_provider` VALUES (3, '智谱AI', 'zhipu', 'https://ruoyi-ai-1254149996.cos.ap-guangzhou.myqcloud.com/2025/12/15/a43e98fb7b3b4861b8caa6184e6fa40a.png', '智谱AI大模型服务', 'https://open.bigmodel.cn', '0', 3, NULL, '2025-12-14 21:48:11', '1', '1', '2026-02-06 00:49:14', '智谱AI厂商', NULL, '1', NULL, 0); -INSERT INTO `chat_provider` VALUES (5, 'ollama', 'ollama', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/afecabebc8014d80b0f06b4796a74c5d.png', 'ollama大模型', 'http://127.0.0.1:11434', '0', 5, NULL, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:48:48', 'ollama厂商', NULL, '0', NULL, 0); -INSERT INTO `chat_provider` VALUES (2000585060904435714, 'PPIO', 'ppio', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/049bb6a507174f73bba4b8d8b9e55b8a.png', 'api聚合厂商', 'https://api.ppinfra.com/openai', '0', 5, 103, '2025-12-15 23:13:42', '1', '1', '2026-02-25 20:49:01', 'api聚合厂商', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (1, 'OpenAI', 'openai', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/01091be272334383a1efd9bc22b73ee6.png', 'OpenAI官方API服务商', 'https://api.openai.com', '0', 1, 103, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:46:59', 'OpenAI厂商', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (11, '深度求索', 'deepseek', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/04/19/5ba8c30f153246898a4d7dc7b846de8d.png', 'DeepSeek官方API', 'https://api.deepseek.com', '0', 0, 103, '2026-04-19 12:52:34', '1', '1', '2026-04-19 13:13:25', 'DeepSeek官方API', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (12, '智谱AI', 'zhipu', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/04/19/da071783c9284fdd9ed1ce1b57b3c75c.png', '智谱AI大模型服务', 'https://open.bigmodel.cn', '0', 4, 103, '2025-12-14 21:48:11', '1', '1', '2026-04-19 13:14:00', '智谱AI厂商', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (13, '小米MIMO', 'xiaomi', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/04/19/18dd39365ce244e3ae5e030da036760e.png', '小米官方API', 'https://api.xiaomimimo.com/anthropic/v1/messages', '0', 3, 103, '2026-04-19 12:48:24', '1', '1', '2026-04-19 13:14:22', '小米官方API', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (14, '阿里云百炼', 'qianwen', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/de2aa7e649de44f3ba5c6380ac6acd04.png', '阿里云百炼大模型服务', 'https://dashscope.aliyuncs.com', '0', 2, 103, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:49:13', '阿里云厂商', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (15, 'PPIO', 'ppio', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/049bb6a507174f73bba4b8d8b9e55b8a.png', 'api聚合厂商', 'https://api.ppinfra.com/openai', '0', 5, 103, '2025-12-15 23:13:42', '1', '1', '2026-02-25 20:49:01', 'api聚合厂商', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (16, 'MiniMax', 'minimax', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/04/19/fdc712e90e0e4d78b05862ad230884e5.png', 'MiniMax大模型服务,支持M2.7、M2.5等模型', 'https://api.minimax.io/v1', '0', 6, 103, '2026-04-19 12:50:12', '1', '1', '2026-04-19 13:14:59', 'MiniMax厂商', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (17, 'ollama', 'ollama', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/afecabebc8014d80b0f06b4796a74c5d.png', 'ollama大模型', 'http://127.0.0.1:11434', '0', 7, 103, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:48:48', 'ollama厂商', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (18, '自定义厂商', 'custom_api', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/04/19/c1a8e122510f4e2f90deb36958af710b.png', 'OPENAI兼容格式', '自定义', '0', 8, 103, '2026-04-19 12:35:57', '1', '1', '2026-04-19 13:17:20', 'OPENAI兼容格式', NULL, '0', NULL, 0); -- ---------------------------- -- Table structure for chat_session diff --git a/docs/script/sql/update/updat-0419.sql b/docs/script/sql/update/updat-0419.sql new file mode 100644 index 00000000..f744f8a5 --- /dev/null +++ b/docs/script/sql/update/updat-0419.sql @@ -0,0 +1,92 @@ +/* + Navicat Premium Dump SQL + + Source Server : localhost-mysql + Source Server Type : MySQL + Source Server Version : 80045 (8.0.45) + Source Host : localhost:3306 + Source Schema : ruoyi-ai + + Target Server Type : MySQL + Target Server Version : 80045 (8.0.45) + File Encoding : 65001 + + Date: 19/04/2026 13:36:41 +*/ + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ---------------------------- +-- Table structure for chat_model +-- ---------------------------- +DROP TABLE IF EXISTS `chat_model`; +CREATE TABLE `chat_model` ( + `id` bigint NOT NULL COMMENT '主键', + `category` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '模型分类', + `model_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '模型名称', + `provider_code` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '模型供应商', + `model_describe` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '模型描述', + `model_dimension` int NULL DEFAULT NULL COMMENT '模型维度', + `model_show` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '是否显示', + `api_host` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '请求地址', + `api_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '密钥', + `create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门', + `create_by` bigint NULL DEFAULT NULL COMMENT '创建者', + `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间', + `update_by` bigint NULL DEFAULT NULL COMMENT '更新者', + `update_time` datetime NULL DEFAULT NULL COMMENT '更新时间', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户Id', + PRIMARY KEY (`id`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '模型管理' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of chat_model +-- ---------------------------- +INSERT INTO `chat_model` VALUES (2000585866022060033, 'chat', 'zai-org/glm-5', 'ppio', 'zai-org/glm-5', NULL, 'Y', 'https://api.ppio.com/openai', 'sk_xx', 103, 1, '2025-12-15 23:16:54', 1, '2026-03-15 19:18:48', 'DeepSeek-V3.2 是一款在高效推理、复杂推理能力与智能体场景中表现突出的领先模型。其基于 DeepSeek Sparse Attention(DSA)稀疏注意力机制,在显著降低计算开销的同时优化长上下文性能;通过可扩展强化学习框架,整体能力达到 GPT-5 同级,高算力版本 V3.2-Speciale 更在推理表现上接近 Gemini-3.0-Pro;同时,模型依托大型智能体任务合成管线,具备更强的工具调用与多步骤决策能力,并在 2025 年 IMO 与 IOI 中取得金牌级表现。作为 MaaS 平台,我们已对 DeepSeek-V3.2 完成深度适配,通过动态调度、批处理加速、低延迟推理与企业级 SLA 保障,进一步增强其在企业生产环境中的稳定性、性价比与可控性,适用于搜索、问答、智能体、代码、数据处理等多类高价值场景。', 0); +INSERT INTO `chat_model` VALUES (2007528268536287233, 'vector', 'baai/bge-m3', 'ppio', 'bge-m3', 1024, 'N', 'https://api.ppio.com/openai', 'sk_xx', 103, 1, '2026-01-04 03:03:32', 1, '2026-03-15 19:18:51', 'BGE-M3 是一款具备多维度能力的文本嵌入模型,可同时实现密集检索、多向量检索和稀疏检索三大核心功能。该模型设计上兼容超过100种语言,并支持从短句到长达8192词元的长文本等多种输入形式。在跨语言检索任务中,BGE-M3展现出显著优势,其性能在MIRACL、MKQA等国际基准测试中位居前列。此外,针对长文档检索场景,该模型在MLDR、NarritiveQA等数据集上的表现同样达到行业领先水平。', 0); +INSERT INTO `chat_model` VALUES (2045735140488847361, 'chat', 'deepseek-chat', 'custom_api', 'deepseek-chat', NULL, NULL, 'https://api.deepseek.com', 'sk_xx', 103, 1, '2026-04-19 13:24:00', 1, '2026-04-19 13:24:00', 'deepseek对话模型', 0); + +-- ---------------------------- +-- Table structure for chat_provider +-- ---------------------------- +DROP TABLE IF EXISTS `chat_provider`; +CREATE TABLE `chat_provider` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `provider_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '厂商名称', + `provider_code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '厂商编码', + `provider_icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '厂商图标', + `provider_desc` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '厂商描述', + `api_host` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT 'API地址', + `status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '状态(0正常 1停用)', + `sort_order` int NULL DEFAULT 0 COMMENT '排序', + `create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门', + `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间', + `create_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '创建者', + `update_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '更新者', + `update_time` datetime NULL DEFAULT NULL COMMENT '更新时间', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注', + `version` int NULL DEFAULT NULL COMMENT '版本', + `del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '删除标志', + `update_ip` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '更新IP', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户Id', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `unique_provider_code`(`provider_code` ASC, `tenant_id` ASC, `del_flag` ASC) USING BTREE, + INDEX `idx_status`(`status` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 2045727230803255298 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '厂商管理表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of chat_provider +-- ---------------------------- +INSERT INTO `chat_provider` VALUES (1, 'OpenAI', 'openai', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/01091be272334383a1efd9bc22b73ee6.png', 'OpenAI官方API服务商', 'https://api.openai.com', '0', 1, 103, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:46:59', 'OpenAI厂商', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (11, '深度求索', 'deepseek', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/04/19/5ba8c30f153246898a4d7dc7b846de8d.png', 'DeepSeek官方API', 'https://api.deepseek.com', '0', 0, 103, '2026-04-19 12:52:34', '1', '1', '2026-04-19 13:13:25', 'DeepSeek官方API', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (12, '智谱AI', 'zhipu', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/04/19/da071783c9284fdd9ed1ce1b57b3c75c.png', '智谱AI大模型服务', 'https://open.bigmodel.cn', '0', 4, 103, '2025-12-14 21:48:11', '1', '1', '2026-04-19 13:14:00', '智谱AI厂商', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (13, '小米MIMO', 'xiaomi', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/04/19/18dd39365ce244e3ae5e030da036760e.png', '小米官方API', 'https://api.xiaomimimo.com/anthropic/v1/messages', '0', 3, 103, '2026-04-19 12:48:24', '1', '1', '2026-04-19 13:14:22', '小米官方API', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (14, '阿里云百炼', 'qianwen', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/de2aa7e649de44f3ba5c6380ac6acd04.png', '阿里云百炼大模型服务', 'https://dashscope.aliyuncs.com', '0', 2, 103, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:49:13', '阿里云厂商', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (15, 'PPIO', 'ppio', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/049bb6a507174f73bba4b8d8b9e55b8a.png', 'api聚合厂商', 'https://api.ppinfra.com/openai', '0', 5, 103, '2025-12-15 23:13:42', '1', '1', '2026-02-25 20:49:01', 'api聚合厂商', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (16, 'MiniMax', 'minimax', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/04/19/fdc712e90e0e4d78b05862ad230884e5.png', 'MiniMax大模型服务,支持M2.7、M2.5等模型', 'https://api.minimax.io/v1', '0', 6, 103, '2026-04-19 12:50:12', '1', '1', '2026-04-19 13:14:59', 'MiniMax厂商', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (17, 'ollama', 'ollama', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/afecabebc8014d80b0f06b4796a74c5d.png', 'ollama大模型', 'http://127.0.0.1:11434', '0', 7, 103, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:48:48', 'ollama厂商', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (18, '自定义厂商', 'custom_api', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/04/19/c1a8e122510f4e2f90deb36958af710b.png', 'OPENAI兼容格式', '自定义', '0', 8, 103, '2026-04-19 12:35:57', '1', '1', '2026-04-19 13:17:20', 'OPENAI兼容格式', NULL, '0', NULL, 0); + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 009cedfc..7cf10404 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -265,7 +265,7 @@ demo: # 是否开启演示模式(开启后所有写操作将被拦截) enabled: false # 提示消息 - message: "演示模式,不允许进行写操作" +message: "演示模式,不允许操作" # 排除的路径(这些路径不受演示模式限制) excludes: - /login @@ -276,7 +276,9 @@ demo: - /chat/send - /system/session/** - /system/message/** - + - /system/attach/** + - /system/fragment/** + - /system/info/** --- # warm-flow工作流配置 warm-flow: # 是否开启工作流,默认true diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java index 62729ce2..066d6c3a 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java @@ -17,7 +17,8 @@ public enum ChatModeType { OPEN_AI("openai", "openai"), PPIO("ppio", "ppio"), CUSTOM_API("custom_api", "自定义API"), - MINIMAX("minimax", "MiniMax"); + MINIMAX("minimax", "MiniMax"), + XIAOMI("xiaomi", "小米MiMo"); private final String code; private final String description; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MiMoServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MiMoServiceImpl.java new file mode 100644 index 00000000..85b8a099 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MiMoServiceImpl.java @@ -0,0 +1,47 @@ +package org.ruoyi.service.chat.impl.provider; + + +import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.openai.OpenAiStreamingChatModel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.chat.domain.dto.request.ChatRequest; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.enums.ChatModeType; +import org.ruoyi.observability.MyChatModelListener; +import org.ruoyi.service.chat.AbstractChatService; +import org.springframework.stereotype.Service; + +import java.util.List; + + +/** + * 小米MiMo服务调用 + *

+ * 小米提供OpenAI兼容的API接口,支持MiMo等模型。 + * + * @author ageerle + * @date 2026/4/19 + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class MiMoServiceImpl implements AbstractChatService { + + @Override + public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) { + return OpenAiStreamingChatModel.builder() + .baseUrl(chatModelVo.getApiHost()) + .apiKey(chatModelVo.getApiKey()) + .modelName(chatModelVo.getModelName()) + .listeners(List.of(new MyChatModelListener())) + .returnThinking(chatRequest.getEnableThinking()) + .build(); + } + + @Override + public String getProviderName() { + return ChatModeType.XIAOMI.getCode(); + } + +} From 2c6ff668301f109372078f7e74de242f814f9f95 Mon Sep 17 00:00:00 2001 From: wangle Date: Sun, 19 Apr 2026 13:42:39 +0800 Subject: [PATCH 15/17] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3application.yml?= =?UTF-8?q?=E6=BC=94=E7=A4=BA=E6=A8=A1=E5=BC=8Fmessage=E7=BC=A9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- 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 7cf10404..14d4faa4 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -265,7 +265,7 @@ demo: # 是否开启演示模式(开启后所有写操作将被拦截) enabled: false # 提示消息 -message: "演示模式,不允许操作" + message: "演示模式,不允许操作" # 排除的路径(这些路径不受演示模式限制) excludes: - /login From 80ca76ea37d5822571842a0246fa9cf1b699c1e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=8C=AF?= Date: Mon, 20 Apr 2026 01:02:09 +0800 Subject: [PATCH 16/17] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E9=87=8D=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/bo/knowledge/KnowledgeInfoBo.java | 23 +++ .../ruoyi/domain/bo/rerank/RerankRequest.java | 44 +++++ .../ruoyi/domain/bo/rerank/RerankResult.java | 72 ++++++++ .../ruoyi/domain/bo/vector/QueryVectorBo.java | 26 +++ .../dto/request/ZhipuRerankRequest.java | 48 ++++++ .../dto/response/ZhipuRerankResponse.java | 65 +++++++ .../entity/knowledge/KnowledgeInfo.java | 20 +++ .../domain/vo/knowledge/KnowledgeInfoVo.java | 24 +++ .../org/ruoyi/factory/RerankModelFactory.java | 106 ++++++++++++ .../service/chat/impl/ChatServiceFacade.java | 14 +- .../service/rerank/RerankModelService.java | 70 ++++++++ .../rerank/impl/JinaRerankModelService.java | 107 ++++++++++++ .../rerank/impl/ZhiPuRerankModelService.java | 162 ++++++++++++++++++ .../retrieval/KnowledgeRetrievalService.java | 24 +++ .../impl/KnowledgeRetrievalServiceImpl.java | 135 +++++++++++++++ 15 files changed, 938 insertions(+), 2 deletions(-) create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/rerank/RerankRequest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/rerank/RerankResult.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/request/ZhipuRerankRequest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/response/ZhipuRerankResponse.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/RerankModelFactory.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/RerankModelService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/JinaRerankModelService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/ZhiPuRerankModelService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/KnowledgeRetrievalService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceImpl.java diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/knowledge/KnowledgeInfoBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/knowledge/KnowledgeInfoBo.java index 113a2847..4d5cf619 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/knowledge/KnowledgeInfoBo.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/knowledge/KnowledgeInfoBo.java @@ -77,10 +77,33 @@ public class KnowledgeInfoBo extends BaseEntity { */ private String embeddingModel; + /** + * 是否启用重排序(0 否 1是) + */ + private Integer enableRerank; + + /** + * 重排序模型名称 + */ + private String rerankModel; + + /** + * 重排序后返回的文档数量 + */ + private Integer rerankTopN; + + /** + * 重排序相关性分数阈值 + */ + private Double rerankScoreThreshold; + + /** * 备注 */ private String remark; + + } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/rerank/RerankRequest.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/rerank/RerankRequest.java new file mode 100644 index 00000000..a71c2841 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/rerank/RerankRequest.java @@ -0,0 +1,44 @@ +package org.ruoyi.domain.bo.rerank; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 重排序请求参数 + * + * @author yang + * @date 2026-04-19 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RerankRequest { + + /** + * 查询文本 + */ + private String query; + + /** + * 候选文档列表 + */ + private List documents; + + /** + * 返回的文档数量(topN) + * 如果不指定,默认返回所有文档 + */ + private Integer topN; + + /** + * 是否返回原始文档内容 + * 默认为 true + */ + @Builder.Default + private Boolean returnDocuments = true; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/rerank/RerankResult.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/rerank/RerankResult.java new file mode 100644 index 00000000..32400a5d --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/rerank/RerankResult.java @@ -0,0 +1,72 @@ +package org.ruoyi.domain.bo.rerank; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 重排序结果 + * + * @author yang + * @date 2026-04-19 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RerankResult { + + /** + * 重排序后的文档结果列表 + */ + private List documents; + + /** + * 原始请求中的文档总数 + */ + private Integer totalDocuments; + + /** + * 重排序耗时(毫秒) + */ + private Long durationMs; + + /** + * 单个重排序文档结果 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class RerankDocument { + + /** + * 文档在原始列表中的索引位置 + */ + private Integer index; + + /** + * 相关性分数(通常 0-1 之间,越高越相关) + */ + private Double relevanceScore; + + /** + * 文档内容 + */ + private String document; + } + + /** + * 创建空结果 + */ + public static RerankResult empty() { + return RerankResult.builder() + .documents(List.of()) + .totalDocuments(0) + .durationMs(0L) + .build(); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/vector/QueryVectorBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/vector/QueryVectorBo.java index 0f881632..bb5634a3 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/vector/QueryVectorBo.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/vector/QueryVectorBo.java @@ -51,4 +51,30 @@ public class QueryVectorBo { */ private String baseUrl; + + // ========== 重排序相关参数 ========== + + /** + * 是否启用重排序 + * 默认为 false + */ + private Boolean enableRerank = false; + + /** + * 重排序模型名称 + */ + private String rerankModelName; + + /** + * 重排序后返回的文档数量(topN) + * 如果不指定,默认与 maxResults 相同 + */ + private Integer rerankTopN; + + /** + * 重排序相关性分数阈值 + * 低于此阈值的文档将被过滤 + */ + private Double rerankScoreThreshold; + } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/request/ZhipuRerankRequest.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/request/ZhipuRerankRequest.java new file mode 100644 index 00000000..f5be847a --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/request/ZhipuRerankRequest.java @@ -0,0 +1,48 @@ +package org.ruoyi.domain.dto.request; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.List; + +/** + * 智谱AI重排序请求DTO + * + * @author yang + * @date 2026-04-19 + */ +public record ZhipuRerankRequest( + String model, + String query, + List documents, + Integer top_n, + Boolean return_documents +) { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** + * 创建智谱重排序请求 + */ + public static ZhipuRerankRequest create(String modelName, String query, + List documents, Integer topN, + Boolean returnDocuments) { + return new ZhipuRerankRequest( + modelName, + query, + documents, + topN != null ? topN : documents.size(), + returnDocuments != null ? returnDocuments : true + ); + } + + /** + * 转换为JSON字符串 + */ + public String toJson() { + try { + return OBJECT_MAPPER.writeValueAsString(this); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("序列化智谱重排序请求失败", e); + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/response/ZhipuRerankResponse.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/response/ZhipuRerankResponse.java new file mode 100644 index 00000000..ff08d129 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/response/ZhipuRerankResponse.java @@ -0,0 +1,65 @@ +package org.ruoyi.domain.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.ruoyi.domain.bo.rerank.RerankResult; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 智谱AI重排序响应DTO + * + * @author yang + * @date 2026-04-19 + */ +public record ZhipuRerankResponse( + String model, + String object, + List results, + UsageInfo usage +) { + /** + * 单个重排序结果项 + */ + public record ResultItem( + Integer index, + @JsonProperty("relevance_score") + Double relevanceScore, + String document + ) {} + + /** + * Token使用信息 + */ + public record UsageInfo( + @JsonProperty("total_tokens") + Integer totalTokens, + @JsonProperty("input_tokens") + Integer inputTokens, + @JsonProperty("output_tokens") + Integer outputTokens + ) {} + + /** + * 转换为通用RerankResult + */ + public RerankResult toRerankResult(int totalDocs, long durationMs) { + if (results == null || results.isEmpty()) { + return RerankResult.empty(); + } + + List documents = results.stream() + .map(item -> RerankResult.RerankDocument.builder() + .index(item.index()) + .relevanceScore(item.relevanceScore()) + .document(item.document()) + .build()) + .collect(Collectors.toList()); + + return RerankResult.builder() + .documents(documents) + .totalDocuments(totalDocs) + .durationMs(durationMs) + .build(); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/knowledge/KnowledgeInfo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/knowledge/KnowledgeInfo.java index a51cf7da..948d04c0 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/knowledge/KnowledgeInfo.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/knowledge/KnowledgeInfo.java @@ -78,6 +78,26 @@ public class KnowledgeInfo extends BaseEntity { */ private String embeddingModel; + /** + * 是否启用重排序(0 否 1是) + */ + private Integer enableRerank; + + /** + * 重排序模型名称 + */ + private String rerankModel; + + /** + * 重排序后返回的文档数量 + */ + private Integer rerankTopN; + + /** + * 重排序相关性分数阈值 + */ + private Double rerankScoreThreshold; + /** * 备注 */ diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeInfoVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeInfoVo.java index bf0580dd..41d48480 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeInfoVo.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeInfoVo.java @@ -94,6 +94,30 @@ public class KnowledgeInfoVo implements Serializable { @ExcelProperty(value = "向量模型") private String embeddingModel; + /** + * 是否启用重排序(0 否 1是) + */ + @ExcelProperty(value = "是否启用重排序") + private Integer enableRerank; + + /** + * 重排序模型名称 + */ + @ExcelProperty(value = "重排序模型") + private String rerankModel; + + /** + * 重排序后返回的文档数量 + */ + @ExcelProperty(value = "重排序返回数量") + private Integer rerankTopN; + + /** + * 重排序相关性分数阈值 + */ + @ExcelProperty(value = "重排序分数阈值") + private Double rerankScoreThreshold; + /** * 备注 */ diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/RerankModelFactory.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/RerankModelFactory.java new file mode 100644 index 00000000..2b9ff9fe --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/RerankModelFactory.java @@ -0,0 +1,106 @@ +package org.ruoyi.factory; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.service.chat.IChatModelService; +import org.ruoyi.service.rerank.RerankModelService; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 重排序模型工厂服务类 + * 参考设计模式:EmbeddingModelFactory + * 负责创建和管理重排序模型实例 + * + * @author yang + * @date 2026-04-19 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class RerankModelFactory { + + private final ApplicationContext applicationContext; + + private final IChatModelService chatModelService; + + /** + * 模型缓存,使用ConcurrentHashMap保证线程安全 + */ + private final Map modelCache = new ConcurrentHashMap<>(); + + /** + * 创建重排序模型实例 + * 如果模型已存在于缓存中,则直接返回;否则创建新的实例 + * + * @param rerankModelName 重排序模型名称 + */ + public RerankModelService createModel(String rerankModelName) { + return modelCache.computeIfAbsent(rerankModelName, name -> { + ChatModelVo modelConfig = chatModelService.selectModelByName(rerankModelName); + + if (modelConfig == null) { + throw new IllegalArgumentException("未找到重排序模型配置,name=" + name); + } + return createModelInstance(modelConfig.getProviderCode(), modelConfig); + }); + } + + /** + * 刷新模型缓存 + * 根据给定的模型ID从缓存中移除对应的模型 + * + * @param modelId 模型的唯一标识ID + */ + public void refreshModel(Long modelId) { + modelCache.remove(modelId); + } + + /** + * 获取所有支持模型工厂的列表 + * + * @return 支持的模型工厂名称列表 + */ + public List getSupportedFactories() { + return new ArrayList<>(applicationContext.getBeansOfType(RerankModelService.class) + .keySet()); + } + + /** + * 创建具体的模型实例 + * 根据提供的工厂名称和配置信息创建并配置模型实例 + * + * @param factory 工厂名称,用于标识模型类型(providerCode) + * @param config 模型配置信息 + * @return RerankModelService 配置好的模型实例 + * @throws IllegalArgumentException 当无法获取指定的模型实例时抛出 + */ + private RerankModelService createModelInstance(String factory, ChatModelVo config) { + try { + // 优先尝试使用 providerCode + "Rerank" 作为 Bean 名称 + // 例如:zhipu -> zhipuRerank,jina -> jinaRerank + String rerankBeanName = factory + "Rerank"; + RerankModelService model = applicationContext.getBean(rerankBeanName, RerankModelService.class); + model.configure(config); + log.info("成功创建重排序模型: factory={}, modelName={}", rerankBeanName, config.getModelName()); + return model; + } catch (NoSuchBeanDefinitionException e) { + // 如果找不到,尝试使用原始的 providerCode + try { + RerankModelService model = applicationContext.getBean(factory, RerankModelService.class); + model.configure(config); + log.info("成功创建重排序模型: factory={}, modelName={}", factory, config.getModelName()); + return model; + } catch (NoSuchBeanDefinitionException ex) { + throw new IllegalArgumentException("获取不到重排序模型: " + factory + " 或 " + factory + "Rerank", ex); + } + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java index 108c3cba..3bd0876e 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java @@ -54,6 +54,7 @@ import org.ruoyi.service.chat.AbstractChatService; import org.ruoyi.service.chat.IChatMessageService; import org.ruoyi.service.chat.impl.memory.PersistentChatMemoryStore; import org.ruoyi.service.knowledge.IKnowledgeInfoService; +import org.ruoyi.service.retrieval.KnowledgeRetrievalService; import org.ruoyi.service.vector.VectorStoreService; import org.springframework.stereotype.Service; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -89,6 +90,8 @@ public class ChatServiceFacade implements IChatService { private final VectorStoreService vectorStoreService; + private final KnowledgeRetrievalService knowledgeRetrievalService; + private final SseEmitterManager sseEmitterManager; private final IChatMessageService chatMessageService; @@ -452,8 +455,8 @@ public class ChatServiceFacade implements IChatService { // 构建向量查询参数 QueryVectorBo queryVectorBo = buildQueryVectorBo(chatRequest, knowledgeInfoVo, chatModel); - // 获取向量查询结果(知识库内容作为系统上下文,放在历史消息之后) - List nearestList = vectorStoreService.getQueryVector(queryVectorBo); + // 使用知识库检索服务(支持重排序) + List nearestList = knowledgeRetrievalService.retrieveTexts(queryVectorBo); for (String prompt : nearestList) { // 知识库内容作为系统上下文添加 messages.add(new AiMessage(prompt)); @@ -480,6 +483,13 @@ public class ChatServiceFacade implements IChatService { queryVectorBo.setVectorModelName(knowledgeInfoVo.getVectorModel()); queryVectorBo.setEmbeddingModelName(knowledgeInfoVo.getEmbeddingModel()); queryVectorBo.setMaxResults(knowledgeInfoVo.getRetrieveLimit()); + + // 设置重排序参数 + queryVectorBo.setEnableRerank(knowledgeInfoVo.getEnableRerank() != null && knowledgeInfoVo.getEnableRerank() == 1); + queryVectorBo.setRerankModelName(knowledgeInfoVo.getRerankModel()); + queryVectorBo.setRerankTopN(knowledgeInfoVo.getRerankTopN()); + queryVectorBo.setRerankScoreThreshold(knowledgeInfoVo.getRerankScoreThreshold()); + return queryVectorBo; } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/RerankModelService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/RerankModelService.java new file mode 100644 index 00000000..a80d97e1 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/RerankModelService.java @@ -0,0 +1,70 @@ +package org.ruoyi.service.rerank; + +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.model.output.Response; +import dev.langchain4j.model.scoring.ScoringModel; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.domain.bo.rerank.RerankRequest; +import org.ruoyi.domain.bo.rerank.RerankResult; + +import java.util.List; + +/** + * 重排序模型服务接口 + * 继承 langchain4j 的 ScoringModel 接口 + * 参考设计模式:BaseEmbedModelService + * + * @author Yzm + * @date 2026-04-19 + */ +public interface RerankModelService extends ScoringModel { + + /** + * 根据配置信息配置重排序模型 + * + * @param config 包含模型配置信息的 ChatModelVo 对象 + */ + void configure(ChatModelVo config); + + /** + * 执行重排序(批量文档) + * 这是业务层使用的便捷方法 + * + * @param rerankRequest 重排序请求,包含查询文本和候选文档列表 + * @return 重排序结果,包含排序后的文档和相关性分数 + */ + RerankResult rerank(RerankRequest rerankRequest); + + /** + * 实现 ScoringModel 接口的 scoreAll 方法 + * 将 ScoringModel 的调用转换为重排序调用 + */ + @Override + default Response> scoreAll(List segments, String query) { + // 将 TextSegment 转换为文档字符串列表 + List documents = segments.stream() + .map(TextSegment::text) + .toList(); + + RerankRequest request = RerankRequest.builder() + .query(query) + .documents(documents) + .topN(documents.size()) + .returnDocuments(false) + .build(); + + RerankResult result = rerank(request); + + // 提取分数列表,按原始顺序排列 + List scores = new java.util.ArrayList<>( + java.util.Collections.nCopies(documents.size(), 0.0)); + + for (RerankResult.RerankDocument doc : result.getDocuments()) { + if (doc.getIndex() != null && doc.getIndex() < documents.size()) { + scores.set(doc.getIndex(), doc.getRelevanceScore()); + } + } + + return Response.from(scores); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/JinaRerankModelService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/JinaRerankModelService.java new file mode 100644 index 00000000..642e807d --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/JinaRerankModelService.java @@ -0,0 +1,107 @@ +package org.ruoyi.service.rerank.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.langchain4j.http.client.*; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.domain.bo.rerank.RerankRequest; +import org.ruoyi.domain.bo.rerank.RerankResult; +import org.ruoyi.service.rerank.RerankModelService; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Jina AI 重排序模型实现 + * 参考设计模式:OpenAiEmbeddingProvider + * 使用 Jina 官方重排序API + * + * @author yang + * @date 2026-04-19 + */ +@Slf4j +@Component("jina") +public class JinaRerankModelService implements RerankModelService { + + protected ChatModelVo chatModelVo; + protected HttpClient httpClient; + protected final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void configure(ChatModelVo config) { + this.chatModelVo = config; + HttpClientBuilder httpClientBuilder = HttpClientBuilderLoader.loadHttpClientBuilder(); + this.httpClient = httpClientBuilder.build(); + } + + @Override + public RerankResult rerank(RerankRequest rerankRequest) { + long startTime = System.currentTimeMillis(); + + try { + // 构建Jina重排序请求体 + Map requestBody = new HashMap<>(); + requestBody.put("model", chatModelVo.getModelName()); + requestBody.put("query", rerankRequest.getQuery()); + requestBody.put("documents", rerankRequest.getDocuments()); + requestBody.put("top_n", rerankRequest.getTopN() != null ? + rerankRequest.getTopN() : rerankRequest.getDocuments().size()); + requestBody.put("return_documents", rerankRequest.getReturnDocuments()); + + // 构建HTTP请求 + HttpRequest httpRequest = HttpRequest.builder() + .url(chatModelVo.getApiHost()) + .method(HttpMethod.POST) + .addHeader("Content-Type", "application/json") + .addHeader("Authorization", "Bearer " + chatModelVo.getApiKey()) + .body(objectMapper.writeValueAsString(requestBody)) + .build(); + + // 发送请求 + SuccessfulHttpResponse httpResponse = httpClient.execute(httpRequest); + + // 解析响应 + @SuppressWarnings("unchecked") + Map response = objectMapper.readValue(httpResponse.body(), Map.class); + return parseResponse(response, rerankRequest.getDocuments().size(), + System.currentTimeMillis() - startTime); + + } catch (Exception e) { + log.error("Jina重排序失败: {}", e.getMessage(), e); + throw new RuntimeException("重排序服务调用失败: " + e.getMessage(), e); + } + } + + /** + * 解析Jina API响应 + */ + @SuppressWarnings("unchecked") + private RerankResult parseResponse(Map response, int totalDocs, long durationMs) { + List> results = (List>) response.get("results"); + if (results == null || results.isEmpty()) { + return RerankResult.empty(); + } + + List documents = new ArrayList<>(); + for (Map result : results) { + Integer index = (Integer) result.get("index"); + Double score = ((Number) result.get("relevance_score")).doubleValue(); + String document = (String) result.get("document"); + + documents.add(RerankResult.RerankDocument.builder() + .index(index) + .relevanceScore(score) + .document(document) + .build()); + } + + return RerankResult.builder() + .documents(documents) + .totalDocuments(totalDocs) + .durationMs(durationMs) + .build(); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/ZhiPuRerankModelService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/ZhiPuRerankModelService.java new file mode 100644 index 00000000..ff15908a --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/ZhiPuRerankModelService.java @@ -0,0 +1,162 @@ +package org.ruoyi.service.rerank.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.MacAlgorithm; +import lombok.extern.slf4j.Slf4j; +import okhttp3.*; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.domain.bo.rerank.RerankRequest; +import org.ruoyi.domain.bo.rerank.RerankResult; +import org.ruoyi.domain.dto.request.ZhipuRerankRequest; +import org.ruoyi.domain.dto.response.ZhipuRerankResponse; +import org.ruoyi.service.rerank.RerankModelService; +import org.springframework.stereotype.Component; + +import javax.crypto.spec.SecretKeySpec; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +/** + * 智谱AI 重排序模型实现 + * 参考设计模式:AliBaiLianMultiEmbeddingProvider + * + * @author yang + * @date 2026-04-19 + */ +@Slf4j +@Component("zhipuRerank") +public class ZhiPuRerankModelService implements RerankModelService { + + private final OkHttpClient okHttpClient; + private final ObjectMapper objectMapper = new ObjectMapper(); + private ChatModelVo chatModelVo; + + public ZhiPuRerankModelService() { + this.okHttpClient = new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build(); + } + + @Override + public void configure(ChatModelVo config) { + this.chatModelVo = config; + } + + @Override + public RerankResult rerank(RerankRequest rerankRequest) { + long startTime = System.currentTimeMillis(); + + try { + // 构建请求 + ZhipuRerankRequest request = buildRequest(rerankRequest); + ZhipuRerankResponse response = executeRequest(request); + + return response.toRerankResult( + rerankRequest.getDocuments().size(), + System.currentTimeMillis() - startTime + ); + + } catch (Exception e) { + log.error("智谱重排序失败: {}", e.getMessage(), e); + throw new RuntimeException("重排序服务调用失败: " + e.getMessage(), e); + } + } + + /** + * 构建请求对象 + */ + private ZhipuRerankRequest buildRequest(RerankRequest rerankRequest) { + return ZhipuRerankRequest.create( + chatModelVo.getModelName(), + rerankRequest.getQuery(), + rerankRequest.getDocuments(), + rerankRequest.getTopN(), + rerankRequest.getReturnDocuments() + ); + } + + /** + * 执行HTTP请求并解析响应 + */ + private ZhipuRerankResponse executeRequest(ZhipuRerankRequest request) throws IOException { + String jsonBody = request.toJson(); + RequestBody body = RequestBody.create(jsonBody, MediaType.get("application/json")); + + // 生成智谱认证Token + String token = generateToken(chatModelVo.getApiKey()); + + String url = chatModelVo.getApiHost() + "/" + chatModelVo.getModelName(); + Request httpRequest = new Request.Builder() + .url(url) + .addHeader("Authorization", token) + .post(body) + .build(); + + try (okhttp3.Response response = okHttpClient.newCall(httpRequest).execute()) { + if (!response.isSuccessful()) { + String err = response.body() != null ? response.body().string() : "无错误信息"; + throw new IllegalArgumentException("智谱API调用失败: " + response.code() + " - " + err); + } + + ResponseBody responseBody = response.body(); + if (responseBody == null) { + throw new IllegalArgumentException("响应体为空"); + } + + return parseResponse(responseBody.string()); + } + } + + /** + * 解析响应 + */ + private ZhipuRerankResponse parseResponse(String responseBody) throws IOException { + return objectMapper.readValue(responseBody, ZhipuRerankResponse.class); + } + + /** + * 生成智谱JWT Token + */ + private String generateToken(String apiKey) { + try { + String[] apiKeyParts = apiKey.split("\\."); + String keyId = apiKeyParts[0]; + String secret = apiKeyParts[1]; + + long expireMillis = 1000L * 60 * 30; // 30分钟 + java.util.Map payload = new java.util.HashMap<>(); + payload.put("api_key", keyId); + payload.put("exp", System.currentTimeMillis() + expireMillis); + payload.put("timestamp", System.currentTimeMillis()); + + // 使用反射创建 MacAlgorithm(兼容不同版本的 jjwt) + MacAlgorithm macAlgorithm; + try { + Class c = Class.forName("io.jsonwebtoken.impl.security.DefaultMacAlgorithm"); + Constructor ctor = c.getDeclaredConstructor(String.class, String.class, int.class); + ctor.setAccessible(true); + macAlgorithm = (MacAlgorithm) ctor.newInstance("HS256", "HmacSHA256", 128); + } catch (Exception e) { + macAlgorithm = Jwts.SIG.HS256; + } + + String token = Jwts.builder() + .header() + .add("alg", "HS256") + .add("sign_type", "SIGN") + .and() + .content(objectMapper.writeValueAsString(payload)) + .signWith(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"), macAlgorithm) + .compact(); + + return "Bearer " + token; + } catch (Exception e) { + throw new RuntimeException("生成智谱Token失败: " + e.getMessage(), e); + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/KnowledgeRetrievalService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/KnowledgeRetrievalService.java new file mode 100644 index 00000000..9c42dd1a --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/KnowledgeRetrievalService.java @@ -0,0 +1,24 @@ +package org.ruoyi.service.retrieval; + +import org.ruoyi.domain.bo.vector.QueryVectorBo; + +import java.util.List; + +/** + * 知识库检索服务接口 + * 整合粗召回(向量检索)和重排序流程 + * + * @author yang + * @date 2026-04-19 + */ +public interface KnowledgeRetrievalService { + + /** + * 执行知识库检索,返回文本内容 + * 流程:向量粗召回 -> 重排序(可选) -> 返回结果 + * + * @param queryVectorBo 查询参数 + * @return 文本内容列表 + */ + List retrieveTexts(QueryVectorBo queryVectorBo); +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceImpl.java new file mode 100644 index 00000000..42f6cf68 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceImpl.java @@ -0,0 +1,135 @@ +package org.ruoyi.service.retrieval.impl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.domain.bo.rerank.RerankRequest; +import org.ruoyi.domain.bo.rerank.RerankResult; +import org.ruoyi.domain.bo.vector.QueryVectorBo; +import org.ruoyi.factory.RerankModelFactory; +import org.ruoyi.service.rerank.RerankModelService; +import org.ruoyi.service.retrieval.KnowledgeRetrievalService; +import org.ruoyi.service.vector.VectorStoreService; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +/** + * 知识库检索服务实现 + * 整合粗召回(向量检索)和重排序流程 + * + * @author yang + * @date 2026-04-19 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class KnowledgeRetrievalServiceImpl implements KnowledgeRetrievalService { + + private final VectorStoreService vectorStoreService; + private final RerankModelFactory rerankModelFactory; + + /** + * 粗召回默认扩大倍数 + * 如果启用重排序,粗召回会获取更多结果供重排序筛选 + */ + private static final int RERANK_EXPANSION_FACTOR = 3; + + @Override + public List retrieveTexts(QueryVectorBo queryVectorBo) { + log.info("开始知识库检索, kid={}, query={}", queryVectorBo.getKid(), queryVectorBo.getQuery()); + + // 1. 粗召回阶段 - 向量检索 + List coarseResults = coarseRetrieval(queryVectorBo); + log.debug("粗召回返回 {} 条结果", coarseResults.size()); + + if (coarseResults.isEmpty()) { + return coarseResults; + } + + // 2. 重排序阶段(可选) + if (Boolean.TRUE.equals(queryVectorBo.getEnableRerank()) && + queryVectorBo.getRerankModelName() != null) { + return rerank(queryVectorBo, coarseResults); + } + + return coarseResults; + } + + /** + * 粗召回阶段 - 向量检索 + */ + private List coarseRetrieval(QueryVectorBo queryVectorBo) { + // 如果启用重排序,扩大粗召回数量 + int originalMaxResults = queryVectorBo.getMaxResults(); + int expandedResults = originalMaxResults; + if (Boolean.TRUE.equals(queryVectorBo.getEnableRerank()) && + queryVectorBo.getRerankModelName() != null) { + expandedResults = originalMaxResults * RERANK_EXPANSION_FACTOR; + log.debug("启用重排序,粗召回数量从 {} 扩大到 {}", originalMaxResults, expandedResults); + } + + // 临时修改查询数量 + queryVectorBo.setMaxResults(expandedResults); + try { + return vectorStoreService.getQueryVector(queryVectorBo); + } finally { + // 恢复原始值 + queryVectorBo.setMaxResults(originalMaxResults); + } + } + + /** + * 重排序阶段 + */ + private List rerank(QueryVectorBo queryVectorBo, List coarseResults) { + long startTime = System.currentTimeMillis(); + + try { + // 1. 通过工厂获取重排序模型 + RerankModelService rerankModel = rerankModelFactory.createModel(queryVectorBo.getRerankModelName()); + + // 2. 构建重排序请求 + int topN = queryVectorBo.getRerankTopN() != null ? + queryVectorBo.getRerankTopN() : queryVectorBo.getMaxResults(); + + RerankRequest rerankRequest = RerankRequest.builder() + .query(queryVectorBo.getQuery()) + .documents(coarseResults) + .topN(topN) + .returnDocuments(true) + .build(); + + log.info("执行重排序, model={}, documents={}, topN={}", + queryVectorBo.getRerankModelName(), coarseResults.size(), topN); + + // 3. 执行重排序 + RerankResult rerankResult = rerankModel.rerank(rerankRequest); + + // 4. 转换重排序结果 + List finalResults = new ArrayList<>(); + for (RerankResult.RerankDocument doc : rerankResult.getDocuments()) { + // 应用分数阈值过滤 + if (queryVectorBo.getRerankScoreThreshold() != null && + doc.getRelevanceScore() < queryVectorBo.getRerankScoreThreshold()) { + continue; + } + + if (doc.getDocument() != null) { + finalResults.add(doc.getDocument()); + } + } + + long duration = System.currentTimeMillis() - startTime; + log.info("重排序完成, 返回 {} 条结果, 耗时 {}ms", finalResults.size(), duration); + + return finalResults; + + } catch (Exception e) { + log.error("重排序失败: {}", e.getMessage(), e); + // 重排序失败时返回原始粗召回结果(截取到期望数量) + int limit = Math.min(queryVectorBo.getMaxResults(), coarseResults.size()); + return new ArrayList<>(coarseResults.subList(0, limit)); + } + } +} From e1b8a5f011fbd0164c6617f7ada6012eed9cc4e9 Mon Sep 17 00:00:00 2001 From: yangzhen Date: Mon, 20 Apr 2026 16:07:02 +0800 Subject: [PATCH 17/17] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8D=83=E9=97=AE3?= =?UTF-8?q?=E9=87=8D=E6=8E=92=E5=BA=8F=E6=A8=A1=E5=9E=8B=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E9=99=84=E5=B8=A6=E6=96=B0=E5=A2=9Esql=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/script/sql/update/updat-0420.sql | 46 +++++++ .../dto/request/AliBaiLianRerankRequest.java | 55 ++++++++ .../response/AliBaiLianRerankResponse.java | 81 +++++++++++ .../dto/response/ZhipuRerankResponse.java | 3 + .../impl/AliBaiLianRerankModelService.java | 115 ++++++++++++++++ .../rerank/impl/JinaRerankModelService.java | 107 --------------- .../rerank/impl/ZhiPuRerankModelService.java | 5 +- .../AliBaiLianRerankModelServiceTest.java | 126 ++++++++++++++++++ .../rerank/impl/AliBaiLianRerankTestMain.java | 73 ++++++++++ 9 files changed, 502 insertions(+), 109 deletions(-) create mode 100644 docs/script/sql/update/updat-0420.sql create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/request/AliBaiLianRerankRequest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/response/AliBaiLianRerankResponse.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/AliBaiLianRerankModelService.java delete mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/JinaRerankModelService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/rerank/impl/AliBaiLianRerankModelServiceTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/rerank/impl/AliBaiLianRerankTestMain.java diff --git a/docs/script/sql/update/updat-0420.sql b/docs/script/sql/update/updat-0420.sql new file mode 100644 index 00000000..628a6b5c --- /dev/null +++ b/docs/script/sql/update/updat-0420.sql @@ -0,0 +1,46 @@ +/* + Navicat Premium Dump SQL + + Source Server : localhost-mysql + Source Server Type : MySQL + Source Server Version : 80045 (8.0.45) + Source Host : localhost:3306 + Source Schema : ruoyi-ai + + Target Server Type : MySQL + Target Server Version : 80045 (8.0.45) + File Encoding : 65001 + + Date: 20/04/2026 15:30:00 +*/ + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ---------------------------- +-- 新增:重排序模型(chat_model) +-- ---------------------------- +INSERT INTO `chat_model` +(id, category, model_name, provider_code, model_describe, model_dimension, model_show, api_host, api_key, create_dept, create_by, create_time, update_by, update_time, remark, tenant_id) +VALUES(2045071617578237953, 'rerank', 'rerank', 'zhipu', '智谱重排序', NULL, 'Y', 'https://open.bigmodel.cn', 'e9xx', 103, 1, '2026-04-17 17:27:24', 1, '2026-04-20 15:21:48', '智谱重排序', 0); + +INSERT INTO `chat_model` +(id, category, model_name, provider_code, model_describe, model_dimension, model_show, api_host, api_key, create_dept, create_by, create_time, update_by, update_time, remark, tenant_id) +VALUES(2046119803482902530, 'rerank', 'qwen3-rerank', 'qianwen', '千问3重排序', NULL, NULL, 'https://dashscope.aliyuncs.com', 'sk-xx', 103, 1, '2026-04-20 14:52:31', 1, '2026-04-20 15:03:13', '千问3文本重排序', 0); + +-- ---------------------------- +-- 新增:字典类型 - 重排序模型分类 +-- ---------------------------- +INSERT INTO `sys_dict_data` +(dict_code, tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, create_dept, create_by, create_time, update_by, update_time, remark) +VALUES(2045070879435259905, '000000', 4, '重排序', 'rerank', 'chat_model_category', NULL, '#000000', 'N', 103, 1, '2026-04-17 17:24:28', 1, '2026-04-19 01:02:20', '重排序模型'); + +-- ---------------------------- +-- 修改表:knowledge_info 增加重排序相关字段 +-- ---------------------------- +ALTER TABLE `knowledge_info` ADD COLUMN `enable_rerank` tinyint DEFAULT 0 NULL COMMENT '是否启用重排序(0否 1是)'; +ALTER TABLE `knowledge_info` ADD COLUMN `rerank_score_threshold` double NULL COMMENT '重排序相关性分数阈值'; +ALTER TABLE `knowledge_info` ADD COLUMN `rerank_top_n` int NULL COMMENT '重排序后返回的文档数量'; +ALTER TABLE `knowledge_info` ADD COLUMN `rerank_model` varchar(100) NULL COMMENT '重排序模型名称'; + +SET FOREIGN_KEY_CHECKS = 1; \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/request/AliBaiLianRerankRequest.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/request/AliBaiLianRerankRequest.java new file mode 100644 index 00000000..285db7c2 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/request/AliBaiLianRerankRequest.java @@ -0,0 +1,55 @@ +package org.ruoyi.domain.dto.request; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.List; + +/** + * 阿里百炼重排序请求DTO(OpenAI兼容格式) + * + * @author yang + * @date 2026-04-20 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record AliBaiLianRerankRequest( + String model, + List documents, + String query, + @JsonProperty("top_n") + Integer topN, + String instruct, + @JsonProperty("return_documents") + Boolean returnDocuments +) { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** + * 创建文本重排序请求 + */ + public static AliBaiLianRerankRequest create(String modelName, String query, + List documents, Integer topN, + Boolean returnDocuments) { + return new AliBaiLianRerankRequest( + modelName, + documents, + query, + topN != null ? topN : documents.size(), + null, + returnDocuments != null ? returnDocuments : true + ); + } + + /** + * 转换为JSON字符串 + */ + public String toJson() { + try { + return OBJECT_MAPPER.writeValueAsString(this); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("序列化阿里百炼重排序请求失败", e); + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/response/AliBaiLianRerankResponse.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/response/AliBaiLianRerankResponse.java new file mode 100644 index 00000000..3c68267c --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/response/AliBaiLianRerankResponse.java @@ -0,0 +1,81 @@ +package org.ruoyi.domain.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.ruoyi.domain.bo.rerank.RerankResult; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 阿里百炼重排序响应DTO(OpenAI兼容格式) + * + * @author yang + * @date 2026-04-20 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record AliBaiLianRerankResponse( + String id, + String object, + List results, + UsageInfo usage +) { + /** + * 单个重排序结果项 + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record ResultItem( + Integer index, + @JsonProperty("relevance_score") + Double relevanceScore, + Object document + ) { + /** + * 获取文档文本内容 + */ + public String getDocumentText() { + if (document == null) return null; + if (document instanceof String) return (String) document; + if (document instanceof Map) { + Object text = ((Map) document).get("text"); + return text != null ? text.toString() : null; + } + return document.toString(); + } + } + + /** + * Token使用信息 + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record UsageInfo( + @JsonProperty("total_tokens") + Integer totalTokens, + @JsonProperty("prompt_tokens") + Integer promptTokens + ) {} + + /** + * 转换为通用RerankResult + */ + public RerankResult toRerankResult(int totalDocs, long durationMs) { + if (results == null || results.isEmpty()) { + return RerankResult.empty(); + } + + List documents = results.stream() + .map(item -> RerankResult.RerankDocument.builder() + .index(item.index()) + .relevanceScore(item.relevanceScore()) + .document(item.getDocumentText()) + .build()) + .collect(Collectors.toList()); + + return RerankResult.builder() + .documents(documents) + .totalDocuments(totalDocs) + .durationMs(durationMs) + .build(); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/response/ZhipuRerankResponse.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/response/ZhipuRerankResponse.java index ff08d129..848b1cdc 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/response/ZhipuRerankResponse.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/response/ZhipuRerankResponse.java @@ -1,5 +1,6 @@ package org.ruoyi.domain.dto.response; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import org.ruoyi.domain.bo.rerank.RerankResult; @@ -12,6 +13,7 @@ import java.util.stream.Collectors; * @author yang * @date 2026-04-19 */ +@JsonIgnoreProperties(ignoreUnknown = true) public record ZhipuRerankResponse( String model, String object, @@ -31,6 +33,7 @@ public record ZhipuRerankResponse( /** * Token使用信息 */ + @JsonIgnoreProperties(ignoreUnknown = true) public record UsageInfo( @JsonProperty("total_tokens") Integer totalTokens, diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/AliBaiLianRerankModelService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/AliBaiLianRerankModelService.java new file mode 100644 index 00000000..a7975bf8 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/AliBaiLianRerankModelService.java @@ -0,0 +1,115 @@ +package org.ruoyi.service.rerank.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import okhttp3.*; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.domain.bo.rerank.RerankRequest; +import org.ruoyi.domain.bo.rerank.RerankResult; +import org.ruoyi.domain.dto.request.AliBaiLianRerankRequest; +import org.ruoyi.domain.dto.response.AliBaiLianRerankResponse; +import org.ruoyi.service.rerank.RerankModelService; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +/** + * 阿里百炼重排序模型实现 + * 参考设计模式:AliBaiLianMultiEmbeddingProvider + * + * @author yang + * @date 2026-04-20 + */ +@Slf4j +@Component("qianwenRerank") +public class AliBaiLianRerankModelService implements RerankModelService { + + private final OkHttpClient okHttpClient; + private final ObjectMapper objectMapper = new ObjectMapper(); + private ChatModelVo chatModelVo; + + public AliBaiLianRerankModelService() { + this.okHttpClient = new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build(); + } + + @Override + public void configure(ChatModelVo config) { + this.chatModelVo = config; + } + + @Override + public RerankResult rerank(RerankRequest rerankRequest) { + long startTime = System.currentTimeMillis(); + + try { + // 构建请求 + AliBaiLianRerankRequest request = buildRequest(rerankRequest); + AliBaiLianRerankResponse response = executeRequest(request); + + return response.toRerankResult( + rerankRequest.getDocuments().size(), + System.currentTimeMillis() - startTime + ); + + } catch (Exception e) { + log.error("阿里百炼重排序失败: {}", e.getMessage(), e); + throw new RuntimeException("重排序服务调用失败: " + e.getMessage(), e); + } + } + + /** + * 构建请求对象 + */ + private AliBaiLianRerankRequest buildRequest(RerankRequest rerankRequest) { + return AliBaiLianRerankRequest.create( + chatModelVo.getModelName(), + rerankRequest.getQuery(), + rerankRequest.getDocuments(), + rerankRequest.getTopN(), + rerankRequest.getReturnDocuments() + ); + } + + /** + * 执行HTTP请求并解析响应 + */ + private AliBaiLianRerankResponse executeRequest(AliBaiLianRerankRequest request) throws IOException { + String jsonBody = request.toJson(); + RequestBody body = RequestBody.create(jsonBody, MediaType.get("application/json")); + + // 阿里百炼重排序 OpenAI兼容端点 + String url = chatModelVo.getApiHost() + "/compatible-api/v1/reranks"; + Request httpRequest = new Request.Builder() + .url(url) + .addHeader("Authorization", "Bearer " + chatModelVo.getApiKey()) + .addHeader("Content-Type", "application/json") + .post(body) + .build(); + + try (Response response = okHttpClient.newCall(httpRequest).execute()) { + if (!response.isSuccessful()) { + String err = response.body() != null ? response.body().string() : "无错误信息"; + throw new IllegalArgumentException("阿里百炼API调用失败: " + response.code() + " - " + err); + } + + ResponseBody responseBody = response.body(); + if (responseBody == null) { + throw new IllegalArgumentException("响应体为空"); + } + + return parseResponse(responseBody.string()); + } + } + + /** + * 解析响应 + */ + private AliBaiLianRerankResponse parseResponse(String responseBody) throws IOException { + return objectMapper.readValue(responseBody, AliBaiLianRerankResponse.class); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/JinaRerankModelService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/JinaRerankModelService.java deleted file mode 100644 index 642e807d..00000000 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/JinaRerankModelService.java +++ /dev/null @@ -1,107 +0,0 @@ -package org.ruoyi.service.rerank.impl; - -import com.fasterxml.jackson.databind.ObjectMapper; -import dev.langchain4j.http.client.*; -import lombok.extern.slf4j.Slf4j; -import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; -import org.ruoyi.domain.bo.rerank.RerankRequest; -import org.ruoyi.domain.bo.rerank.RerankResult; -import org.ruoyi.service.rerank.RerankModelService; -import org.springframework.stereotype.Component; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * Jina AI 重排序模型实现 - * 参考设计模式:OpenAiEmbeddingProvider - * 使用 Jina 官方重排序API - * - * @author yang - * @date 2026-04-19 - */ -@Slf4j -@Component("jina") -public class JinaRerankModelService implements RerankModelService { - - protected ChatModelVo chatModelVo; - protected HttpClient httpClient; - protected final ObjectMapper objectMapper = new ObjectMapper(); - - @Override - public void configure(ChatModelVo config) { - this.chatModelVo = config; - HttpClientBuilder httpClientBuilder = HttpClientBuilderLoader.loadHttpClientBuilder(); - this.httpClient = httpClientBuilder.build(); - } - - @Override - public RerankResult rerank(RerankRequest rerankRequest) { - long startTime = System.currentTimeMillis(); - - try { - // 构建Jina重排序请求体 - Map requestBody = new HashMap<>(); - requestBody.put("model", chatModelVo.getModelName()); - requestBody.put("query", rerankRequest.getQuery()); - requestBody.put("documents", rerankRequest.getDocuments()); - requestBody.put("top_n", rerankRequest.getTopN() != null ? - rerankRequest.getTopN() : rerankRequest.getDocuments().size()); - requestBody.put("return_documents", rerankRequest.getReturnDocuments()); - - // 构建HTTP请求 - HttpRequest httpRequest = HttpRequest.builder() - .url(chatModelVo.getApiHost()) - .method(HttpMethod.POST) - .addHeader("Content-Type", "application/json") - .addHeader("Authorization", "Bearer " + chatModelVo.getApiKey()) - .body(objectMapper.writeValueAsString(requestBody)) - .build(); - - // 发送请求 - SuccessfulHttpResponse httpResponse = httpClient.execute(httpRequest); - - // 解析响应 - @SuppressWarnings("unchecked") - Map response = objectMapper.readValue(httpResponse.body(), Map.class); - return parseResponse(response, rerankRequest.getDocuments().size(), - System.currentTimeMillis() - startTime); - - } catch (Exception e) { - log.error("Jina重排序失败: {}", e.getMessage(), e); - throw new RuntimeException("重排序服务调用失败: " + e.getMessage(), e); - } - } - - /** - * 解析Jina API响应 - */ - @SuppressWarnings("unchecked") - private RerankResult parseResponse(Map response, int totalDocs, long durationMs) { - List> results = (List>) response.get("results"); - if (results == null || results.isEmpty()) { - return RerankResult.empty(); - } - - List documents = new ArrayList<>(); - for (Map result : results) { - Integer index = (Integer) result.get("index"); - Double score = ((Number) result.get("relevance_score")).doubleValue(); - String document = (String) result.get("document"); - - documents.add(RerankResult.RerankDocument.builder() - .index(index) - .relevanceScore(score) - .document(document) - .build()); - } - - return RerankResult.builder() - .documents(documents) - .totalDocuments(totalDocs) - .durationMs(durationMs) - .build(); - } -} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/ZhiPuRerankModelService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/ZhiPuRerankModelService.java index ff15908a..54054091 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/ZhiPuRerankModelService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/ZhiPuRerankModelService.java @@ -90,14 +90,15 @@ public class ZhiPuRerankModelService implements RerankModelService { // 生成智谱认证Token String token = generateToken(chatModelVo.getApiKey()); - String url = chatModelVo.getApiHost() + "/" + chatModelVo.getModelName(); + // 智谱重排序固定端点路径 + String url = chatModelVo.getApiHost() + "/api/paas/v4/rerank"; Request httpRequest = new Request.Builder() .url(url) .addHeader("Authorization", token) .post(body) .build(); - try (okhttp3.Response response = okHttpClient.newCall(httpRequest).execute()) { + try (Response response = okHttpClient.newCall(httpRequest).execute()) { if (!response.isSuccessful()) { String err = response.body() != null ? response.body().string() : "无错误信息"; throw new IllegalArgumentException("智谱API调用失败: " + response.code() + " - " + err); diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/rerank/impl/AliBaiLianRerankModelServiceTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/rerank/impl/AliBaiLianRerankModelServiceTest.java new file mode 100644 index 00000000..a63ded68 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/rerank/impl/AliBaiLianRerankModelServiceTest.java @@ -0,0 +1,126 @@ +package org.ruoyi.service.rerank.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.domain.bo.rerank.RerankRequest; +import org.ruoyi.domain.bo.rerank.RerankResult; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 阿里百炼重排序模型测试类 + * 运行前请设置环境变量 DASHSCOPE_API_KEY 或直接修改 apiKey + */ +class AliBaiLianRerankModelServiceTest { + + private AliBaiLianRerankModelService service; + + // 请替换为你的 API Key + private static final String API_KEY = System.getenv("DASHSCOPE_API_KEY"); + private static final String API_HOST = "https://dashscope.aliyuncs.com"; + private static final String MODEL_NAME = "qwen3-rerank"; + + @BeforeEach + void setUp() { + service = new AliBaiLianRerankModelService(); + } + + @Test + void testConfigure() { + ChatModelVo config = createConfig(); + service.configure(config); + assertNotNull(service); + } + + @Test + void testRerank() { + // 跳过测试如果没有配置 API Key + if (API_KEY == null || API_KEY.isEmpty()) { + System.out.println("跳过测试: 未设置环境变量 DASHSCOPE_API_KEY"); + return; + } + + ChatModelVo config = createConfig(); + service.configure(config); + + List documents = Arrays.asList( + "文本排序模型广泛用于搜索引擎和推荐系统中,它们根据文本相关性对候选文本进行排序", + "量子计算是计算科学的一个前沿领域", + "预训练语言模型的发展给文本排序模型带来了新的进展" + ); + + RerankRequest request = RerankRequest.builder() + .query("什么是文本排序模型") + .documents(documents) + .topN(2) + .returnDocuments(true) + .build(); + + RerankResult result = service.rerank(request); + + System.out.println("=== 重排序结果 ==="); + System.out.println("总文档数: " + result.getTotalDocuments()); + System.out.println("耗时: " + result.getDurationMs() + "ms"); + + result.getDocuments().forEach(doc -> { + System.out.println("索引: " + doc.getIndex() + + ", 相关性分数: " + doc.getRelevanceScore() + + ", 文档: " + doc.getDocument()); + }); + + assertNotNull(result); + assertNotNull(result.getDocuments()); + assertFalse(result.getDocuments().isEmpty()); + assertEquals(2, result.getDocuments().size()); + } + + @Test + void testRerankWithFullDocuments() { + if (API_KEY == null || API_KEY.isEmpty()) { + System.out.println("跳过测试: 未设置环境变量 DASHSCOPE_API_KEY"); + return; + } + + ChatModelVo config = createConfig(); + service.configure(config); + + List documents = Arrays.asList( + "Java是一种广泛使用的编程语言", + "Python是人工智能领域最流行的语言", + "Go语言由Google开发,适合并发编程" + ); + + RerankRequest request = RerankRequest.builder() + .query("哪种语言适合AI开发") + .documents(documents) + .build(); + + RerankResult result = service.rerank(request); + + System.out.println("=== 重排序结果2 ==="); + result.getDocuments().forEach(doc -> { + System.out.println("索引: " + doc.getIndex() + + ", 分数: " + doc.getRelevanceScore() + + ", 文档: " + doc.getDocument()); + }); + + assertNotNull(result); + assertEquals(3, result.getDocuments().size()); + + // Python相关文档应该排在前面 + assertEquals(1, result.getDocuments().get(0).getIndex()); + assertTrue(result.getDocuments().get(0).getRelevanceScore() > 0.5); + } + + private ChatModelVo createConfig() { + ChatModelVo config = new ChatModelVo(); + config.setApiHost(API_HOST); + config.setApiKey(API_KEY != null ? API_KEY : "test-api-key"); + config.setModelName(MODEL_NAME); + return config; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/rerank/impl/AliBaiLianRerankTestMain.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/rerank/impl/AliBaiLianRerankTestMain.java new file mode 100644 index 00000000..cbd70449 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/rerank/impl/AliBaiLianRerankTestMain.java @@ -0,0 +1,73 @@ +package org.ruoyi.service.rerank.impl; + +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.domain.bo.rerank.RerankRequest; +import org.ruoyi.domain.bo.rerank.RerankResult; + +import java.util.Arrays; +import java.util.List; + +/** + * 阿里百炼重排序模型测试 - Main方法直接运行 + * 运行前请设置 API_KEY + */ +public class AliBaiLianRerankTestMain { + + // 请替换为你的 API Key + private static final String API_KEY = "sk-your-api-key-here"; + private static final String API_HOST = "https://dashscope.aliyuncs.com"; + private static final String MODEL_NAME = "qwen3-rerank"; + + public static void main(String[] args) { + AliBaiLianRerankModelService service = new AliBaiLianRerankModelService(); + + // 配置 + ChatModelVo config = new ChatModelVo(); + config.setApiHost(API_HOST); + config.setApiKey(API_KEY); + config.setModelName(MODEL_NAME); + service.configure(config); + + // 测试数据 + List documents = Arrays.asList( + "文本排序模型广泛用于搜索引擎和推荐系统中,它们根据文本相关性对候选文本进行排序", + "量子计算是计算科学的一个前沿领域", + "预训练语言模型的发展给文本排序模型带来了新的进展" + ); + + RerankRequest request = RerankRequest.builder() + .query("什么是文本排序模型") + .documents(documents) + .topN(2) + .returnDocuments(true) + .build(); + + System.out.println("=== 开始测试阿里百炼重排序 ==="); + System.out.println("API Host: " + API_HOST); + System.out.println("Model: " + MODEL_NAME); + System.out.println("Query: 什么是文本排序模型"); + System.out.println(); + + try { + RerankResult result = service.rerank(request); + + System.out.println("=== 重排序结果 ==="); + System.out.println("总文档数: " + result.getTotalDocuments()); + System.out.println("耗时: " + result.getDurationMs() + "ms"); + System.out.println(); + + result.getDocuments().forEach(doc -> { + System.out.println("索引: " + doc.getIndex()); + System.out.println("相关性分数: " + doc.getRelevanceScore()); + System.out.println("文档: " + doc.getDocument()); + System.out.println("---"); + }); + + System.out.println("=== 测试成功 ==="); + + } catch (Exception e) { + System.err.println("=== 测试失败 ==="); + e.printStackTrace(); + } + } +}